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). Revised
2026-08-01 (explicit exponential retry backoff supersedes polling-interval pacing; see Revision
below). Revised 2026-08-07 (random jitter on the retry backoff; see Revision below). Revised
2026-08-26 (opt-in per-key ordered dispatch, dead letters on their own retention window and
replayable, a backlog-age gauge, one transient retry before an unresolvable type is dead-lettered,
and the consumer-side inbox on by default for broker transports; see Revision below). Amended by
ADR-100 (2026-08-29, v1.170.0): the outbox is no
longer unconditional. MessageBus:EnableOutbox is bool? and resolves from the transport exactly as
the inbox does, ON for a broker and OFF for InProcess, so a single-process host dispatches events
directly and runs neither background service; a broker with the outbox explicitly disabled is refused
at startup; and the OutboxMessages table stays mapped either way, so the flag is never a migration.
Everything below describes the outbox a host that runs it gets, unchanged.
Revised 2026-09-07 (shared broker and cache resources are namespaced per application by default,
so an unset MessageBus:EndpointPrefix now yields prefixed queue names unless
MessageBus:PreserveDefaultEndpointNames is true:
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:75-83).
Extended by ADR-114 (2026-09-09): a second
per-source table, InternalCommands, borrows this record's claim-lease, jittered-backoff and
dead-letter idiom to carry instructions rather than events. The outbox itself is unchanged, and the
two processors share one polling core: both delegate the main loop with its smart wait, the
per-source drain and the jittered retry backoff to the internal static PollingLoop
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Polling/PollingLoop.cs:5-12), while
each keeps its own queries, logging, metrics and activities.
Revised 2026-09-11 (an outbox row now captures the ambient request context when it is written,
TenantId, UserId, UserRoles and CorrelationId, and the processor restores that context onto
a fresh per-row scope before each row is dispatched; a consumer with a relational outbox source adds
one expand-only migration; see the Revision (2026-09-11) at the end).
Revised 2026-10-01 (domain event handlers do not flush a unit of work themselves, enforced by a
transitive fitness rule; see Revision below).
Revised 2026-10-07: each row's lease is renewed before dispatch and its outcome written as soon as
it finishes by a lock-token-guarded update (no batch save), and async-path local domain-event rows
are inserted already leased, so the lease, not the processing delay, bounds duplicate dispatch.
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 retried once before being dead-lettered; see the Revision (2026-08-26)). - Handlers do not save: by default a domain event handler does not flush a unit of work itself. It mutates state and the owning save persists it; a write that must happen independently goes on the outbox. The transitive IL call-graph fitness rule
DomainEventHandlersDoNotSave(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.DomainEventHandlerSaves.cs:76) enforces it, run throughDomainEventHandlerSaveTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/DomainEventHandlerSaveTestsBase.cs:22); a consumer names each accepted exception in its allowlist (see the Revision (2026-10-01)).
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 holds a fresh row back from the background processor for
Outbox:ProcessingDelaySeconds. On the async save path it is not what bounds the duplicate-dispatch window: local domain-event rows are inserted already leased forOutbox:LeaseSeconds(MMCA.Common/.../Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:96-98, lease applied at:305-316), so no poller claims them while the in-process pipeline (save -> dispatch -> mark processed) runs. A failed dispatch releases that lease under its token (:387-390,:408-422;MMCA.Common/.../Outbox/Processing/OutboxFinalizer.cs:67), so the processor retries once the processing delay has elapsed rather than after the full lease. The release is best effort: if it throws, the failure is logged and the rows wait out the fullOutbox:LeaseSeconds(DomainEventSaveChangesInterceptor.cs:403-405,:418-421). The sync path writes no lease and never dispatches in-process, so its rows belong to the processor from the start (DomainEventSaveChangesInterceptor.cs:108-111). A dispatch that outlives the lease can still be 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
retried once (the first unresolvable attempt is treated as transient; see the Revision
(2026-08-26)) and then dead-lettered, which 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 are paced by an explicit exponential backoff, not by the polling interval, and the backoff is randomized. A failure re-leases its own row for
Outbox:RetryBackoffBaseSeconds * 2^(n-1)seconds multiplied by a random jitter factor in[0.8, 1.2], capped atOutbox:LeaseSeconds(the re-lease atMMCA.Common/.../Outbox/Processing/OutboxProcessor.cs:543-544;ComputeRetryBackoffSecondsat:858-859delegates to the jitter-then-cap formula inPollingLoop.ComputeRetryBackoffSecondsatMMCA.Common/.../Persistence/Polling/PollingLoop.cs:183-197, jitter at:193, cap at:196). At the shipped defaults (base 10s,MaxRetries5, lease 300s, batch 50:MMCA.Common/.../Outbox/Administration/OutboxSettings.cs:17,21,84,101) the four waits between the five attempts are ranges rather than fixed values: about 8-12s, 16-24s, 32-48s and 64-96s. A persistently failing message therefore spends about 150 seconds of backoff (2.5 minutes), 120s to 180s across the jitter range, before the fifth failure dead-letters it, and the 300s cap never binds at those defaults (the longest jittered wait tops out near 96s; only a sixth attempt, nominally 320s, could reach the cap). - That backoff total is a floor, not a schedule. A backoff that expires between cycles is only noticed when the processor next wakes, and a failed-but-eligible row never shortens the wait (the next-cycle wait is computed only from the not-yet-eligible remainder:
OutboxProcessor.cs:202-210), so the wall-clock horizon is the floor plus poll granularity at the 2s default interval, and up to one fallback interval per retry (about 20 minutes at the 300s prod interval) when no new write signals the loop sooner. A batch that dispatched nothing also does not re-poll immediately (HasMoreEligibleWorkrequires progress:OutboxProcessor.cs:242, reasoning at:235-239), so a batch of 50 that fails in full cannot hot-spin the processor. With progress, the loop re-polls at once both for a full eligible batch and for key-mates the cycle deferred behind their key's head row, so an ordering key is not serialized at one row per poll (:242). - Rows orphaned by a process crash (no signal exists) wait up to the polling interval before the safety-net pickup. A local domain-event row written on the async path first waits out the lease it was inserted under (
Outbox:LeaseSeconds, default 300s:OutboxSettings.cs:79-84), because nothing released it (DomainEventSaveChangesInterceptor.cs:20-23).
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.
Revision (2026-07-24)
Three capture-side corrections found in a code review. None change the dual-dispatch decision; they close gaps between what it promised and what the interceptor did.
- Capture removes exactly what it captured. The post-dispatch cleanup used to clear an
aggregate's event list wholesale, which also discarded anything a handler raised on that same
aggregate during in-process dispatch: those events arrive after the capture and were wiped
before any later capture could see them, so they never dispatched and never reached the outbox.
Capture now snapshots each aggregate's events and removes only those (
IAggregateRoot.RemoveDomainEvents), leaving a handler-raised event pending for the next save. - A retried operation writes one row per event.
ExecuteInTransactionAsyncruns under an EF execution strategy that re-runs the whole delegate on a transient fault, against the same cachedDbContextinstances. Because capture runs on everySavingChangespass while the aggregate's events are only cleared after a successful save, each attempt appended another outbox row per event: one transient SQL failure published every integration event twice. Capture now discards an abandoned capture's staged rows, and the retry path clears the change tracker first so entities added by the failed attempt are not inserted again either. - Shutdown does not consume retries. The dispatch loop's general
catchalso caught the cancellation raised at host shutdown, incrementingRetryCountand stampingLastErroron the whole remainder of the batch. A graceful restart could therefore dead-letter messages that were never actually attempted. Cancellation now rethrows and the batch is left untouched.
Revision (2026-08-01)
One retry-pacing correction. The dual-dispatch decision is unchanged; the Trade-offs above described a cadence the processor no longer has.
- Retry backoff is explicit, and it supersedes polling-interval pacing. Before
Outbox:RetryBackoffBaseSecondsexisted, a failed row simply kept the claim its cycle had taken, and because the poll skips leased rows the next attempt could not happen until the FULLOutbox:LeaseSeconds(300s) elapsed, whatever the polling interval or an explicit signal said: the retry cadence was an accident of the lease rather than a decision (MMCA.Common/.../Settings/OutboxSettings.cs:89-96). A failure now re-leases its row forRetryBackoffBaseSeconds * 2^(n-1)seconds, capped at the lease so a permanently failing message never holds a claim longer than a dead replica's rows would (MMCA.Common/.../Outbox/OutboxProcessor.cs:644-645, formula at:734-748). At the shipped defaults that was 10s, 20s, 40s and 80s between the five attempts: 150 seconds of enforced backoff before dead-lettering, shortening the first retries while still throttling a message that will never succeed. Those four waits are no longer exact values; the jitter added on 2026-08-07 (see the Revision below) spreads each of them by plus or minus 20%. The Trade-offs bullet that quoted "~25 minutes" at a 300s interval is replaced by the curve plus its wake-cadence ceiling above.
Revision (2026-08-07)
One retry-pacing refinement. The decision and the curve are unchanged; the waits are no longer identical across a batch.
- The retry backoff carries random jitter. The exponential wait is multiplied by a random
factor in
[0.8, 1.2]before the lease cap is applied, so the four waits between the five attempts are about 8-12s, 16-24s, 32-48s and 64-96s at the shipped defaults instead of exactly 10s, 20s, 40s and 80s (MMCA.Common/.../Outbox/OutboxProcessor.cs:744, cap applied at:747). The reason is the failure mode the backoff alone does not cover: one dependency outage fails all 50 rows of a batch in the same instant, and a deterministic curve then retries all 50 on a single shared schedule, re-hammering that dependency in synchronized bursts. Jitter spreads the attempts apart. The jitter is applied before the cap so a capped backoff still lands exactly on the lease bound, and the generator is deliberately pseudorandom: it spaces retries and feeds no security decision. The practical consequence for operators is that "150 seconds before dead-lettering" is now an expectation (120s to 180s), not a guarantee.
Revision (2026-08-26)
Five changes. The dual-dispatch decision is unchanged; what changes is what happens to a message that must not overtake its predecessor, to one that never made it out, and to the duplicate a broker hands a consumer twice.
- Ordered delivery per key, opt-in, with head-of-line semantics. An event that implements
IHasOrderingKey(one member,string? OrderingKey, typically the aggregate id:MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IHasOrderingKey.cs:24-31) has that value copied onto its outbox row (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:79, copied at:116), and the processor refuses to claim a keyed row while an EARLIER unprocessed, non-dead-lettered row carries the same key. The guard lives in the claim itself, as a correlatedNOT EXISTSinside the sameExecuteUpdateAsyncthat takes the lease (.../Outbox/Processing/OutboxProcessor.cs:546-556, applied at:475-483), so a second replica racing the same key loses on the row rather than on a check it made before the race started (:538-542); ordering therefore holds across batches and across scaled-out replicas, not merely within one batch (:439-442). Within a cycle, the candidate set keeps at most one row per key, so a single batch never dispatches two events of one key in parallel (:509-525, reasoning at:502-508). This is head-of-line blocking by design: a keyed row that is failing and backing off blocks every later row with the same key, which is why keys must be as narrow as the ordering requirement really is (one key per aggregate serializes that aggregate; a constant key serializes the whole outbox:IHasOrderingKey.cs:15-22). A row that exhausts its retries stops blocking, so a poison event cannot freeze its key forever (OutboxProcessor.cs:446-447, theRetryCountterm of the predicate at:555). Two costs are recorded in code: a batch containing no keyed row runs exactly the query it always ran, so hosts that never declare a key pay nothing, not even a subquery the optimizer has to prove away (:470-475); and the predecessor test is onOccurredOnalone, so two rows sharing a key and an exact timestamp do not block each other in SQL (:448-452,:556). A tie at tick resolution is not an ordering the outbox claims to observe. - A dead letter is evidence, so it gets its own retention window and a way back.
OutboxCleanupServicesweeps dead-lettered rows onOutbox:DeadLetterRetentionDays, falling back toOutbox:RetentionDayswhen it is0, and keys the cutoff onOccurredOnbecause a dead letter never gets aProcessedOn(.../Outbox/OutboxCleanupService.cs:158-169, rationale at:140-151;.../Settings/OutboxSettings.cs:108, contract at:101-106). Setting that window wider thanRetentionDaysis what keeps an undelivered payload around long enough to diagnose. Deletion is the one cleanup action that cannot be undone, so every sweep that removes rows logs one Warning per source naming the count (OutboxCleanupService.cs:171-174, message at:226). The way back isIOutboxAdministration(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IOutboxAdministration.cs:16), an operator surface a host exposes from an admin endpoint, a support command or a scheduled job (:5-14), registered scoped by the framework (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:202-203):ListDeadLettersAsync(dataSource, skip, take)pages them oldest first across every outbox source the host owns or one named source (IOutboxAdministration.cs:30-34, contract at:18-29);ReplayDeadLettersAsync(dataSource, ids)returns them to the pending pool, withRetryCountto zero and the lease cleared,LastErrordeliberately KEPT because the reason is the first thing anyone asks after a replay, andOccurredOnuntouched so a replayed row keeps its place in its ordering key (:51-54, reasoning at:36-40); andCountPendingAsync(dataSource)counts the tables at the moment of the call, including rows under a claim lease, which is the difference between it and theoutbox.pending.depthgauge (:65, contrast drawn at:56-61). Every method returnsResult(ADR-013): an unreachable or unknown source is a failure an operator screen renders, not an exception (:11-13). The projectedOutboxDeadLetterrecord deliberately omits the event payload, which can carry personal data (ADR-005) and answers nothing a replay decision depends on (:80-87, stated at:68-72). - A new gauge reports how late the backlog already is.
outbox.oldest_pending.age(seconds, taggeddata_source, on the existingMMCA.Common.Outboxmeter:.../Outbox/OutboxMetrics.cs:98-102, meter name at:19, published per cycle at:115-116) is the age of the oldest row still awaiting dispatch, observed per source at the start of its most recent cycle. Whereoutbox.dispatch.lag(:57-60) reports how late the messages that DID arrive were, this one reports how late a stuck backlog is while it is still stuck, which is what an alert on a wedged outbox fires on (:81-86). It costs nothing extra: the poll already fetches pending rows ordered byOccurredOn, so its first row IS the minimum and noMIN()query is ever issued (:87-90). It reuses the poll's predicate, so rows under another replica's lease and dead letters are excluded, which makes it deliverable backlog rather than table age (:90-94); a source with nothing pending reports0rather than dropping out of the series, and a source whose database was unreachable keeps its previous value until its next successful cycle (:94-97). - A stored event identity that survives refactoring is declared, not repaired afterwards.
[EventName("Sales.OrderPlaced.v1")]on the event class is the one stable-identity mechanism (MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/EventNameAttribute.cs:31-32, contract at:3-12): the outbox row stores that name in place of the assembly-qualified type name (.../Outbox/EventNameResolver.cs:47-51, written at.../Outbox/OutboxMessage.cs:107), so a rename, a namespace move and an assembly move all leave the rows already written still resolvable. Resolution reads the stored name alone, CLR name first and the attribute scan only when that misses, with the result cached per stored name (OutboxMessage.cs:147-153, scan atEventNameResolver.cs:75-81). The trade-off is that the attribute only ever changes what NEW rows store, so it has to be applied BEFORE the refactoring: an event renamed without one orphans every row already written under its old CLR name, and those rows dead-letter (EventNameAttribute.cs:14-19). Adopting it while the outbox holds pending rows is therefore a two-step move: drain first, then rename. Beside it, the first unresolvable attempt is treated as transient and retried through the normal backoff, because the assembly declaring the type may simply not be loaded yet (a lazily resolved module assembly, a host still coming up) and a name that resolves one cycle later was never a dead letter (.../Outbox/OutboxProcessor.cs:701-714, reasoning at:689-695). Only the second attempt is terminal (:716-722), which is also the point at which the operator has had a Warning naming the row (:714, message at:826). A host that setOutbox:MaxRetriesto 1 asked for no retries at all and gets none (:707-711). A payload whose fields changed shape is a different problem: it needs a new event type and an upcaster (ADR-090). - The consumer-side inbox is on by default wherever redelivery is possible.
MessageBus:EnableInboxis now three-valued (.../Settings/MessageBusSettings.cs:117): an explicit setting wins in both directions (:111), and left unset it resolves ON for a broker provider and OFF for the in-process one, which has no redelivery to dedup (:102, reasoning at:103-108). Broker delivery is at-least-once by contract, so with the inbox off every redelivery became a duplicate side effect unless each handler happened to be idempotent on its own (:103-107); a host that must not query the table yet setsfalseexplicitly and still gets its one startup Warning (.../Persistence/Inbox/InboxDisabledWarningService.cs:13-16, message at:35). The inbox row also stops being a separate write:TryBeginAsyncSTAGES it in the scope's unit of work unsaved (.../Persistence/Inbox/EfInboxStore.cs:61-68), so a handler that saves on that same scope commits the row in the same transaction as its own mutations, and the window where a crash between "handler committed" and "inbox written" reprocessed the whole event is closed by construction rather than by asking every handler to be idempotent (:16-22;CompleteAsyncwrites only if nothing else did,:71-89). A handler failure abandons the staged row before rethrowing, so the retry does not see its own abandoned attempt as a duplicate (.../Services/IntegrationEventConsumer.cs:69, abandon at:74, rethrow at:81, detach atEfInboxStore.cs:106-109). ADR-021 owns the inbox contract; this is the outbox-side consequence, and it makes the delivery story symmetric: the outbox guarantees the event leaves, the inbox guarantees it lands once.
Revision (2026-09-07)
The dispatch model is unchanged. What changed is the name the outbox's downstream resources take by default, from the 2026-09-07 security review (SEC-Common-53).
ApplicationNamespace.Resolve
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:53)
derives one namespace from Application:Namespace, falling back to the host application name, and
that namespace now supplies three defaults that used to be empty: MessageBus:EndpointPrefix
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:66, resolved at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:793), Cache:KeyPrefix
and with it the distributed-lock keyspace
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:83, rationale at
:68), and the SignalR Redis backplane channel prefix (DependencyInjection.cs:653). Two
applications sharing one broker or one Redis therefore no longer share a queue name or a cache key by
accident.
The cutover rule this creates. Adopting the upgrade renames resources: queue names gain a prefix,
cache and lock keys gain a prefix (a cold cache after deploy), and the backplane moves channel. A
consumer that must keep its current names pins the pre-upgrade values explicitly (set
Application:Namespace, or the individual prefix keys) before upgrading; a consumer that takes
the new names drains the old broker queues on cutover, because in-flight messages sit under the old
endpoint name and nothing reads it afterwards.
Revision (2026-09-11)
The dispatch model is unchanged: same table, same claim lease, same dual dispatch, same retry and dead-letter policy. What changed is what a row carries, because the hop was dropping the thing that made the delivered work attributable.
The hole the columns close. An event written inside a request was delivered one poll cycle later
on a background scope that had no principal, no tenant and no correlation id. A handler reading
ICurrentUserService saw nobody, its own writes were stamped with the audit sentinel, a host running
the shared-schema tenant filter (ADR-073) read across tenants, and
nothing in the logs joined the delivery back to the request that produced it. The deferred-command
queue (ADR-114) had already solved exactly this on its
own row; the outbox now does it through the same helper, so the two hops cannot drift.
Four nullable columns, captured at write time. OutboxMessage gains TenantId
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:93),
UserId (:100), UserRoles (:106) and CorrelationId (:113), filled from an OutboxOrigin
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxOrigin.cs:29) by
FromDomainEvent(IDomainEvent domainEvent, OutboxOrigin origin = default) (OutboxMessage.cs:131,
assignments at :144-:147). The origin is produced once per save by an accessor the scoped context
factory attaches
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:149-:153)
and read through ApplicationDbContext.CurrentOutboxOrigin
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:146,
backing property at :140), so the role flattening costs one pass per save rather than one per row.
All three write paths take it from there: the domain-event interceptor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:247,
:251), InProcessEventBus
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/InProcessEventBus.cs:89, :93) and
BrokerEventBus
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerEventBus.cs:81, :85). Roles
are flattened to a comma-separated list truncated to the column width by the shared helper
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:41, the 512-character
cap at :25). The mapping mirrors the InternalCommands columns deliberately: varchar(64) tenant,
varchar(512) roles, varchar(64) correlation id (ApplicationDbContext.cs:652-:654).
Restored per row, overwritten per row. OutboxProcessor calls AmbientOrigin.Restore on the
cycle's scope before the row is deserialized, published or dispatched
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:607-:613),
stamping the rebuilt identity with the authentication type Outbox (:81), which is what makes it
read as authenticated and names the hop it came back from. The restore overwrites rather than
accumulates: the principal is set or cleared on every call (AmbientOrigin.cs:145-:156) and the
correlation id is overwritten whenever the row carries one (:158-:161), so one row's identity can
never answer for the next row of the same batch. The tenant is the one value that is not overwritten.
It is set only when the scope has not already resolved a different tenant
(AmbientOrigin.cs:137-:143), because ITenantContext is single-valued for the life of a scope by
contract and a scope whose tenant changed mid-flight has already read rows under the previous one.
The consumer-visible cost: one expand-only migration per relational outbox source. Every column is
nullable, nothing is renamed and nothing is dropped, so this is an expand-only change
(ADR-057) and the migration and the package can be
deployed in either order. A row written before the upgrade reads back as "nothing was captured",
which is exactly what it is, and the one-argument FromDomainEvent(domainEvent) still compiles and
still stores four nulls. Cosmos sources need nothing, because CosmosDbContext does not map the
outbox.
The broker half of the same hop is recorded in ADR-021: the same
four values travel as MMCA-* message headers and are restored on the consuming scope before the
inbox is touched.
Revision (2026-10-01)
The dual-dispatch decision is unchanged. One rule is added to it (Decision item 4), and several statements made by earlier revisions no longer describe the code. The current-state sections above are corrected; the earlier Revision sections stay as written and are superseded where noted here.
- Domain event handlers do not save. Dispatch runs after
SaveChangesAsync(after commit inside anITransactionalcommand), so a handler that saves opens a second write in the middle of the first one: it re-enters the change tracker, can raise a fresh event cascade, and persists work the outer transaction may still roll back (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.DomainEventHandlerSaves.cs:23-29).DomainEventHandlersDoNotSave(:76-79) reads IL through Mono.Cecil and walks the call graph breadth-first out of everyIDomainEventHandler<T>method, following direct calls, delegate creations, interface and virtual calls expanded to their implementations, and async state machines (:37-49), to a default depth of 6 (:79). An allowlist entry silences a type and stops the walk from descending into it (:51-60); the shared base defaults it toMMCA.Common, because the framework's outbox event bus persists by design (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/DomainEventHandlerSaveTestsBase.cs:33, the test at:42-44). Its limits are stated in code: a bounded depth, interface dispatch resolved only inside the scanned assemblies, and reflection or DI-resolved delegates invisible (:61-69of the rule file). Adoption: MMCA.ADC subclasses the base (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DomainEventHandlerSaveTests.cs:11) and allowlists one accepted trade-off, the gamificationPointsAwarder(:27). MMCA.Store'smainsubclasses it (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DomainEventHandlerSaveTests.cs:19) and allowlists three handlers that commit an independent, idempotent follow-up write in their own scope:UserRegisteredHandler(:39),OrderCancelledSagaHandler(:53) andProductReviewChangedHandler(:67). MMCA.Helpdesk has no subclass and does not run the rule. - The two polling processors share one implementation of the loop. The 2026-09-09 Status line
said the outbox and internal-command processors shared an idiom rather than an implementation.
Both now delegate to the internal static
PollingLoop(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Polling/PollingLoop.cs:5-12):OutboxProcessoratMMCA.Common/.../Outbox/Processing/OutboxProcessor.cs:113,:163and:692,InternalCommandProcessoratMMCA.Common/.../Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:82,:138and:644. Each passes its own cap to the shared backoff (PollingLoop.cs:181-183): the outbox caps atOutbox:LeaseSeconds. - Each row is delivered on its own scope. The Revision (2026-09-11) restored the captured
context onto the cycle's scope. The processor now creates a fresh scope per row
(
OutboxProcessor.cs:520) and restores onto it (:525-531); the cycle scope's context still tracks the rows for the batch save (rationale at:487-497). The consequence for tenants differs from what that revision recorded: because the tenant context refuses a change once resolved, a shared scope ran every later row of a shared-target batch under the first row's tenant, and the fresh scope is what lets the tenant change between rows (:489-491,:518-519). - Shutdown persists the stamps already earned. The Revision (2026-07-24) item 3 says the batch
is left untouched on cancellation. Cancellation still rethrows without counting a retry
(
OutboxProcessor.cs:579-586), but the cycle first saves the change-tracked stamps of the rows it already handled (:241-251,TryPersistStampsOnCancellationAsyncat:312-324), so a message delivered before a graceful shutdown is not redelivered when its lease expires. Rows not yet reached stay untouched. - Keeping the old queue names is a flag, not a prefix. The Revision (2026-09-07) cutover rule
says a consumer keeping its names pins
Application:Namespaceor the prefix keys. No prefix value reproduces the old queue names, because the endpoint formatter changed (MMCA.Common/CHANGELOG.md:1366-1367); the opt-out isMessageBus:PreserveDefaultEndpointNames=true(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:75-83), which skips the prefixed formatter entirely (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:75-86). - Two omissions, recorded. The broker publish runs under a circuit breaker with no retry
strategy of its own (
OutboxProcessor.cs:96-108, applied at:551-555); an open-circuit rejection follows the normal failure path, incrementingRetryCountand re-leasing the row (:589-599,:603-609), so a broker outage longer than the backoff curve dead-letters messages onceMaxRetriesis reached (:623). And the poll fetches the oldestBatchSizepending rows with no key awareness, so a key withBatchSizeor more pending rows fills the fetch window and delays unrelated newer rows until it drains or its head row dead-letters (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IHasOrderingKey.cs:24-27). - Citations refreshed in the current-state sections. The outbox files now sit under
Persistence/Outbox/Administration/(OutboxSettings,OutboxCleanupService) andPersistence/Outbox/Processing/(OutboxProcessor,OutboxMetrics,EventNameResolver). For readers following the older revisions:IHasOrderingKeyis atIHasOrderingKey.cs:29-36(member:35);OrderingKeyatOutboxMessage.cs:86, copied at:152, the stored event name written at:139and resolved at:183-189; the ordering guardFilterUnblockedatOutboxProcessor.cs:468-478(theRetryCountterm:477, theOccurredOnterm:478), chosen only for a batch holding a keyed row (:394-399), with the one-row-per-key selection at:431-447;HandleUnresolvableTypeat:661-684; the dead-letter sweep atOutboxCleanupService.cs:155-171;IOutboxAdministrationunderMMCA.Common.Application/Interfaces/Infrastructure/Persistence/, registered atDependencyInjection.cs:219-220;EnableInboxatMessaging/MessageBusSettings.cs:133; the consumer's abandon atMessaging/Consumers/IntegrationEventConsumer.cs:101(rethrow:108); and the endpoint prefix resolved atDependencyInjection.Messaging.cs:80-82, the SignalR channel prefix atDependencyInjection.Notifications.cs:59-61.
Revision (2026-10-06)
No content change: the dispatch model, the retry curve and every behavior recorded above still hold. The earlier Revision sections keep the anchors recorded on their dates; for readers following them, the load-bearing locations now are:
OutboxProcessor(MMCA.Common/.../Outbox/Processing/OutboxProcessor.cs):PollingLoop.RunAsyncat:107,PollingLoop.DrainAllAsyncat:152,ComputeRetryBackoffSecondsat:688-689; the per-row scope at:516withAmbientOrigin.Restoreat:522; the cancellation catch at:576andTryPersistStampsOnCancellationAsyncat:304; theMaxRetriesdead-letter check at:620; theOutboxauthentication type at:76.OutboxMessage(MMCA.Common/.../Persistence/Outbox/OutboxMessage.cs):OrderingKeyat:88(copied at:154),TenantIdat:95,UserRolesat:108,CorrelationIdat:115,FromDomainEventat:133(origin assignments at:146-149), the stored event name at:141and resolved at:201.- The origin:
ApplicationDbContext.CurrentOutboxOriginatMMCA.Common/.../Persistence/DbContexts/ApplicationDbContext.cs:177(accessor property:171), attached at.../DbContexts/Factory/DbContextFactory.cs:156;AmbientOrigin.FlattenRolesatMMCA.Common/.../Context/AmbientOrigin.cs:54, the 512-characterMaxRolesLengthat:38,Restoreat:156. IOutboxAdministrationlives atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IOutboxAdministration.cs, registered atMMCA.Common/.../Infrastructure/DependencyInjection.cs:229inside theIsOutboxEnabledbranch (:217).MessageBus:EndpointPrefixatMMCA.Common/.../Messaging/MessageBusSettings.cs:73, resolved atMMCA.Common/.../Infrastructure/DependencyInjection.Messaging.cs:81-83.IHasOrderingKeyatMMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IHasOrderingKey.cs:31, memberOrderingKeyat:37.
Anchors in the current-state sections were re-verified against current source (Trade-offs
OutboxProcessor.cs cites moved).
Revision (2026-10-07)
Re-verified against current source. The dual-dispatch decision, the claim lease, the ordering
guard and the jittered retry curve (MMCA.Common/.../Persistence/Polling/PollingLoop.cs:183-197)
are unchanged. What moved is how a claimed row's outcome reaches the database and how a local
domain-event row is protected while it is dispatched in-process, which supersedes the Revision
(2026-10-01) items 3 and 4 and the "no content change" line of the Revision (2026-10-06). The
current-state Rationale and Trade-offs above are corrected; the earlier Revision sections stay as
written.
- Each row's outcome is written as the row finishes, under the batch's lock token. There is
no batch save (
MMCA.Common/.../Outbox/Processing/OutboxProcessor.cs:229-231). After delivery the cycle callsRecordOutcomeAsync(:592, defined at:612-634), which writes throughWriteOutcomeAsync(:661-669) toRecordDeliveredAsync(:723) orRecordFailureAsync(:757). Both end inStampAsync(:789-810), a set-basedExecuteUpdateAsyncguarded on the row id and the lock token only, with noLockedUntilterm (:795-796). An expired lease alone does not void the write: the stamp still lands while no other replica has claimed the row. Once another replica re-claims it under a new token, the update matches nothing, and the replica logs the lost lease and detaches its stale copy rather than overwriting the new owner's record (:801-805). A database failure while recording the outcome is not charged to the row as a retry; it propagates and fails the source for the cycle (:587-590). - Each row's lease is renewed just before it is dispatched. The claim lease covers the whole
batch, so a slow batch could outlive it.
RenewLeaseAsync(:703-717, called at:484) resets the row'sLockedUntilto now plusOutbox:LeaseSeconds(:709,:713) in a statement guarded on the lock token, and a row that no longer carries the token is skipped, not dispatched (:485-487). The cycle scope's context holds the claimed rows and writes each renewal and outcome; only delivery moves to the fresh per-row scope (:451-452, scope at:498, tenant rationale at:443-447). - Shutdown writes the outcome of the row in flight, then rethrows. If the batch token is
already cancelled when a row finishes, or is cancelled during the write (
:619-631),RecordOutcomeOnShutdownAsync(:640-658) writes that row's outcome under its ownShutdownStampTimeoutof 5 seconds (:89), logs rather than throws a failure of that write, and then rethrows the cancellation (:657), so a delivery that finished before shutdown is not redelivered. One case is not covered: when delivery itself throwsOperationCanceledExceptionunder shutdown, the catch at:524-530rethrows before the outcome write, so that row is not stamped and becomes claimable again once its lease expires. Rows not yet reached stay untouched. - Async-path local domain-event rows are inserted already leased. The async save inserts each
local event's row with
LockedUntilset to now plusOutbox:LeaseSecondsunder a fresh token (MMCA.Common/.../Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:96-98,:280,:305-316;MMCA.Common/.../Outbox/Administration/OutboxSettings.cs:79-84), so no replica's poller delivers the event while this process still handles it. A failed in-process dispatch releases the lease under that token (DomainEventSaveChangesInterceptor.cs:387-390,:408-422;MMCA.Common/.../Outbox/Processing/OutboxFinalizer.cs:67), so the processor retries promptly. The release is best effort: a failed release is logged and the rows wait out the full lease (:403-405,:418-421). A crash leaves the rows to the processor once the lease expires (:20-23). The sync path writes no lease (:108-111). Integration-event rows are never leased at insert (:226). - Anchors re-verified against current source:
OutboxProcessor.cs:PollingLoop.RunAsyncat:110,PollingLoop.DrainAllAsyncat:155,ComputeRetryBackoffSecondsat:858-859, the per-rowCreateTenantScopeat:498withAmbientOrigin.Restoreat:505, the cancellation catch at:524, the failure re-lease at:543-544, theMaxRetriesdead-letter check at:568, theOutboxauthentication type at:79; the broker publish pipeline field at:105(built at:868-870, applied at:685); the eligible split at:202-210and the re-poll test at:242; the ordering guardFilterUnblockedat:424-434(the correlated!outbox.Anyat:431, theRetryCountterm:433, theOccurredOnterm:434), chosen only for a batch holding a keyed row (:353-355) and applied inside the claim'sExecuteUpdateAsync(:357-361);SelectOrderedCandidatesat:387-403;HandleUnresolvableTypeat:828.OutboxSettings.cs:BatchSize:17,MaxRetries:21,LeaseSeconds:84,RetryBackoffBaseSeconds:101.PollingLoop.cs: jitter:193, cap:196.