Skip to main content

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​

SmellLikely Anti-PatternFix
Events named like commandsEvents as commandsUse past tense
EntityUpdated eventCRUD disguised as ESSpecific events
Aggregate with 20+ propertiesGod aggregateSplit aggregates
Side effects in ApplyImpure ApplyMove to handlers
Projection shows wrong dataMissing idempotencyTrack versions
Old events crash appNo schema evolutionVersion events
Write latency spikesSync projectionsAsync projections
Everything is event-sourcedES for everythingBe selective

Next​

Choosing the right event store for your needs.

Next: Event store comparison

Sources​

  • https://www.eventstore.com/blog/event-sourcing-and-cqrs
  • https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing#issues-and-considerations
  • https://verraes.net/2019/08/eventsourcing-state-from-events-vs-events-as-state/