| id | 0033-lifetime-of-command-processor-and-mediator | ||||
|---|---|---|---|---|---|
| title | Lifetime of Command Processor and Mediator | ||||
| status | Proposed | ||||
| author |
|
||||
| created | 2025-08-28 | ||||
| summary | Removes the static IAmABoxTransactionProvider from CommandProcessor, scoping the transaction provider to handler instances instead, and reverts CommandProcessor to a singleton lifetime. | ||||
| tags |
|
Date: 2025-08-28
Proposed
See also ADR #42 for context.
The responsibilities for outbox, producer, and transaction provider have shifted from being managed directly in the CommandProcessor to being managed via the OutboxProducerMediator (formerly ExternalServiceBus). The CommandProcessor now delegates IAmAnOutbox and IAmAMessageProducer operations to the OutboxProducerMediator, which acts as a singleton for the application.
Historical Evolution
- Early Versions
- In earlier versions of Brighter, the producer was an instance field of the
CommandProcessor, but typically theCommandProcessorwas a singleton. Once theIAmAnOutboxwas added to the architecture, to reduce the complexity of theCommandProcessor, and centralize theIAmAMessageProducercode, anExternalServiceBus(nowOutboxProducerMediator) was introduced. TheExternalServiceBus(nowOutboxProducerMediator) took on the responsibility of managing both theIAmAnOutboxand theIAmAMessageProducer. - The
ExternalServiceBuswas injected as a dependency into theCommandProcessorinstance.
- In earlier versions of Brighter, the producer was an instance field of the
- Refactoring to Static Singleton
- The need for a single, application-wide
IAmAnOutboxbecame clear to avoid multipleIAmAnOutboxinstances. This was a particular concern with anInMemoryOutbox, where an instance of theCommandProcessorwould have ended up with its own instance of anInMemoryOutbox, and thusClearwould not find messages that had been deposited. - As
ExternalServiceBus(nowOutboxProducerMediator) held theIAmAnOutboxit was refactored to be static. The usage of a singletonIAmAMessageProduceralso had benefits. - The static singleton pattern was adopted to ensure that only one instance of the
ExternalServiceBus(nowOutboxProducerMediator) existed per instance. This change is reflected in the use of private static fields and the double-lock pattern inInitExtServiceBus. An alternative would be to have managed this by DI, but this would have added complexity to the setup of Brighter.
- The need for a single, application-wide
- CommandProcessor Lifetime
- For producers, originally, the
CommandProcessorwas typically configured as a singleton, but this became problematic when scoping aDbContextper request in web applications.- To address this, the
CommandProcessorwas changed to allow it to be scoped per request (or transient), allowing it to work with aDbContextthat is also scoped per request.
- To address this, the
- For consumers, the
CommandProcessoris scoped to theDispatcher.- This is a change in V10, originally the the Dispatcher took a
Func<IAmACommandProcessorProvider>instead of a direct IAmACommandProcessor - Using a factory (
Func<IAmACommandProcessorProvider>) allowed theDispatcherto be decoupled from a single instance of theCommandProcessor. - In scenarios where the
CommandProcessorhas a dependency with a scoped lifetimes (e.g., per message, per request, or per thread), a factory allowed theDispatcherto create a newCommandProcessorfor each scope. This allows us to support anIAmABoxTransactionProviderthat manages aDbContextscoped per message. - This enabled the
Dispatcherto obtain a fresh or context-specific instance of theCommandProcessorwhen needed, rather than being tied to a single instance for its lifetime.
- This is a change in V10, originally the the Dispatcher took a
- Even where the
CommandProcessoris created with a scoped or transient lifetime, with scope matching to its calling context, it still relies on the staticOutboxProducerMediatorfor outbox and producer operations, ensuring that there is only one outbox.- The static fields are resettable via
ClearServiceBusfor testing purposes, but in production, they are set once and used for the lifetime of the application.
- The static fields are resettable via
- For producers, originally, the
- Transaction Provider
- The
IAmABoxTransactionProvider, which manages database transactions for the Outbox, is set as a Singleton in theCommandProcessor. ThisIAmABoxTransactionProviderto create an instance of the static OutboxProducerMediator by reflecting on it to determine the type of the transaction provider passed into the CommandProcessor. - The singleton
IAmAABoxTransactionProviderwas not otherwised used by theCommandProcessor, as it only extracts the transaction type from it to pass to theOutboxProducerMediator. - The original lifetime for the
IAmABoxTransactionProviderwas that of theCommandProcessor. - When calling
PostorPostAsyncwe useInMemoryOutboxand we don't support Transactional Messaging. Instead we passed null. This created this problem: #3112. Our response led to a design approach of using staticIAmABoxTransactionProviderfor the application, set when the firstCommandProcessoris created thatCommandProcessorinstances could use thatIAmABoxTransactionProviderif one was not supplied. This provided a validIAmABoxTransactionProviderfor theOutboxProducerMediatorto use, but it meant that theIAmABoxTransactionProviderwas effectively a singleton for the application, even if theCommandProcessorwas scoped or transient.
- The
Problems with this Design
- The
OutBoxProducerMediatorsingleton pattern can lead to issues in testing, as tests may interfere with each other if they rely on the same static instance. - Because we only have a single
OutboxProducerMediator, we cannot have different configurations of the Outbox for differentCommandProcessorinstances in the same application. - There is an issue with the static
IAmABoxTransactionProviderwhen the concrete implementation of theIAmABoxTransactionProviderhas state. This workef for scenarios like DynamoDb or Dapper, but not for EF Core. For EF Core the recommendation is to scope theDbContextper request, and use a new instance of theCommandProcessorper request, because the concreteDbContexthas scope. This causes errors because our staticTransactionProvideris set once, and holds a reference to theDbContextthat was passed in when it was set. This means that if theCommandProcessoris created in a different scope, it will have a differentDbContextinstance to the staticIAmABoxTransactionProvider. Whilst this is not an issue for stateless transaction providers, it is an issue for stateful ones like EntityFramework. - Removing the
Func<IAmACommandProcessorProvider>from theDispatchermeans that we cannot create a newCommandProcessorfor each message, which could be an issue if theCommandProcessorhas dependencies with scoped lifetimes.
Given we want to control the scope of V10, we need to remove errors caused by the static IAmABoxTransactionProvider holding a reference to a DbContext that is out of scope.
- The fix to the singleton
IAmABoxTransactionProvideris to remove it. It is already passed into theOutboxProducerMediatorvia a method call, so we do not need the one set when theCommandProcessoris created. This means that theIAmABoxTransactionProvidercan be scoped to theIHandleRequestsderived class, which typically takes it in its constructor as a "unit of work" and users should configure Brighter to use the appropriate handler lifetime, which we will honor from our factory. - The
IAmABoxTransactionProviderparameter that is passed into theCommandProcessorvia its constructor is confusing as it implies we set the transaction provider for theCommandProcessorlifetime. As we only extract the transaction type we will change this to pass aType, the transaction type, to the constructor. We can default it to CommitableTransaction (we need a default forOutboxProducerMediator). In our hostbuilder services, when constructing a command processor instance, we need to pull the type from theIAmABoxTransactionProvidergiven to us in configuration. Because anIAmABoxTransactionProvidermay have a scoped dependency, we need to create a scope for it during construction of our seervices, in order to grab its type. (Ultimately it may make more sense to have a separate configuration option for the transaction type, but this is a bigger change and we want to minimize the impact of this change). - We will expose a helper method to CommandProcessor to extract the transaction type from an
IAmABoxTransactionProviderto simplify changing existing calls. - When we call
CommandProcessorPostorPostAsyncwe will pass null for theIAmABoxTransactionProvider. By usingPostorPostAsyncyou are not using a transactional outbox, so theIAmABoxTransactionProvideris not needed. - We will not fix the issue about the possibility of multiple
IAmAnOutboxthatOutboxProducerMediatorcannot support. This is a limitation but it is likely that most applications will only need one configuration for theIAmAnOutbox. It may need to be addressed in a future version, if we see demand for it. - The testing issues around
CommandProcessoris mitigated by theClearServiceBusmethod, which resets the static fields for testing purposes.
Because we remove the static IAmABoxTransactionProvider, we can also safely still remove the Func<IAmACommandProcessorProvider> from the Dispatcher. A singletone instance of CommandProcessor' does not cause an issue as the dependency on the scoped transaction provider is only in the IHandleRequeststype. This simplifies the design and makes it easier to understand. These changes allow us to revert to a model where theCommandProcessorcan be a Singleton, because it no longer holds a reference to a scopedIAmABoxTransactionProvider. All of the other dependencies of CommandProcessorare either a factory or can have a singleton lifetime. Because we have moved CommandProcessor to be a singleton we can also removed theCommandProcessorLifetimefromBrighterOptions`, as it is no longer needed, and in fact would cause confusion if it was set to something other than singleton.
- The
OutboxProducerMediatorwill remain a static singleton, as we want to ensure that there is only one instance of the outbox/producer for the application. - The
IAmABoxTransactionProviderwill be scoped to theIHandleRequestsinstance, allowing it to work with aDbContextthat is also scoped per request. - The
CommandProcessorcan be configured as a singleton, as it no longer holds a reference to a scopedIAmABoxTransactionProvider. - Testing will still be possible, as the
ClearServiceBusmethod will still be available to reset the static fields for testing purposes.