Designing Event-Driven Systems Correctly
Designing event-driven systems correctly for reliability
Event-driven architecture simplifies scaling and integration, but it is easy to make it brittle with the wrong coupling patterns. This guide focuses on designing for long-term evolvability, correctness, and operational confidence.
Prerequisites
- .NET 8 SDK
- Broker such as Kafka or RabbitMQ
- Relational database for the outbox pattern
Core design principles
- Explicit contracts: events are versioned contracts, not private DTOs.
- Asynchronous boundaries: events represent facts, not synchronous requests.
- Idempotency everywhere: consumers must treat duplicates as normal.
Use the outbox pattern for atomicity
Publish events from the same transaction that mutates state. This avoids the dual-write problem.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public sealed class OrderCreatedOutboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = "order.created.v1";
public string Payload { get; init; } = string.Empty;
public DateTimeOffset OccurredAt { get; init; }
}
public async Task CreateOrderAsync(Order order)
{
await using var tx = await _db.Database.BeginTransactionAsync();
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OrderCreatedOutboxMessage
{
Id = Guid.NewGuid(),
Payload = JsonSerializer.Serialize(order),
OccurredAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
await tx.CommitAsync();
}
Dispatch outbox messages reliably
Use a background worker to read and publish messages. Mark records as sent only when the broker confirms delivery.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var batch = await _db.OutboxMessages
.Where(x => x.SentAt == null)
.OrderBy(x => x.OccurredAt)
.Take(100)
.ToListAsync(stoppingToken);
foreach (var message in batch)
{
await _publisher.PublishAsync(message.Type, message.Payload, stoppingToken);
message.SentAt = DateTimeOffset.UtcNow;
}
await _db.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
Design consumers for idempotency
Store a deduplication key in your business table or maintain a processed-events table with a unique index. This allows you to reprocess safely after failures or retries.
Event choreography vs orchestration
- Choreography works when each service owns its own state and reacts independently.
- Orchestration is better for long-running workflows where you need a single place to enforce consistency.
Observability requirements
- Correlate events with trace IDs and partition keys.
- Emit metrics for processing lag and error rate.
- Capture schema versions in logs for post-incident analysis.
Things to remember
- Use the outbox pattern to guarantee atomic state changes and event publication.
- Design for retries and duplicates from the start.
- Keep event payloads stable and versioned.
This post is licensed under CC BY 4.0 by the author.