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:
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:
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:
// 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:
// 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
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
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.
// 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():
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
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:
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
| Aggregate | Process Manager | |
|---|---|---|
| Purpose | Enforce invariants within one boundary | Coordinate across boundaries |
| Command handlers | (state, cmd) -> Effects | async (state, cmd, ctx) -> ProcessEffects |
| Service access | Never -- pure functions only | ctx.Service<T>() via IProcessContext |
| Dispatches to other aggregates | Never -- aggregates emit events, not commands | Then.Persist().AndDispatch() |
| Event routes | No -- aggregates receive commands directly | Routes.OnProcessEvent<>() / Routes.On<>() |
| Concurrency | Optimistic (version check on append) | Single-writer (Orleans grain mailbox) |
| Builder factories | Events / Commands | Events / Commands / Routes |
| Registration | AddAggregate<>() | .AddProcess<>().DispatchesTo<>().ReactsTo<>() |
| Testing | AggregateTestHarness | ProcessTestHarness |
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
// 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
// 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
Then.Reject("Loan already started")Rejection short-circuits: no events are persisted, no commands are dispatched.
Accept without persisting
Then.Accept()Idempotent acknowledgement. The command was valid but there is nothing new to record.
Chaining
All methods on ProcessEffects<TEvent> are chainable:
| Method | Purpose |
|---|---|
.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:
Process dispatches. The
RequestLoanhandler callsDispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId)).WithProcessId(cmd.LoanId). The events (LoanEvent.Requested) are persisted first, then the outbound command is sent.Command reaches the target aggregate. The
CommandDispatchersendsBorrowBookto theBookAggregateidentified bycmd.BookId. TheProcessId(cmd.LoanId) travels in the command's metadata.Aggregate persists events with ProcessId. The
BookAggregateaccepts the command and persistsBookBorrowed. The event's metadata carriesProcessId = "loan-1"-- the ID of the loan instance that initiated this.Subscription picks up the event. The
.ReactsTo<BookEvent>()registration wired a subscription that tails the book event store. It readsBookBorrowedand sees aProcessIdin the metadata.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 theProcessId, producingnew LoanCommand.TransferConfirmed("loan-1").Command delivered to the process grain. The subscription sends
TransferConfirmedto the process grain identified byProcessId("loan-1").Grain wakes and handles the response. The
InterLibraryLoangrain loads its state, runs theTransferConfirmedhandler, and persistsLoanEvent.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:
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:
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 instancenull-- ignore this event
Common patterns
Correlated response -- the process dispatches to an aggregate with ProcessId, and the aggregate's event routes back via OnProcessEvent:
// 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:
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:
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:
// --- 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:
AddProcessregisters the process type, its Orleans grain, and anIProcessRepository<LoanCommand>for sending commands to the process..DispatchesTo<BookCommand>()registers a dispatch route so the process can sendBookCommandto theBookAggregate. Call once per target aggregate command type..ReactsTo<BookEvent>()wires a subscription that tails theBookEventstore and routes matching events (likeBookBorrowedwith aProcessIdin metadata) back to the loan process.
Sending commands to a process
Use IProcessRepository<TCommand> to send commands to a process instance:
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():
[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:
[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:
[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
[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:
[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():
[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
| Method | Asserts |
|---|---|
.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
builder.Host.UseOrleans(silo => silo.UseLocalhostClustering());Single-silo cluster on localhost. No external infrastructure needed.
Production (Kubernetes)
builder.Host.UseOrleans(silo =>
{
silo.UseKubernetesHosting();
silo.UseRedisClustering(connectionString);
});Production (Azure)
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
| Scenario | Use |
|---|---|
| Validate state and emit events within one boundary | Aggregate |
| Pure state machine, no I/O | Aggregate |
| Simple CRUD-like operations | Aggregate (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 contexts | Message broker (Messaging) |
| Long-running workflow that pauses for external input | Process manager |
| Autonomous real-time assessment and reaction | Process manager |
| Multi-step orchestration with compensation and full audit trail | Process 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:
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.
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:
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.
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.
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:
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;
}
}
}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:
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;
}
}
}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:
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).
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