Skip to content

Process Managers

Why process managers exist

An aggregate enforces invariants within a single consistency boundary. It takes a command, checks its state, and decides whether to accept or reject. This works beautifully when the entire workflow lives inside one boundary.

But real business workflows often span boundaries.

Consider an inter-library loan. A patron requests a book that your library does not have. You need to ask a partner library to lend it. The partner library checks availability, marks the book as borrowed, ships it to you, and you activate the loan for the patron. Eventually the patron returns the book and you ship it back.

No single aggregate owns this. The loan is one boundary. The book at the partner library is another. The workflow involves both, plus decisions that depend on reading projections (is the book available locally?). An aggregate cannot call services, cannot dispatch commands to other aggregates, and cannot react to events from external boundaries. It is deliberately limited to its own consistency boundary.

A process manager coordinates across boundaries. It is not an aggregate -- it does not enforce invariants. It orchestrates: it receives commands, reads from services, persists its own events, and dispatches commands to aggregates. Those aggregates respond with events, which route back to the process manager to continue the workflow.

When to reach for a process manager

If you find yourself wanting to call a service or dispatch a command from inside an aggregate's command handler -- stop. That logic belongs in a process manager.

What starts a process manager

A process manager is an Orleans grain. It activates the first time it receives a command and loads its state (initially empty) from the event store. There are two common ways to send that first command:

From an API endpoint. A user action triggers the workflow directly:

csharp
app.MapPost("/loans/{id}", async (string id, RequestLoanRequest req,
    IProcessRepository<LoanCommand> repo) =>
{
    var reply = await repo.Ask(id,
        new LoanCommand.RequestLoan(id, req.PatronId, req.BookId, req.PartnerLibraryId));
    return reply.IsAccepted ? Results.Accepted($"/loans/{id}") : Results.Conflict();
});

This is the inter-library loan example used throughout this page. A patron clicks a button, an API call sends RequestLoan, and the process begins.

From an event route. An aggregate emits an event that triggers the process through a Forward.To route:

csharp
Routes
    .On<HoldRequestPlaced>((evt, _) =>
        Forward.To(evt.HoldId, new LoanCommand.RequestLoan(
            evt.HoldId, evt.PatronId, evt.BookId, evt.LibraryId)))

Here the process starts autonomously — a HoldRequestPlaced event from another aggregate is picked up by the subscription and forwarded as a command to a new process instance. No API call involved.

Both patterns deliver a command to the grain. The grain doesn't know or care where it came from. From its perspective, a command arrived, there is no prior state, and the command handler decides what to do.

The three parts of a process manager

A process manager in Nagare has exactly three parts, defined by three override methods. We will walk through each using the InterLibraryLoan example from the library sample.

Before we look at the process itself, here are the domain types it works with:

csharp
// The loan's own commands
public abstract record LoanCommand
{
    public record RequestLoan(string LoanId, string PatronId, string BookId, string PartnerLibraryId) : LoanCommand;
    public record TransferConfirmed(string BookId) : LoanCommand;
    public record TransferRejected(string Reason) : LoanCommand;
    public record BookReturned() : LoanCommand;
}

// The loan's own events
public abstract record LoanEvent : IAggregateEvent<LoanEvent>
{
    public record Requested(string PatronId, string BookId, string PartnerLibraryId) : LoanEvent;
    public record BookReceived(string BookId) : LoanEvent;
    public record Cancelled(string Reason) : LoanEvent;
    public record Returned(string BookId, string PartnerLibraryId) : LoanEvent;
}

// The loan's state
public enum LoanStatus { NotStarted, Requested, AwaitingTransfer, Active, Returned, Cancelled }

public record LoanState : IAggregateState<LoanState>
{
    public LoanStatus Status { get; init; } = LoanStatus.NotStarted;
    public string PatronId { get; init; } = "";
    public string BookId { get; init; } = "";
    public string PartnerLibraryId { get; init; } = "";

    public static LoanState Default => new();
}

And the external types the process interacts with:

csharp
// Book aggregate commands -- the loan process dispatches these
public record BorrowBook(string BorrowerId) : BookCommand;
public record ReturnBook() : BookCommand;

// Book aggregate events -- BookBorrowed routes back to the loan process
public record BookBorrowed(string BorrowerId, DateTimeOffset BorrowedAt) : BookEvent;

// Read-only service for checking local availability
public interface IBookCatalog
{
    Task<BookAvailability> CheckAvailability(string bookId);
}

public record BookAvailability(bool IsAvailableLocally);

Part 1: State -- how the process tracks where it is

csharp
protected override EventHandlers<LoanEvent, LoanState> RegisterEventHandlers() =>
    Events
        .On<LoanEvent.Requested>((s, e) =>
            s with
            {
                Status = LoanStatus.AwaitingTransfer,
                PatronId = e.PatronId,
                BookId = e.BookId,
                PartnerLibraryId = e.PartnerLibraryId
            })
        .On<LoanEvent.BookReceived>((s, _) =>
            s with { Status = LoanStatus.Active })
        .On<LoanEvent.Cancelled>((s, _) =>
            s with { Status = LoanStatus.Cancelled })
        .On<LoanEvent.Returned>((s, _) =>
            s with { Status = LoanStatus.Returned })
        .Build();

This is identical to aggregate event handlers. Pure functions, (state, event) -> state, no side effects. The Events builder factory is inherited from Process<>. Nothing new here -- if you understand aggregate state folding, you understand process state folding.

Part 2: Decisions -- what happens at each step

