Architecture Decision Record
ADR-096: Best-Effort Side-Effect Contract
Status
Accepted (2026-08-23).
Context
A command that has already committed often has follow-up work attached to it: evict the output-cache entries the write invalidated, broadcast the new state to a live channel, send the notification the user is waiting for. That work can fail on its own, and when it does the question is not whether to retry but what the failure is allowed to do to the caller. Turning it into an exception would roll back, retry or 500 an operation whose real work already succeeded.
Five records each answer that question locally, for their own feature, and each answer is right:
ADR-024 makes a push delivery failure non-fatal and records MarkAsFailed instead
(024-push-notifications.md:55), ADR-026 makes cross-service cache eviction best-effort so a broken
eviction store cannot dead-letter a coherence hint, ADR-076 degrades a data-subject export per section
rather than failing the package (076-data-subject-export.md:74), ADR-091 composes the reset email in
the handler and delivers it best-effort, "awaited and its failure caught, logged and swallowed"
(091-cache-backed-password-reset.md:71-76), and ADR-054 makes compensation best-effort per order
line (054-saga-compensation-and-reconciliation.md:158). What none of them decides is the policy:
which failures may be swallowed at all, at what severity, whether cancellation counts as one of them,
and how a swallow is made visible to somebody who is not reading the log. Answered per call site, that
produces a repo full of hand-rolled catch (Exception) blocks, each choosing its own severity, its
own treatment of cancellation and its own decision to count nothing. ADR-041 records the counter this
record's helper emits and notes that it is wired to no alert (041-observability-and-telemetry.md:175-180,
:197-200), but it records the instrument, not the contract behind it.
Decision
One framework helper defines the contract, and a swallow that does not go through it is a deliberate, documented exception.
BestEffort.ExecuteAsync(operation, logger, action, cancellationToken = default), a static helper in the Application layer (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:45-49), runs the side effect and absorbs its failure.- The action is awaited, not fire-and-forget. The helper awaits
action(cancellationToken)(:57), so the side effect completes (or fails) before the caller continues; nothing is left as an orphan task racing the response. - A failure produces exactly one Warning.
BestEffortLog.DispatchFailedis a source-generated[LoggerMessage]atWarning(:81-84) reading "Best-effort operation '{Operation}' failed and was swallowed; the caller's outcome is unaffected" (:83), with the exception attached. The catch is deliberately broad, with the reason written into the code: the whole contract is that nothing the side effect throws reaches the caller (:65-71). - A failure produces exactly one metric increment.
besteffort.dispatch.failedis aCounter<long>on its own meter,MMCA.Common.BestEffort(:102, instrument at:107-115), incremented with anoperationtag (:115). It is a meter of its own rather than a counter folded intoMMCA.Common.Cqrs, because best-effort dispatch is not part of the CQRS pipeline and an operator can drop or keep it independently of the RED metrics (:93-97). The Aspire service defaults subscribe it (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:170). - The operation name is a low-cardinality constant. It becomes a metric tag (
:22), so call sites pass aconstor a fixed prefix plus a value from a small fixed set. A blank name throwsArgumentExceptionand a null logger or action throwsArgumentNullException(:51-53): the helper swallows the side effect's failures, never its own caller's bugs. - Cancellation is not swallowed. An
OperationCanceledExceptionraised while the caller's own token is cancelled is rethrown (:59-63), so a host shutdown or an abandoned request unwinds promptly instead of being recorded as a spurious side-effect failure. A cancellation that is not the caller's (an inner timeout) is a genuine failure of the side effect and is swallowed like any other. - Post-commit work passes
CancellationToken.Noneon purpose. The write has committed, so its follow-up must outlive a caller that has already walked away (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:34-40; the ADC broadcasts take the parameter's default for the same reason,.../SubmitQuestionHandler.cs:122-124). - The contract is pinned by tests.
BestEffortTestscovers the transparent success path, token passthrough, one-Warning-per-failure, theoperation-tagged increment observed through aMeterListener, the rethrow of the caller's cancellation, the swallow of a non-caller cancellation, and argument validation (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Services/BestEffortTests.cs:15-141).
Adoption today is seven production call sites across two apps. Six are in ADC Engagement: the
live-channel drain worker, whose operation name is the prefix live-channel-publish: plus the work
item's event name and whose own catch turns the rethrown cancellation into a quiet stop
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:36,
:45-58, :60-65); three session-question broadcasts, session-question-submit-broadcast
(.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:34, call at :131),
session-question-moderation-broadcast (.../UseCases/Moderate/ModerateQuestionHandler.cs:30, call at
:136) and session-question-upvote-broadcast
(.../DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:45, call at :52); the poll-results
broadcast livepoll-results-broadcast
(.../LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:44, call at :51); and the
cross-host cache eviction bookmark-cache-evict-broadcast
(.../UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:56, call at
:68-80). The seventh is Store Catalog's TryEvictByTagAsync extension, whose operation name is the
prefix catalog.output-cache-evict: plus the tag
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:18,
:30-41); four controllers evict through it (Controllers/CategoriesController.cs:157,
ProductsController.cs:222, ProductVariantsController.cs:129, ProductImagesController.cs:281).
Two swallows deliberately stay hand-rolled, and both say so in code. Store's AddVariantHandler
publishes ProductVariantChanged after the commit and catches around it, logging at Error with
the ProductId and ProductVariantId: the event is lost, Sales does not auto-create the zero-stock
inventory record, and an operator needs those ids to create it by hand. Routing it through the helper
would both downgrade an unrecoverable loss to Warning and drop the ids, which is why that one site
stays as it is while every other swallow in the repo uses the helper
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/UseCases/AddVariant/AddVariantHandler.cs:81-95,
catch at :97-108). The framework's own OutputCacheEvictionHandler hand-rolls the same
swallow-log-count shape against cache.eviction.failed on the MMCA.Common.OutputCache meter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:51-62),
because BestEffort lives in Application and the API package cannot reach it without a layer-crossing
reference or a duplicated meter name (recorded in ADR-026, 026-caching-strategy.md:412-417).
Rationale
- One policy beats five local leniencies. Each feature record is still right about its own degradation; what they could not each decide is the shape of the swallow. A single helper fixes severity, cardinality and the cancellation rule once, so a new post-commit side effect inherits them instead of re-litigating them.
- A swallowed failure has to be countable. A Warning in a log nobody reads is how a side effect quietly stops working for weeks. The counter turns "the broadcast has been failing since Tuesday" into a question a dashboard can answer, which is the only thing that makes swallowing defensible.
- Cancellation is not a failure. Swallowing it would turn an orderly shutdown into a burst of spurious warnings and a metric spike, and would let a stopping host keep doing work it was told to stop. Rethrowing keeps shutdown a shutdown.
- Awaiting keeps the failure attributable. A detached task would still fail, just later, off the request's context and without the logger scope that names what it was doing.
- Fixing the severity at Warning is a filter, not a limitation. A swallow that genuinely deserves
Error, with ids an operator must act on, is evidence the work is not best-effort.
AddVariantHandleris exactly that case, and it stays outside the helper.
Trade-offs
- Nothing gates use of the helper. There is no fitness rule, analyzer or architecture test that
fails a build for a hand-rolled
catch (Exception)that should have been aBestEffortcall; the helper is a convention backed by review. The only inventory is a search, which is how the seven call sites and two deviations above were enumerated. - The Warning carries the operation name and the exception, nothing else. No entity id, no
correlation payload beyond the ambient scope.
SubmitQuestionHandlerrecords that cost explicitly: the question id is one log line earlier, not in the best-effort warning (.../Submit/SubmitQuestionHandler.cs:125-127). - The counter is failure-only and alerts on nothing. A healthy system emits zero, and zero is
indistinguishable from a host that never wired the meter. ADR-041 puts it in exactly that gap
(
041-observability-and-telemetry.md:197-200). - The meter name is a duplicated literal.
MMCA.Common.Aspiresubscribes it by string because that package has no reference to Application (BestEffort.cs:89-92,Extensions.cs:170), so a rename has to move in two places or the metric silently stops being exported. - A swallow is still a loss. The helper decides that the caller does not see the failure; it does not make the side effect happen. A cache entry heals on its own TTL, but a lost broadcast never replays, and callers whose loss is unrecoverable have to say so themselves.
- Two "best effort" counters exist. The layer constraint on the API package means
cache.eviction.failedandbesteffort.dispatch.failedcount the same shape of event on different meters, so an operator asking "what is silently failing" has two places to look.
Related
ADR-024 (push delivery failure is non-fatal and recorded rather than
raised, one of the local leniencies this policy generalizes),
ADR-026 (eviction is best-effort, and its OutputCacheEvictionHandler is
the framework's one documented non-reuse of this helper),
ADR-041 (records besteffort.dispatch.failed, its meter, and
that it is wired to no alert),
ADR-054 (compensation is best-effort per line, and its
one hand-rolled swallow is the same question answered locally),
ADR-076 (per-section degradation makes an incomplete package the
contract rather than a failure),
ADR-091 (the reset email is awaited, caught, logged and
swallowed, the shape this helper standardizes).