Event-Driven Architecture: Systems That Communicate Through Events
A practical guide to event-driven architecture — the design philosophy where services communicate by publishing and reacting to events rather than calling each other directly — covering event types, choreography vs. orchestration, event sourcing, CQRS, consistency trade-offs, and how the messaging technologies covered elsewhere in this series fit into the bigger picture.
Table of Contents
- Introduction
- Direct Calls vs. Events: The Core Trade-off
- Types of Events
- Choreography vs. Orchestration
- Event Sourcing
- CQRS: Separating Reads from Writes
- The Outbox Pattern: Solving the Dual-Write Problem
- Eventual Consistency and Its Consequences
- Idempotency: The Non-Negotiable Discipline
- The Saga Pattern for Distributed Transactions
- Observability in Event-Driven Systems
- When Event-Driven Architecture Is (and Isn't) the Right Choice
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Event-driven architecture (EDA) is a design philosophy where services communicate primarily by publishing events — facts about something that already happened — and reacting to events published by others, rather than calling each other's APIs directly and waiting for a response. This guide sits a level above the specific messaging technologies covered elsewhere in this series (RabbitMQ, Kafka, Azure Service Bus) — it's about the architectural patterns and trade-offs that make event-driven systems work well, regardless of which broker actually carries the events.
Direct call: OrderService → (synchronous HTTP/gRPC call) → InventoryService, EmailService, ShippingService
Event-driven: OrderService → publishes "OrderCreated" → InventoryService, EmailService, ShippingService
each independently reacts, in their own time
An event like OrderCreated is a statement of fact — it already happened, it's immutable, and the publisher has no expectation of (or dependency on) how, or even whether, anyone reacts to it. This is the conceptual foundation everything else in this guide builds on.
1. Direct Calls vs. Events: The Core Trade-off
What direct calls (REST, gRPC) get you
var inventoryResult = await _inventoryClient.ReserveStockAsync(orderId, items);
if (!inventoryResult.Success) return Results.Conflict("Insufficient stock");
var shippingResult = await _shippingClient.ScheduleAsync(orderId);
// caller knows immediately, synchronously, whether each step succeeded
Direct, synchronous calls (covered in this series' REST and gRPC guides) give immediate feedback — the caller knows right away whether an operation succeeded, and can react accordingly within the same request. This is exactly right for scenarios where the caller genuinely needs an answer before proceeding.
What direct calls cost you
OrderService calls InventoryService, EmailService, ShippingService, and AnalyticsService,
synchronously, one after another (or in parallel) — if ANY of them is slow or down,
the order-creation request itself is slow or fails, even for concerns unrelated to
whether the order itself was valid
Every direct call couples the caller to the callee's availability and latency — a synchronous chain of calls is only as fast as its slowest link and only as reliable as its least reliable link, even when several of those calls represent genuinely non-critical, "nice to have happen eventually" side effects (like updating an analytics dashboard) rather than something the original request should actually wait on or fail because of.
What events buy you instead
await orderRepository.SaveAsync(order);
await eventPublisher.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerId, order.Total));
return Results.Created($"/orders/{order.Id}", order);
// the request completes here — inventory, email, shipping, analytics all react independently, later
Publishing an event and moving on decouples the publisher from every consumer's availability, latency, and even existence — OrderService doesn't need to know how many services care about OrderCreated, doesn't block on any of them, and isn't affected if a new consumer is added six months later. This is the same decoupling-in-time-space-and-synchronization benefit covered in this series' RabbitMQ guide, framed here as an architectural, not just a technical, choice.
The trade-off, stated plainly
Events buy decoupling and resilience at the cost of immediacy and simplicity — the caller no longer knows synchronously whether a downstream action succeeded, the system's true end-to-end behavior is spread across multiple independently-deployed, independently-reasoned-about services, and debugging "why didn't X happen" requires tracing through an asynchronous chain rather than reading a single call stack. Neither approach is universally correct; the right architecture usually mixes both, applying events specifically where their trade-offs are worth it.
2. Types of Events
Not all events serve the same purpose, and being explicit about which kind you're publishing shapes the whole design.
Event notification: "something happened, go find out more if you care"
{ "eventType": "OrderCreated", "orderId": 1001 }
A minimal event carrying just enough information to identify what happened — interested consumers query back to the source (or another API) for any additional detail they need. This keeps events small and avoids duplicating a large, evolving data model across every event, at the cost of an additional round-trip for consumers that need more than the bare notification.
Event-carried state transfer: "here's everything you need, no follow-up required"
{
"eventType": "OrderCreated",
"orderId": 1001,
"customerId": 42,
"items": [{ "productId": 7, "quantity": 2, "price": 29.99 }],
"total": 59.98,
"shippingAddress": { "city": "Cambridge", "postalCode": "..." }
}
The event carries the full relevant state a consumer would need, avoiding any follow-up call back to the publisher — this reduces consumer-side latency and coupling (a consumer doesn't need network access to the publisher's API at all, just the event stream) at the cost of larger messages and the schema-governance discipline covered in this series' Kafka guide, since every consumer now depends on the shape of this richer payload remaining compatible over time.
Domain events vs. integration events
// Domain event: internal to a single service/bounded context, may carry rich, internal detail
public record OrderTotalRecalculated(int OrderId, decimal OldTotal, decimal NewTotal);
// Integration event: the public, cross-service contract — deliberately more stable and minimal
public record OrderCreatedIntegrationEvent(int OrderId, int CustomerId, decimal Total);
A useful distinction in more mature event-driven systems: domain events are internal to a service's own boundary (used, for instance, to trigger side effects within the same service, or feed an event-sourced aggregate, Section 4) and can change freely as internal implementation details evolve; integration events are the deliberate, stable, versioned public contract published across service boundaries — treating these as genuinely different things, rather than publishing raw internal domain events directly to other services, avoids tightly coupling other teams to your internal implementation details.
Command vs. event: a genuinely important distinction
Event: "OrderCreated" — a fact, already happened, past tense, no expectation of any specific reaction
Command: "CreateShippingLabel" — an instruction, addressed to a specific recipient, expecting a specific action
An event is a statement of fact with no addressee and no expectation of action; a command is an explicit instruction directed at a specific recipient, expecting a specific outcome. Publishing something named CreateShippingLabelCommand to a general topic that many services might be subscribed to is a common architectural smell — it reveals that what's actually being modeled is a direct request to a specific service, just delivered asynchronously, which is a different (and valid!) pattern from genuine event-driven fan-out, but conflating the two naming and reasoning about them as if they were the same thing leads to confusing, hard-to-reason-about systems.
3. Choreography vs. Orchestration
Choreography: no central coordinator, each service reacts independently
OrderService: publishes OrderCreated
InventoryService: subscribes to OrderCreated → reserves stock → publishes StockReserved
ShippingService: subscribes to StockReserved → schedules shipment → publishes ShipmentScheduled
EmailService: subscribes to OrderCreated → sends confirmation email
In choreography, each service independently knows what events to react to and what events to publish in turn — there's no central authority dictating the overall sequence; the end-to-end business process emerges from the sum of each service's independent, local reactions. This is the natural, decentralized expression of the choreography-vs-orchestration distinction that also shows up (with different terminology) in the GitOps guide's discussion of declarative, distributed reconciliation versus centrally-orchestrated deployment pipelines.
The problem choreography creates as complexity grows
Which service is actually responsible for knowing the full order-fulfillment process?
→ Nobody, by design — it emerges from N services' individually reasonable local decisions
→ Debugging "why didn't the order ship" means tracing across 4+ services' independent event handlers
Choreography scales well organizationally (each team owns their service's reactions independently, with minimal cross-team coordination needed to add a new step) but becomes genuinely difficult to reason about holistically once a business process spans more than a handful of steps — there's no single place to look to understand "what happens when an order is created," only the emergent behavior of every independently-reacting service.
Orchestration: a central coordinator drives the process explicitly
public class OrderFulfillmentOrchestrator
{
public async Task RunAsync(int orderId)
{
await _inventoryService.ReserveStockAsync(orderId);
await _paymentService.ChargeAsync(orderId);
await _shippingService.ScheduleAsync(orderId);
await _emailService.SendConfirmationAsync(orderId);
}
}
In orchestration, a dedicated coordinator explicitly drives the sequence of steps, calling out to each participating service (which may still be event-driven internally, or exposed as commands/APIs) and handling the overall process's success/failure logic centrally — this is the model implemented by Durable Functions (covered in this series' Azure Compute guide) and dedicated workflow engines, giving a single, explicit, debuggable definition of the business process at the cost of that central coordinator becoming a more significant, more tightly-coupled dependency for every step it drives.
Choosing between them
| Choreography | Orchestration | |
|---|---|---|
| Coordination | Decentralized, emergent from independent reactions | Centralized, explicit |
| Understandability of the full process | Requires tracing across many services | Visible in one place |
| Coupling | Looser — services don't know about each other directly | Tighter — the orchestrator knows about and calls every participant |
| Best for | A small number of steps, or steps genuinely owned by independent teams with no need for central coordination | A business process with meaningful sequencing/compensation logic that benefits from being explicit and centrally visible |
Many real systems use both: choreography for genuinely independent, fan-out side effects (an order confirmation email, an analytics event) where no team needs central visibility into whether it happened, and orchestration for the core, must-succeed-as-a-unit business process (payment, inventory reservation, shipping) where explicit sequencing and failure handling genuinely matter — this is directly the same reasoning behind the saga pattern's two implementation styles (Section 9).
4. Event Sourcing
Storing state as a sequence of events, not a current snapshot
Traditional model: Orders table, row for order 1001: { Status: "Shipped", Total: 149.97 }
← only the CURRENT state is stored; how it got there is lost
Event-sourced model: OrderCreated(1001, ...) → ItemAdded(1001, ...) → PaymentReceived(1001, ...) → OrderShipped(1001, ...)
← the full HISTORY is stored; current state is derived by replaying these events
Event sourcing stores every state-changing event as the system of record, rather than just the current state — an entity's current state is computed by replaying its full event history from the beginning (or, more practically, from a periodic snapshot plus recent events). This is conceptually the same log-as-source-of-truth idea covered in this series' Kafka guide, applied specifically as a persistence strategy for an application's core domain entities, not just as a messaging transport.
Why this is genuinely valuable
- A complete, auditable history — not just "the order is currently Shipped," but the exact sequence of every state change that led there, which matters enormously for domains with real audit/compliance requirements (financial transactions, healthcare records).
- Replay for debugging and new read models — since the full history is retained, a bug in how current state is derived can be fixed and the entire history reprocessed to correct it, and an entirely new read-optimized view (Section 5) can be built later by replaying history that already exists, without needing the original write path to have anticipated that future need.
- Temporal queries — "what did this order look like as of last Tuesday" is a natural query against an event-sourced history, and a genuinely difficult one against a system that only ever stored current state.
The real cost
// Reconstructing current state means replaying (or loading from a snapshot + recent events)
var events = await _eventStore.GetEventsAsync(orderId);
var order = events.Aggregate(new Order(), (state, evt) => state.Apply(evt));
Event sourcing is a genuinely significant architectural commitment — queries that would be a simple SELECT against a traditional table now require either replaying history or maintaining a separate, synchronized read model (Section 5); the team needs discipline around event schema evolution (echoing this series' Kafka guide's Schema Registry discussion, now applied to the core data model itself, not just inter-service messaging); and it's a pattern that pays off specifically for domains with genuine audit/history/replay value, not a default choice for every entity in every system.
5. CQRS: Separating Reads from Writes
The core idea
CQRS (Command Query Responsibility Segregation) separates the model used to write data (handling commands, enforcing business rules) from the model used to read it (optimized purely for query performance and shape) — rather than one unified model serving both purposes, which often ends up compromising on both.
Write side: OrderAggregate — enforces business rules, emits events on state change
Read side: OrderSummaryReadModel — a denormalized, query-optimized table/view,
updated asynchronously by consuming the events the write side emits
Why CQRS and event-driven architecture pair naturally
// A projection: a consumer that builds/maintains a read-optimized view from events
public class OrderSummaryProjection
{
public async Task HandleAsync(OrderCreatedEvent evt)
{
await _readDb.InsertAsync(new OrderSummary { OrderId = evt.OrderId, Status = "Created", Total = evt.Total });
}
public async Task HandleAsync(OrderShippedEvent evt)
{
await _readDb.UpdateStatusAsync(evt.OrderId, "Shipped");
}
}
The events a write-side model emits (whether from event sourcing specifically, or simply as integration events published alongside a traditional write) are exactly what a read-side projection consumes to build and maintain its own, independently-optimized denormalized view — this is a direct, practical application of the multiple-independent-consumer-groups capability covered in this series' Kafka guide: the read-model projection is just another consumer of the same event stream, entirely decoupled from the write side's own persistence mechanism.
Multiple read models from the same events
Events: OrderCreated, OrderShipped, PaymentReceived
→ Read Model A: OrderSummaryView (for the customer-facing order history page)
→ Read Model B: FulfillmentDashboard (for the warehouse team, joined with inventory data)
→ Read Model C: RevenueByRegion (for a finance reporting dashboard, aggregated differently entirely)
Because events are published once and can be consumed by any number of independent projections, CQRS combined with event-driven architecture naturally supports building several purpose-built read models from the same underlying events — each optimized for its own specific query pattern (a relational table for one, a document store for another, an in-memory cache for a third), without the write side needing to know or care how many read models exist or how they're each shaped.
CQRS without event sourcing
It's worth explicitly noting CQRS doesn't require event sourcing — a traditional write-side database with a standard, current-state schema can still publish integration events on every meaningful change, feeding read-side projections built the same way; event sourcing and CQRS are complementary, commonly paired patterns, but each is independently valuable and adoptable on its own.
6. The Outbox Pattern: Solving the Dual-Write Problem
The problem: two separate systems, one logical operation
// ❌ Two independent writes — what happens if the process crashes between them?
await _dbContext.SaveChangesAsync(); // 1. save the order to the database
await _eventPublisher.PublishAsync(orderCreatedEvent); // 2. publish the event — a SEPARATE system, SEPARATE failure mode
This is one of the most common, easy-to-overlook correctness bugs in event-driven systems: saving to a database and publishing an event are two independent operations against two independent systems, with no atomic guarantee tying them together — if the process crashes (or the publish call simply fails) between the two lines, the order exists in the database, but the event announcing it was never published, and every downstream consumer relying on that event never finds out.
The outbox pattern: making it atomic via a single database transaction
using var transaction = await _dbContext.Database.BeginTransactionAsync();
_dbContext.Orders.Add(order);
_dbContext.OutboxMessages.Add(new OutboxMessage
{
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(orderCreatedEvent),
CreatedAt = DateTimeOffset.UtcNow
});
await _dbContext.SaveChangesAsync(); // both the order AND the outbox message commit together, atomically
await transaction.CommitAsync();
// A separate background process (a BackgroundService, per this series' companion guide)
// polls the outbox table and actually publishes to the message broker, then marks the row as sent
public class OutboxPublisherWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pending = await _dbContext.OutboxMessages.Where(m => !m.Published).ToListAsync(stoppingToken);
foreach (var message in pending)
{
await _eventPublisher.PublishAsync(message.EventType, message.Payload);
message.Published = true;
}
await _dbContext.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
}
The outbox pattern solves the dual-write problem by writing the event to an "outbox" table within the same database transaction as the actual business data change — since both writes are now part of one atomic transaction (an ordinary relational transaction, exactly as covered in this series' EF Core and SQL Server guides), they either both commit or both roll back together, eliminating the window where one succeeds and the other doesn't. A separate process then reliably publishes from the outbox table to the actual message broker, retrying as needed — and since the outbox table itself is the durable source of truth for "what needs to be published," a crash mid-publish simply means the next polling cycle picks up the still-unpublished row and tries again.
Change Data Capture as an alternative implementation
Instead of a separate polling worker, a CDC mechanism (Debezium, or a database-native equivalent)
watches the outbox table's transaction log directly and publishes new rows automatically
Change Data Capture (CDC) tools like Debezium can watch a database's transaction log directly (rather than polling a table) and automatically publish new outbox rows to Kafka or another broker — a more real-time, lower-latency implementation of the same fundamental outbox pattern, at the cost of additional infrastructure (a CDC connector) to operate.
7. Eventual Consistency and Its Consequences
What "eventual" actually means in practice
t=0ms: OrderService commits the order, publishes OrderCreated
t=50ms: InventoryService's consumer processes the event, reserves stock
t=200ms: A customer, viewing their order immediately after checkout, might see
"Order placed" but NOT yet see "Stock reserved" — that hasn't happened yet
In an event-driven system, different parts of the overall state update at different times, converging toward consistency only eventually — this is a genuine, visible trade-off, not just an implementation detail: a user interface built against an event-driven backend needs to be designed with the expectation that not everything is instantly, globally consistent the moment a request completes.
Designing UX around eventual consistency
❌ "Your order is confirmed!" immediately, with a UI that assumes inventory is ALREADY reserved
✅ "Your order has been received and is being processed" — honest about the asynchronous nature,
with a status that updates (via polling, SignalR, or a websocket push) as downstream steps complete
This directly connects to this series' SignalR guide — pushing real-time status updates to a client as an order progresses through its asynchronous, event-driven fulfillment steps is a natural, honest way to build a good user experience around eventual consistency, rather than either lying to the user about instant completion or forcing them to manually refresh to check progress.
When eventual consistency is genuinely unacceptable
Not every operation tolerates eventual consistency well — a bank balance check immediately after a transfer, or an inventory count checked immediately before allowing a purchase, often needs to reflect the very latest state, not a stale, eventually-consistent view. Recognizing which specific operations genuinely need strong consistency (and keeping those as direct, synchronous calls or within a single transactional boundary) versus which can tolerate eventual consistency (and can be safely decoupled via events) is one of the more important architectural judgment calls in designing an event-driven system — not every interaction should be forced into the same consistency model uniformly.
8. Idempotency: The Non-Negotiable Discipline
Why this keeps recurring throughout this series
As covered in this series' RabbitMQ, Kafka, and Azure Service Bus guides, essentially every message broker provides at-least-once delivery by default — meaning any event-driven system must be designed assuming a given event might be delivered and processed more than once, and this isn't an edge case to handle defensively "just in case," it's baseline, expected behavior.
Designing idempotent event handlers
public async Task HandleAsync(OrderCreatedEvent evt)
{
if (await _processedEvents.HasBeenProcessedAsync(evt.EventId))
{
return; // already handled this exact event, skip reprocessing
}
await _inventoryService.ReserveStockAsync(evt.OrderId, evt.Items);
await _processedEvents.MarkAsProcessedAsync(evt.EventId);
}
Explicitly tracking processed event IDs (an "inbox" pattern, the consumer-side mirror of the outbox pattern from Section 6) is one direct way to guarantee idempotency regardless of whether the underlying operation is naturally idempotent — though where possible, designing the operation itself to be naturally idempotent (an "upsert" rather than an "insert," a SET stock = 5 rather than stock = stock - 1) is often simpler and avoids needing separate deduplication bookkeeping at all.
Natural idempotency vs. explicit deduplication
// Naturally idempotent — running this twice produces the same end state
await _db.ExecuteAsync("UPDATE Inventory SET Reserved = @Quantity WHERE ProductId = @ProductId", ...);
// NOT naturally idempotent — running this twice double-decrements
await _db.ExecuteAsync("UPDATE Inventory SET Available = Available - @Quantity WHERE ProductId = @ProductId", ...);
Preferring operations that are naturally idempotent by construction (setting an absolute value rather than applying a relative delta) removes an entire category of duplicate-processing bugs without needing any explicit tracking machinery — worth considering as a first-line defense before reaching for an inbox table.
9. The Saga Pattern for Distributed Transactions
The problem: no distributed ACID transaction across services
OrderService reserves an order (its own database)
InventoryService reserves stock (a DIFFERENT database)
PaymentService charges a card (a THIRD system entirely)
If step 3 fails, how do we "roll back" steps 1 and 2, each in a completely different system?
A traditional database transaction (covered in this series' SQL Server and PostgreSQL guides) can't span multiple independent services, each with their own database — there's no single ACID transaction wrapping "reserve inventory in Service A, charge a card in Service B." The saga pattern is the standard answer: a sequence of local transactions, each in its own service, with explicit compensating actions defined to undo a prior step if a later step fails.
Choreography-based sagas
OrderService: creates order (Pending) → publishes OrderCreated
InventoryService: reserves stock → publishes StockReserved
PaymentService: charges card → publishes PaymentFailed (something went wrong)
InventoryService: subscribes to PaymentFailed → releases the reserved stock (the COMPENSATING action)
OrderService: subscribes to PaymentFailed → marks the order Cancelled
Each service reacts to failure events by running its own compensating action — directly the choreography model from Section 3, applied specifically to rolling back a partially-completed distributed business process.
Orchestration-based sagas
public class OrderSagaOrchestrator
{
public async Task RunAsync(int orderId)
{
try
{
await _inventoryService.ReserveStockAsync(orderId);
await _paymentService.ChargeAsync(orderId);
await _shippingService.ScheduleAsync(orderId);
}
catch (PaymentFailedException)
{
await _inventoryService.ReleaseStockAsync(orderId); // explicit compensation, centrally driven
await _orderService.CancelAsync(orderId);
}
}
}
A central saga orchestrator explicitly drives both the forward steps and, on failure, the compensating rollback steps — giving the same centralized-visibility benefit covered in Section 3's orchestration discussion, and often the more manageable choice once a saga involves more than 2-3 steps or has genuinely branching compensation logic, echoing exactly the Durable Functions pattern referenced in this series' Azure Compute guide.
Compensation is not the same as a database rollback
A crucial distinction: a compensating action semantically undoes a completed step's effect (releasing reserved stock, issuing a refund) — it does not, and cannot, magically make the original action never have happened, the way a database transaction rollback does. This means saga design needs to account for the fact that other things might observe the intermediate, not-yet-compensated state (a customer might briefly see "stock reserved" before a payment failure triggers its release) — connecting directly to the eventual-consistency UX considerations from Section 7.
10. Observability in Event-Driven Systems
Why tracing matters more here than in a synchronous system
A synchronous call stack: OrderController → OrderService → InventoryClient
← visible in one stack trace, one request log
An event-driven flow: OrderService publishes → (time passes) → InventoryService's consumer runs
← two entirely separate execution contexts, no shared call stack, potentially minutes apart
Debugging "why didn't this order ship" in an event-driven system means tracing across multiple independent services' logs, each processing the relevant event at a different, unpredictable time — without deliberate tooling, this is genuinely harder than debugging a synchronous call chain, which is a real cost of the architecture, not just an inconvenience to shrug off.
Correlation IDs: the minimum viable tracing mechanism
var evt = new OrderCreatedEvent(order.Id, order.CustomerId, order.Total)
{
CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString()
};
Propagating a consistent correlation ID through every event in a chain — included in the original request, carried through every published event, and included in every consumer's log output — is the minimum viable tooling for being able to answer "show me everything that happened as a result of this one order being created" across an otherwise disconnected set of service logs.
Distributed tracing with OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource("MyApp.Messaging")
.AddAspNetCoreInstrumentation());
As referenced in this series' ASP.NET Core guide, OpenTelemetry's distributed tracing extends naturally to messaging — many broker client libraries (including Confluent.Kafka and Azure.Messaging.ServiceBus) support propagating trace context through message headers, letting a tracing backend (Application Insights, Jaeger, and others) reconstruct the full, cross-service, asynchronous flow as a single visual trace, closing much of the observability gap event-driven architecture otherwise introduces.
Event schema and flow documentation
Beyond per-request tracing, maintaining an explicit, up-to-date map of "which services publish which events, and which services consume them" — even something as simple as a shared architecture diagram or a registry — matters increasingly as the number of services and event types grows, since (as covered in Section 3's choreography discussion) there's no single place in the code where the full picture naturally lives otherwise.
11. When Event-Driven Architecture Is (and Isn't) the Right Choice
Good fits
- Genuinely independent side effects that don't need to block the primary operation's response (sending a confirmation email, updating an analytics dashboard, triggering a downstream recommendation-engine refresh).
- Multiple, decoupled consumers of the same underlying fact, potentially added over time without the publisher needing to change (exactly the multi-consumer-group strength covered in this series' Kafka guide).
- Smoothing bursty load — absorbing a traffic spike into a queue and processing it at a sustainable rate, rather than every downstream system needing to handle peak load synchronously.
- Long-running or multi-step business processes naturally modeled as a sequence of state transitions (an order's lifecycle from creation through fulfillment).
Poor fits, or at least worth real hesitation
- Anything the caller genuinely needs an immediate, synchronous answer to — "is this credit card valid" is not a good candidate for "publish an event and eventually find out."
- A small system with few services, where the coordination and observability overhead of event-driven architecture outweighs any decoupling benefit — a monolith or a small number of tightly-related services calling each other directly is often simpler and entirely appropriate, not a compromise.
- Teams without the operational maturity for the added complexity — correlation-ID tracing, idempotent consumers, outbox-pattern discipline, and eventual-consistency-aware UX design are all genuinely more work than a synchronous call chain, and adopting event-driven architecture without also adopting these disciplines tends to produce a system that's simultaneously more complex and less reliable than the synchronous alternative it replaced.
The practical middle ground most real systems land on
Most production systems aren't purely one or the other — a typical architecture uses direct, synchronous calls (REST or gRPC) for anything the caller needs an immediate answer to, and events specifically for the genuinely decoupled, asynchronous, multi-consumer, or bursty-load portions of the system. Recognizing which category a given interaction actually falls into — rather than dogmatically committing to "everything is an event" or "everything is a direct call" — is the core architectural judgment this entire guide has been building toward.
12. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| The dual-write problem (saving to a DB and publishing separately, non-atomically) | An event can be silently lost or duplicated relative to the actual data change | Use the outbox pattern to make both writes atomic |
| Assuming exactly-once delivery | Every mainstream broker is at-least-once by default | Design every consumer to be idempotent |
| Publishing rich internal domain events directly as the cross-service contract | Tightly couples other teams to your internal implementation details | Maintain a distinct, deliberately stable integration event contract |
| "Everything is an event," including things the caller needs an immediate answer to | Forces synchronous-feeling interactions through an asynchronous, delayed mechanism | Use direct calls for anything genuinely needing an immediate response |
| No correlation ID propagation | "Why didn't this happen" becomes nearly untraceable across services | Propagate a correlation/trace ID through every event in a chain |
| Confusing a command with an event | Obscures the actual coupling and expectations between publisher and consumer | Name and reason about commands (addressed, expects action) and events (unaddressed, a fact) distinctly |
| No compensating actions defined for a multi-step process | A partial failure leaves the system in a permanently inconsistent state | Design sagas with explicit compensation for every forward step that has side effects |
| Choreography sprawl with no documented event flow | Nobody can answer "what happens when X occurs" without reading every service's code | Maintain an explicit event/flow map as the system grows |
Quick Reference Table
| Concept | Purpose |
|---|---|
| Event notification vs. event-carried state transfer | Minimal "something happened" vs. a self-contained payload |
| Domain event vs. integration event | Internal implementation detail vs. deliberate, stable cross-service contract |
| Command vs. event | Addressed instruction expecting action vs. unaddressed statement of fact |
| Choreography | Decentralized, emergent coordination via independent reactions |
| Orchestration | Centralized, explicit coordination of a multi-step process |
| Event sourcing | Storing state as a full sequence of events, not just current state |
| CQRS | Separate, independently-optimized models for writes and reads |
| Outbox pattern | Atomically pairing a data change with the event announcing it |
| Saga pattern | Sequenced local transactions with explicit compensating actions |
| Idempotent consumer | Safely handles the same event being processed more than once |
| Correlation ID | Traces a single logical operation across otherwise-disconnected async steps |
Conclusion
Event-driven architecture is, at its core, a trade of immediacy and simplicity for decoupling and resilience — and like every architectural trade-off covered throughout this series, it's worth adopting deliberately, for the specific interactions where that trade genuinely pays off, rather than as a wholesale philosophy applied uniformly. The messaging technologies this series has covered in depth — RabbitMQ's flexible routing, Kafka's replayable log, Azure Service Bus's managed enterprise features — are the transport; the patterns in this guide (choreography vs. orchestration, event sourcing, CQRS, the outbox pattern, sagas) are what actually make systems built on that transport correct, debuggable, and maintainable at scale.
The disciplines that separate a well-built event-driven system from a fragile one are consistent across every pattern covered here: idempotency treated as non-negotiable rather than an afterthought, atomicity between a data change and its announcing event via the outbox pattern, deliberate compensation logic for anything that can partially fail, and honest, eventual-consistency-aware design at every layer that surfaces state to a user — from the UI down through the events themselves. Get those right, and the genuine benefits of decoupling, independent scalability, and resilience to partial failure are well worth the added complexity; skip them, and event-driven architecture tends to produce exactly the kind of hard-to-debug, silently-inconsistent system its critics warn about.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the outbox pattern that finally closed the gap between "the order was saved" and "the event was actually published."
Top comments (0)