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.