| id | 0069-factory-registry-ownership-and-disposal-cascade | ||||
|---|---|---|---|---|---|
| title | Ownership and disposal cascade for mapper/transform factories | ||||
| status | Accepted | ||||
| author |
|
||||
| created | 2026-07-28 | ||||
| summary | The MessageMapperRegistry owns and disposes the factories it was built with; the OutboxProducerMediator and the Dispatcher are IDisposable and cascade disposal into the registry and transform factories they own; a component that did not create a disposable does not dispose it. | ||||
| tags |
|
Date: 2026-07-28
Accepted
Scope: which component disposes the mapper registry and the mapper/transform factories, so the per-resolution DI scopes of ADR 0067 are actually reclaimed.
A per-resolution DI scope that a caller fails to release is drained only when its factory is disposed (ADR 0067). For an IoC-backed factory nothing else can reach those factories to dispose them, so an owner must — and must do so exactly once, without disposing a factory or registry that another owner is still using.
Four ADRs came out of the #4252 lifetime work, one decision each, and they are meant to be read in order:
| ADR | Decides |
|---|---|
| 0066 | what a factory returns, so that Release can name the resolution it is releasing |
| 0067 | that a Transient resolution gets its own DI scope, tracked by scope identity and released idempotently |
| 0068 | that disposal is deterministic on the explicit path and best-effort in the finalizer |
| 0069 (this one) | who owns, and therefore who disposes, the registry and the factories |
ADRs 0070–0074 then build on all four: they give a pipeline its own DI scope, and let it join one the host already owns. The ownership rule stated here is why the registry is the object that speaks for the factories it holds.
Disposal follows ownership: the component that created a disposable disposes it, and disposal cascades from the owner down.
Ownership is declared, not inferred, and it defaults to not owning. The cascade only runs as far as the flags say it may:
flowchart TB
di["the DI composition — news up a registry and<br/>transform factories solely for one root"] -- "passes true" --> roots
manual["manual wiring and the control bus — a registry<br/>routinely shared between a bus and a Dispatcher"] -- "leaves the flags at their false default" --> roots
roots["OutboxProducerMediator (producer)<br/>Dispatcher (consumer)<br/>both IDisposable, both take ownsRegistry and ownsTransformerFactories"]
roots -- "only for a flag it was told it owns" --> reg["MessageMapperRegistry"]
roots -- "only for a flag it was told it owns" --> tf["the two transform factories"]
reg -- "always: it created them" --> mf["the sync and async mapper factories,<br/>disposed in a try/finally so a fault draining one<br/>cannot orphan the scopes retained by the other"]
mf --> scopes["the per-resolution DI scopes of ADR 0067,<br/>reclaimed at the latest when their factory is disposed"]
tf --> scopes
noncre["pipeline validation and diagnostics — take a Func that<br/>builds a registry, never a registry instance"] -- "own and dispose only what they<br/>themselves built" --> scopes
MessageMapperRegistry is IDisposable and disposes the mapper factories it was constructed with — both the synchronous and asynchronous factory. It disposes both even when the first Dispose throws (try/finally), so a fault draining one factory cannot orphan the scopes retained by the other, and the disposal is idempotent (the disposed flag is claimed with a single atomic exchange) so an owner and the container can both dispose it exactly once.
Ownership is declared explicitly and defaults to non-owning. Both roots take ownsRegistry and ownsTransformerFactories constructor flags that default to false, and each disposes the registry / transform factories only when the matching flag is true. A root handed a shared registry or factories — the manual-wiring and control-bus paths, where a registry is routinely shared with another bus or Dispatcher — therefore never tears them down. The DI composition, which news up a registry and factories solely for one root, opts in by passing true.
- The
OutboxProducerMediator(producer side) isIDisposableand, for each flag it owns, cascades into theIAmAMessageMapperRegistryand both transform factories, disposing each quietly (a failure to dispose one is logged, not allowed to skip the rest). AReferenceEqualsguard avoids disposing the same object twice when the sync and async registry are one instance (the DI path). - The
Dispatcher(consumer side) is likewiseIDisposableand disposes the registry and transform factories only for the flags it was told it owns.
Components that receive a registry they did not create do not dispose it. The pipeline validation and diagnostics components take a Func<MessageMapperRegistry> rather than a registry instance: each invokes the factory once and owns and disposes only the registry it thereby created, so it can never dispose a caller's shared registry.
- Every per-resolution DI scope is reclaimed at the latest when its owning root is disposed, bounding retention to the host lifetime; in the DI path each root gets its own registry and factories, so disposal is airtight.
- The non-owning default makes manual sharing safe, but the ownership flags can still be misused: if a
MessageMapperRegistryor a transformer factory is shared between independently-disposed owners (e.g. aCommandProcessorexternal bus and aDispatcher) and more than one of them is constructed withownsRegistry/ownsTransformerFactories = true, disposing that owner tears down objects the others still use, and their subsequent resolutions throwObjectDisposedException. This is a runtime break with no compile-time signal; the guidance is that at most one owner may claim ownership of a shared instance — give each owner its own registry/factories, or leave the flagsfalseand let the DI container own disposal.