Event-driven development builds systems around events — immutable facts that something happened — and reactions to those events. Producers publish events without knowing who will consume them; consumers subscribe and handle events independently. This decouples components: the order service doesn’t call the inventory service directly, it publishes OrderPlaced and the inventory service reacts on its own schedule.

The pattern appears at two scales: in-process (domain events within a single application, dispatched via MediatR or a simple in-memory bus) and distributed (events published to a message broker like RabbitMQ, Azure Service Bus, or Kafka, consumed by separate services).

Events vs Commands

A distinction “event-driven” tends to blur:

  • A command is an instruction to one handler to do something — imperative, present tense (ReserveStock); the sender expects it carried out, and it can be rejected.
  • An event is a notification that something already happened — a past-tense immutable fact (OrderPlaced), broadcast to zero-to-many subscribers who each decide how to react. It can’t be rejected — it’s history.

Routing a command to one handler is the Mediator pattern; fanning an event out to many is an Event Bus over a message broker.

Four Styles of “Event-Driven” (Fowler)

The term covers four distinct patterns that are often conflated — naming which one you mean avoids a lot of confusion:

  1. Event Notification — the event carries just an ID/reference (“order 42 placed”); consumers call back to the source for details. Lowest coupling, but chatty and the source must stay available.
  2. Event-Carried State Transfer — the event carries all the data a consumer needs (“order 42: items, total, address”), so consumers keep a local copy and never call back. More decoupled and resilient, at the cost of data duplication and eventual consistency.
  3. Event Sourcing — events are the source of truth; current state is derived by replaying the log. See Event Sourcing.
  4. CQRS — separate the write model (commands) from read models (queries), often kept in sync via events. See CQRS.

The first two are communication styles (how services talk); the last two are architectural patterns (how you store and model state). Most systems use event notification or state transfer without event sourcing.

In-Process Domain Events

// Event: an immutable fact
public sealed record OrderPlaced(string OrderId, decimal Total, DateTimeOffset OccurredAt);
 
// Publisher: raises the event after persisting state
public sealed class OrderService(IEventBus bus, IOrderRepository repo)
{
    public async Task PlaceAsync(string orderId, decimal total, CancellationToken ct)
    {
        await repo.SaveAsync(new Order(orderId, total), ct);
        // Publish AFTER save — event reflects committed state
        await bus.PublishAsync(new OrderPlaced(orderId, total, DateTimeOffset.UtcNow), ct);
    }
}
 
// Consumer: reacts without being called directly
public sealed class InventoryHandler : IEventHandler<OrderPlaced>
{
    public Task HandleAsync(OrderPlaced evt, CancellationToken ct)
    {
        // Reserve stock for the placed order
        return Task.CompletedTask;
    }
}

Distributed Events and the Outbox Pattern

Publishing to a message broker after a database write introduces a reliability gap: the DB write succeeds but the broker publish fails, leaving the event lost.

The Outbox pattern solves this by writing the event to an OutboxMessages table in the same database transaction as the domain change. A background worker then reads the outbox and publishes to the broker, retrying until acknowledged:

// In the same transaction: save order + write outbox entry
await using var tx = await db.Database.BeginTransactionAsync(ct);
await repo.SaveAsync(order, ct);
await db.OutboxMessages.AddAsync(new OutboxMessage
{
    Type    = nameof(OrderPlaced),
    Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow))
}, ct);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Background worker publishes OutboxMessages to the broker

Pitfalls

Publishing Before Persisting

What goes wrong: the event is published to the broker before the database transaction commits. If the commit fails, consumers react to an event that never happened.

Why it happens: publishing feels like a natural “last step” after business logic, but it happens before the DB confirms success.

Mitigation: always publish events after a successful commit, or use the Outbox pattern for guaranteed delivery.

Ignoring Consumer Idempotency

What goes wrong: the broker delivers the same event twice (at-least-once delivery is the default for most brokers). The consumer processes it twice, double-charging a customer or double-reserving stock.

Why it happens: most message brokers guarantee at-least-once delivery, not exactly-once.

Mitigation: make consumers idempotent. Track processed event IDs in a ProcessedEvents table and skip duplicates. Design operations to be naturally idempotent where possible (e.g., SET stock = X instead of stock -= Y).

Tradeoffs

ApproachStrengthsWeaknessesWhen to use
In-process events (MediatR)Simple, no infrastructure, synchronous optionLost on process crash, no cross-service deliveryDomain events within one bounded context
Distributed broker (Service Bus, Kafka)Durable, cross-service, scalableOperational complexity, at-least-once delivery, ordering challengesCross-service workflows, audit trails, high-throughput pipelines

Decision rule: start with in-process events for domain logic within a single service. Move to a distributed broker when you need cross-service communication, durability across restarts, or fan-out to multiple consumers. Always pair distributed events with the Outbox pattern for reliability.

Questions

References