csharp
protected override AsyncCommandHandlers<LoanCommand, LoanEvent, LoanState>
    RegisterCommandHandlers() =>
    Commands

        .On<LoanCommand.RequestLoan>(async (state, cmd, ctx) =>
        {
            if (state.Status != LoanStatus.NotStarted)
                return Then.Reject("Loan already started");

            // Consult a read model before deciding
            var catalog = ctx.Service<IBookCatalog>();
            var availability = await catalog.CheckAvailability(cmd.BookId);

            if (availability.IsAvailableLocally)
                return Then.Reject(
                    "Book is available locally — no inter-library loan needed");

            return Then
                .Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
                .AndDispatch(
                    Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
                        .WithProcessId(cmd.LoanId));
        })

        .On<LoanCommand.TransferConfirmed>(async (state, cmd, ctx) =>
        {
            if (state.Status != LoanStatus.AwaitingTransfer)
                return Then.Reject(
                    $"Not awaiting transfer, current status: {state.Status}");

            return Then.Persist(new LoanEvent.BookReceived(state.BookId));
        })

        .On<LoanCommand.TransferRejected>(async (state, cmd, ctx) =>
        {
            if (state.Status != LoanStatus.AwaitingTransfer)
                return Then.Reject(
                    $"Not awaiting transfer, current status: {state.Status}");

            return Then.Persist(new LoanEvent.Cancelled(cmd.Reason));
        })

        .On<LoanCommand.BookReturned>(async (state, cmd, ctx) =>
        {
            if (state.Status != LoanStatus.Active)
                return Then.Reject(
                    $"Loan is not active, current status: {state.Status}");

            return Then
                .Persist(new LoanEvent.Returned(state.BookId, state.PartnerLibraryId))
                .AndDispatch(Dispatch.To(state.BookId, new ReturnBook()));
        })

        .Build();

Command handlers are where a process manager diverges from an aggregate. Three differences matter:

The signature is async with a context parameter. Where an aggregate handler is (state, cmd) -> Effects, a process handler is async (state, cmd, ctx) -> ProcessEffects. The ctx is an IProcessContext that provides service access.

Service calls are allowed — and this is critical. Process managers regularly need to read from projections to make decisions. The RequestLoan handler consults the book catalog projection before deciding whether to proceed. This is what separates a process manager from an aggregate: it can query the world, not just its own state.

csharp
// Consult a read model before deciding
var catalog = ctx.Service<IBookCatalog>();
var availability = await catalog.CheckAvailability(cmd.BookId);

if (availability.IsAvailableLocally)
    return Then.Reject("Book is available locally — no inter-library loan needed");

Services should be read-only: projections, lookup tables, external API clients. The process coordinates writes by dispatching commands to aggregates, not by mutating state directly. Typical services include:

  • Projections — query read models (availability, capacity, schedules)
  • Lookup tables — reference data (branches, regions, skills)
  • API clients — external systems (mapping APIs, notification services)

Commands can be dispatched to other aggregates. The Then DSL gains .AndDispatch():

csharp
Then
    .Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
    .AndDispatch(
        Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
            .WithProcessId(cmd.LoanId))

Dispatch.To(targetId, command) creates an outbound command for the book aggregate. .WithProcessId(cmd.LoanId) attaches the loan's ID to the dispatch -- this is how the response finds its way back. More on that in Part 3.

WARNING

Aggregates must never call services or dispatch commands. They are pure functions. Process managers are the place for async operations and cross-boundary coordination.

Part 3: Responses -- how the loop closes

csharp
protected override EventRoutes RegisterEventRoutes() =>
    Routes
        .OnProcessEvent<BookBorrowed>((evt, loanId) =>
            new LoanCommand.TransferConfirmed(loanId))
        .Build();

This is the novel part. When the loan process dispatches BorrowBook to the book aggregate, it attaches .WithProcessId(cmd.LoanId). When the book aggregate persists BookBorrowed, the ProcessId is stored in the event's metadata. A subscription tails the book event store, finds events with a ProcessId, and delivers them to the matching route. The route maps BookBorrowed to TransferConfirmed and delivers it to the loan instance identified by loanId.

The loop closes. The process dispatched a command, an aggregate processed it and emitted an event, and that event routed back as a command on the originating process instance.

The complete process manager

Here is the entire InterLibraryLoan process in one block:

csharp
public class InterLibraryLoan : Process<LoanCommand, LoanEvent, LoanState>
{
    protected override EventHandlers<LoanEvent, LoanState> RegisterEventHandlers() =>
        Events
            .On<LoanEvent.Requested>((s, e) =>
                s with
                {
                    Status = LoanStatus.AwaitingTransfer,
                    PatronId = e.PatronId,
                    BookId = e.BookId,
                    PartnerLibraryId = e.PartnerLibraryId
                })
            .On<LoanEvent.BookReceived>((s, _) =>
                s with { Status = LoanStatus.Active })
            .On<LoanEvent.Cancelled>((s, _) =>
                s with { Status = LoanStatus.Cancelled })
            .On<LoanEvent.Returned>((s, _) =>
                s with { Status = LoanStatus.Returned })
            .Build();

    protected override AsyncCommandHandlers<LoanCommand, LoanEvent, LoanState>
        RegisterCommandHandlers() =>
        Commands

            .On<LoanCommand.RequestLoan>(async (state, cmd, ctx) =>
            {
                if (state.Status != LoanStatus.NotStarted)
                    return Then.Reject("Loan already started");

                // Consult a read model before deciding
                var catalog = ctx.Service<IBookCatalog>();
                var availability = await catalog.CheckAvailability(cmd.BookId);

                if (availability.IsAvailableLocally)
                    return Then.Reject(
                        "Book is available locally — no inter-library loan needed");

                return Then
                    .Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
                    .AndDispatch(
                        Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
                            .WithProcessId(cmd.LoanId));
            })

            .On<LoanCommand.TransferConfirmed>(async (state, cmd, ctx) =>
            {
                if (state.Status != LoanStatus.AwaitingTransfer)
                    return Then.Reject(
                        $"Not awaiting transfer, current status: {state.Status}");

                return Then.Persist(new LoanEvent.BookReceived(state.BookId));
            })

