Event-driven

Event-Driven Development

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).

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

Approach Strengths Weaknesses When to use
In-process events (MediatR) Simple, no infrastructure, synchronous option Lost on process crash, no cross-service delivery Domain events within one bounded context
Distributed broker (Service Bus, Kafka) Durable, cross-service, scalable Operational complexity, at-least-once delivery, ordering challenges Cross-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


Whats next