Dual Handler Pattern: Separating Saga Coordination from Entity Persistence

TL;DR: We refactored a ~3,000-line monolithic saga into a pattern where the saga handles only coordination (saga data) while a second handler on the same message handles entity persistence. We are not using the Outbox or Transactional Session and have no plans to adopt them for now. Looking for feedback on whether this is a sound approach or if there are pitfalls we’re missing.


The Problem

We have a saga that orchestrates a process. Over time, the saga grew to ~3,000 lines because it was doing everything inside its Handle methods:

  • Updating saga data (coordination state)
  • Loading and mutating domain entities
  • Calling SaveChanges on the DbContext, often multiple times per handler, scattered across different code paths within the same Handle method
  • Sending/publishing messages, often multiple times per handler as well

This created several issues:

  1. Violated Particular’s guidance — Saga handlers were persisting entity state directly, which David Boike described as a “somewhat dangerous antipattern”
  2. No separation of concerns — Saga coordination, domain logic, and infrastructure were all in one place
  3. Hard to reason about failures — If SaveChanges failed, saga data had already been modified in the same handler

See the diagram at the end of the post.

The Solution: Dual Handler Pattern

We split each message’s processing into two handlers that both handle the same message type:

  1. Saga handler — Updates only saga data (coordination state). Lightweight, no entity work.
  2. Domain handler — Loads entities, applies domain transitions, calls SaveChanges, sends messages. Reads saga data from the pipeline context when needed.

Both handlers execute within the same NServiceBus pipeline invocation.

Important: We are not using the Outbox or Transactional Session. Our entity persistence goes through EF Core’s SaveChanges within the handler, and saga persistence is managed by NServiceBus via SynchronizedStorageSession. We have no plans to adopt Outbox or Transactional Session for now.

See the diagram at the end of the post.

How It Works

1. Handler Ordering

We use ExecuteTheseHandlersFirst to guarantee the saga runs before the domain handler:

endpointConfig.ExecuteTheseHandlersFirst(typeof(Saga));

2. Pipeline Behaviors Bridge Saga Data to the Domain Handler

Two pipeline behaviors coordinate the data flow:

See the diagram at the end of the post.

InitBehavior — Creates a shared SagaDataAccessor in the pipeline context before any handler runs:

public class SagaDataAccessor
{
    public SagaData? Data { get; set; }
}

public class InitBehavior : Behavior<IIncomingLogicalMessageContext>
{
    public override async Task Invoke(IIncomingLogicalMessageContext context, Func<Task> next)
    {
        context.Extensions.Set(new SagaDataAccessor());
        await next();
    }
}

FillBehavior — After the saga handler runs, copies saga data into the shared accessor so the domain handler can read it:

public class FillBehavior : Behavior<IInvokeHandlerContext>
{
    public override async Task Invoke(IInvokeHandlerContext context, Func<Task> next)
    {
        await next();

        if (context.MessageHandler.Instance is Saga saga
            && context.Extensions.TryGet<SagaDataAccessor>(out var accessor))
        {
            accessor.Data = saga.Data;
        }
    }
}

3. Domain Handler Structure

Every domain handler follows the same four-step pattern:

See the diagram at the end of the post.

  1. Read saga data doing var sagaData = context.Extensions.Get<SagaDataAccessor>().Data!; if needed
  2. Call an application service — The service loads entities, applies domain logic, and tracks entity mutations. Critically, the service cannot write to the database and cannot send messages. It can only track entities (via a DbContext) and return a list of messages to be sent.
  3. SaveChanges — The handler persists all tracked entity mutations in a single transaction.
  4. Send messages — The handler dispatches the messages returned by the service in step 2.

This enforces a clear boundary: the service is a pure orchestrator (Functional Core), and the handler is the imperative shell that performs side effects.

4. Concrete Example

For an ExampleMessage, the saga captures coordination state and the domain handler does the entity work:

Saga handler (~10 lines) — coordination only:

public partial class Saga
{
    public async Task Handle(ExampleMessage message, IMessageHandlerContext context)
    {
        if (Data.IsInCertainState)
        {
            Data.SomeEnum = message.SomeFlag ? EnumOptionA : EnumOptionB;
        }
    }
}

Domain handler (~15 lines) — follows the four-step pattern:

