Architecture Decision Record
ADR-003: Outbox Pattern with Dual Dispatch
Status
Accepted. Revised 2026-07-19 (integration-event routing via IMessageBus, lease-based claims for
safe scale-out, dead-letter visibility, post-commit dispatch; see Revision below).
Context
Domain events must be reliably published after aggregate changes are persisted. Two failure modes exist:
- In-process dispatch fails (e.g., handler throws) — the event is lost if not persisted.
- Process crashes between persistence and dispatch — the event is lost if only dispatched in-memory.
Decision
Use a dual-dispatch strategy:
- Outbox persistence: Domain events are serialized into
OutboxMessagerows within the same database transaction as the aggregate changes. This guarantees at-least-once persistence. - In-process dispatch: After
SaveChangesAsync, events are dispatched immediately in-process viaDomainEventDispatcherfor low-latency handling. - Background processor:
OutboxProcessor(aBackgroundService) wakes on an in-memory signal when new entries are written, or after a fallback polling interval (Outbox:PollingIntervalSeconds, default 2s; ADC prod sets 300s). Entries become eligibleOutbox:ProcessingDelaySecondsafter creation (default 5s); when a cycle sees pending-but-not-yet-eligible entries it smart-waits only until the earliest becomes eligible instead of sleeping the full interval. Eligible entries that throw during dispatch are retried up to 5 times, then dropped from the eligible set (a message whose event type cannot be resolved is dead-lettered immediately on first pickup; see Trade-offs).
Rationale
- Guaranteed delivery: The outbox table is written atomically with the aggregate changes. Even if the process crashes after persistence, the background processor catches up.
- Low latency: In-process dispatch handles the happy path without polling delay. In broker mode (
BrokerEventBuspersists the event to the outbox + signals;OutboxProcessorthen publishes it to the broker viaIMessageBus/BrokerMessageBus), the signal plus smart wait deliver integration events ~`ProcessingDelaySeconds` after publish even when the fallback interval is minutes long. - Idempotent handlers: Domain event handlers must be idempotent since the same event may be dispatched both in-process and by the background processor if the in-process mark-as-processed fails.
- Processing delay: The eligibility delay prevents the background processor from re-dispatching events that were already dispatched in-process but not yet marked as processed. It bounds the duplicate-dispatch window — the in-process pipeline (save → dispatch → mark processed) must finish within it, or the event is re-dispatched (idempotency absorbs this).
- Cheap idle polling: A long fallback interval in deployed environments cuts idle DB chatter and its telemetry; additionally, the poll query runs inside an
OutboxPollactivity thatOutboxPollFilterProcessor(MMCA.Common.Aspire) suppresses from telemetry export, so idle polls do not flood Application Insights ingestion.
Trade-offs
- Domain event handlers must be idempotent (this is a good practice regardless).
- The outbox table grows until processed entries are cleaned up —
OutboxCleanupServicepurges rows whoseProcessedOnis older thanOutbox:RetentionDays(default 7; set0to disable). See ADR-005. - Two distinct failure mechanisms exist. A message whose event type cannot be resolved is
dead-lettered immediately on first pickup (it can never succeed) and requires manual investigation.
A message that throws during dispatch is retried up to
Outbox:MaxRetries(default 5) times, then dropped from the eligible set (it stops being polled onceRetryCount >= MaxRetries). - Failed-message retries pace at the polling interval: with a 300s prod interval, a persistently failing message dead-letters after ~25 minutes instead of seconds (an intentional, healthier backoff).
- Rows orphaned by a process crash (no signal exists) wait up to the polling interval before the safety-net pickup.
Revision (2026-07-19)
Four changes from the 2026-07-19 full review:
- Integration events route through the outbox to
IMessageBus, never local dispatch. AnIIntegrationEventraised viaAddDomainEventused to be dispatched in-process and marked processed, silently never reaching the wire in broker mode. NowDomainEventSaveChangesInterceptorwrites its outbox row but does NOT dispatch it in-process; the row stays unprocessed andOutboxProcessorpublishes it viaIMessageBus, so the registered transport (in-process for the monolith, MassTransit broker for extracted services) determines delivery.AddDomainEvent(integrationEvent)is therefore broker-correct. Pure domain events keep the dual-dispatch fast path described above. - Lease-based claims make scale-out safe by construction.
OutboxMessagegainsLockedUntilandLockToken: before dispatching, a processor replica claims the eligible batch with an atomicExecuteUpdateAsynclease (Outbox:LeaseSeconds, default 300); other replicas skip rows under an unexpired lease and a race between two claim updates resolves per row (each replica processes only rows carrying its own token). A replica that dies mid-batch releases its rows implicitly when the lease expires. RunningminReplicas: 1is therefore no longer a correctness requirement for the outbox (previously two replicas could drain the same rows and double-dispatch every event); it remains a cost choice, and ADR-030's sole-migrator rationale for the setting stands on its own. - Dead-letter visibility. Retry exhaustion is now loud: the
outbox.dead_letter.countmetric gets areason=retries_exhaustedtag (beside the existingtype_unresolvable), an Error-level log fires at the moment of exhaustion (the operator's last signal before the row leaves the poll), andOutbox:DeadLetterRetentionDaysretains dead-lettered payloads longer thanOutbox:RetentionDays(0 = same retention) for diagnosis and manual replay beforeOutboxCleanupServicepurges them. - In-process dispatch defers until after commit. When the save runs inside a transaction (the ADR-014 Transactional path), the post-save dispatch/mark-processed work is deferred and flushed only after a successful commit; rollback (exception or the new business-failure rollback, ADR-014 Revision) drops it together with the outbox rows.