Commands & Events
Events persist forever. A bad name is permanent technical debt. A good name is an investment that pays dividends every time someone reads the code, debugs a production issue, or onboards onto the team.
This page is about getting the names right.
Commands: the imperative mood
Commands express intent — what someone wants to happen. They're named as instructions: "do this."
public record PlaceOrder(string ProductId, int Quantity) : OrderCommand;
public record CancelBooking(string Reason) : BookingCommand;
public record TransferFunds(string From, string To, decimal Amount) : BankingCommand;Good commands read like sentences: "Place this order." "Cancel this booking." "Transfer these funds."
What makes a good command
One command, one intent. Don't combine UpdateOrderAndShip. Split into UpdateOrder + ShipOrder. Each command should express a single, clear intention.
Minimum data. A command carries only what the aggregate needs to make its decision. Don't pass entire entities — pass the specific values.
Domain verbs, not CRUD verbs. Create, Update, Delete hide business intent. They describe database operations, not business operations. Prefer verbs your domain experts actually use:
| CRUD verb | Domain verb |
|---|---|
CreateOrder | PlaceOrder |
UpdateOrder | ChangeShippingAddress, AddLineItem |
DeleteOrder | CancelOrder |
UpdateStatus | MarkAsShipped, ConfirmDelivery |
Commands can be rejected. They represent a request, not a guarantee. "Place this order" might fail because the product is out of stock. This is normal and expected.
Anti-pattern: the god command
// Bad — what does "update" mean?
public record UpdateOrder(
string? NewAddress,
string? NewPaymentMethod,
int? NewQuantity,
string? CancellationReason) : OrderCommand;This command does five different things depending on which fields are set. It's impossible to reason about, impossible to test clearly, and impossible to audit. Split it into specific commands that each express one intent.
Events: the past tense
Events are facts — things that already happened. They're named in the past tense: "this happened."
public record OrderPlaced(string ProductId, int Quantity, decimal Price) : OrderEvent;
public record OrderShipped(string TrackingNumber) : OrderEvent;
public record BookBorrowed(string BorrowerId, DateTimeOffset BorrowedAt) : BookEvent;Good events read as headlines: "An order was placed." "A book was borrowed."
What makes a good event
Business meaning, not implementation detail. CustomerVerified tells you what happened in the domain. CustomerFlagSetToTrue tells you what happened in the database. The first survives refactoring; the second doesn't.
Self-contained context. An event should carry everything needed to understand what happened at that moment. Include relevant data at the time of occurrence — don't rely on being able to look up related state later.
// Good — captures the full context at the time of the event
public record OrderPlaced(
string ProductId,
string ProductName, // denormalized — the product might be renamed later
int Quantity,
decimal UnitPrice, // the price at the time of ordering
decimal TotalPrice) : OrderEvent;
// Risky — requires looking up the product later, which may have changed
public record OrderPlaced(string ProductId, int Quantity) : OrderEvent;Specific, not generic. OrderCancelled is useful. OrderUpdated is not — what was updated? Every generic event is a future debugging session.
| Generic (bad) | Specific (good) |
|---|---|
OrderUpdated | ShippingAddressChanged, LineItemAdded |
OrderStateChanged | OrderShipped, OrderCancelled, OrderRefunded |
StatusChanged | PaymentCaptured, PaymentFailed, PaymentRefunded |
Domain language
If your domain experts say "the order was fulfilled," the event is OrderFulfilled — not OrderCompleted, not OrderDone, not OrderFinished. Use the language of the domain, not the language of programmers.
This is the core insight of Domain-Driven Design's ubiquitous language: the code should read like a conversation with a domain expert.
Discriminator tags
When events are serialized to the store, they need a type discriminator — a stable identifier that maps the JSON back to the right C# type. Nagare uses System.Text.Json polymorphism:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(OrderPlaced), "order-placed")]
[JsonDerivedType(typeof(OrderShipped), "order-shipped")]
[JsonDerivedType(typeof(OrderCancelled), "order-cancelled")]
public abstract record OrderEvent : IAggregateEvent<OrderEvent>, IJsonable;Tags are persisted forever alongside the events. Choose them carefully:
| Rule | Example | Why |
|---|---|---|
| Kebab-case | order-placed | Human-readable, queryable in SQL |
| Stable | Never rename a tag | Existing events still need to deserialize |
| Meaningful | order-placed, not evt-001 | You'll be reading these in production logs |
| Versioned when breaking | order-placed-v2 | Paired with an upcaster for the old version |
Tags are permanent
Changing a discriminator tag breaks deserialization of all existing events with that tag. If you need to change an event's structure, keep the old tag and use an upcaster to transform old events at read time.
Schema evolution
Events are immutable, but requirements change. Nagare provides upcasters to bridge the gap:
public class OrderPlacedV1Upcaster : IEventUpcaster
{
public string FromEventTag => "order-placed-v1";
public string ToEventTag => "order-placed";
public string Upcast(string jsonPayload)
{
// Transform V1 JSON → current format
var node = JsonNode.Parse(jsonPayload)!;
node["TotalPrice"] = node["Quantity"]!.GetValue<int>()
* node["UnitPrice"]!.GetValue<decimal>();
return node.ToJsonString();
}
}Upcasters are applied at read time. The stored events are never modified. This means:
- Old events and new events coexist in the same stream
- You can chain upcasters: V1 → V2 → V3 → current
- Rolling back a deployment doesn't corrupt your data
The complete type hierarchy
A well-structured domain module looks like this:
// Commands — imperative mood
public abstract record BookCommand;
public record AddBook(string Title, string Author, string Isbn) : BookCommand;
public record BorrowBook(string BorrowerId) : BookCommand;
public record ReturnBook() : BookCommand;
public record ReportLost() : BookCommand;
// Events — past tense, with stable tags
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(BookAdded), "book-added")]
[JsonDerivedType(typeof(BookBorrowed), "book-borrowed")]
[JsonDerivedType(typeof(BookReturned), "book-returned")]
[JsonDerivedType(typeof(BookLost), "book-lost")]
public abstract record BookEvent : IAggregateEvent<BookEvent>, IJsonable;
public record BookAdded(string Title, string Author, string Isbn) : BookEvent;
public record BookBorrowed(string BorrowerId, DateTimeOffset BorrowedAt) : BookEvent;
public record BookReturned(DateTimeOffset ReturnedAt) : BookEvent;
public record BookLost(DateTimeOffset ReportedAt) : BookEvent;
// State — minimal, only what command handlers need
public record BookState(
bool Exists,
string? Title,
string? BorrowerId,
bool IsBorrowed,
bool IsLost) : IAggregateState<BookState>
{
public static BookState Default => new(false, null, null, false, false);
}Commands are specific. Events are self-documenting. State is minimal. Tags are stable. The code reads like a specification of your domain.
Next: Projections — build read models from the stream.