public class DomainHandler(DbContext dbContext, DomainService service) : IHandleMessages<ExampleMessage>
{
    public async Task Handle(ExampleMessage message, IMessageHandlerContext context)
    {
        // 1. Read saga data
        var sagaData = context.Extensions.Get<SagaDataAccessor>().Data!;

        // 2. Call service — cannot write DB, cannot send messages, only tracks entities and returns messages to send
        var messages = await service.ExecuteAsync(sagaData, context.CancellationToken);

        // 3. Persist all tracked entity mutations
        await dbContext.SaveChanges(context.CancellationToken);

        // 4. Send messages returned by the service
        await context.SendMessagesAsync(messages, context.CancellationToken);
    }
}

Atomicity: What Happens on Failure?

We rely on SynchronizedStorageSession to guarantee that saga data and entity changes are committed or rolled back together.

See the diagram at the end of the post.

We verified this by deploying a test to a real environment (SQL Persistence + Azure Service Bus transport in ReceiveOnly transport transaction mode):

  • Saga handler increments Data.TestValue from 0 → 1
  • Domain handler reads TestValue, then throws
  • On retry, saga handler sees TestValue is 0 again — proving SynchronizedStorageSession rolled back the saga data mutation

Safety Guarantees

Scenario Behavior
Saga handler fails Pipeline short-circuits. Domain handler never runs.
Domain handler fails before or during SaveChanges Entity changes are never persisted, messages are never dispatched and SynchronizedStorageSession rolls back saga data. Both handlers retry together.
No saga instance found NServiceBus skips the saga. We call DoNotContinueDispatchingCurrentMessageToHandlers() to prevent the domain handler from running.
Domain handler succeeds SaveChanges commits entities. SynchronizedStorageSession commits saga data. Messages dispatched.

Known Tradeoff: Post-SaveChanges Failures

We are aware that if SaveChanges succeeds but a failure occurs after (e.g., during message dispatch or saga data commit), we can end up with:

  • Zombie records — Entity changes persisted to the database but the corresponding messages never sent
  • Inconsistent saga data — Entity state advanced but saga data rolled back, leaving them out of sync

We accept this tradeoff.

Benefits We’ve Seen

  1. Saga handlers are trivial — Most are 3-10 lines. Easy to review, test, and reason about.
  2. Domain handlers are independently testable — They receive saga data as input and produce entity changes + messages as output. No saga infrastructure needed in tests.
  3. Functional Core / Imperative Shell — Domain logic is pure computation (no I/O). The domain handler is the imperative shell that calls SaveChanges and sends messages.
  4. Single SaveChanges — One transaction per message. No split-brain between saga storage and entity storage.

Diagram: Saga Data Accessor + Atomic Retry

Questions for the Community

  1. Is this a sound use of SynchronizedStorageSession? We rely on the fact that saga data mutations are rolled back when a subsequent handler in the same pipeline throws. Is this a documented guarantee, or are we depending on an implementation detail?

  2. Are there edge cases with ExecuteTheseHandlersFirst + DoNotContinueDispatchingCurrentMessageToHandlers? We use this combination to ensure the saga always runs first and to prevent the domain handler from running when no saga instance is found. Any known pitfalls?

  3. Has anyone else used this pattern? We couldn’t find prior art for “two handlers on the same message where one is a saga and the other does entity work.” Is there a reason this isn’t more common?

  4. Pipeline behavior ordering — We register two behaviors (InitBehavior at IIncomingLogicalMessageContext and FillBehavior at IInvokeHandlerContext). Is there a cleaner way to bridge saga data to a second handler?

Would love to hear thoughts from @Andreas.Ohlund and @Daniel.Marbach on whether this aligns with the intended use of the saga infrastructure.


Using NServiceBus 9.x with SQL Persistence and Azure Service Bus transport in ReceiveOnly transport transaction mode. Not using Outbox or Transactional Session.

Hi

Thanks for your post. It looks interesting, but before I go deep, I’d like to clarify one thing. In one place, you mention that you accept the trade-off of an inconsistent state:

Inconsistent saga data — Entity state advanced but saga data rolled back, leaving them out of sync

But in another place, you mention

We rely on SynchronizedStorageSession to guarantee that **saga data and entity changes are committed or rolled back together

It seems that these statements contradict one another. Or am I missing something?

Szymon

Hello Szymon, let me rephrase. Those two statements describe different failure scenarios.

Before DbContext.SaveChanges() — SSS guarantees consistency

We rely on SynchronizedStorageSession to ensure that the saga handler and domain handler retry together. Our saga handlers are intentionally simple. They only set properties, increment counters, and manage coordination state such as retries and timeouts. There is no business logic in the saga; it is pure coordination.

Given that, SynchronizedStorageSession gives us this guarantee: if the domain handler fails due to business logic, validation, or during DbContext.SaveChanges(), saga data changes are not committed. Both handlers run again on retry with the same saga data and business data as before the failed attempt.