            .On<LoanCommand.TransferRejected>(async (state, cmd, ctx) =>
            {
                if (state.Status != LoanStatus.AwaitingTransfer)
                    return Then.Reject(
                        $"Not awaiting transfer, current status: {state.Status}");

                return Then.Persist(new LoanEvent.Cancelled(cmd.Reason));
            })

            .On<LoanCommand.BookReturned>(async (state, cmd, ctx) =>
            {
                if (state.Status != LoanStatus.Active)
                    return Then.Reject(
                        $"Loan is not active, current status: {state.Status}");

                return Then
                    .Persist(new LoanEvent.Returned(state.BookId, state.PartnerLibraryId))
                    .AndDispatch(Dispatch.To(state.BookId, new ReturnBook()));
            })

            .Build();

    protected override EventRoutes RegisterEventRoutes() =>
        Routes
            .OnProcessEvent<BookBorrowed>((evt, loanId) =>
                new LoanCommand.TransferConfirmed(loanId))
            .Build();
}

How it differs from an aggregate

AggregateProcess Manager
PurposeEnforce invariants within one boundaryCoordinate across boundaries
Command handlers(state, cmd) -> Effectsasync (state, cmd, ctx) -> ProcessEffects
Service accessNever -- pure functions onlyctx.Service<T>() via IProcessContext
Dispatches to other aggregatesNever -- aggregates emit events, not commandsThen.Persist().AndDispatch()
Event routesNo -- aggregates receive commands directlyRoutes.OnProcessEvent<>() / Routes.On<>()
ConcurrencyOptimistic (version check on append)Single-writer (Orleans grain mailbox)
Builder factoriesEvents / CommandsEvents / Commands / Routes
RegistrationAddAggregate<>().AddProcess<>().DispatchesTo<>().ReactsTo<>()
TestingAggregateTestHarnessProcessTestHarness

When to use which

If the logic is pure validation and state transitions within one boundary, use an aggregate. If you need to coordinate across aggregates with tracked progress and correlated responses, use a process manager. If you just need to react to events statelessly (update a read model, call an API), use a subscription or message handler — you don't need a process manager for that.

The ProcessEffects DSL

Process command handlers return ProcessEffects<TEvent> through the Then DSL.

Persist events

csharp
// Single event
Then.Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))

// Multiple events
Then.PersistAll([
    new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId),
    new LoanEvent.BookReceived(cmd.BookId)
])

Dispatch commands to other aggregates

csharp
// Persist + dispatch with ProcessId for correlation
Then
    .Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
    .AndDispatch(
        Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
            .WithProcessId(cmd.LoanId))

// Persist + dispatch without correlation (fire-and-forget)
Then
    .Persist(new LoanEvent.Returned(state.BookId, state.PartnerLibraryId))
    .AndDispatch(Dispatch.To(state.BookId, new ReturnBook()))

Dispatches are sent after the events are persisted. They are fire-and-forget from the grain -- if a target aggregate needs to report back, it publishes an event that the process receives through an event route.

The .WithProcessId(loanId) call attaches the loan's ID as ProcessId in the dispatched command's metadata. When the book aggregate persists BookBorrowed, the ProcessId is stored alongside it. The event route subscription reads this metadata to deliver the event back to the correct loan instance.

Reject

csharp
Then.Reject("Loan already started")

Rejection short-circuits: no events are persisted, no commands are dispatched.

Accept without persisting

csharp
Then.Accept()

Idempotent acknowledgement. The command was valid but there is nothing new to record.

Chaining

All methods on ProcessEffects<TEvent> are chainable:

MethodPurpose
.AndPersist(event)Append another event
.AndPersistAll(events)Append multiple events
.AndDispatch(dispatch)Add an outbound command
.AndDispatchAll(dispatches)Add multiple outbound commands

The dispatch-response cycle

This is the most important concept to understand. Here is the complete flow when the loan process dispatches BorrowBook to the book aggregate:

  1. Process dispatches. The RequestLoan handler calls Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId)).WithProcessId(cmd.LoanId). The events (LoanEvent.Requested) are persisted first, then the outbound command is sent.

  2. Command reaches the target aggregate. The CommandDispatcher sends BorrowBook to the BookAggregate identified by cmd.BookId. The ProcessId (cmd.LoanId) travels in the command's metadata.

  3. Aggregate persists events with ProcessId. The BookAggregate accepts the command and persists BookBorrowed. The event's metadata carries ProcessId = "loan-1" -- the ID of the loan instance that initiated this.

  4. Subscription picks up the event. The .ReactsTo<BookEvent>() registration wired a subscription that tails the book event store. It reads BookBorrowed and sees a ProcessId in the metadata.

  5. Event route maps to a command. The subscription checks the process's event routes. OnProcessEvent<BookBorrowed> matches. It calls the mapping function with the event and the ProcessId, producing new LoanCommand.TransferConfirmed("loan-1").

  6. Command delivered to the process grain. The subscription sends TransferConfirmed to the process grain identified by ProcessId ("loan-1").

  7. Grain wakes and handles the response. The InterLibraryLoan grain loads its state, runs the TransferConfirmed handler, and persists LoanEvent.BookReceived. The loan is now active.

    InterLibraryLoan                  BookAggregate
    (grain: loan-1)                   (aggregate: book-1)
         │                                  │
         │  1. Dispatch BorrowBook          │
         │  (ProcessId: "loan-1")           │
         │─────────────────────────────────>│
         │                                  │
         │                                  │  2. Persist BookBorrowed
         │                                  │  (metadata.ProcessId: "loan-1")
         │                                  │
         │  3. Event route subscription     │
         │  reads BookBorrowed, extracts    │
         │  ProcessId, maps to             │
         │  TransferConfirmed               │
         │<─────────────────────────────────│
         │                                  │
         │  4. Handle TransferConfirmed     │
         │  Persist BookReceived            │
         │  Status → Active                 │
         │                                  │

