Three dispatch timings for in-process domain events and what survives a crash
An in-process domain event is a plain object raised by an aggregate, such as OrderPlaced, and delivered to handlers inside the same process. The aggregate records that something happened, and handlers own the side effects: emails, projections, and calls into other modules. The aggregate stays free of dependencies on those concerns. The open question is when handlers run relative to the database transaction. There are three common timings:
- Inline in-transaction: handlers run before commit and share the aggregate's transaction.
- After-commit callback: handlers are queued in memory and run once the commit succeeds.
- Outbox: events are written as rows in the same transaction, and a separate worker processes them later.
command ──► [ BEGIN ── load aggregate ── mutate ── (1) inline handlers ── write outbox rows ── COMMIT ] ──► (2) after-commit callbacks
│
└──► (3) outbox worker polls rows, runs handlers, marks done
What survives a crash depends on where the process dies:
| Crash point | Inline | After-commit | Outbox |
|---|---|---|---|
| Before commit | State and effects roll back together | Nothing committed, nothing dispatched | State and event rows roll back together |
| Between commit and dispatch | Not applicable (no gap) | State committed, event lost | Rows persist; worker picks them up |
| During handler | Whole transaction rolls back | Partial effects, no retry | Row stays pending; handler retried |
Only inline and outbox avoid losing events. Inline does it by coupling handler failure to the command. The outbox does it by persisting intent, which means at-least-once delivery, so handlers must be idempotent.
command
▼
┌─────────────── DB transaction ───────────────┐
│ BEGIN │
│ load aggregate ─► mutate ─► raise events │
│ │ │
│ ├─► (1) inline handlers run here │
│ │ (failure rolls back all) │
│ └─► (3) INSERT outbox rows │
│ │ ◄─ crash A: before commit
│ COMMIT │
└──────────────────────┬───────────────────────┘
│ ◄─ crash B: after commit,
│ before dispatch
┌────────────┴────────────┐
▼ ▼
┌───────────────────┐ ┌─────────────────────┐
│ (2) after-commit │ │ outbox table │
│ callbacks │ │ rows durable since │
│ in-memory queue │ │ COMMIT; survive B │
│ runs post-COMMIT │ └──────────┬──────────┘
│ lost if crash B │ │
│ no retry (crash C)│ ▼
└───────────────────┘ ┌─────────────────────┐
│ (3) outbox worker │
│ poll ─► handle ─► │
│ mark done │
│ crash C: row stays │
│ pending, retried │
└─────────────────────┘
Inline handlers run inside the transaction. After-commit callbacks run from an in-memory queue after COMMIT and lose events on a crash. Outbox rows are written atomically with the state and processed later by a worker that retries until it marks them done.
When synchronous in-transaction handlers are the right choice
Coupling handler failure to the command is the point when the handler enforces a rule that must never be observed broken. Consider a ReservationPlaced event in an inventory module. Its handler decrements available stock on a separate StockLevel aggregate and throws if stock would go negative. Both aggregates live in the same module and the same database, so the handler runs inside the command's unit of work. It reads the reservation the command just wrote (read-your-writes), updates stock, and either everything commits or the exception rolls back the reservation too. No reconciliation job is needed, and no reader ever sees a confirmed reservation without stock to back it.
This is a cross-aggregate invariant enforced atomically. An outbox cannot provide that, because the handler runs after the decision is already durable.
The costs show up as the system grows:
- Handler failures fail the command. A bug in a secondary handler, such as one updating a search projection, now rejects valid reservations. Only invariant-enforcing logic belongs inline.
- Transactions get longer. Every handler extends the time row locks are held. A handler that adds one slow query lengthens every command that raises that event.
- Contention and deadlocks increase under load. Handlers touch rows in an order the command author didn't choose. Two commands can end up acquiring the same locks in opposite orders.
- Handler count grows without anyone noticing. Subscribing is a one-line registration, so nobody reviews the cumulative transaction footprint. Add a test that asserts which handlers run inline for each event, so new subscriptions show up in code review.
import sqlite3
class InsufficientStock(Exception):
pass
def on_reservation_placed(conn, event):
# Inline handler: uses the command's connection, so it runs inside the same transaction.
# Read-your-writes: the reservation row the command just inserted is visible here.
(qty,) = conn.execute("SELECT qty FROM reservation WHERE id = ?", (event["reservation_id"],)).fetchone()
cur = conn.execute(
"UPDATE stock_level SET available = available - ? WHERE sku = ? AND available >= ?",
(qty, event["sku"], qty))
if cur.rowcount == 0:
# Raising rolls back the reservation too: no reader ever sees it without stock behind it.
raise InsufficientStock(event["sku"])
# Keep this list short: only invariant-enforcing handlers belong inline.
INLINE_HANDLERS = {"ReservationPlaced": [on_reservation_placed]}
def place_reservation(conn, reservation_id, sku, qty):
with conn: # unit of work: commit on success, rollback on any exception
conn.execute("INSERT INTO reservation (id, sku, qty) VALUES (?, ?, ?)", (reservation_id, sku, qty))
event = {"reservation_id": reservation_id, "sku": sku}
for handler in INLINE_HANDLERS["ReservationPlaced"]:
handler(conn, event)
def test_inline_handlers_are_explicit():
# A new inline subscription fails this test, forcing it into code review.
assert [h.__name__ for h in INLINE_HANDLERS["ReservationPlaced"]] == ["on_reservation_placed"]
if __name__ == "__main__":
conn = sqlite3.connect(":memory:")
conn.executescript("CREATE TABLE reservation (id TEXT PRIMARY KEY, sku TEXT, qty INTEGER);"
"CREATE TABLE stock_level (sku TEXT PRIMARY KEY, available INTEGER);"
"INSERT INTO stock_level VALUES ('SKU-1042', 2);")
try:
place_reservation(conn, "res-001", "SKU-1042", 5) # asks for more than is available
except InsufficientStock:
pass
# The command's own INSERT was rolled back along with the failed stock update.
assert conn.execute("SELECT COUNT(*) FROM reservation").fetchone()[0] == 0
A minimal unit of work where an inline ReservationPlaced handler reads the just-written reservation, enforces non-negative stock, and rolls back the whole command on failure, plus a test that pins which handlers run inline.
Why after-commit callbacks are not a substitute for an outbox
An after-commit hook fixes one problem: it never fires for work that was rolled back. It does nothing about the gap after the commit. The database has made the state change durable, but the event still exists only in process memory. If anything ends the process inside that window, the event is gone and nothing records that it ever existed. That includes an OOM kill, a rolling deploy sending SIGTERM, a pod eviction, or an exception thrown by an earlier hook in the same callback list. No row is left to retry from, and no log line reliably flags the loss.
This looks reliable in development because the window is short and nothing interrupts it. Locally the process stays up, deploys don't overlap with requests, and the dispatcher runs within milliseconds of the commit. In production the loss rate follows deploy frequency and crash rate, not traffic. You get a handful of missing events per week, spread across thousands of successful ones. Each one surfaces later as a customer report that "the email never came" or a ledger that doesn't reconcile. By then nobody connects it to a restart.
Treat after-commit hooks as best-effort. They suit effects where losing the event costs a little latency or a recomputation, never correctness.
- Fine: evicting a cached product page after a price update. If the hook is lost, the entry expires on its TTL and the next read is correct.
- Not fine: emitting
PaymentCapturedto trigger fulfilment. If the hook is lost, the customer has paid and nothing ships. That needs an outbox row written in the same transaction.
from django.core.cache import cache
from django.db import transaction
from shop.models import OutboxEvent, Payment, Product # app models (placeholder)
from shop.bus import publish # in-process dispatcher (placeholder)
def update_price(product_id: int, new_price_cents: int) -> None:
with transaction.atomic():
Product.objects.filter(pk=product_id).update(price_cents=new_price_cents)
# FINE: best-effort. If the process dies before this runs,
# the cache entry expires on its TTL and the next read is correct.
transaction.on_commit(lambda: cache.delete(f"product-page:{product_id}"))
def capture_payment_unsafe(payment: Payment) -> None:
with transaction.atomic():
payment.status = "captured"
payment.save(update_fields=["status"])
# NOT FINE: the commit is durable, but this event lives only in memory.
# A SIGTERM, OOM kill or earlier failing hook loses it with no trace,
# so the customer is charged and fulfilment never starts.
transaction.on_commit(
lambda: publish("PaymentCaptured", {"payment_id": payment.pk})
)
def capture_payment(payment: Payment) -> None:
with transaction.atomic():
payment.status = "captured"
payment.save(update_fields=["status"])
# Outbox row commits atomically with the state change. A relay
# polls unsent rows and publishes them, retrying after a crash.
OutboxEvent.objects.create(
event_type="PaymentCaptured",
payload={"payment_id": payment.pk},
)
Django's transaction.on_commit is acceptable for best-effort cache eviction, but a PaymentCaptured event needs an outbox row written in the same transaction so it survives a crash between commit and dispatch.
How to implement a transactional outbox inside a monolith
Schema and the write path
The outbox table needs these columns:
event_id(UUID)partition_key(usually the aggregate ID)event_typepayload(JSON)status(pending,done,dead)attemptsnext_attempt_atlast_errorcreated_at
The repository inserts the row in the same transaction as the state change, so both commit or neither does.
The poller
Each worker selects pending rows where next_attempt_at <= now(), ordered by insertion, limited to a batch (say 50–200), using FOR UPDATE SKIP LOCKED on databases that support it, such as PostgreSQL and recent MySQL. Concurrent workers then take disjoint rows instead of waiting on each other's locks. Small batches keep lock duration short and keep the cost of a retried batch low.
On failure, increment attempts and push next_attempt_at out exponentially, with jitter. After a fixed limit, mark the row dead and alert on it.
Delivery guarantees and idempotency
A worker can crash after a handler succeeds but before it marks the row done, so delivery is at-least-once. Each handler therefore inserts (event_id, handler_name) into a processed_events table with that pair as the primary key, in the same transaction as its own side effects. If the insert conflicts, the handler skips the event.
Ordering and poison messages
SKIP LOCKED alone breaks per-aggregate order. You can restore it in two ways:
- Hash
partition_keyto a fixed worker slot. - Only claim a row when no earlier pending row exists for the same key.
Either way, different aggregates still process in parallel.
The cost is that a poison event stalls its partition until it is dead-lettered. Decide per event type whether later events wait for manual repair or proceed.
Cleanup
Delete or archive done rows past a retention window, in small batches. On PostgreSQL, time-based table partitioning lets you drop whole partitions instead of deleting row by row.
-- PostgreSQL. id gives insertion order; event_id is the idempotency key.
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id uuid NOT NULL UNIQUE,
partition_key text NOT NULL, -- usually the aggregate ID
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending | done | dead
attempts int NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX outbox_pending ON outbox (partition_key, id) WHERE status = 'pending';
CREATE TABLE processed_events (
event_id uuid NOT NULL,
handler_name text NOT NULL,
PRIMARY KEY (event_id, handler_name)
);
-- Claim a batch: skip rows locked by other workers, and only take a row
-- if no older pending row exists for the same aggregate (per-key order).
SELECT o.* FROM outbox o
WHERE o.status = 'pending' AND o.next_attempt_at <= now()
AND NOT EXISTS (SELECT 1 FROM outbox e
WHERE e.partition_key = o.partition_key
AND e.status = 'pending' AND e.id < o.id)
ORDER BY o.id LIMIT 100
FOR UPDATE SKIP LOCKED;
-- Handler, inside its own side-effect transaction: zero rows returned = already handled, skip.
INSERT INTO processed_events (event_id, handler_name) VALUES ($1, 'send_invoice_email')
ON CONFLICT DO NOTHING RETURNING event_id;
-- On failure: exponential backoff with jitter (capped at 1h), dead-letter after 10 attempts.
UPDATE outbox SET attempts = attempts + 1, last_error = $2,
next_attempt_at = now() + least(interval '1 hour', interval '1 second' * power(2, attempts)) * (0.5 + random()),
status = CASE WHEN attempts + 1 >= 10 THEN 'dead' ELSE 'pending' END
WHERE event_id = $1;
A PostgreSQL outbox schema with a processed-events table, a SKIP LOCKED batch claim that preserves per-aggregate order, an idempotency guard for handlers, and a retry update with jittered exponential backoff and dead-lettering.
Choosing per handler, and the rules for modular monolith events
The decision is made per handler, not per event. One OrderPlaced event can have a synchronous handler that decrements stock and an outbox-backed handler that sends the confirmation email. Ask two questions about each handler. Does it write only to the same database? Must its effect commit or roll back together with the command? If both answers are yes, run it synchronously. If it calls an external system, sends email, or crosses a module boundary, route it through the outbox.
| Handler characteristic | Dispatch path | Reason |
|---|---|---|
| Same database, must be atomic with the command | Synchronous, in-transaction | One commit, no partial state |
| Calls external API, broker, or sends email | Outbox | Side effect cannot roll back; needs retries |
| Consumer lives in another module | Outbox | Keeps modules decoupled in time and failure |
| Slow or failure-prone work | Outbox | Keeps it off the request's latency and error path |
This matters most for teams whose handler count is growing, for modular monoliths, and for teams that expect to extract services later.
Cross-module events are contracts
Treat events that leave a module as public API. Give each one an explicit schema and a version field, and keep it separate from internal entity types. When the consuming module already reads from the outbox, extracting it into a service only changes the transport, from a polling dispatcher to a broker. The semantics stay the same.
Do not mix the two models without a written convention. Without one, two handlers on the same event can end up with different guarantees, and nobody notices until an incident exposes it. Make the dispatch path visible at registration, and check it in code review.
Alternatives to consider
Events are not the only way to decouple a side effect from the code that triggers it, and sometimes they are the wrong tool.
Direct method calls. If one module always needs another to act, and that will not change, a plain call is easier to read and debug than an event. The cost is coupling: the caller must know the callee exists. This is a reasonable choice when there is exactly one consumer and the dependency direction is already correct.
Writing directly to an external broker. Publishing to a broker inside the request, next to a database commit, is a dual write. The two systems cannot commit atomically, so a crash between them leaves one write without the other. Retries do not fix this. They only change which inconsistency you get.
Change data capture as the relay. You can keep the outbox table and replace the polling relay with a reader that tails the database's replication log. This lowers relay latency and removes the polling queries. It adds an operational component, and that component depends on log retention and on the shape of your schema.
Everything synchronous. If every side effect is cheap, local and allowed to fail the request, keep them all in the transaction. Throughput drops and latency grows, but nothing can drift out of sync.
| Approach | Atomic with state change | Coupling | Operational cost |
|---|---|---|---|
| Direct call | Yes | High | None |
| Direct broker write | No (dual write) | Low | Broker |
| Outbox + CDC relay | Yes, eventual delivery | Low | Log reader |
| All synchronous | Yes | Medium | None |
Impact on cost, engineering time, testing and observability
What you pay
Every event adds an insert inside the business transaction, plus an update or delete once it is processed. You also own a background worker that must be deployed, scaled and restarted, and its polling queries run against the primary. Eventual consistency becomes visible to users: an order saves, but the confirmation email or search index lags behind. Tests that assert immediately after a command start failing intermittently. The hardest migration step is not the table. It is retrofitting idempotency onto handlers that send emails or call payment providers, because at-least-once delivery will eventually run them twice.
What you get back
You spend less time investigating events that never fired. A failing downstream handler no longer rolls back the command that triggered it. Extracting a module later means pointing a relay at a broker instead of untangling direct calls.
Testing and monitoring
Test synchronous handlers inside the unit-of-work boundary. Run the command, force a rollback, and assert that nothing from the handler persisted. Test outbox handlers by delivering the same event twice and asserting exactly one side effect. Monitor outbox lag and the age of the oldest unprocessed row. A rising oldest-row age reveals a stuck worker even when average throughput looks healthy.
Illustrative estimate (assumed numbers, not a benchmark).
- Extra writes per day = commands/day × events per command × 2 (insert + mark processed). With 200,000 commands and 1.5 events each: 200,000 × 1.5 × 2 = 600,000.
- Polling queries per day = workers × 86,400 ÷ poll interval in seconds. With 2 workers polling every 0.5 s: 2 × 86,400 ÷ 0.5 = 345,600. Most of these queries return no rows, so index the unprocessed-row predicate.
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 in-process domain events monolith, 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.