Core Concepts
Nagare has a small number of concepts. Once you understand them, the API is predictable.
The flow
Every interaction follows the same path:
- A command arrives — someone wants something to happen
- The aggregate loads its history, rebuilds its state, and decides: accept, reject, or persist new events
- Events are appended to the store — immutable, ordered, forever
- Projections subscribe to the event stream and build read models shaped for specific queries
This is CQRS (Command Query Responsibility Segregation). Writes go through the aggregate. Reads come from projections. The event store sits between the two.
Commands
Commands are instructions in the imperative mood: do this. They say what someone wants to happen, not what has happened.
public record PlaceOrder(string ProductId, int Quantity) : OrderCommand;
public record CancelOrder(string Reason) : OrderCommand;
public record ShipOrder(string TrackingNumber) : OrderCommand;Commands can be rejected. "Place this order" might fail because the product is out of stock. "Ship this order" might fail because it's already cancelled. The aggregate decides.
Events
Events are facts in the past tense: this happened. They are the source of truth.
public record OrderPlaced(string ProductId, int Quantity, decimal Price) : OrderEvent;
public record OrderCancelled(string Reason, DateTimeOffset CancelledAt) : OrderEvent;
public record OrderShipped(string TrackingNumber) : OrderEvent;Events are immutable. Once written, they never change. If you need to correct something, you emit a new event (AddressCorrected), not an UPDATE to OrderPlaced. The history stays intact.
State
Given everything that happened, what do I need to know right now?
public record OrderState(
bool IsPlaced,
bool IsShipped,
bool IsCancelled,
string? TrackingNumber) : IAggregateState<OrderState>
{
public static OrderState Default => new(false, false, false, null);
}State is derived, not stored. It's rebuilt by replaying events through handlers, pure functions from (state, event) → newState:
events
.On<OrderPlaced>((state, e) =>
state with { IsPlaced = true })
.On<OrderCancelled>((state, e) =>
state with { IsCancelled = true })
.On<OrderShipped>((state, e) =>
state with { IsShipped = true, TrackingNumber = e.TrackingNumber });Start with Default, apply each event in sequence, arrive at current state. Same events, same state. No side effects.
State is minimal
State should contain only what command handlers need to make decisions. If a field is never checked in a command handler, it doesn't belong in state — it belongs in a projection.
Aggregates
An aggregate is where commands and events meet. It enforces business rules and keeps the domain consistent.
In Nagare, an aggregate has three types and two registration methods that return pre-built handler sets:
public class OrderAggregate : Aggregate<OrderCommand, OrderEvent, OrderState>
{
private readonly EventHandlers<...> _events = ...;
private readonly CommandHandlers<...> _commands = ...;
protected override EventHandlers<...> RegisterEventHandlers() => _events;
protected override CommandHandlers<...> RegisterCommandHandlers() => _commands;
}Command handlers look at the current state and decide what happens:
.On<ShipOrder>((state, cmd) =>
!state.IsPlaced ? Then.Reject("Order not placed")
: state.IsCancelled ? Then.Reject("Order is cancelled")
: state.IsShipped ? Then.Reject("Already shipped")
: Then.Persist(new OrderShipped(cmd.TrackingNumber)))The Then DSL:
Then.Persist(event)— accept and record what happenedThen.Reject("reason")— refuse with an explanationThen.Accept()— acknowledge without persisting (idempotent)
Event handlers are pure functions that update state. No I/O, just with expressions:
events.On<OrderShipped>((state, e) =>
state with { IsShipped = true, TrackingNumber = e.TrackingNumber });Everything else (loading events, rebuilding state, optimistic concurrency, serialization, snapshotting) is handled by the framework.
Projections
Projections subscribe to the event stream and build read models. Given what happened, what does the world look like from this angle?
protected override OrderReadModel Apply(
OrderReadModel? current, EventEnvelope<OrderEvent> envelope) =>
envelope.Event switch
{
OrderPlaced e => new OrderReadModel
{
Id = envelope.AggregateId.Value,
Product = e.ProductId,
Status = "placed"
},
OrderShipped e => current! with
{
Status = "shipped",
TrackingNumber = e.TrackingNumber
},
_ => current!
};Projections are eventually consistent (they lag behind by milliseconds), disposable (reset the checkpoint and rebuild from scratch), and independent (each has its own checkpoint and pace). You can add a new projection years later and it replays the entire history.
Process managers
Some workflows span multiple aggregates: an inter-library loan triggers a book borrow at the partner library, waits for transfer confirmation, then activates the loan. A process manager is a long-running, event-sourced coordinator that orchestrates this across aggregate boundaries.
A process manager overrides three methods -- RegisterEventHandlers() for state, RegisterCommandHandlers() for decisions, and RegisterEventRoutes() for responses:
public class InterLibraryLoan : Process<LoanCommand, LoanEvent, LoanState>
{
// State — pure event folding, same as aggregates
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();
// Decisions — async, with service access and dispatch
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");
return Then
.Persist(new LoanEvent.Requested(cmd.PatronId, cmd.BookId, cmd.PartnerLibraryId))
.AndDispatch(
Dispatch.To(cmd.BookId, new BorrowBook(cmd.PatronId))
.WithProcessId(cmd.LoanId));
})
.Build();
// Responses — external events route back via ProcessId in metadata
protected override EventRoutes RegisterEventRoutes() =>
Routes
.OnProcessEvent<BookBorrowed>((evt, loanId) =>
new LoanCommand.TransferConfirmed(loanId))
.Build();
}Process managers differ from aggregates in three ways: command handlers are async with service access (ctx.Service<T>()), they can dispatch commands to other aggregates (Dispatch.To().WithProcessId()), and external events route back to them (Routes.OnProcessEvent<>()). Orleans provides single-writer semantics under the hood.
See Process Managers for the full guide.
The Decider pattern
Every Nagare aggregate follows the Decider pattern: three functions that capture the entire lifecycle.
| Function | Signature | Purpose |
|---|---|---|
decide | (state, command) → events | Business rules |
evolve | (state, event) → state | State transitions |
initialState | () → state | Starting point |
RegisterCommandHandlers is decide. RegisterEventHandlers is evolve. Default is initialState. The pattern works the same whether your aggregate handles one event or a thousand.
Putting it together
- Commands carry intent into the system
- Aggregates apply business rules to accept or reject
- Events record what happened, immutably
- State is rebuilt from events (the left fold)
- Projections build read models from events
- Subscriptions deliver events to projections
- Process managers coordinate across boundaries (when needed)
The event store is the single source of truth. State, read models, search indexes, analytics: all derived. If any derived view goes wrong, rebuild it from the events.
Next: Glossary