Event route patterns

Event routes declare which external events feed this process. The runtime reads these at registration and wires subscriptions automatically. There are two patterns.

OnProcessEvent -- correlated responses

When a process dispatches a command to an aggregate with .WithProcessId(), the aggregate's events carry that ProcessId in metadata. OnProcessEvent reads this metadata to route the event back to the originating process instance:

csharp
protected override EventRoutes RegisterEventRoutes() =>
    Routes
        .OnProcessEvent<BookBorrowed>((evt, loanId) =>
            new LoanCommand.TransferConfirmed(loanId))
        .Build();

The loanId parameter is the ProcessId extracted from the event's metadata -- the same value passed via .WithProcessId(cmd.LoanId) when the command was dispatched. Events without a ProcessId in metadata are silently ignored.

Use OnProcessEvent when the process dispatched a command and expects a response from the target aggregate.

On with Forward.To -- trigger events

When an external event carries its own routing key (for example, an order ID in the event payload), use On with Forward.To:

csharp
protected override EventRoutes RegisterEventRoutes() =>
    Routes
        .On<PaymentEvent.Completed>((evt, _) =>
            Forward.To(evt.OrderId, new OrderCommand.PaymentReceived(evt.Amount)))
        .On<PaymentEvent.Failed>((evt, _) =>
            Forward.To(evt.OrderId, new OrderCommand.PaymentFailed(evt.Reason)))
        .Build();

Each route receives the external event and optional metadata, and returns either:

  • Forward.To(processId, command) -- deliver this command to the process instance
  • null -- ignore this event

Common patterns

Correlated response -- the process dispatches to an aggregate with ProcessId, and the aggregate's event routes back via OnProcessEvent:

csharp
// In the command handler: dispatch with ProcessId
Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
    .WithProcessId(cmd.LoanId)

// In the event routes: route back via ProcessId in metadata
Routes
    .OnProcessEvent<BookBorrowed>((evt, loanId) =>
        new LoanCommand.TransferConfirmed(loanId))

Trigger -- an external event starts or feeds a process instance:

csharp
Routes
    .On<HoldRequestPlaced>((evt, _) =>
        Forward.To(evt.HoldId, new LoanCommand.RequestLoan(
            evt.HoldId, evt.PatronId, evt.BookId, evt.LibraryId)))

Conditional routing -- return null to skip events that do not apply:

csharp
Routes
    .On<BookEvent>((evt, _) => evt switch
    {
        BookLost e => Forward.To(e.BookId,
            new LoanCommand.TransferRejected("Book reported lost")),
        _ => null
    })

Registration

Process managers require Orleans for single-writer guarantees. Configure Orleans on your host, then register the process with its dispatch routes and event route subscriptions:

csharp
// --- Storage ---
builder.Services.AddNagare();
builder.Services.AddSqliteEventStore<BookEvent>("BookEvents");
builder.Services.AddSqliteEventStore<LoanEvent>("LoanEvents");
builder.Services.AddSqliteCheckpointStore();

// --- Book aggregate (dispatch target) ---
builder.Services.AddAggregate<BookAggregate, BookCommand, BookEvent, BookState>();

// --- Projections ---
builder.Services.AddSingleton<BookCatalogProjection>();
builder.Services.AddSingleton<IBookCatalog>(sp => sp.GetRequiredService<BookCatalogProjection>());
builder.Services.AddSubscription<BookCatalogProjection, BookEvent>();

// --- Orleans + process manager ---
builder.Host.UseOrleans(silo => silo.UseLocalhostClustering());
builder.Services
    .AddProcess<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
    .DispatchesTo<BookCommand>()
    .ReactsTo<BookEvent>();

What each line does:

  • AddProcess registers the process type, its Orleans grain, and an IProcessRepository<LoanCommand> for sending commands to the process.
  • .DispatchesTo<BookCommand>() registers a dispatch route so the process can send BookCommand to the BookAggregate. Call once per target aggregate command type.
  • .ReactsTo<BookEvent>() wires a subscription that tails the BookEvent store and routes matching events (like BookBorrowed with a ProcessId in metadata) back to the loan process.

Sending commands to a process

Use IProcessRepository<TCommand> to send commands to a process instance:

csharp
app.MapPost("/loans/{id}", async (
    string id,
    RequestLoanRequest req,
    IProcessRepository<LoanCommand> repo) =>
{
    var reply = await repo.Ask(id,
        new LoanCommand.RequestLoan(id, req.PatronId, req.BookId, req.PartnerLibraryId));
    return reply.IsAccepted ? Results.Accepted($"/loans/{id}") : Results.Conflict();
});

app.MapPost("/loans/{id}/return", async (
    string id,
    IProcessRepository<LoanCommand> repo) =>
{
    var reply = await repo.Ask(id, new LoanCommand.BookReturned());
    return reply.IsAccepted ? Results.Ok() : Results.Conflict();
});

Testing with ProcessTestHarness

ProcessTestHarness provides the same Given/When/Then pattern as aggregate testing, with additional support for service injection and dispatch assertions. No Orleans, no database, no infrastructure.

Happy path — with service injection

The RequestLoan handler calls ctx.Service<IBookCatalog>() to check local availability before proceeding. In tests, provide the service via .WithService():

