Anti-Patterns (mistakes that will bite you)
Event sourcing has a steep learning curve. These are the mistakes teams makeβlearn from them.
Anti-Pattern 1: CRUD disguised as eventsβ
The mistakeβ
// BAD: This is just CRUD with extra steps
public sealed record CustomerUpdated(
Guid CustomerId,
string? Name,
string? Email,
string? Phone,
string? Address
) : IDomainEvent;
// Usage
customer.Update(new CustomerUpdated(
CustomerId: id,
Name: "New Name",
Email: null, // unchanged
Phone: null, // unchanged
Address: null // unchanged
));
Why it's badβ
- Events don't capture intent or what actually happened
- You can't tell if email was cleared or just not changed
- Projections become complex (handle nulls everywhere)
- You lose the audit trail benefit
The fixβ
// GOOD: Specific events for specific changes
public sealed record CustomerNameChanged(Guid CustomerId, string NewName) : IDomainEvent;
public sealed record CustomerEmailChanged(Guid CustomerId, string NewEmail) : IDomainEvent;
public sealed record CustomerPhoneChanged(Guid CustomerId, string NewPhone) : IDomainEvent;
public sealed record CustomerAddressMoved(
Guid CustomerId,
string NewStreet,
string NewCity,
string NewPostalCode
) : IDomainEvent;
// Now you know exactly what happened
Anti-Pattern 2: God aggregateβ
The mistakeβ
// BAD: One aggregate to rule them all
public sealed class Order : AggregateRoot
{
public Guid CustomerId { get; private set; }
public List<LineItem> Items { get; } = new();
public List<Payment> Payments { get; } = new();
public List<Shipment> Shipments { get; } = new();
public List<Return> Returns { get; } = new();
public List<Refund> Refunds { get; } = new();
public List<CustomerMessage> Messages { get; } = new();
public List<InternalNote> Notes { get; } = new();
public CustomerProfile Customer { get; private set; } // Embedded!
public ShippingAddress ShippingAddress { get; private set; }
public BillingAddress BillingAddress { get; private set; }
// ... 50 more properties
}
Why it's badβ
- Stream grows indefinitely (performance death)
- Every write conflicts with every other write
- Invariants become impossible to reason about
- Testing is a nightmare
The fixβ
// GOOD: Separate aggregates with clear boundaries
public sealed class Order : AggregateRoot
{
// Just the core order lifecycle
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public int ItemCount { get; private set; }
}
public sealed class OrderFulfillment : AggregateRoot
{
// Shipping concerns only
public Guid OrderId { get; private set; }
public List<Shipment> Shipments { get; } = new();
}
public sealed class OrderPayments : AggregateRoot
{
// Payment concerns only
public Guid OrderId { get; private set; }
public List<Payment> Payments { get; } = new();
}
// Customer is a completely separate bounded context
Anti-Pattern 3: Events as commandsβ
The mistakeβ
// BAD: Event that sounds like a command
public sealed record WithdrawMoney(Guid AccountId, decimal Amount) : IDomainEvent;
// BAD: Future tense or imperative
public sealed record SendEmail(Guid UserId, string Subject, string Body) : IDomainEvent;
public sealed record ProcessPayment(Guid OrderId, decimal Amount) : IDomainEvent;
Why it's badβ
- Events are facts that have happened
- Commands are requests that might fail
- Confusing the two breaks the mental model
The fixβ
// GOOD: Past tense, factual
public sealed record MoneyWithdrawn(decimal Amount) : IDomainEvent;
public sealed record EmailSent(Guid UserId, string Subject, DateTimeOffset SentAt) : IDomainEvent;
public sealed record PaymentProcessed(Guid OrderId, decimal Amount, string TransactionId) : IDomainEvent;
// Commands are separate
public sealed record WithdrawMoney(Guid AccountId, decimal Amount) : ICommand;
Anti-Pattern 4: Side effects in Applyβ
The mistakeβ
// BAD: Side effects during event application
protected override void Apply(IDomainEvent @event)
{
switch (@event)
{
case OrderPlaced e:
Id = e.OrderId;
Status = OrderStatus.Placed;
// WRONG: Side effects!
_emailService.SendOrderConfirmation(e.OrderId);
_inventoryService.Reserve(e.Items);
_logger.LogInformation("Order placed: {OrderId}", e.OrderId);
break;
}
}
Why it's badβ
- Apply is called during replay (rebuilds)
- You'll send duplicate emails on every projection rebuild
- You'll reserve inventory multiple times
- Non-deterministic behavior breaks everything
The fixβ
// GOOD: Apply is pure and deterministic
protected override void Apply(IDomainEvent @event)
{
switch (@event)
{
case OrderPlaced e:
Id = e.OrderId;
Status = OrderStatus.Placed;
// That's it. No side effects.
break;
}
}
// Side effects happen in handlers AFTER successful append
public sealed class OrderPlacedHandler
{
public async Task Handle(OrderPlaced @event, CancellationToken ct)
{
await _emailService.SendOrderConfirmation(@event.OrderId);
// This only runs once, when the event is first processed
}
}
Anti-Pattern 5: Storing derived data in eventsβ
The mistakeβ
// BAD: Derived data in event
public sealed record MoneyWithdrawn(
decimal Amount,
decimal BalanceAfter, // Derived!
decimal TotalWithdrawnToday // Derived!
) : IDomainEvent;
Why it's badβ
- Derived data can become inconsistent
- If you fix a bug in calculation, old events have wrong values
- Events should contain the input, not the output
The fixβ
// GOOD: Only the facts
public sealed record MoneyWithdrawn(decimal Amount) : IDomainEvent;
// Balance is computed by replaying events
// TotalWithdrawnToday is a projection concern
Anti-Pattern 6: Synchronous projectionsβ
The mistakeβ
// BAD: Blocking on projection update
public async Task<AppendResult> AppendToStream(
string streamId,
long expectedVersion,
IReadOnlyList<StoredEvent> events,
CancellationToken ct)
{
// Write events
var result = await _innerStore.AppendToStream(streamId, expectedVersion, events, ct);
// WRONG: Synchronously update all projections
foreach (var projector in _projectors)
{
foreach (var @event in events)
{
await projector.Apply(@event); // Blocks the write!
}
}
return result;
}
Why it's badβ
- Write latency includes all projection updates
- One slow projector blocks all writes
- Projection failure fails the write (should be independent)
- Doesn't scale
The fixβ
// GOOD: Async projection processing
public async Task<AppendResult> AppendToStream(
string streamId,
long expectedVersion,
IReadOnlyList<StoredEvent> events,
CancellationToken ct)
{
// Just write events
return await _innerStore.AppendToStream(streamId, expectedVersion, events, ct);
}
// Projections run separately, asynchronously
public sealed class ProjectionWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var @event in _subscription.Subscribe(ct))
{
await _projector.Apply(@event);
await _checkpoint.Save(@event.Position);
}
}
}
Anti-Pattern 7: Missing idempotencyβ
The mistakeβ
// BAD: Projection assumes exactly-once delivery
public void Apply(IDomainEvent @event)
{
if (@event is MoneyDeposited e)
{
_balance += e.Amount; // Will double-count on replay!
}
}
Why it's badβ
- Message delivery is usually at-least-once
- Projections will be rebuilt (replayed)
- You'll get duplicate processing
The fixβ
// GOOD: Idempotent projection with version tracking
public void Apply(long streamVersion, IDomainEvent @event)
{
if (streamVersion <= _lastProcessedVersion)
return; // Already processed
if (@event is MoneyDeposited e)
{
_balance += e.Amount;
}
_lastProcessedVersion = streamVersion;
}
Anti-Pattern 8: Leaking infrastructure into domain eventsβ
The mistakeβ
// BAD: Infrastructure concerns in domain events
public sealed record OrderPlaced(
Guid OrderId,
string KafkaPartitionKey, // Infrastructure!
int SqlRowId, // Infrastructure!
string CosmosDbEtag, // Infrastructure!
string CorrelationId // Should be metadata!
) : IDomainEvent;
Why it's badβ
- Domain events should be infrastructure-agnostic
- Changing infrastructure means changing events
- Events become coupled to deployment details
The fixβ
// GOOD: Clean domain event
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal TotalAmount,
List<OrderItem> Items
) : IDomainEvent;
// Infrastructure concerns in envelope/metadata
public sealed record EventEnvelope(
Guid EventId,
string EventType,
DateTimeOffset OccurredAt,
Guid? CorrelationId,
Guid? CausationId,
IDomainEvent Payload,
// Infrastructure metadata separate from domain
Dictionary<string, string>? InfrastructureMetadata
);
Anti-Pattern 9: Event sourcing for everythingβ
The mistakeβ
Using event sourcing for:
- Simple CRUD entities
- Configuration settings
- User preferences
- Static reference data
Why it's badβ
- Massive overhead for simple operations
- Complexity without benefit
- Team confusion
The fixβ
Use event sourcing selectively:
// GOOD: ES for complex domain with audit needs
public sealed class BankAccount : AggregateRoot { /* ... */ }
public sealed class TradingPosition : AggregateRoot { /* ... */ }
// GOOD: Simple CRUD for simple data
public sealed class UserPreferences
{
public Guid UserId { get; set; }
public string Theme { get; set; }
public string Language { get; set; }
// Just use a regular database
}
public sealed class ProductCatalogEntry
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
// ES would be overkill here
}
Anti-Pattern 10: Not planning for schema evolutionβ
The mistakeβ
// V1: Deployed to production
public sealed record CustomerRegistered(
Guid CustomerId,
string Name
) : IDomainEvent;
// 6 months later: "We need email!"
// BAD: Just add it and hope for the best
public sealed record CustomerRegistered(
Guid CustomerId,
string Name,
string Email // Old events don't have this!
) : IDomainEvent;
Why it's badβ
- Deserialization fails for old events
- Projections crash on rebuild
- No way to handle the transition
The fixβ
// GOOD: Explicit versioning
public sealed record CustomerRegisteredV1(
Guid CustomerId,
string Name
) : IDomainEvent;
public sealed record CustomerRegisteredV2(
Guid CustomerId,
string Name,
string Email
) : IDomainEvent;
// Upcaster for old events
public sealed class CustomerRegisteredUpcaster : IEventUpcaster
{
public bool CanUpcast(string eventType, int version) =>
eventType == "CustomerRegistered" && version == 1;
public IDomainEvent Upcast(string eventType, int version, byte[] data)
{
var v1 = Deserialize<CustomerRegisteredV1>(data);
return new CustomerRegisteredV2(
v1.CustomerId,
v1.Name,
Email: "unknown@example.com" // Default for old events
);
}
}
Anti-Pattern 11: Ignoring eventual consistencyβ
The mistakeβ
// BAD: Expecting immediate consistency
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var orderId = await _orderService.CreateOrder(request);
// WRONG: Projection might not be updated yet!
var order = await _orderReadModel.GetById(orderId);
return Ok(order);
}
Why it's badβ
- Read model updates are async
- You might read stale data
- Race conditions everywhere
The fixβ
// GOOD: Return what you know, or wait explicitly
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var orderId = await _orderService.CreateOrder(request);
// Option 1: Return the command result directly
return Accepted(new { OrderId = orderId });
// Option 2: Wait for projection (with timeout)
var order = await _orderReadModel.WaitForOrder(orderId, timeout: TimeSpan.FromSeconds(5));
if (order is null)
return Accepted(new { OrderId = orderId, Message = "Order created, details pending" });
return Ok(order);
}
// Read model with wait capability
public async Task<Order?> WaitForOrder(Guid orderId, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var order = await GetById(orderId);
if (order is not null)
return order;
await Task.Delay(100);
}
return null;
}
Anti-Pattern 12: Not testing event replayβ
The mistakeβ
Only testing the happy path, never testing:
- Projection rebuilds
- Schema migration
- Event replay after code changes
Why it's badβ
- Bugs hide until production rebuild
- Schema changes break silently
- "Works on my machine" syndrome
The fixβ
// GOOD: Test that old events still work
public sealed class EventCompatibilityTests
{
[Fact]
public void Can_replay_v1_events()
{
// Load actual events from test fixtures
var v1Events = LoadTestEvents("fixtures/customer-v1-events.json");
var aggregate = new Customer();
aggregate.LoadFromHistory(v1Events);
Assert.NotNull(aggregate.Id);
Assert.NotEmpty(aggregate.Name);
}
[Fact]
public void Projection_handles_all_event_versions()
{
var projector = new CustomerProjector();
// Mix of v1 and v2 events
var events = new IDomainEvent[]
{
new CustomerRegisteredV1(Guid.NewGuid(), "Old Customer"),
new CustomerRegisteredV2(Guid.NewGuid(), "New Customer", "new@example.com"),
};
foreach (var e in events)
{
projector.Apply(e); // Should not throw
}
}
[Fact]
public async Task Full_projection_rebuild_succeeds()
{
// Use production-like test data
var store = await LoadTestEventStore();
var projector = new CustomerProjector();
await foreach (var @event in store.ReadAll(0, CancellationToken.None))
{
projector.Apply(Deserialize(@event));
}
// Verify projector state is valid
Assert.True(projector.GetAll().Count() > 0);
}
}
Quick reference: Smell β Fixβ
| Smell | Likely Anti-Pattern | Fix |
|---|---|---|
| Events named like commands | Events as commands | Use past tense |
EntityUpdated event | CRUD disguised as ES | Specific events |
| Aggregate with 20+ properties | God aggregate | Split aggregates |
Side effects in Apply | Impure Apply | Move to handlers |
| Projection shows wrong data | Missing idempotency | Track versions |
| Old events crash app | No schema evolution | Version events |
| Write latency spikes | Sync projections | Async projections |
| Everything is event-sourced | ES for everything | Be selective |
Nextβ
Choosing the right event store for your needs.
Sourcesβ
https://www.eventstore.com/blog/event-sourcing-and-cqrshttps://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing#issues-and-considerationshttps://verraes.net/2019/08/eventsourcing-state-from-events-vs-events-as-state/