Skip to content

Process Managers

Nagare.Process is a long-running, stateful coordinator that lives between aggregates. It's hosted as an Orleans grain, persists its own events to the same store aggregates use, and turns incoming events into outbound commands. Aggregates stay pure; the cross-boundary glue ends up in one explicit place instead of leaking into endpoints.

A process is a Process<TCommand, TEvent, TState> subclass with three overrides: RegisterEventHandlers, RegisterCommandHandlers, and RegisterEventRoutes. Everything else — concurrency, hydration, retries, correlation — is the runtime's problem.

Choosing your approach

Three orthogonal decisions cover most cross-aggregate work. Get the first right and the rest follow.

1. Endpoint orchestration vs subscription vs process manager

PickWhen
Endpoint orchestration — a controller reads a projection, issues commandsOne request, one user, finishes synchronously. The 95% case.
Stateless subscriptionISubscription<TEvent> translates events to commandsOne bounded context, one-way fan-out, no state to track between steps.
Process managerProcess<> grain with its own event journalMulti-step, survives restarts, needs a queryable decision trail, may pause for external input.

The endpoint pattern is described in Modular Monolith — Within a bounded context. Reach past it only when one of the three process-manager triggers actually applies.

2. Correlated response vs trigger event

PickWhen
CorrelatedOnProcessEvent<T> reads ProcessId from event metadataThe process dispatched a command and wants the resulting event back.
TriggerOn<T> with Forward.To(id, command)An external event starts or feeds the process; the routing key lives in the event payload.

Both register in RegisterEventRoutes(). The runtime wires a subscription per declared event type and routes matches to the grain.

3. Same store vs separate store

Process events are appended to the same IEventStore<TEvent> as any other aggregate of that event type. There is no separate "process store" — the journal is just an event stream with the process's ID as the aggregate key. This means projections can subscribe to process events the same way they subscribe to aggregate events.

The three parts

Walk-through uses InterLibraryLoan from samples/Nagare.Samples.Library.

Event handlers — pure state folding

csharp
protected override EventHandlers<LoanEvent, LoanState> RegisterEventHandlers() =>
    Events
        .On<LoanEvent.Requested>((s, e) =>
            s with
            {
                Status = LoanStatus.AwaitingTransfer,
                LoanId = e.LoanId,
                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();

Identical to aggregate event handlers: (state, event) -> state, no I/O. The grain replays this from the event store on activation to reconstruct _state.

Command handlers — decisions with services and dispatch

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");

            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.LoanId, cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
                .AndDispatch(
                    Dispatch.To(cmd.BookId,
                            new BookCommand.RegisterPartnerLoan(cmd.PatronId, cmd.LoanId, cmd.PartnerLibraryId))
                        .WithProcessId(cmd.LoanId))
                .AndSchedule(TransferDeadlinePurpose, TransferDeadline, new LoanCommand.TransferTimedOut());
        })
        .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))
                .AndCancelSchedule(TransferDeadlinePurpose);
        })
        .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 BookCommand.ReleasePartnerLoan(state.LoanId)));
        })
        .Build();

Three differences from an aggregate handler:

  • Signature is async (state, cmd, ctx) -> ProcessEffects. The ctx is an IProcessContext exposing Service<T>() — pull projections, lookups, HTTP clients from DI.
  • Service calls are allowed. Reading a read model to make a decision is the whole point. The pre-condition for RequestLoan is "no local copy" and the only way to know is to ask the catalog projection.
  • Then.Persist(...).AndDispatch(...) chains outbound commands. The dispatch rides the same transaction as the events.

Persist before you dispatch

The grain rejects an effects payload that has dispatches but no events. Outbox writes ride the event-append transaction; without an event, there is nothing to commit them atomically with. If you want a side-effect-only step, persist a marker event for it.

Event routes — turn external events into local commands

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

Two route shapes:

csharp
// Correlated — read ProcessId from event metadata
.OnProcessEvent<BookEvent.PartnerLoanLinked>((evt, processId) =>
    new LoanCommand.TransferConfirmed(evt.LoanId))

// Trigger — routing key comes from the event payload
.On<HoldRequestPlaced>((evt, _) =>
    Forward.To(evt.HoldId,
        new LoanCommand.RequestLoan(evt.HoldId, evt.PatronId, evt.BookId, evt.LibraryId)))

Return null from an On<> handler to ignore an event. The runtime reads EventTypes at registration and wires one subscription per declared type via .ReactsTo<TEvent>().

Causation, correlation, and replay idempotency

This is the part most process-manager implementations get wrong. Nagare's answer is DispatchIdGenerator.

When the grain emits dispatches it computes a deterministic dispatch_id for each one:

csharp
// src/Nagare/Outbox/DispatchIdGenerator.cs
public static Guid Compute(string sourceProcess, long sourceVersion, int index)
{
    var name = $"{sourceProcess}:{sourceVersion}:{index}";
    return UuidV5(Namespace, name);
}