csharp
[Fact]
public async Task Request_loan_checks_catalog_then_dispatches()
{
    var catalog = Substitute.For<IBookCatalog>();
    catalog.CheckAvailability(Arg.Any<string>())
        .Returns(new BookAvailability(IsAvailableLocally: false));

    var result = await ProcessTestHarness
        .For<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
        .WithService(catalog)
        .When(new LoanCommand.RequestLoan("loan-1", "patron-1", "book-1", "partner-lib"));

    result
        .ThenAccepts()
        .ThenPersists<LoanEvent.Requested>(e =>
            e.PatronId == "patron-1" && e.BookId == "book-1")
        .ThenDispatches<BorrowBook>(
            d => d.TargetId.Value == "book-1");
}

Rejection from a read model

When the catalog reports the book is available locally, the process rejects without dispatching:

csharp
[Fact]
public async Task Request_loan_rejected_when_available_locally()
{
    var catalog = Substitute.For<IBookCatalog>();
    catalog.CheckAvailability(Arg.Any<string>())
        .Returns(new BookAvailability(IsAvailableLocally: true));

    var result = await ProcessTestHarness
        .For<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
        .WithService(catalog)
        .When(new LoanCommand.RequestLoan("loan-1", "patron-1", "book-1", "partner-lib"));

    result
        .ThenRejects("available locally")
        .ThenDoesNotDispatch();
}

Setting up prior state

.Given() replays events to build state before the command runs. Same as aggregate testing:

csharp
[Fact]
public async Task Transfer_confirmed_activates_the_loan()
{
    var result = await ProcessTestHarness
        .For<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
        .Given(
            new LoanEvent.Requested("patron-1", "book-1", "partner-lib"))
        .When(new LoanCommand.TransferConfirmed("book-1"));

    result
        .ThenAccepts()
        .ThenPersists<LoanEvent.BookReceived>();
}

State-based rejection

csharp
[Fact]
public async Task Request_loan_when_already_started_rejects()
{
    var catalog = Substitute.For<IBookCatalog>();
    catalog.CheckAvailability(Arg.Any<string>())
        .Returns(new BookAvailability(IsAvailableLocally: false));

    var result = await ProcessTestHarness
        .For<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
        .WithService(catalog)
        .Given(
            new LoanEvent.Requested("patron-1", "book-1", "partner-lib"))
        .When(new LoanCommand.RequestLoan("loan-1", "patron-1", "book-1", "partner-lib"));

    result
        .ThenRejects("Loan already started")
        .ThenDoesNotDispatch();
}

Missing service error

If a command handler calls ctx.Service<T>() for a type that was not registered via .WithService(), the test throws an InvalidOperationException with a clear message:

csharp
[Fact]
public async Task Missing_service_throws_clear_error()
{
    var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
        ProcessTestHarness
            .For<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
            .When(new LoanCommand.RequestLoan("loan-1", "patron-1", "book-1", "partner-lib")));

    Assert.Contains("IBookCatalog", ex.Message);
    Assert.Contains("WithService", ex.Message);
}

Testing event routes directly

Event routes can be tested without the harness by instantiating the process and calling GetEventRoutes():

csharp
[Fact]
public void Event_routes_map_book_borrowed_to_transfer_confirmed()
{
    var process = new InterLibraryLoan();
    var routes = process.GetEventRoutes();

    var bookBorrowed = new BookBorrowed("patron-1", DateTimeOffset.UtcNow);
    var metadata = new EventMetadata(ProcessId: "loan-1");
    var route = routes.Registrations
        .Select(r => r.Map(bookBorrowed, metadata))
        .FirstOrDefault(r => r is not null);

    Assert.NotNull(route);
    Assert.Equal("loan-1", route!.TargetProcessId.Value);
    Assert.IsType<LoanCommand.TransferConfirmed>(route.Command);
}

Assertion reference

MethodAsserts
.ThenAccepts()Command was accepted (not rejected)
.ThenRejects("reason")Command was rejected, reason contains the substring
.ThenPersists<T>()An event of type T was persisted
.ThenPersists<T>(predicate)An event of type T matching the predicate was persisted
.ThenDispatches<T>()A command of type T was dispatched
.ThenDispatches<T>(predicate)A dispatch matching the predicate was sent
.ThenDoesNotDispatch()No outbound commands were dispatched

How Orleans is used

The developer never interacts with Orleans directly. Under the hood:

  • Each process instance is an Orleans grain identified by the process ID
  • The grain provides single-writer semantics -- all commands for a given process instance are processed sequentially through the grain's actor mailbox
  • State is loaded from the Nagare event store on grain activation (not Orleans persistence)
  • Events are appended to the Nagare event store on each command
  • Outbound dispatches are fire-and-forget from the grain -- responses come back through event routes, not return values

This means:

  • No optimistic concurrency conflicts. The grain serializes all writes.
  • No concurrent side effects. Service calls and dispatches happen one at a time.
  • The developer writes a Process<> class. Orleans provides the runtime guarantees.

Local development

csharp
builder.Host.UseOrleans(silo => silo.UseLocalhostClustering());

Single-silo cluster on localhost. No external infrastructure needed.

Production (Kubernetes)

csharp
builder.Host.UseOrleans(silo =>
{
    silo.UseKubernetesHosting();
    silo.UseRedisClustering(connectionString);
});

Production (Azure)

csharp
builder.Host.UseOrleans(silo =>
{
    silo.UseAzureStorageClustering(options =>
        options.ConfigureTableServiceClient(connectionString));
});

Already using Orleans?

If your application already configures Orleans, AddProcess taps into the existing silo. No additional configuration needed.

When to use a process manager

You probably don't need one

Most coordination does not require a process manager. In the vast majority of cases — 95% or more — an API endpoint or gateway that reads projections and dispatches commands to the right aggregates is all you need. The caller orchestrates: read a projection to check state, issue a command, read another projection, issue another command. This is simple, explicit, and easy to debug.

A step up from that is a stateless subscription that translates events into commands within the same bounded context — the process manager pattern without the ceremony. See Modular Monolith — Within a bounded context for a worked example.

