onse
Architecture

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

GuaranteeMeaningConsumer duty
At-most-onceMay lose messagesRarely acceptable for business events
At-least-onceMay duplicateIdempotent handlers
Exactly-onceRare end-to-endUsually “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 bus

This pairs cleanly with hexagonal architecture: the outbox is a persistence adapter concern; handlers remain application services.

Commands vs events

CommandEvent
IntentDo thisThis happened
NamingImperative (PlaceOrder)Past tense (OrderPlaced)
AudienceOne handler ideallyMany subscribers
FailureCaller often caresPublisher 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

Related articles

On this page