What domain events after commit are and why in-transaction dispatch is a bug
A domain event is a record that something meaningful happened in your domain: OrderPlaced, InvoicePaid, SubscriptionCancelled. Inside a monolith, these events are usually in-process messages. An aggregate raises them, and handlers in the same process react by sending a confirmation email, warming a cache or notifying another module.
The after-commit rule is simple. Handlers that touch anything outside the database transaction run only after that transaction has committed successfully. Until the commit returns, the change might not exist.
Dispatching inside the transaction breaks this in two ways.
Side effects outlive a rollback. Suppose a handler sends "your order is confirmed" during the transaction. A unique constraint then fails at flush time, or a deadlock forces a retry. The database rolls back, but the email has already gone out. The same applies to an HTTP call to a payment provider, or a cache entry that now describes a row that was never written. You can't roll back an SMTP server or a remote API.
Handler failures abort the business operation. If the analytics handler throws, the exception travels up through the transaction and the order is never saved. A secondary concern now has veto power over the primary one. Slow handlers also hold row locks and connections open for longer.
This section applies to backend and platform engineers working on monoliths or modular monoliths where a unit of work or an ORM manages transaction boundaries. In those codebases, it is easy to dispatch in-process without noticing that you are doing it too early.
Who should use after-commit dispatch and the outbox
This pattern is for teams that run a single deployable application backed by one relational database, and that already use domain events or are about to. The usual signs that you need it:
- Handlers send emails, call third-party APIs, invalidate caches or update search indexes in response to state changes.
- You have seen "ghost" side effects: a notification for an order that a rollback removed, or a cache entry for a row that was never written.
- The codebase is split, or is being split, into modules that should not call each other's internals directly.
- Someone is considering extracting a module into a separate service and wants the event contracts to survive the move.
It is less useful in a few cases. If your application has no side effects outside the database, if every reaction to a change belongs in the same transaction, or if the system is a small CRUD service with no cross-module reactions, direct calls inside the transaction are simpler and enough. Teams already running a separate service per bounded context with a message broker face the same crash-window problem, but they apply the outbox at the service boundary rather than inside a monolith.
The two stages can be adopted separately. After-commit listeners are a low-cost fix that any team using a transactional framework can apply today. The outbox is worth adding when losing an event after a crash would be visible to users or would corrupt data in another module.
How after-commit dispatch works
The mechanism has three parts, and each later section covers one of them in detail.
- Record. Aggregates append events to an in-memory list when their state changes. Recording does no I/O, so a rolled-back command leaves nothing behind except discarded objects.
- Collect at the transaction boundary. The unit of work, or the framework's transaction manager, gathers the pending events from every aggregate it tracked. It holds them until the commit result is known. On rollback, the events are dropped. On commit, they are released.
- Dispatch after commit. Handlers run only once the data is durable. Each runs in isolation, so one handler's failure neither undoes the commit nor stops the other handlers.
The weak point is the gap between steps 2 and 3. The events exist only in process memory, so a crash in that gap loses them. The transactional outbox closes the gap by writing the events to a table in the same transaction as the state change. A relay then reads from that table and dispatches. Step 3 becomes "dispatch from durable storage, at least once", and handlers are made idempotent so that redelivery is harmless.
How to record events on aggregates and collect them in the unit of work
The recording side is deliberately simple. Each aggregate root holds a private list of pending events. When a command method changes state, for example order.confirm(), it validates the invariant, mutates its fields, and appends an OrderConfirmed event carrying the identifiers and values a handler will need. The aggregate does not publish anything and has no reference to a bus, a logger, or the database. It only records that something happened. This keeps the domain model testable with plain object assertions and means the event exists only if the state change it describes also exists.
The unit of work already tracks every aggregate loaded or added during the request, so it is the natural place to collect these lists. The lifecycle runs in four steps:
- Execute the command. The application service loads aggregates through repositories and calls domain methods. Events accumulate in memory.
- Flush. The unit of work writes pending changes to the database inside the open transaction. Constraint violations surface here, before any event leaves the aggregate.
- Commit. The unit of work walks the tracked aggregates, copies their pending events into one ordered batch, and clears each list so a retry or second flush cannot emit duplicates. Only after the commit succeeds does it move on.
- Publish collected events. An after-commit hook dispatches the batch to in-process handlers. Each handler runs in its own try/catch. Failures are logged and reported, but they are not rethrown.
That last rule matters. The data is already durable. If a handler error turned the response into a 500, the client would believe the command failed and might resubmit it.
type DomainEvent = { type: string; occurredAt: Date; [key: string]: unknown };
type Handler = (e: DomainEvent) => Promise<void>;
// Domain layer: no bus, logger, or DB reference. It only records facts.
abstract class AggregateRoot {
private pending: DomainEvent[] = [];
protected record(e: DomainEvent): void { this.pending.push(e); }
// Return and clear in one step so a second flush cannot emit duplicates.
pullEvents(): DomainEvent[] { const out = this.pending; this.pending = []; return out; }
}
class Order extends AggregateRoot {
constructor(readonly id: string, private status: "placed" | "confirmed", private total: number) { super(); }
confirm(): void {
if (this.status !== "placed") throw new Error(`Order ${this.id} cannot be confirmed`);
this.status = "confirmed";
this.record({ type: "OrderConfirmed", occurredAt: new Date(), orderId: this.id, total: this.total });
}
}
interface Tx { flush(): Promise<void>; commit(): Promise<void>; rollback(): Promise<void>; }
// Application layer: the unit of work already knows every tracked aggregate.
class UnitOfWork {
constructor(private tx: Tx, private tracked: AggregateRoot[],
private handlersFor: (type: string) => Handler[],
private log: { error(msg: string, err: unknown): void }) {}
async complete(): Promise<void> {
let batch: DomainEvent[];
try {
await this.tx.flush(); // constraint errors surface here
batch = this.tracked.flatMap((a) => a.pullEvents()); // one ordered batch, lists cleared
await this.tx.commit(); // events are kept only if this succeeds
} catch (err) { await this.tx.rollback(); throw err; } // command failed: caller sees the error
// After commit: the data is durable, so handler failures must not change the response.
for (const event of batch)
for (const handle of this.handlersFor(event.type))
try { await handle(event); } catch (err) { this.log.error(`Handler failed for ${event.type}`, err); }
}
}
An aggregate records events in a private list. The unit of work flushes, pulls and clears those lists into one batch, and commits. An after-commit loop then runs each handler in its own try/catch, so a handler error is logged and does not fail the response.
Mapping the pattern onto framework transactional event listeners
Most frameworks with transaction management already give you this hook, so you rarely need to build the unit-of-work flush yourself. A transactional event listener is a handler bound to a phase of the surrounding transaction rather than to the moment the event is published. The phases map cleanly onto handler types:
| Phase | When it runs | Fits | Avoid |
|---|---|---|---|
| Before commit | Inside the transaction, just before commit | Validation or derived writes that must be atomic with the command | Network calls, slow work |
| After commit | Once data is durable | Emails, cache invalidation, enqueueing jobs | Assuming the original transaction is usable |
| After rollback | Once the transaction is undone | Compensating logs, metrics on failures | Business side effects |
| After completion | After either outcome | Releasing resources, cleanup | Logic that depends on success |
The after-commit phase causes the most bugs. The transaction is finished, but the connection or session may still be bound to the thread. A write issued there either fails or is silently never committed, depending on the framework. If an after-commit handler persists anything, it must explicitly open a new transaction. In pseudocode, this means on OrderPlaced after_commit: begin_new_transaction { save(Notification(order_id)) }. The new transaction is independent, so its failure cannot undo the order.
Nesting adds ambiguity. When an inner service joins an outer transaction, its listeners usually fire only at the outermost commit. That is correct, but it can surprise you in tests. When no transaction is active at all, some frameworks run the listener immediately, and others drop the event without an error. Check which behaviour yours has before you rely on it.
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
class OrderPlacedListener {
private final NotificationRepository notifications;
OrderPlacedListener(NotificationRepository notifications) {
this.notifications = notifications;
}
// Runs only once the order's transaction has committed. The original
// transaction is finished, so any write here needs its own transaction.
// REQUIRES_NEW opens a fresh, independent one: if this save fails,
// the order is already durable and is not rolled back.
// fallbackExecution defaults to false, so if OrderPlaced is published
// with no active transaction, this listener is skipped without an error.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(OrderPlaced event) {
notifications.save(new Notification(event.orderId()));
}
// Inside the original transaction, before commit: derived writes here
// are atomic with the order. Keep it fast and avoid network calls.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void validate(OrderPlaced event) {
if (event.orderId() == null) {
throw new IllegalStateException("order id missing");
}
}
}
A Spring listener bound to the after-commit phase that opens its own REQUIRES_NEW transaction for its write, alongside a before-commit listener that runs inside the original transaction.
When to use after-commit dispatch and when to avoid it
Before wiring up listeners, sort each handler by what it depends on. If a handler must stay consistent with the change, it does not belong after commit. Updating a second aggregate in the same module, such as reserving stock when an order is placed, is part of the command. Do it inside the same transaction, or call it directly from the command handler. Handlers that only need committed state, such as emails, search projections or cache invalidation, are the right fit for after-commit dispatch.
Sometimes the right choice is no event at all. Within a module, a plain method call is easier to follow, easier to debug, and shows the dependency in the code. Not every state change needs an event either. Publish one when another part of the system has a real reason to react.
| Handler type | Example | Where it runs |
|---|---|---|
| Same-module consistency | Reserve stock for a new order | Same transaction or direct call |
| Committed-state reaction | Send confirmation email | After commit |
| Read-side update | Refresh order summary projection | After commit |
| Speculative, no consumer | "OrderViewed" with no listener | Don't emit |
In-process after-commit dispatch carries its own risks:
- Latency. Synchronous handlers run before the response returns, so a slow mail server slows the request.
- Misleading errors. An exception raised after commit can reach the caller, who sees a failure even though the data was saved.
- Partial completion. A failed handler leaves the system committed but incomplete. Rollback is no longer possible, so you need retries, alerting, or a compensating action.
Closing the crash window with a transactional outbox and idempotent handlers
There is a more basic gap. In-process dispatch keeps pending events only in memory. If the process crashes, is OOM-killed, or is stopped during a deploy after COMMIT but before the handlers run, those events are lost. The order exists, but the confirmation email and stock reservation never happen, and nothing records that they should have.
The transactional outbox fixes this. Serialize each event into an outbox table in the same transaction as the aggregate change. Typical columns are event_id (UUID), aggregate_id, aggregate_seq, event_type, payload, correlation_id, causation_id, occurred_at, and a nullable processed_at. The state change and its events now commit or roll back together.
A relay polls for unprocessed rows, dispatches them, and then sets processed_at. A crash between dispatch and marking causes the event to be sent again. That makes delivery at-least-once, so handlers must tolerate duplicates:
- Processed-events table. Each consumer module keeps a table keyed by
(consumer, event_id). Insert into it in the same transaction as the handler's writes, and skip the event if the key already exists. - Natural idempotency. Upserts keyed on business identifiers, such as "set status to SHIPPED for order 42", give the same result when applied twice.
For ordering, use per-aggregate sequence numbers, or partition the relay by aggregate_id. Strict global ordering forces a single serialized consumer, which limits throughput. Most invariants only need order within one aggregate.
Handlers often emit events of their own. Copy the correlation_id from the incoming event, and set causation_id to that event's event_id. This lets you trace a full cascade from a single request.
-- PostgreSQL. Outbox rows are inserted in the SAME transaction as the aggregate change.
CREATE TABLE outbox (
event_id uuid PRIMARY KEY, -- stable ID, reused on every redelivery
aggregate_id uuid NOT NULL,
aggregate_seq bigint NOT NULL, -- per-aggregate order, not global
event_type text NOT NULL,
payload jsonb NOT NULL,
correlation_id uuid NOT NULL, -- copied from the originating request/event
causation_id uuid, -- event_id of the event that caused this one
occurred_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz, -- NULL until the relay has dispatched it
UNIQUE (aggregate_id, aggregate_seq)
);
-- Partial index keeps polling cheap as processed rows pile up.
CREATE INDEX outbox_pending ON outbox (aggregate_id, aggregate_seq) WHERE processed_at IS NULL;
-- Dedup table owned by each consumer module.
CREATE TABLE processed_events (
consumer text NOT NULL,
event_id uuid NOT NULL,
PRIMARY KEY (consumer, event_id)
);
-- Relay (one worker, or one worker per aggregate_id partition, to preserve order):
BEGIN;
SELECT event_id, event_type, payload, correlation_id FROM outbox
WHERE processed_at IS NULL ORDER BY aggregate_id, aggregate_seq LIMIT 100 FOR UPDATE;
-- application code dispatches each row; a crash before COMMIT means redelivery
UPDATE outbox SET processed_at = now() WHERE event_id = ANY($1::uuid[]);
COMMIT;
-- Idempotent handler in the inventory module: dedup row and side effect commit together.
BEGIN;
INSERT INTO processed_events (consumer, event_id) VALUES ('inventory', $1)
ON CONFLICT DO NOTHING; -- 0 rows affected => duplicate: ROLLBACK and skip
INSERT INTO stock_reservations (order_id, sku, qty) VALUES ($2, $3, $4)
ON CONFLICT (order_id, sku) DO UPDATE SET qty = EXCLUDED.qty; -- upsert is safe to repeat
COMMIT;
A PostgreSQL outbox schema with a polling relay that marks rows processed after dispatch, plus a consumer that deduplicates on (consumer, event_id) and upserts in the same transaction to tolerate at-least-once delivery.
Module boundaries: integration events in a modular monolith
Those cascades stay readable only if each module controls which events leave it. Domain events describe an aggregate's internals: field names, value objects, states that only make sense inside the module. If the billing module subscribes to OrderLineQuantityAdjusted from the ordering module, every refactor of the order aggregate becomes a cross-module change. The two modules are still coupled, just through events instead of method calls.
Publish a separate integration-event contract instead. An after-commit handler inside the owning module translates the domain event into an integration event such as OrderPlaced.v1. That event carries primitive fields, a stable name, an explicit version, and the same event_id and correlation_id. The domain type stays private, and the contract lives in a small shared package that consumers can depend on. Because the contract is the only thing other modules see, you can extract ordering into its own service later by replacing the in-process dispatcher with a broker publisher. Consumers and payloads keep their shape.
| Approach | Coupling | Durability | Operational burden |
|---|---|---|---|
| Direct calls | High: caller knows callee's API and blocks on it | Same transaction or none | Lowest |
| In-process events | Medium: shared process, typed contract | Lost on crash after commit | Low |
| Outbox + in-process relay | Low: contract only | Survives crashes, at-least-once | Moderate: relay, cleanup, idempotency |
| External broker | Lowest: network contract | Durable if outbox feeds it | Highest: infrastructure, monitoring, schema evolution |
Most monoliths should use the outbox with an in-process relay. Adopt a broker only after a module actually needs independent deployment.
from dataclasses import dataclass, asdict
from decimal import Decimal
from uuid import UUID
@dataclass(frozen=True)
class OrderPlacedDomain:
event_id: UUID
correlation_id: UUID
order: "Order" # rich aggregate with value objects and internal state
@dataclass(frozen=True)
class OrderPlacedV1:
event_id: str # same id as the domain event, so consumers can dedupe
correlation_id: str
order_id: str
customer_id: str
total_amount: str # decimal as string: no float rounding, no Money type leak
currency: str
name: str = "ordering.OrderPlaced"
version: int = 1
def translate(evt: OrderPlacedDomain) -> OrderPlacedV1:
o = evt.order
return OrderPlacedV1(
event_id=str(evt.event_id),
correlation_id=str(evt.correlation_id),
order_id=str(o.id),
customer_id=str(o.customer_id),
total_amount=str(o.total.amount.quantize(Decimal("0.01"))),
currency=o.total.currency,
)
def on_order_placed(evt: OrderPlacedDomain, publisher) -> None:
# publisher is in-process today; swap in a broker publisher when ordering
# is extracted. The contract and the consumers do not change.
publisher.publish(translate(evt).name, asdict(translate(evt)))
The ordering module keeps its domain event private and translates it into a versioned, primitive-only OrderPlaced.v1 contract, so the publisher behind it can later change from in-process to a broker without touching consumers.
Alternatives to after-commit dispatch and the outbox
The table above compares coupling and durability for the main dispatch styles. Several other approaches solve parts of the same problem, and each comes with its own trade-offs.
Do the work inside the transaction. If a reaction only writes to the same database, run it in the same transaction as a direct call or a before-commit listener. You get atomicity without events, relays or idempotency keys. The cost is coupling: the caller depends on the callee, and a failure in either aborts both. This option is not available for anything outside the database.
Distributed transactions (two-phase commit). XA-style two-phase commit coordinates a database and another transactional resource, such as a message queue, so that both commit or neither does. It removes the crash window in principle. In practice, many resources you care about do not support it at all, including SMTP servers, most HTTP APIs and many brokers. It also adds a coordinator, holds locks across the prepare phase, and has complicated recovery behaviour when the coordinator fails. For a monolith, the outbox usually gives the same practical guarantee with fewer moving parts.
Change data capture instead of a polling relay. Rather than polling the outbox table, a CDC process reads the database's write-ahead log or replication stream and emits a message for each inserted outbox row. This avoids polling latency and query load on the table, but it requires access to the replication stream and another component to run and monitor. It replaces the relay. It does not replace the outbox table or idempotent handlers. Pointing CDC directly at business tables instead of an outbox exposes your internal schema as the event contract, which brings back the coupling described in the previous section.
Listen-to-yourself publishing. The application publishes to a broker first, then consumes its own message to update the database. Only one write is "primary", so there is no dual-write between database and broker. The trade-off is that the database is no longer immediately consistent with the request, read-your-writes behaviour becomes harder to guarantee, and the broker becomes required infrastructure.
Event sourcing. If the event log is the system of record, appending events is the state change, and there is nothing separate to keep in sync. Subscribers read from the log with stored positions. This removes the dual-write problem entirely, but it is a different persistence model with its own costs: projections, event schema evolution and replay tooling. It is rarely worth adopting only to fix dispatch timing.
Scheduled reconciliation. A periodic job scans for business rows whose follow-up is missing, such as confirmed orders with no notification record, and performs the missing work. This is simple to add to an existing system and catches failures from any cause, including bugs in handlers. However, it detects gaps late, and every new side effect needs its own query. Many teams use it as a safety net alongside after-commit listeners or an outbox, not as a replacement.
Background job queue written in the same transaction. If your stack already has a job queue backed by the same database, enqueueing a job inside the transaction is an outbox under another name. The job row commits atomically with the change, and a worker processes it later. The same rules apply: jobs can run more than once, so they must be idempotent.
Testing the dispatch path and the impact on cost and engineering time
Three tests worth writing
Rollback leaves no trace. Run a use case that raises an event, then force the transaction to fail. Assert that the spy handler never ran and that no outbox row exists. This catches the common regression where someone moves dispatch back inside the transaction.
One commit, one dispatch. Commit successfully and assert that the in-process handler ran exactly once. Check the exact count, not just that it ran. A listener registered twice or a nested unit of work flushing twice both produce duplicates that a simple "was called" assertion will miss.
Crash after commit. Let the transaction commit, then stop the relay before it marks the row published. Restart the relay. Assert that the row is delivered again, but the handler's effect (one email record, one ledger entry) still exists only once. This test proves idempotency instead of assuming it.
What it costs and what it saves
The outbox adds work:
- a table and its migration
- a relay or scheduler
- either polling latency or the operational load of change-data-capture
- retention cleanup for published rows
- idempotency keys in every handler
In return, you stop chasing ghost emails for rolled-back orders and notifications lost in crashes. You also avoid rewriting dispatch when a module is extracted later.
| Side effect | Example | Cheapest adequate option |
|---|---|---|
| Loss is tolerable | Cache invalidation, metrics | After-commit listener |
| Loss is visible to users | Email, webhook | Outbox plus idempotent handler |
| Loss corrupts other data | Billing, cross-module inventory | Outbox, idempotent handler, alerting on relay lag |
Classify each side effect separately. Pay for the outbox only where losing or duplicating that effect costs more than the outbox does.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to domain events after commit, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.