When an aggregate raises a domain event and an in-process listener reacts to it by modifying a different aggregate, should that listener's change happen in the same database transaction as the original aggregate's change? What are the consistency implications either way?
answer
- one aggregate per transaction rule
- same-tx listener = strong consistency but coupling
- after-commit listener = eventual consistency, less coupling
- listener failure after first commit needs retry/compensation
- AFTER_COMMIT phase example
basics
~20 sYou can make the listener run in the same transaction so both aggregates change together, or let it run after the transaction commits so they change separately. Same-transaction means both always agree, but couples them tightly. After-commit means they're looser, but for a moment one might be out of date.
solid answer
~50 sTwo options exist. Running the listener synchronously inside the same transaction as the aggregate that raised the event gives strong consistency - both aggregates change atomically, or neither does - but couples the two aggregates through a single transaction, which DDD's aggregate-boundary rule generally advises against: each aggregate should be its own transactional/consistency boundary. Deferring the listener to run after the transaction commits preserves aggregate independence and avoids growing transaction scope, at the cost of a brief window where the second aggregate hasn't caught up, plus the operational question of what happens if the after-commit step fails - the first aggregate's change is already durable and can't be rolled back to match. The DDD convention is: one aggregate per transaction, and cross-aggregate effects triggered by domain events are expected to be eventually consistent, even when 'eventually' is only milliseconds later within the same process.
go deeper
Should understand that changing two aggregates 'at once' isn't automatically safe or automatically consistent - it's a design decision.
Should be able to state the one-aggregate-per-transaction convention and recognize when a listener is violating it.
Should weigh same-transaction versus after-commit dispatch deliberately for a given scenario, and design the after-commit path to be retryable/idempotent.
Should set the architectural default for the codebase and design the reconciliation/compensation strategy for when a downstream listener transaction fails.
## The two rules in tension This question sits at the intersection of two DDD tactical rules: 1. an aggregate should be the sole unit of consistency and transactional boundary in your model; 2. domain events are the primary mechanism for one aggregate to trigger effects on another without directly referencing it. The tension is that if a domain event's handler runs inside the same transaction as the aggregate that raised it and also modifies a second aggregate, you've made two aggregates **transactionally consistent with each other** — exactly what the one-aggregate-per-transaction rule advises against, because it grows lock scope, increases contention, and makes each aggregate's persistence depend on another's. ## The two choices, side by side | | Listener in the same transaction | Listener after commit | |---|---|---| | Consistency | strong (ACID) consistency between order placement and stock decrement | eventually consistent across those two aggregates, even within one process | | Boundary | grows lock scope, increases contention | keeps each aggregate's persistence in its own transaction | ## Same transaction: atomic, and coupled Concretely: suppose an `Order` aggregate raises `OrderPlaced`, and a listener decrements an `Inventory` aggregate's available stock in response. If that listener runs inside the same database transaction as the order's save, both changes commit or roll back together — strong (ACID) consistency between order placement and stock decrement, so you can never observe an order that was placed but didn't decrement stock, or vice versa. This is attractive when the two facts truly must never diverge, e.g. a financial ledger debit and credit that must always balance. The cost is **coupling**: - the transaction now touches two aggregates' rows, so whatever locks each aggregate's persistence uses now contend with each other, and a spike in contention on `Inventory` rows can slow down or fail Order-placement transactions that have nothing to do with inventory conceptually; - it also means the Order use case's failure modes now include everything that can go wrong while persisting `Inventory`; - as more aggregates get pulled into the same transaction this way, the system drifts toward one where "the transaction" spans most of the domain model, defeating the purpose of drawing aggregate boundaries at all. ## After commit: independent, and eventually consistent The alternative — dispatching the listener after the originating transaction commits, but still in-process (e.g. Spring's `@TransactionalEventListener(phase = AFTER_COMMIT)`) — keeps each aggregate's persistence in its own transaction. The `Order` transaction commits on its own; only once that's durable does the Inventory-decrementing listener run, in a second, separate transaction. This preserves the aggregate-per-transaction rule and avoids the lock-contention coupling above, but introduces a real **consistency gap**: between the Order transaction committing and the Inventory listener's transaction committing, a reader could observe an order that's Placed but inventory not yet decremented. For most systems this window is milliseconds and harmless, but it's a genuine trade-off — the system is now eventually consistent across those two aggregates, even within one process. ## When the second transaction fails The sharper failure mode is what happens when the second transaction fails after the first has already committed. The `Order` is durably Placed; the `Inventory` decrement throws a constraint violation or times out. Because the first transaction already committed, there's no automatic rollback available — recovering requires either: - a retry mechanism (ideally idempotent), or - a compensating action (cancel the order, or flag it for manual reconciliation). Systems that do this well build the listener to be idempotent and retryable, and often use a durable queue or outbox-adjacent mechanism to guarantee the listener eventually runs even across a process crash between commit and dispatch — though guaranteed delivery across process boundaries belongs to the event-delivery/broker layer, not the domain-modeling decision itself. ## The rule of thumb A well-known real-world instance of choosing the after-commit path deliberately is Spring's `@TransactionalEventListener` with `AFTER_COMMIT` phase, widely used in DDD-flavored Spring Boot codebases specifically to keep an aggregate's event listeners from ever running against data that later got rolled back, while still keeping each aggregate's own save in its own transaction. The practical rule of thumb: keep each aggregate's own invariants and persistence inside one transaction; treat any effect on a second aggregate, triggered by an event the first one raised, as inherently eventually consistent, and design that second step to be safely retryable rather than assuming it will always succeed in lockstep with the first.
- What specifically goes wrong if you keep growing a single transaction to include more and more aggregates triggered by cascading domain events?Lock scope and contention grow, since the transaction holds locks across every touched aggregate's rows until it commits, so unrelated operations on any of those aggregates start blocking on each other. It also means a failure anywhere in the cascade rolls back everything, so an unrelated aggregate's transient issue can abort an operation that conceptually had nothing to do with it.
- If a listener runs after the originating transaction commits and then itself fails, what are the realistic recovery options?Retry the listener's transaction, ideally built idempotently so re-running it after a partial failure doesn't double-apply the effect, until it succeeds. If retries are exhausted or the failure is not transient, the alternative is a compensating action against the first aggregate, since the first transaction already committed and can't be rolled back after the fact.
- Does choosing after-commit dispatch mean you're doing event sourcing or using a message broker?No - after-commit dispatch is purely about when, within the same process, an in-process listener runs relative to the originating transaction; it says nothing about how state is persisted or how events are transported to other services. You can use after-commit in-process dispatch with a completely ordinary CRUD-style aggregate and no message broker involved at all.
It's like two separate bank tellers processing your deposit and your loan payment at two different windows - you could force them to work in lockstep at one window so both always finish together, but normally each teller finalizes their own transaction independently, and if the second one hits a snag after the first already succeeded, the bank reconciles it afterward rather than undoing the first.
saying these in an interview costs you the question
- Assumes cross-aggregate listeners should always run in the same transaction as the originating change
- Doesn't recognize that after-commit dispatch means eventual, not immediate, consistency between the two aggregates
- Has no plan for what happens if the second transaction fails after the first already committed
- Conflates 'domain event consistency boundary' with event sourcing or message brokers
- Grows a single transaction across many aggregates without noticing the lock-contention cost