Process managers are a powerful tool, but they add real complexity: Orleans hosting, event routes, the dispatch-response cycle, state reconstruction on activation. Reach for one only when the simpler approaches genuinely cannot solve the problem.

What a process manager is actually for

A process manager earns its complexity when the workflow is long-running, stateful, and autonomous — it cannot be driven by a single API call because it unfolds over time, may pause for external input, and must remember where it is across those pauses.

Scheduling and human-in-the-loop workflows. Build a draft roster with some shifts pre-filled. The process remembers which shifts are filled and which need human input. It waits — hours, days — for a manager to confirm or override choices. Each decision is an event. The entire decision trail is auditable.

Real-time assessment and reaction. Ingest GPS coordinates in real time. The process tracks whether a delivery driver is on schedule. If they fall behind a threshold, it autonomously triggers counter-actions: reassign the delivery, notify the customer, update ETAs. The decision history — when the delay was detected, what was tried, what worked — is preserved in the event stream.

Multi-step orchestration with compensation. An inter-library loan dispatches a borrow request, waits for confirmation or rejection, and if the book is lost mid-transit, compensates by cancelling the patron's hold. Each step depends on the outcome of the previous one, and the process must handle every branch.

The common thread: the process itself is event-sourced. Every decision, every external response, every state transition is recorded. This gives you a complete audit trail of how the workflow unfolded — not just the final result, but every step along the way.

Decision table

ScenarioUse
Validate state and emit events within one boundaryAggregate
Pure state machine, no I/OAggregate
Simple CRUD-like operationsAggregate (or skip ES entirely)
Coordinate aggregates from an API call (read projection, issue commands)Gateway / endpoint
One aggregate's event should trigger a command on another (same context, one-way)Stateless subscription
React to events and update a read model (stateless)Subscription / Projection
React to events and call an external API (stateless)Message handler
Coordinate across bounded contextsMessage broker (Messaging)
Long-running workflow that pauses for external inputProcess manager
Autonomous real-time assessment and reactionProcess manager
Multi-step orchestration with compensation and full audit trailProcess manager

The key distinction: a subscription or message handler is stateless — it reacts to an event and forgets. A gateway orchestrates but doesn't remember — if it crashes mid-flow, the state is lost. A process manager is stateful and durable — it reacts, remembers what happened, survives restarts, and decides what to do next based on the full history of the workflow.

When you do NOT need one

A process manager introduces Orleans, grains, and event route subscriptions. Most cross-aggregate coordination doesn't need it. This section works through concrete examples of what a module can handle, and where the boundary is.

Example 1: bulk book returns

A library receives a crate of 20 returned books. Each book is its own aggregate. The librarian scans them all at once:

csharp
public async Task<BulkReturnResult> BulkReturn(List<string> bookIds)
{
    var successes = new List<string>();
    var failures = new List<(string BookId, string Reason)>();

    foreach (var bookId in bookIds)
    {
        var book = await _bookRepo.Load(new AggregateId(bookId));
        var reply = await book.Ask(new ReturnBook());

        if (reply.IsAccepted) successes.Add(bookId);
        else failures.Add((bookId, reply.RejectionReason));
    }

    return new BulkReturnResult(successes, failures);
}

This finishes in one request. If the server crashes after book 12, the librarian scans the remaining 8 again. Each BookReturned event is already persisted — that's the audit trail. No process manager needed.

Example 2: cascading a patron ban

A patron is banned from the library. All their active loans need to be recalled, their reservations cancelled, and their account suspended. Three different aggregates.

csharp
public async Task BanPatron(string patronId)
{
    // 1. Suspend the account
    var account = await _accountRepo.Load(new AggregateId(patronId));
    await account.Ask(new SuspendAccount("Patron banned"));

    // 2. Cancel all reservations
    var reservations = await _reservationQuery.GetActiveForPatron(patronId);
    foreach (var resId in reservations)
    {
        var res = await _reservationRepo.Load(new AggregateId(resId));
        await res.Ask(new CancelReservation("Patron banned"));
    }

    // 3. Recall all active loans
    var loans = await _loanQuery.GetActiveForPatron(patronId);
    foreach (var loanId in loans)
    {
        var loan = await _loanRepo.Load(new AggregateId(loanId));
        await loan.Ask(new RecallLoan("Patron banned"));
    }
}

This touches multiple aggregate types, reads from projections, and has compensating semantics (if the account suspension succeeds but a loan recall fails, you have a partially banned patron). But it still finishes in one request. The librarian sees the result. The events on each aggregate are the audit trail. A process manager would add complexity without adding value.

Example 3: where a module breaks down

Now consider a library network that wants to redistribute 500 books across 12 branches after a branch closes. For each book: check which branch has demand, check shelf capacity at that branch, transfer the book, update the catalog. Some branches reject (shelf full), so you try the next best branch. This takes minutes, not seconds.

A module doing this in a loop:

  • Holds 500 in-flight states in memory. If the server restarts at book 300, everything is lost.
  • The librarian stares at a spinner for two minutes with no progress indication.
  • When a branch rejects a book, the module retries in-memory. But between the read and the retry, another book may have filled that branch's last slot. The retry logic gets complicated.
  • After the redistribution, someone asks "why did this book go to branch X instead of branch Y?" The loop made that decision in memory. It's gone.

This is where a process manager earns its place.

The three questions

Before reaching for a process manager, ask:

  1. Does it take longer than a request-response cycle? The bulk return (20 books, < 1 second) and the patron ban (3 aggregate types, < 2 seconds) both finish within a request. The redistribution (500 books with scoring and retries) takes minutes.

  2. Does it need to survive a restart? If the bulk return crashes at book 12, scan the other 8 again. If the redistribution crashes at book 300, you don't want to re-check 300 branches that already accepted.

  3. Do you need a queryable decision trail beyond what aggregates produce? The bulk return and patron ban are simple: each aggregate records what happened. The redistribution involves decisions: which branch was tried first, why it rejected, which branch was tried next, what the demand scores were. Without a process manager, those decisions lived in a loop variable and are gone.