It is a UUIDv5 (SHA-1 over a fixed namespace) of "{processId}:{version}:{index}". The outbox table has a UNIQUE constraint on dispatch_id. Together those two facts give you replay idempotency for free:

  • If the grain crashes after appending events but before the outbox commit, the next attempt re-runs the same handler against the same (processId, version, index) and produces the same UUID. The unique constraint absorbs the duplicate insert.
  • If the same event is delivered twice through an event route, both runs land on the same (processId, version, index) triple because version is monotonic — re-handling the same trigger event is rejected by the version check on Persist, never reaching the dispatch step.

Beyond dispatch_id, the grain also propagates:

FieldSourceCarries forward to
CausationIdThe last persisted event's StreamIdDispatched command metadata + downstream aggregate events
CorrelationIdTrigger event's CorrelationId, falling back to W3C trace-idEvery event and dispatch in the chain
ProcessId.WithProcessId(...) on the dispatchTarget aggregate's event metadata, so OnProcessEvent can route the reply back
TraceParentActivity.Current?.Id (current span)Downstream events for distributed tracing
ActorId, HeadersTrigger metadataPass-through for audit

The full chain (endpoint → process → aggregate → projection → another process) is queryable end-to-end: every row in the event stream and the outbox shares a CorrelationId, every row points back at its parent via CausationId, and every dispatch is fingerprinted by DispatchId.

When to use one vs an aggregate subscription

The boundary is subtle. Use a stateless ISubscription<TEvent> when the reaction is one-shot and does not depend on prior reactions:

csharp
public class HoldOnPlace : ISubscription<OrderEvent>
{
    public Task Handle(EventEnvelope<OrderEvent> evt) => evt.Event switch
    {
        OrderEvent.Placed e => _inventory.Ask(new ReserveItems(e.OrderId, e.Items)),
        _ => Task.CompletedTask
    };
}

That works because "place an order → reserve inventory" has no state to remember between events. The subscription is dispatch glue.

Reach for a process manager when any of these holds:

  1. The next step depends on a prior step. "If the partner library rejects, try the next-best partner" needs to remember which partners have already been tried.
  2. You need a queryable trail of decisions. Why was branch B chosen over branch A? A subscription has no journal; a process emits BranchARejected("capacity full") and the answer is in the stream.
  3. The workflow pauses for external input. Payment received seconds later, a manager confirms a draft hours later, a partner library ships a book days later. A subscription is a momentary reaction; a process holds the conversation open across all of it.

If none of those holds, an endpoint or a subscription is the right tool. Process managers add Orleans hosting, grain activation cost, and an extra event stream, so they only earn their keep on long-running, branching, auditable workflows.

Common patterns

Saga-style compensation

The process tracks what it has done and reverses it on failure:

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

InventoryReserved was persisted earlier and folded into state.Items. The compensating dispatch reads from that state — the process remembers what to undo.

Conditional retry

State the route as a switch and return null for cases that don't apply:

csharp
Routes
    .On<BookEvent>((evt, _) => evt switch
    {
        BookEvent.PartnerLoanLinked e => Forward.To(e.LoanId,
            new LoanCommand.TransferConfirmed(e.LoanId)),
        BookEvent.PartnerLoanRejected e => Forward.To(e.LoanId,
            new LoanCommand.TransferRejected(e.Reason)),
        _ => null
    })

Dispatch failure recovery

Outbox dispatch is retried by OutboxRunner with exponential backoff up to OutboxRunnerOptions.MaxAttempts (default 10). After that the row moves to the dead-letter state and the OutboxDeadLetterHealthCheck flips. Triage means querying the outbox for state = 'DeadLetter', inspecting the payload and last error, fixing whatever rejected it, and calling the store's resurrect method to flip the row back to Pending.

The dispatching grain has already returned. From the process's perspective the command is in flight; from the target's perspective it is delayed. State on the process side stays AwaitingTransfer until the corresponding event routes back — which means a dead-lettered dispatch presents as a stuck process, not a corrupt one.

Driving the first command

Two ways to start a process:

csharp
// From an endpoint
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();
});

// From an event route — kicks off autonomously
Routes
    .On<HoldRequestPlaced>((evt, _) =>
        Forward.To(evt.HoldId, new LoanCommand.RequestLoan(
            evt.HoldId, evt.PatronId, evt.BookId, evt.LibraryId)))

The grain doesn't care which arrived first. Activation hydrates state from the event store, runs the handler, persists.

Timeouts

Sagas need durable deadlines: "if the partner library hasn't confirmed in 7 days, cancel the loan." AndSchedule writes that deadline into a nagare_timeouts table in the same transaction as the process's events — the schedule can never be lost after the command returned accepted, and it can never exist without the event that justifies it.

csharp
.AndSchedule("transfer-deadline", TimeSpan.FromDays(7), new LoanCommand.TransferTimedOut())

