Anti-Patterns
A reference of the most common mistakes in event-sourced systems, the harm each one causes, and the cure. Organised by where the mistake lives: events, aggregates, projections, consistency, versioning.
The pattern guidance (Event Design, Consistency) tells you what to do; this page tells you what to undo.
Event-shape anti-patterns
State leaking into events
The single most damaging mistake. Computed values, derived rollups, status flags, anything reconstructible by evolve. None of it belongs in the event payload.
// ❌ Bad. TotalAmount is a fold of earlier ItemAdded events
public record CartCheckedOut(
string PaymentMethod,
decimal TotalAmount,
string Status) : CartEvent;
// ✅ Good. Only the decision being recorded
public record CartCheckedOut(string PaymentMethod) : CartEvent;Why it's harmful. Today's bug in the calculation gets baked into the event log permanently. When you fix the bug, replay produces values that disagree with the recorded ones. You have two truths, no canonical answer, and no clean way to migrate.
Cure. Compute in evolve. Keep the event payload to facts decided. See Event Design.
Fat events with mutable carry-forward
Carrying immutable identifiers into events is fine. Carrying mutable values (customer name, address, current status) is dangerous. By the time a consumer reads it, the value may have changed at the source, and the consumer cannot tell.
// ❌ Bad. CustomerName changes; the event keeps an old copy forever
public record OrderPlaced(
string OrderId,
string CustomerId,
string CustomerName,
string CustomerEmail) : OrderEvent;
// ✅ Good. Carry the ID, look up the current name when needed
public record OrderPlaced(string OrderId, string CustomerId) : OrderEvent;Cure. Carry only immutable values into events. For cross-context integration events, deliberate carry-forward is sometimes legitimate. See Event Design § Fat events.
Property sourcing
Emitting one event per field change, with no business meaning attached.
// ❌ Bad. CRUD with extra steps
public record FirstNameChanged(string FirstName) : CustomerEvent;
public record LastNameChanged(string LastName) : CustomerEvent;
public record EmailChanged(string Email) : CustomerEvent;
// ✅ Good. The business operation
public record PersonalDetailsUpdated(
string? FirstName,
string? LastName,
string? Email) : CustomerEvent;Cure. Group by business operation, not by data shape. If your events read like SQL UPDATE statements, you're doing CRUD with ceremony.
CRUDy event names
Created, Updated, Deleted for events that should have business names.
// ❌ Bad
public record OrderCreated(...) : OrderEvent;
public record OrderUpdated(...) : OrderEvent;
public record OrderDeleted(...) : OrderEvent;
// ✅ Good
public record OrderPlaced(...) : OrderEvent;
public record OrderShipped(...) : OrderEvent;
public record OrderCancelled(...) : OrderEvent;Cure. Use the language the business uses. See Commands & Events.
Old/new pairs in events
Tempting and wrong. Old/new pairs in events couple event design to a presentation concern.
// ❌ Bad. Old value is in service of the audit screen, not the decision
public record AddressChanged(string OldAddress, string NewAddress) : CustomerEvent;
// ✅ Good. The projection has prior state and computes the diff
public record AddressChanged(string Address) : CustomerEvent;Cure. Projections are single-threaded left-folds. They have prior state in hand. Compute the audit row in the projection.
One fat event covering multiple decisions
// ❌ Bad. Three decisions in one event
public record OrderProcessed(
decimal AmountCharged,
string ShipmentTrackingNumber,
DateTimeOffset CompletedAt) : OrderEvent;
// ✅ Good. Three events, one per decision
public record PaymentReceived(decimal Amount) : OrderEvent;
public record OrderShipped(string TrackingNumber) : OrderEvent;
public record OrderCompleted(DateTimeOffset At) : OrderEvent;Cure. Then.PersistAll([...]) exists for this. Emit several thin events from one command.
Embedding entities in events
// ❌ Bad. Embeds a mutable entity
public record OrderPlaced(string OrderId, Customer Customer) : OrderEvent;
// ✅ Good. Embeds an immutable identifier
public record OrderPlaced(string OrderId, string CustomerId) : OrderEvent;Cure. Embed IDs and the specific scalar values that mattered at decision time. Look up entities through projections when needed.
Aggregate anti-patterns
Side effects in event handlers
// ❌ Bad. Non-deterministic; replay produces different results
.On<OrderPlaced>((state, e) => state with {
PlacedAt = DateTimeOffset.UtcNow,
Reference = Guid.NewGuid().ToString()
})Why it's harmful. Event handlers run on every replay. A clock read or RNG call here means the rebuilt state differs from the original. Snapshots and rebuilds drift apart.
Cure. Generate timestamps and IDs in the command handler before persisting, not in the event handler. The event payload carries the value; the event handler reads it back deterministically.
// ✅ Good. Non-determinism lives in decide, not evolve
.On<PlaceOrder>((state, cmd) =>
Then.Persist(new OrderPlaced(cmd.ProductId, DateTimeOffset.UtcNow)))
.On<OrderPlaced>((state, e) => state with { PlacedAt = e.PlacedAt })Aggregate state larger than it needs to be
Every byte of State is rebuilt on every command dispatch (modulo snapshots). State exists to inform decisions, not to serve reads.
// ❌ Bad. Keeping all line items just to render the order page
public record OrderState(
bool IsPlaced,
bool IsShipped,
ImmutableList<LineItem> Items, // only the projection needs this
ImmutableList<HistoryEntry> History) ... // pure UI concernCure. State holds what command handlers need to decide. The Nagare test is exact: if you remove a field and no command handler breaks, the field shouldn't be in state. If Items is only used to render, project it. If it's used to enforce a business rule (max 50 lines per order), keep just enough state for the rule (a counter), not the full list.
Working around the protected State field
Nagare makes State protected on Aggregate<TCommand, TEvent, TState>. Production code physically cannot read it. The anti-pattern shows up when teams work around that protection:
// ❌ Bad. Public accessor that exposes State to controllers
public class OrderAggregate : Aggregate<OrderCommand, OrderEvent, OrderState>
{
public OrderState GetCurrentState() => State;
public bool IsShipped => State.IsShipped;
}
// ❌ Bad. Query command that returns State
public record GetOrderState : OrderCommand;
// ❌ Bad. Echoing State into an event so projections can recover it
.On<ShipOrder>((state, cmd) =>
Then.Persist(new OrderShipped(cmd.Tracking, state.LineItems, state.CustomerId)))Why each is harmful. The moment a non-command path depends on State, the state-minimality test loses its teeth. Removing a field would break the public API, so the field stays even when no command handler needs it. State drifts toward being the read model, replay slows down, and the API contract becomes coupled to write-side internals. That's the failure mode CQRS exists to prevent.
Cure. State is a test affordance. AggregateTestHarness reads it via reflection. Don't add public wrappers, don't add query commands, don't bake State into events. For read-after-write, see Consistency § Read-after-write.
Wrong aggregate boundary: too big
Multiple entities that change independently, sharing one stream.
Symptoms:
- High concurrency conflicts on append.
RegisterCommandHandlersis a 30-case switch.- Most commands only touch one of the bundled entities.
Cure. Split. Each entity becomes its own aggregate. Coordinate via Process Managers.
Wrong aggregate boundary: too small
Two entities that change atomically together, in different streams.
Symptoms:
- Every command needs cross-stream validation that the aggregate boundary cannot provide.
- Version tokens / reservations everywhere.
- Inconsistencies show up under load that no individual aggregate's invariants protected against.
Cure. Merge. Co-transactional entities belong in the same aggregate, even if it makes the aggregate look bigger.
Read-time joins inside the aggregate
// ❌ Bad. Aggregate reads from a projection mid-decision
.On<ShipOrder>(async (state, cmd, ctx) => {
var customer = await ctx.Service<CustomerReadModel>().Get(state.CustomerId);
if (customer.IsBlocked) return Then.Reject("Blocked customer");
return Then.Persist(new OrderShipped(cmd.Tracking));
})Why it's harmful. The aggregate's decision now depends on an eventually-consistent read. Two simultaneous orders can both pass the check before the projection sees the customer's block. The aggregate is silently making decisions on stale data.
Cure. One of:
- Move the rule to the customer aggregate if possible.
- Use a version token (see Consistency).
- Use a Process Manager. Process managers are allowed to read projections; aggregates aren't.
if/else cascade instead of state-based dispatch
// ❌ Bad. Every handler re-checks state
.On<ShipOrder>((state, cmd) =>
!state.IsPlaced ? Then.Reject("Not placed")
: state.IsCancelled ? Then.Reject("Cancelled")
: state.IsShipped ? Then.Reject("Already shipped")
: Then.Persist(new OrderShipped(cmd.Tracking)))Cure. Use state-based dispatch in RegisterCommandHandlers(). Each state declares which commands it accepts; invalid transitions reject at the dispatch level. See Aggregates § State-based command handlers.
Generic god commands
// ❌ Bad. What does this even do?
public record UpdateOrder(Dictionary<string, object> Changes) : OrderCommand;
public record SetOrderStatus(string Status) : OrderCommand;
// ✅ Good. One intent per command
public record CancelOrder(string Reason) : OrderCommand;
public record ChangeShippingAddress(string NewAddress) : OrderCommand;Cure. One command, one intent.
Projection anti-patterns
Projection-to-projection dependencies
A projection reads from another projection's tables during Apply. Rebuild order suddenly matters; subscription parallelism breaks; replay produces inconsistent intermediate states.
Cure. Projections are roots, not a graph. Each projection takes events and produces its own tables. If two projections need the same join, do it in a third projection that subscribes to both event types and writes the joined table directly.
Side effects on replay
// ❌ Bad. Replay sends an email for every historical OrderPlaced
public class EmailSubscription : ISubscription<OrderEvent> {
public async Task Handle(EventEnvelope<OrderEvent> evt) {
if (evt.Event is OrderPlaced op) {
await emailService.Send(op.CustomerEmail, "Thanks!");
}
}
}Cure. For handlers that fire real-world side effects, register them via AddLiveTap (or AddPostgresLiveSubscription). Live subscriptions skip historical events; they only see events appended after the runner started. See Projections § Live subscriptions (tap mode).
Non-idempotent projection writes
// ❌ Bad. Applying the same event twice double-counts
.On<PaymentReceived>((doc, e) => doc with {
Total = doc.Total + e.Amount // not idempotent
})Why it's harmful. Event delivery is at-least-once. Replays, crashes, retries can deliver the same event twice. Non-idempotent updates corrupt the read model.
Cure. Make updates idempotent. Either upsert by event id, or write the absolute value rather than a delta. The checkpoint store deduplicates by position, but treat that as a safety net, not the primary defence.
Trying to make a projection synchronous with the write
Nagare's subscriptions are async by design. There is no "run this projection in the same transaction as the append" knob, and that's deliberate. The anti-pattern shows up when teams try to fake it: blocking the command response on a polling loop, manually invoking a projection's Apply from inside a controller after the append, or writing a custom subscription with an in-process notification that the controller waits on.
Cure. Treat projections as eventually consistent, full stop. For read-after-write, use one of the patterns in Consistency § Read-after-write.
Consistency anti-patterns
Naive read-then-act
// ❌ Bad. Race window between read and append
var existing = await readModel.FindByEmail(cmd.Email);
if (existing is not null) return Reject("Email taken");
var customer = await repo.Load(...);
return await customer.Ask(cmd);Cure. Reservation pattern or version tokens. See Consistency.
Cross-aggregate atomic invariants
Trying to enforce "X across aggregates A and B must always equal Y" by checking both before writing one. The two aggregates are different streams; there's no atomic check across them.
Cure. If the invariant must hold strongly, the two aggregates are the same aggregate. Merge. If the invariant can be eventually consistent, model the violation explicitly: a process manager detects mismatch and emits a compensating event.
Domain events leaking across context boundaries
// ❌ Bad. Reporting context references Library's domain event
using Library.Events;
public class ReportingProjection : ISubscription<BookBorrowed> { ... }Cure. Publish integration events (separate types in a contract assembly) through a MessageProducer + IMessageMapper. Domain events stay private to the context. See Messaging.
Versioning anti-patterns
Mutating persisted events
Editing an existing event payload in the store to fix a typo, change a name, or "correct" an old value. Never do this.
Cure. If a recorded event is wrong, write a correction event expressing the new fact. The history is now "we recorded X, then later corrected to Y," which is true to what actually happened.
Replacing event types in place
Renaming an event type or changing its discriminator tag without writing an upcaster.
Cure. Keep the old discriminator tag readable forever. For schema changes, write an upcaster that transforms old payloads into new ones at read time. See Event Versioning.
Breaking changes via type-replacement
// ❌ Bad. Old OrderPlaced events in the store can't be deserialized as the new type
public record OrderPlaced(string ProductId, decimal Price) : OrderEvent;
// ↓ change to
public record OrderPlaced(string ProductId, Money Price) : OrderEvent;Cure. Prefer additive changes (nullable fields). For genuinely breaking shapes, introduce a new event tag (order-placed-v2) and write an upcaster from the old tag.
Quick reference
| Anti-pattern | Lives in | Cure |
|---|---|---|
| State leaking into events | Event payload | Compute in evolve |
| Fat events with mutable carry-forward | Event payload | Carry IDs only |
| Property sourcing | Event design | Group by business operation |
| CRUDy event names | Event design | Use domain language |
| Old/new pairs in events | Event payload | Compute diff in projection |
| One event covering multiple decisions | Event payload | Split via Then.PersistAll |
| Embedding entities | Event payload | Embed IDs |
Side effects in evolve | Aggregate | Move to decide |
| Oversized state | Aggregate | Keep what handlers need |
Working around protected State | Aggregate | Use a read-after-write pattern |
| Wrong aggregate size | Aggregate | Split or merge |
| Read-time join in aggregate | Aggregate | Move rule, use version token, or Process<> |
if/else state cascade | Aggregate | State-based dispatch |
| God commands | Command design | One intent per command |
| Projection-to-projection deps | Projection | Each projection is a root |
| Side effects on replay | Projection | AddLiveTap |
| Non-idempotent projection writes | Projection | Upsert / write absolute values |
| Trying to make projections sync | Projection | Async by design — use a read-after-write pattern |
| Naive read-then-act | Consistency | Reservation or version token |
| Cross-aggregate atomic invariants | Consistency | Merge aggregates or accept eventual |
| Domain events crossing contexts | Consistency | Integration events via MessageProducer |
| Mutating persisted events | Versioning | Correction events |
| Replacing event types in place | Versioning | Upcasters |
| Breaking schema changes | Versioning | Additive + new tag + upcaster |