Additionally, if the saga handler itself were to fail, the pipeline short-circuits and the domain handler never runs, so there is no inconsistency risk in that scenario either.

After DbContext.SaveChanges() — no guarantee, and we accept that

We know that if a failure occurs after DbContext.SaveChanges() succeeds, for example during message dispatch or SSS commit, entity state will have advanced but saga data may roll back. This is the inconsistency tradeoff we accept due to not using the Outbox or Transactional Session.

Hi

You don’t need to use Outbox or TransactionalSession to have the atomicity between the handler and a saga. You can configure your Entity Framework to use the connection managed by SynchronizedStorageSession and then there will be a single transaction spanning both saga and handler. You can still have multiple SaveChanges() within that transaction, they will all become part of a single underlying ADO.NET transaction.

2 Likes

Thank you, @SzymonPobiega, for the guidance. Do you have any additional feedback regarding these 3 questions?

Also, does having these 2 handlers run in the same transaction cause the locking on the saga record to span the entire transaction, while both handlers are being executed? Other than clean code organization, is this effectively the same as having the code that interacts with the saga and the code that interacts with the business data in the same handler? I often see Particular caution against updating saga state and business data in the same handler. Does any of that guidance also apply here when we are mutating the state of both in the same transaction?

This pattern makes me more than a little nervous.

The biggest is what @BBrandtTX starts to ask about in the previous comment. Your suspicion is correct - if you have 2 message handlers for the same message, and one is in a saga, then both are going to be inside of the scope of the saga database transaction. The saga behavior will load the saga data, then pass off control to the execute handlers behavior, which is going to execute both handlers in series, and according to your ordering rules, before returning out of the execute handlers behavior back to the saga persistence behavior.

So by doing additional stuff in the second handler, you’re forcing that saga transaction to be open for longer, increasing the likelhood of deadlocks, which might happen simply because the number of concurrent messages being processed leads to lock escalation from the row level (granted I don’t know what persistence you’re using but I’m thinking in SQL terms here) to page/table level which could affect other transactions. A scatter/gather type of saga with extra handlers attached like this would certainly run into problems.

Beyond that, I’m concerned about the overall complexity around getting this to work, with the extra behaviors to bridge the saga data between the two handlers. I also don’t understand how the domain handlers are easy to test given they are now dependent upon loading the saga data from an external source, which is one more thing you have to mock/bootstrap.

But the separation exercise here is good because I think it leads to what I would recommend.

The saga handler would still “go first” but on its own, and then instead of having another handler ride on the same message id, that saga handler should just send a new, related message. So if you’re processing ExampleMessage (and that’s a saga message) do your saga stuff and then send ExampleMessageDomain. That class could contain the original ExampleMessage instance or map the properties from it, along with whatever data from the saga data that the domain handler will need to do its work.

So the advantages:

  1. The handlers classes are already set up for this, just change the message type of the domain handler and change the plumbing to route the necessary data in.
  2. Domain message does not get sent (and so no work done) if the saga is over.
  3. Domain handler has no ambient saga data transaction hanging over its head.
  4. Saga message handlers are evaluated VERY fast and changes are committed immediately.
  5. Change can be rolled out one message at a time and the system should still work.
  6. Data for each handler is explicit in the messages, rather than sharing access to saga data, which should make testing easier.
  7. No pipeline behaviors or handler ordering required for everything to work right.

Only disadvantage I see: You need to think about eventual consistency issues on each “handler split” you do. Is the saga handler itself sending messages that are going to cause other things to happen in the system before the domain handler finishes? If so, those message sends can be moved from the saga handler to the end of the domain handler.

I briefly thought about changes that would be visible elsewhere in the system (i.e. someone refreshing a webpage) but if the saga handler is only making decisions and sending messages, given that the saga data is encapsulated, there shouldn’t be any more widely visible system changes until after the domain handler executes anyway.

All that said, more specific answers to the questions posed:

  • Edge cases with Execute…First, DoNotContinue… - Not that I know of, but then again I wasn’t aware of the DoNotContinueDispatchingCurrentMessageToHandlers API. I will say that ExecuteTheseHandlersFirst is a leftover API from before NServiceBus had a behavior pipeline. It’s deprecated in NServiceBus 10, but replaced by an AddHandler() method we introduced for unrelated reasons around reducing our dependency on assembly scanning.
  • Anyone else used this pattern? This is the first I’ve heard of it.
  • Pipeline behavior ordering - Don’t know if there’s a cleaner way to do it in your current design, but with my suggestion you won’t need them at all.