When the deadline passes, the TimeoutRunner hosted service dispatches the scheduled command back to the process instance through the same ICommandDispatcher path the outbox uses. The process handles it like any other command:

csharp
.On<LoanCommand.TransferTimedOut>(async (state, cmd, ctx) =>
{
    // Idempotent: a deadline that fires late is a no-op, not a rejection.
    if (state.Status != LoanStatus.AwaitingTransfer)
        return Then.Accept();

    return Then.Persist(new LoanEvent.Cancelled("Transfer deadline passed"));
})

Three rules cover the lifecycle:

  • One pending timeout per (process, purpose). The timeout id is deterministic (UUIDv5 of processId:purpose, like DispatchIdGenerator). Scheduling transfer-deadline again replaces the pending deadline — new payload, new due time — instead of piling up a second row.
  • Cancel by purpose. AndCancelSchedule("transfer-deadline") marks the pending row cancelled in the same transaction as the confirmation event. A timeout that already fired is untouched — its command is already in flight.
  • Firing is at-least-once, so make the handler idempotent. The fired command carries a deterministic DispatchId (derived from timeout id + due time), and the runner retries transient failures with the outbox's backoff conventions before dead-lettering. A state-guarded handler (Then.Accept() when the deadline no longer applies) absorbs re-fires.

Scheduling and cancelling follow the same constraint as dispatching: they ride the event-append transaction, so the step must persist at least one event.

Registration mirrors the outbox — a store per backend plus the drainer:

csharp
builder.Services.AddPostgresTimeouts();   // or AddSqliteTimeouts / AddMySqlTimeouts / AddSqlServerTimeouts
builder.Services.AddTimeoutRunner();      // drains due rows; single-leader via ILockProvider
builder.Services.AddProcessOutboxSink();  // the dispatch sink is shared with the outbox

Firing granularity is the runner's poll cadence (TimeoutRunnerOptions.IdleDelay, default 30s) — a timeout fires on the first tick at or after its deadline. These are day-scale deadlines, not precision timers; for sub-second scheduling you still want a real scheduler.

Testing needs no wall clock: the harness asserts schedules and fires them directly (see Testing):

csharp
var result = await harness.When(new LoanCommand.RequestLoan(...));
result.ThenSchedules<LoanCommand.TransferTimedOut>("transfer-deadline", t => t.Delay == TimeSpan.FromDays(7));

var fired = await harness.WhenFiring("transfer-deadline");
fired.ThenPersists<LoanEvent.Cancelled>();

Against a real store, TimeoutDriver drains the runner against an explicit clock — pass DateTimeOffset.UtcNow + deadline and the timeout fires on the spot.

Honest limitations

A process manager today is not a workflow engine. Specifically:

  • No built-in scheduled-timeout primitive. Solved — see Timeouts. What timeouts still don't do: fire at sub-minute precision (the runner polls), notify the process when a firing dead-letters (operator triage, same as outbox dead letters), or schedule recurring reminders — every timeout is a one-shot deadline you re-schedule if you need another.
  • Dispatch failure is observable, not handled. A dispatch that exhausts retries lands in the outbox dead-letter table and surfaces through OutboxDeadLetterHealthCheck. The process does not get notified and cannot react in code. Operator triage is the recovery path.
  • No automatic compensation on partial failure. If Persist(...).AndDispatch(d1).AndDispatch(d2) produces two dispatches and d1 dead-letters while d2 succeeds, the process won't roll back d2. Compensation is your responsibility — usually via an event route that catches the failure event and persists a compensating command.
  • No await across grain calls. Dispatch is fire-and-forget at the grain boundary. You don't await the dispatched command; you wait for its event to route back. If you need synchronous chaining within the same step, that's an aggregate, not a process.
  • Snapshot policy is the only knob for hydration cost. Long-lived processes with thousands of events replay the full stream on activation by default. Override SnapshotPolicy to opt in, e.g. SnapshotPolicy.EveryNVersions<TState, TEvent>(20).

For most of these the workaround is "do it outside the process and feed the result back as a command or event." That keeps the process model pure (every state change is a journaled event, every external interaction is an explicit dispatch or route), at the cost of the framework not solving every workflow problem for you.

Registration

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

builder.Services
    .AddProcess<InterLibraryLoan, LoanCommand, LoanEvent, LoanState>()
    .DispatchesTo<BookCommand>()
    .ReactsTo<BookEvent>();
  • AddProcess registers the grain and IProcessRepository<LoanCommand>.
  • .DispatchesTo<TCommand>() registers a dispatch route per target aggregate command type.
  • .ReactsTo<TEvent>() wires one subscription per event type declared in RegisterEventRoutes.

Sending a command:

csharp
var reply = await repo.Ask(loanId, new LoanCommand.RequestLoan(...));

Testing without Orleans uses ProcessTestHarness; see Testing.

流れ — flow.