If all three are no, use a module. If any is yes, consider a process manager.

Concurrency makes the case stronger

The redistribution runs while branch librarians are simultaneously updating their own shelves. The process proposes sending "Domain-Driven Design" to branch A. Branch A's shelf aggregate rejects: "capacity full" (a librarian just added 5 books). The process needs to re-score, try branch B, and remember that branch A was already tried for this book.

A module handles this with in-memory retries. A process manager handles it structurally: the shelf aggregate rejects, the rejection routes back as an event, the process persists TransferRejected(bookId, branchA, "capacity full"), re-scores excluding branch A, and dispatches to branch B. If the server restarts between these steps, the grain rehydrates from its events: "I already tried branch A for this book, it was rejected, I should try branch B."

For 20 books, in-memory retries work. For 500 books over several minutes while other people are editing, you want the structural approach.

Projecting process state

A process manager persists events to its own journal. Those events are subscribable like any other. This is where progress tracking and audit trails come from.

Order tracking dashboard

The order fulfillment process emits Placed, InventoryReserved, PaymentConfirmed, PaymentRejected, Shipped. A projection builds a customer-facing order tracker:

csharp
public class OrderTrackingProjection : ISubscription<OrderEvent>
{
    public async Task Handle(EventEnvelope<OrderEvent> evt)
    {
        switch (evt.Event)
        {
            case OrderEvent.Placed e:
                await _db.ExecuteAsync("""
                    INSERT INTO order_tracking (id, order_id, items, status)
                    VALUES (@Id, @OrderId, @Items, 'awaiting payment')
                    """, new { Id = evt.AggregateId.Value, e.OrderId,
                               Items = string.Join(", ", e.Items) });
                break;

            case OrderEvent.PaymentConfirmed:
                await _db.ExecuteAsync(
                    "UPDATE order_tracking SET status = 'awaiting shipment' WHERE id = @Id",
                    new { Id = evt.AggregateId.Value });
                break;

            case OrderEvent.PaymentRejected e:
                await _db.ExecuteAsync(
                    "UPDATE order_tracking SET status = 'failed', reason = @Reason WHERE id = @Id",
                    new { Id = evt.AggregateId.Value, e.Reason });
                break;

            case OrderEvent.Shipped e:
                await _db.ExecuteAsync(
                    "UPDATE order_tracking SET status = 'shipped', tracking = @Tracking WHERE id = @Id",
                    new { Id = evt.AggregateId.Value, Tracking = e.TrackingNumber });
                break;
        }
    }
}
csharp
builder.Services.AddSubscription<OrderTrackingProjection, OrderEvent>();

Result:

| order_id | items            | status           | tracking  | reason        |
|----------|------------------|------------------|-----------|---------------|
| order-1  | item-a, item-b   | shipped          | TRACK-123 |               |
| order-2  | item-c           | awaiting payment |           |               |
| order-3  | item-a           | failed           |           | Card declined |

Loan audit trail

The inter-library loan process emits Requested, BookReceived, Cancelled, Returned. A projection tracks each loan with timestamps:

csharp
public class LoanAuditProjection : ISubscription<LoanEvent>
{
    public async Task Handle(EventEnvelope<LoanEvent> evt)
    {
        switch (evt.Event)
        {
            case LoanEvent.Requested e:
                await _db.ExecuteAsync("""
                    INSERT INTO loan_audit (loan_id, patron_id, book_id, partner_library, status, requested_at)
                    VALUES (@LoanId, @PatronId, @BookId, @PartnerLibraryId, 'awaiting transfer', @At)
                    """, new { LoanId = evt.AggregateId.Value, e.PatronId, e.BookId,
                               e.PartnerLibraryId, At = evt.Timestamp });
                break;

            case LoanEvent.BookReceived:
                await _db.ExecuteAsync(
                    "UPDATE loan_audit SET status = 'active', received_at = @At WHERE loan_id = @Id",
                    new { Id = evt.AggregateId.Value, At = evt.Timestamp });
                break;

            case LoanEvent.Cancelled e:
                await _db.ExecuteAsync(
                    "UPDATE loan_audit SET status = 'cancelled', reason = @Reason, cancelled_at = @At WHERE loan_id = @Id",
                    new { Id = evt.AggregateId.Value, e.Reason, At = evt.Timestamp });
                break;

            case LoanEvent.Returned:
                await _db.ExecuteAsync(
                    "UPDATE loan_audit SET status = 'returned', returned_at = @At WHERE loan_id = @Id",
                    new { Id = evt.AggregateId.Value, At = evt.Timestamp });
                break;
        }
    }
}
csharp
builder.Services.AddSubscription<LoanAuditProjection, LoanEvent>();

Result:

| loan_id | patron  | book_id | partner_library | status    | requested_at        | received_at         | returned_at         | reason           |
|---------|---------|---------|-----------------|-----------|---------------------|---------------------|---------------------|------------------|
| loan-1  | pat-42  | book-1  | partner-lib-1   | returned  | 2026-03-01 09:00:00 | 2026-03-03 14:30:00 | 2026-03-17 10:15:00 |                  |
| loan-2  | pat-7   | book-5  | partner-lib-2   | cancelled | 2026-03-02 11:00:00 |                     |                     | Book reported lost|
| loan-3  | pat-12  | book-3  | partner-lib-1   | active    | 2026-03-10 08:45:00 | 2026-03-12 16:00:00 |                     |                  |

