Event-Driven Architecture
Decouple producers and consumers with events, understand delivery semantics, and keep consistency workable with outboxes and idempotency.
Event-driven architecture (EDA) structures systems so that components react to facts that have already happened—events—rather than calling each other for every side effect. Producers emit events; consumers subscribe and act. The goal is temporal and logical decoupling: a billing module need not know every system that cares about InvoicePaid.
When events help
Use events when:
- Multiple subsystems must react to the same business fact
- You want to avoid synchronous chains of remote calls
- Work can be completed asynchronously (notifications, projections, analytics)
- You are preparing a modular monolith for later extraction
Avoid using events as a substitute for a query API, or as a distributed transaction protocol without additional patterns.
- Domain event
A record that something meaningful occurred in the domain—e.g.
OrderShipped—named in the past tense and carrying enough data for consumers to react.
Topology
Brokers (Kafka, RabbitMQ, cloud buses) provide durable fan-out. In a monolith, an in-process dispatcher or outbox table can publish the same conceptual events without a network.
Delivery semantics
| Guarantee | Meaning | Consumer duty |
|---|---|---|
| At-most-once | May lose messages | Rarely acceptable for business events |
| At-least-once | May duplicate | Idempotent handlers |
| Exactly-once | Rare end-to-end | Usually “effectively once” via idempotency + dedupe |
The dual-write problem
Updating a database and publishing to a broker in two steps can diverge: the DB commits and the publish fails, or vice versa. The transactional outbox pattern writes the event to an outbox table in the same transaction as business state; a relay publishes reliably afterward.
await using var tx = await _db.BeginTransactionAsync(ct);
var order = Order.Place(...);
_db.Orders.Add(order);
_db.Outbox.Add(new OutboxMessage(
type: "order.placed",
payload: OrderPlaced.From(order)));
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Separate process reads Outbox and publishes to the busThis pairs cleanly with hexagonal architecture: the outbox is a persistence adapter concern; handlers remain application services.
Commands vs events
| Command | Event | |
|---|---|---|
| Intent | Do this | This happened |
| Naming | Imperative (PlaceOrder) | Past tense (OrderPlaced) |
| Audience | One handler ideally | Many subscribers |
| Failure | Caller often cares | Publisher usually does not wait for all consumers |
Confusing the two produces “events” that are really RPCs over a bus—brittle and hard to reason about.
Consistency and UX
EDA embraces eventual consistency. Inventory may lag orders by seconds. Design:
- User-visible acknowledgements that work is accepted
- Read models (projections) updated by consumers
- Clear compensation or retry for failed reactions
When strong consistency across aggregates is mandatory inside one request, keep that path synchronous (or a single module transaction) and emit events for downstream work.
Scaling and partition keys
High-throughput streams need partitioning. Choose keys so related events stay ordered where order matters (e.g. orderId). Partition strategies relate to consistent hashing and database sharding: the same instinct—stable affinity of related data—applies to log partitions and consumer assignment.
Anti-patterns
- Chatty technical events (
RowUpdated) that leak schema - Giant payloads that couple producers to every consumer’s needs—prefer IDs + pull, or versioned contracts
- Synchronous request/response over messaging without timeouts and correlation
- Missing poison-message strategy — infinite retries on bad payloads