Architecture Decision Record
ADR-096: Best-Effort Side-Effect Contract
Status
Accepted (2026-08-23). Revised 2026-08-31. Revised 2026-09-19. Revised 2026-09-25 (the ADR-054 cross-reference is re-anchored onto that record's current best-effort trade-off). Revised 2026-10-01. Revised 2026-10-06: adoption is thirteen call sites with the ADC points award, and every Store Catalog controller evicts both catalog tags.
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:65-67), 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:82-83), 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:81-86), and ADR-054 makes compensation best-effort per order
line (054-saga-compensation-and-reconciliation.md:252-263). 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:235-240,
:258-261), 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.Telemetry.cs:313). - 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: the framework's own output-cache eviction helper passes it explicitly (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:78-92, the token at:90; the ADC submit and moderation broadcasts take the parameter's default for the same reason,.../SubmitQuestionHandler.cs:197-200, the call closing at:235, and.../ModerateQuestionHandler.cs:157, while the three ADC domain-event handlers pass their owncancellationToken,.../SessionQuestionUpvoteChangedHandler.cs:84,.../LivePollVoteChangedHandler.cs:83and.../SessionQuestionSubmittedPointsHandler.cs:102, and the hosted bookmark eviction processor passes itsstoppingToken,.../Caching/BookmarkCacheEvictionProcessor.cs:77). - 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 thirteen call sites: seven in ADC Engagement, five in Store, and the framework's own
eviction helper. The ADC seven are 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:41, call at :206, which enqueues
onto the live-channel publish queue at :217-218 rather than publishing inline),
session-question-moderation-broadcast (.../UseCases/Moderate/ModerateQuestionHandler.cs:31, call at
:138) 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
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Caching/BookmarkCacheEvictionProcessor.cs:49,
call at :62-77, a hosted processor that drains the signal the domain-event handler raises); and the
question-asked points award session-question-points-award, which runs after the question's transaction
commits so a failed award never fails the committed question
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:59,
call at :82-102). The Store five are a checkout display label, checkout-customer-name, resolved by the
checkout preflight outside the transaction so an unreachable Identity leaves the name null instead of
failing an otherwise valid checkout
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/ShoppingCarts/UseCases/CheckOut/CheckOutPreflight.cs:77-87,
the operation name at :78, the outside-the-transaction reasoning at :17-24);
three inventory label fetches sharing the operation name inventory-catalog-labels, so a Catalog service
that drops out leaves rows with whatever labels they had
(.../Inventory/UseCases/Create/CreateInventoryItemHandler.cs:68,
.../Inventory/UseCases/BulkSet/BulkSetInventoryHandler.cs:67 and
.../Inventory/UseCases/Set/AdjustInventoryHandler.cs:66-67); and the post-save eviction broadcast
review-anonymize-cache-evict-broadcast raised after a customer erasure anonymizes reviews, where a
broker fault must not fail an erasure that has already committed
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Reviews/IntegrationEventHandlers/CustomerErasedHandler.cs:62,
call at :109-115). Four of those five are pre-commit reads rather than post-commit follow-ups (the
create fetch runs before base.PersistAsync at CreateInventoryItemHandler.cs:82, the set fetch before
SaveChangesAsync at AdjustInventoryHandler.cs:81): the
contract is about what a failure is allowed to do to the caller, not about where in the handler the
work sits. The thirteenth is the framework's own multi-tag eviction helper,
OutputCacheEvictionExtensions.TryEvictTagsAsync, whose operation name is the constant prefix
output-cache-evict: plus the tag being evicted
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:34,
:78-92). Store Catalog reaches it from seven controllers, each passing fixed low-cardinality cache
tags, and every one of them evicts catalog:products and catalog:categories together:
Controllers/CategoriesController.cs:174, ProductsController.cs:172, ProductVariantsController.cs:206,
ProductImagesController.cs:221, ReviewsController.cs:324, ReviewModerationController.cs:166 and
ProductAttributesController.cs:154.
One swallow deliberately stays hand-rolled. 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): it is
the one documented non-reuse of this helper inside the framework, and ADR-026 records the rationale
(026-caching-strategy.md:585-590).
Store's AddVariantHandler used to be the second hand-rolled case, and it now shows what the contract
says to do when a side effect is too important to swallow: stop swallowing it. It no longer catches
anything and no longer publishes inline. After SaveChangesAsync populates the database-generated
ProductVariantId, it schedules a durable ADR-114 internal
command, PublishProductVariantChangedInternalCommand, carrying the ProductId and that
ProductVariantId, with CancellationToken.None so the follow-up outlives a caller
that has walked away
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/UseCases/AddVariant/AddVariantHandler.cs:92-95).
The command is ITransactional (AddVariantCommand.cs:25), so the scheduler only enrolls the row and
the transactional pipeline saves it just before the commit: the row commits with the variant
(AddVariantHandler.cs:77, :79-82, :97-98).
The scheduled row is the durable record, so a broker fault retries with backoff instead of stranding the
variant without inventory; an inline publish left a window in which a crash between the commit and the
publish lost the event outright, and the outbox could not help because the row only lands there once
PublishAsync has been reached. What survives is much narrower: only a failure to write the row itself
loses the event. That failure arrives as a Result, not an exception, and is still isolated from the
caller's outcome (the variant is committed, and a client retry with a null SKU would create a duplicate),
and it is still logged at Error with both ids, because it is the case where an admin has to create
the inventory record by hand (:99-100, the [LoggerMessage] at :110-113).
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.
AddVariantHandlerwas exactly that case, and the answer was to make the work durable rather than to keep it outside the helper: the publish became a scheduled internal command, and only the narrow failure to record that command still logs at Error.
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 thirteen call sites and the one remaining hand-rolled swallow 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:200-202). - 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:258-261). - 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.Telemetry.cs:313), 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.
cache.eviction.failedandbesteffort.dispatch.failedcount the same shape of event on different meters, one per the ADR-026 non-reuse and one from this helper, so an operator asking "what is silently failing" has two places to look.
Revision (2026-10-01)
No decision or rationale changed; the adoption inventory and several statements about it are
corrected. Adoption is twelve call sites, not eleven: Store's inventory set path is a fifth Store site,
a third inventory-catalog-labels fetch
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Inventory/UseCases/Set/AdjustInventoryHandler.cs:66-67).
The checkout-customer-name call lives in the checkout preflight, not the handler
(.../ShoppingCarts/UseCases/CheckOut/CheckOutPreflight.cs:77-87). Four of the five Store sites are
pre-commit reads, not two. Only the ADC submit and moderation broadcasts take the token parameter's
default; the two domain-event handlers pass their own token
(.../SessionQuestionUpvoteChangedHandler.cs:84, .../LivePollVoteChangedHandler.cs:83) and the
bookmark eviction processor passes its stoppingToken (.../BookmarkCacheEvictionProcessor.cs:77). Store Catalog reaches TryEvictTagsAsync from
seven controllers, three of them evicting two tags at once (CategoriesController.cs:171). The
AddVariantHandler schedule runs inside the ITransactional command and commits with the variant
rather than after the commit (AddVariantHandler.cs:76-79, :94-95). Citations refreshed: the meter
subscription (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Telemetry.cs:313),
SubmitQuestionHandler, AddVariantHandler, the controller lines, and the ADR-024, ADR-026 and
ADR-041 cross-references.
Revision (2026-10-06)
No decision or rationale changed; the adoption inventory is corrected again.
- Adoption is thirteen call sites, not twelve: ADC adds the question-asked points award,
session-question-points-award(.../Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:59, call at:82-102), which passes its owncancellationToken(:102), so three ADC domain-event handlers do, not two. - All seven Store Catalog controllers now evict
catalog:productsandcatalog:categoriestogether; none evictscatalog:productsalone (ReviewsController.cs:324). - The hand-rolled
OutputCacheEvictionHandlerparagraph no longer claims the code itself documents the non-reuse: the handler's comment justifies only the broad catch (OutputCacheEvictionHandler.cs:58-59). - Anchors re-verified against current source: the ADR-026, ADR-054 and ADR-091 cross-references,
AddVariantHandlerand the seven controller lines.
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),
ADR-114 (the durable queue AddVariantHandler now
schedules onto, the alternative to swallowing a side effect whose loss is unrecoverable).