An auditor asks: "What happened to the loan for book-5?" Query the table: it was requested on March 2, the partner library never confirmed the transfer, and it was cancelled with reason "Book reported lost." That cancellation came through the event route — someone at the partner library reported the book lost, which triggered LoanCommand.TransferRejected via a conditional route.

Live progress via SSE

For long-running processes where users want real-time updates, add a live subscription:

csharp
builder.Services.AddLiveSubscription<OrderSseBroadcast, OrderEvent>();

Connected clients see status transitions as they happen. No polling.

Multiple projections, one journal

You can subscribe multiple projections to the same process journal. The order fulfillment process emits events once. One projection builds the customer-facing tracker. Another builds an internal operations dashboard with different columns. A third feeds a notification system that sends emails on payment confirmation.

The process manager doesn't know about any of this. It persists events. Projections are separate subscribers that build whatever views you need. You can add new projections later without changing the process. Same separation that makes aggregate projections powerful: the write side doesn't know or care how the events are consumed.

Second example: order fulfillment

The library loan shows correlated dispatch-response (OnProcessEvent). Order fulfillment shows a different shape: reading from a projection before the first step, then reacting to external trigger events (Forward.To).

csharp
public class OrderFulfillment : Process<OrderCommand, OrderEvent, OrderState>
{
    protected override EventHandlers<OrderEvent, OrderState> RegisterEventHandlers() =>
        Events
            .On<OrderEvent.Placed>((s, e) =>
                s with { Status = OrderStatus.AwaitingPayment,
                         OrderId = e.OrderId, Items = e.Items })
            .On<OrderEvent.InventoryReserved>((s, _) => s)
            .On<OrderEvent.PaymentConfirmed>((s, _) =>
                s with { Status = OrderStatus.AwaitingShipment })
            .On<OrderEvent.PaymentRejected>((s, e) =>
                s with { Status = OrderStatus.Failed, Reason = e.Reason })
            .On<OrderEvent.Shipped>((s, _) =>
                s with { Status = OrderStatus.Shipped })
            .Build();

    protected override AsyncCommandHandlers<OrderCommand, OrderEvent, OrderState>
        RegisterCommandHandlers() =>
        Commands

            .On<OrderCommand.Place>(async (state, cmd, ctx) =>
            {
                // Read from inventory projection before deciding
                var stock = await ctx.Service<IInventoryProjection>()
                    .Check(cmd.Items);

                if (!stock.AllAvailable)
                    return Then.Reject(
                        $"Items unavailable: {string.Join(", ", stock.UnavailableItems)}");

                return Then
                    .PersistAll([
                        new OrderEvent.Placed(cmd.OrderId, cmd.Items),
                        new OrderEvent.InventoryReserved()
                    ])
                    .AndDispatch(Dispatch.To(cmd.OrderId,
                        new InventoryCommand.Reserve(cmd.Items)));
            })

            .On<OrderCommand.PaymentReceived>(async (state, cmd, ctx) =>
            {
                if (state.Status != OrderStatus.AwaitingPayment)
                    return Then.Reject(
                        $"Not awaiting payment, current status: {state.Status}");

                return Then
                    .Persist(new OrderEvent.PaymentConfirmed(cmd.Amount))
                    .AndDispatch(Dispatch.To(state.OrderId,
                        new ShippingCommand.Create(state.Items)));
            })

            .On<OrderCommand.PaymentFailed>(async (state, cmd, ctx) =>
            {
                return Then
                    .Persist(new OrderEvent.PaymentRejected(cmd.Reason))
                    .AndDispatch(Dispatch.To(state.OrderId,
                        new InventoryCommand.Release(state.Items)));
            })

            .On<OrderCommand.ShipmentConfirmed>(async (state, cmd, ctx) =>
                Then.Persist(new OrderEvent.Shipped(cmd.TrackingNumber)))

            .Build();

    // External payment events route to this process via Forward.To
    protected override EventRoutes RegisterEventRoutes() =>
        Routes
            .On<PaymentEvent.Completed>((evt, _) =>
                Forward.To(evt.OrderId,
                    new OrderCommand.PaymentReceived(evt.Amount)))
            .On<PaymentEvent.Failed>((evt, _) =>
                Forward.To(evt.OrderId,
                    new OrderCommand.PaymentFailed(evt.Reason)))
            .Build();
}

This process shows several patterns worth noting:

Projection check on entry. The Place handler calls ctx.Service<IInventoryProjection>() to verify stock before committing. Same pattern as the loan checking local availability.

Compensating action on failure. When payment fails, the process dispatches InventoryCommand.Release to undo the reservation. The process knows what to compensate because its state tracks the reserved items.

Trigger events via Forward.To. Payment events come from an external system. They carry the order ID in the payload, not in a ProcessId in metadata. Forward.To(evt.OrderId, ...) routes them to the right order instance. No prior dispatch needed -- the payment system doesn't know about this process.

Multi-step with waiting. After placing the order, the process waits for payment. It doesn't poll. It doesn't set a timer. The payment event arrives through the event route whenever the external system publishes it. Could be seconds, could be hours.

The flow:

POST /orders → Place → check inventory → persist Placed + InventoryReserved
                                        → dispatch Reserve to inventory
                                        → status: AwaitingPayment
                                        (process goes idle)

Payment service publishes PaymentCompleted
  → event route: Forward.To(orderId, PaymentReceived)
  → persist PaymentConfirmed
  → dispatch Create to shipping
  → status: AwaitingShipment

Shipping confirms
  → ShipmentConfirmed → persist Shipped
  → status: Shipped (terminal)

Start simple

Start with aggregates and subscriptions. Move to a process manager only when you need to track progress across multiple steps involving multiple aggregates. Most domains need one or two process managers at most. If your coordination finishes within a single request and doesn't need a decision trail beyond what the aggregates already produce, a module with a loop is the right choice.


Next: Read Models

流れ — flow.