Przejdź do treści głównej

Event-Driven Architecture Patterns 2025

Comprehensive guide to Event-Driven Architecture: Event Sourcing, CQRS, Saga pattern, message brokers (Kafka, RabbitMQ, Azure Service Bus). Practical EDA patterns in microservices with real e-commerce case study.

Author: Michał Wojciechowski··16 min read
Event-driven architecture with flowing data streams

Why Event-Driven Architecture is Revolutionizing Systems in 2025?

Imagine a system that reacts to changes in real-time, automatically scales under load, and never loses a single piece of information about what happened.

That's exactly what Event-Driven Architecture (EDA) gives you – an architectural pattern where components communicate through events instead of traditional direct calls. In practice, this means your services don't have to wait for each other, they can work in parallel and independently.

The numbers speak for themselves. According to Gartner 2024 report, 60% of enterprise applications already use event-driven patterns. Giants like Amazon, Netflix, Uber, and Spotify build their systems on events.

Why? Because EDA solves three fundamental problems of distributed systems: tight coupling between services, limited scalability, and low resilience to failures. If your system processes more than a few hundred requests per second, EDA stops being "nice to have" and becomes a necessity.

In this comprehensive guide, I'll show you practical EDA patterns with real code examples. You'll learn how to implement Event Sourcing, CQRS, and Saga pattern. We'll compare the most popular message brokers (Apache Kafka, RabbitMQ, Azure Service Bus) and you'll see a complete e-commerce platform case study. You'll discover asynchronous communication, event streaming, and integration patterns for distributed systems. If you're considering migrating to microservices architecture, understanding EDA is absolutely crucial. Also check our API integrations guide for full system integration context.

Key EDA Concepts 2025:

  • Event Sourcing – storing state as a sequence of immutable events
  • CQRS – separation of read and write models for optimized queries
  • Message Brokers – Kafka, RabbitMQ, Azure Service Bus for reliable delivery
  • Saga Pattern – distributed transactions in microservices
  • Event Streaming – real-time processing for millions events/second

EDA Fundamentals - How Does It Work in Practice?

Before we dive into advanced patterns, let's understand the fundamentals.

Event-Driven Architecture is based on three key elements: Events, Event Producers, and Event Consumers. An event is an immutable fact representing something that actually happened in the system – for example "order was placed" or "payment was processed".

Event Structure

Event contains metadata and payload describing what happened:

{
  "eventId": "evt_123456",
  "eventType": "OrderPlaced",
  "timestamp": "2025-11-22T10:30:00Z",
  "aggregateId": "order_789",
  "version": 1,
  "data": {
    "customerId": "cust_456",
    "orderTotal": 299.99,
    "items": [
      {
        "productId": "prod_123",
        "quantity": 2,
        "price": 149.99
      }
    ]
  },
  "metadata": {
    "correlationId": "corr_abc",
    "causationId": "evt_000"
  }
}

Producers & Consumers

Producers emit events, consumers react to them:

  • Producer: Order Service publishes OrderPlaced event
  • Consumer 1: Inventory Service reserves stock
  • Consumer 2: Payment Service processes payment
  • Consumer 3: Notification Service sends email
  • Consumer 4: Analytics Service writes to warehouse

Loose coupling: Producer doesn't know about consumers. Adding new consumer requires no changes in producer.

EDA vs Request-Response

AspectRequest-ResponseEvent-Driven
CouplingTight - caller knows calleeLoose - producer doesn't know consumers
SynchronicitySynchronous blockingAsynchronous non-blocking
ScalabilityLimited - caller waitsHigh - parallel processing
ResilienceCascade failuresIsolated failures, retry mechanisms
ExtensibilityAdding endpoint needs coordinationNew consumers without changes in producers

When to use EDA?

Perfect use cases: E-commerce order processing, IoT sensor data, trading systems, user activity tracking, microservices communication, real-time analytics, notification systems, external API integrations.

Avoid EDA when: Building simple CRUD applications, immediate strong consistency required, debugging complexity is unacceptable for your business, or when the team lacks distributed systems experience.

Data flow and event processing visualization

Event Sourcing - Full History Instead of Just Current State

Traditional databases store only the current state – "order has status: shipped". When you update a record, the previous value is lost forever.

Event Sourcing flips this logic. Instead of storing the current state, you record the full sequence of events – all the changes that led to the current state. Every change is an immutable event. You recreate the current state by "replaying" all events from the beginning. It's like time-travel for your application – at any moment you can see exactly what happened and why.

Traditional State Storage vs Event Sourcing

Traditional (CRUD)

// Database row - current state only
{
  "orderId": "order_789",
  "status": "Shipped",
  "total": 299.99,
  "updatedAt": "2025-11-22T14:00:00Z"
}

// Lost information:
// - When was the order placed?
// - Was it modified?
// - Who approved the payment?
// - History irreversibly lost

Event Sourcing

// Event stream - full history
[
  {
    "type": "OrderPlaced",
    "timestamp": "2025-11-22T10:00:00Z",
    "data": { "total": 299.99 }
  },
  {
    "type": "PaymentProcessed",
    "timestamp": "2025-11-22T10:05:00Z",
    "data": { "amount": 299.99 }
  },
  {
    "type": "OrderShipped",
    "timestamp": "2025-11-22T14:00:00Z",
    "data": { "trackingId": "TR123" }
  }
]

// Current state = replay events
// Full audit trail preserved

Event Sourcing in Practice - .NET Implementation

Let's see how an aggregate with Event Sourcing looks in .NET. This code shows key elements: command handler, event handler, and replay mechanism:

public class Order : AggregateRoot
{
    public Guid OrderId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal Total { get; private set; }
    private List<OrderItem> _items = new();

    // Command handler
    public void PlaceOrder(Guid customerId, List<OrderItem> items)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Order already placed");

        var @event = new OrderPlacedEvent
        {
            OrderId = Guid.NewGuid(),
            CustomerId = customerId,
            Items = items,
            Total = items.Sum(i => i.Price * i.Quantity),
            Timestamp = DateTime.UtcNow
        };

        ApplyEvent(@event); // Apply & store event
    }

    // Event handler - mutates state
    private void Apply(OrderPlacedEvent @event)
    {
        OrderId = @event.OrderId;
        _items = @event.Items;
        Total = @event.Total;
        Status = OrderStatus.Placed;
    }

    // Replay from event stream
    public static Order FromEvents(IEnumerable<DomainEvent> events)
    {
        var order = new Order();
        foreach (var @event in events)
        {
            order.ApplyEvent(@event, isNew: false);
        }
        return order;
    }
}

Event Store Schema

Event store is an append-only log of events per aggregate:

-- Event Store table
CREATE TABLE EventStore (
    EventId UNIQUEIDENTIFIER PRIMARY KEY,
    AggregateId UNIQUEIDENTIFIER NOT NULL,
    AggregateType NVARCHAR(255) NOT NULL,
    EventType NVARCHAR(255) NOT NULL,
    EventData NVARCHAR(MAX) NOT NULL, -- JSON
    Version INT NOT NULL,
    Timestamp DATETIME2 NOT NULL,
    Metadata NVARCHAR(MAX),

    CONSTRAINT UQ_Aggregate_Version
        UNIQUE (AggregateId, Version)
);

CREATE INDEX IX_AggregateId
    ON EventStore(AggregateId, Version);

-- Sample query: Load aggregate
SELECT EventType, EventData, Version
FROM EventStore
WHERE AggregateId = 'order_789'
ORDER BY Version ASC;

Snapshots - Performance Optimization

What if an aggregate has 10,000 events? Replaying them all on every read would be slow. Solution: snapshots. You save a state snapshot every N events (e.g., every 100), then only replay events after the last snapshot:

public class SnapshotStore
{
    // Save snapshot every N events (e.g., every 100)
    public async Task SaveSnapshot(
        Guid aggregateId,
        object state,
        int version)
    {
        await _db.Snapshots.AddAsync(new Snapshot
        {
            AggregateId = aggregateId,
            State = JsonSerializer.Serialize(state),
            Version = version,
            Timestamp = DateTime.UtcNow
        });
    }

    // Load: snapshot + events after snapshot
    public async Task<Order> LoadAggregate(Guid orderId)
    {
        // 1. Load latest snapshot
        var snapshot = await _db.Snapshots
            .Where(s => s.AggregateId == orderId)
            .OrderByDescending(s => s.Version)
            .FirstOrDefaultAsync();

        Order order;
        int fromVersion;

        if (snapshot != null)
        {
            order = JsonSerializer
                .Deserialize<Order>(snapshot.State);
            fromVersion = snapshot.Version + 1;
        }
        else
        {
            order = new Order();
            fromVersion = 1;
        }

        // 2. Replay events after snapshot
        var events = await _eventStore
            .GetEvents(orderId, fromVersion);

        foreach (var @event in events)
        {
            order.ApplyEvent(@event);
        }

        return order;
    }
}

// Result: 10,000 events → 100 events replay
// (snapshot at 9,900 + 100 new events)

Event Sourcing Benefits & Trade-offs

Benefits:

  • • Complete audit trail - compliance, debugging
  • • Temporal queries - state at any point in time
  • • Event replay - bug fixes, analytics
  • • No data loss - all changes preserved
  • • Natural fit for EDA patterns

Trade-offs:

  • • Learning curve - mindset shift
  • • Event schema evolution complexity
  • • Query performance - replay overhead (mitigated by snapshots)
  • • Storage growth - more data than CRUD
  • • Eventual consistency challenges

CQRS - Separate Write Operations from Read Operations

Have you ever wondered why you use the same data model for both writing and reading?

CQRS (Command Query Responsibility Segregation) is a pattern that separates write model (commands) from read model (queries). Commands mutate system state, while queries read optimized, denormalized data projections. It's like having two different databases – one optimized for writes and business logic, another for fast reads and data display. CQRS combined with Event Sourcing creates an incredibly powerful combination for scalable systems.

CQRS Architecture

┌─────────────┐         ┌──────────────────┐
│   Client    │         │   Command Side   │
│             │─Command→│                  │
│  (UI/API)   │         │  - Validation    │
│             │         │  - Business Logic│
│             │         │  - Event Store   │
└─────────────┘         └──────────────────┘
      │                          │
      │                     Events │
      │                          ↓
      │                 ┌─────────────────┐
      │                 │  Event Stream   │
      │                 │  (Kafka/etc)    │
      │                 └─────────────────┘
      │                          │
      │                     Events │
      │                          ↓
      │                 ┌─────────────────┐
      │                 │  Projections    │
      │                 │  - Read Model 1 │
      │                 │  - Read Model 2 │
      │                 │  - Read Model N │
      ↓                 └─────────────────┘
┌─────────────┐                  │
│ Query Side  │←─────────────────┘
│             │
│ - Optimized │
│ - Cached    │
│ - Fast Read │
└─────────────┘

Command Side Implementation

Commands represent the intention to change state:

// Command
public record PlaceOrderCommand(
    Guid CustomerId,
    List<OrderItem> Items
) : ICommand;

// Command Handler
public class PlaceOrderCommandHandler
    : ICommandHandler<PlaceOrderCommand>
{
    private readonly IEventStore _eventStore;
    private readonly IEventBus _eventBus;

    public async Task<Result> Handle(
        PlaceOrderCommand command)
    {
        // 1. Validate
        if (!command.Items.Any())
            return Result.Failure("No items");

        // 2. Create aggregate
        var order = new Order();
        order.PlaceOrder(
            command.CustomerId,
            command.Items
        );

        // 3. Save events
        var events = order.GetUncommittedEvents();
        await _eventStore.SaveEvents(
            order.OrderId,
            events
        );

        // 4. Publish events
        foreach (var @event in events)
        {
            await _eventBus.Publish(@event);
        }

        return Result.Success(order.OrderId);
    }
}

Query Side - Projections

Projections build optimized read models from event stream:

// Projection: OrderSummaryReadModel
public class OrderSummaryProjection
{
    private readonly IReadModelStore _readStore;

    // Event handler - builds read model
    public async Task Handle(OrderPlacedEvent @event)
    {
        var summary = new OrderSummaryReadModel
        {
            OrderId = @event.OrderId,
            CustomerId = @event.CustomerId,
            Total = @event.Total,
            Status = "Placed",
            PlacedAt = @event.Timestamp,
            ItemCount = @event.Items.Count
        };

        await _readStore.Save(summary);
    }

    public async Task Handle(OrderShippedEvent @event)
    {
        var summary = await _readStore
            .Get<OrderSummaryReadModel>(@event.OrderId);

        summary.Status = "Shipped";
        summary.ShippedAt = @event.Timestamp;

        await _readStore.Update(summary);
    }
}

// Read Model - denormalized, optimized for queries
public class OrderSummaryReadModel
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public string CustomerName { get; set; } // Denormalized
    public decimal Total { get; set; }
    public string Status { get; set; }
    public int ItemCount { get; set; }
    public DateTime PlacedAt { get; set; }
    public DateTime? ShippedAt { get; set; }
}

// Query - fast, no joins needed
public class OrderQueryService
{
    private readonly IReadModelStore _readStore;

    public async Task<OrderSummaryReadModel>
        GetOrderSummary(Guid orderId)
    {
        // Direct lookup, no aggregate replay
        return await _readStore
            .Get<OrderSummaryReadModel>(orderId);
    }

    public async Task<List<OrderSummaryReadModel>>
        GetCustomerOrders(Guid customerId)
    {
        // Optimized query, indexed read model
        return await _readStore
            .Query<OrderSummaryReadModel>()
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.PlacedAt)
            .ToListAsync();
    }
}

Multiple Projections - Different Use Cases

One event stream → multiple specialized read models:

  • OrderSummaryProjection: UI list view, dashboards
  • OrderDetailsProjection: Detail page with full information
  • CustomerOrderHistoryProjection: Customer-specific queries
  • AnalyticsProjection: Data warehouse, reports
  • SearchProjection: Elasticsearch index for full-text search

Each projection can use different database technology optimal for the use case (SQL, NoSQL, Search, Cache).

CQRS Complexity Spectrum

Simple CQRS: Shared database, separate models. CQRS + Event Sourcing: Event store for commands, projections for queries. Full CQRS: Separate databases, eventual consistency, multiple specialized projections. Start simple, evolve as needed. Not every system needs full CQRS.

Network communication and message flow

Message Brokers - Which to Choose: Kafka, RabbitMQ, or Azure Service Bus?

The message broker is the heart of every event-driven system. It ensures reliable event delivery between producers and consumers.

Choosing the right message broker can determine the success or failure of your entire architecture. Kafka excels with millions of events per second, RabbitMQ dominates in traditional task queues, and Azure Service Bus is the natural choice for Azure ecosystem. Let's examine each one and see which fits your needs.

Kafka - Event Streaming Platform

Apache Kafka is a distributed event streaming platform for high-throughput, low-latency scenarios:

Key Features:

  • • Throughput: millions messages/second
  • • Event retention: days/weeks (configurable)
  • • Event replay: consumers can re-read history
  • • Partitioning: parallel processing, ordering within partition
  • • Exactly-once semantics (transactions)
  • • Kafka Streams: stream processing

Best For:

  • • Event Sourcing event store
  • • Real-time analytics pipelines
  • • Log aggregation (applications, metrics)
  • • IoT sensor data processing
  • • Microservices event bus
  • • CDC (Change Data Capture)
// Kafka Producer (.NET)
var config = new ProducerConfig
{
    BootstrapServers = "kafka:9092",
    Acks = Acks.All // Wait for all replicas
};

using var producer = new ProducerBuilder<string, string>(config)
    .Build();

var message = new Message<string, string>
{
    Key = order.OrderId.ToString(),
    Value = JsonSerializer.Serialize(orderPlacedEvent)
};

await producer.ProduceAsync("orders", message);

// Kafka Consumer
var config = new ConsumerConfig
{
    BootstrapServers = "kafka:9092",
    GroupId = "order-processor",
    AutoOffsetReset = AutoOffsetReset.Earliest
};

using var consumer = new ConsumerBuilder<string, string>(config)
    .Build();

consumer.Subscribe("orders");

while (true)
{
    var result = consumer.Consume(cancellationToken);
    var @event = JsonSerializer
        .Deserialize<OrderPlacedEvent>(result.Message.Value);

    await ProcessEvent(@event);
    consumer.Commit(result); // Manual commit
}

RabbitMQ - Traditional Message Broker

RabbitMQ is a message queue with flexible routing, ideal for traditional messaging patterns:

Key Features:

  • • Routing: exchanges, queues, bindings
  • • Message TTL, dead-letter queues
  • • Priority queues
  • • Consumer acknowledgements
  • • Plugin ecosystem (shovel, federation)
  • • AMQP, MQTT, STOMP protocols

Best For:

  • • Task queues (background jobs)
  • • Request-response patterns
  • • RPC (Remote Procedure Call)
  • • Complex routing scenarios
  • • Lower throughput (<100k msg/sec)
  • • Transient messages (not event store)
// RabbitMQ Publisher (.NET)
var factory = new ConnectionFactory
{
    HostName = "rabbitmq",
    UserName = "user",
    Password = "pass"
};

using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();

// Declare exchange & queue
channel.ExchangeDeclare(
    exchange: "orders",
    type: ExchangeType.Topic,
    durable: true
);

channel.QueueDeclare(
    queue: "order.placed",
    durable: true,
    exclusive: false,
    autoDelete: false
);

channel.QueueBind(
    queue: "order.placed",
    exchange: "orders",
    routingKey: "order.placed"
);

// Publish message
var body = Encoding.UTF8.GetBytes(
    JsonSerializer.Serialize(orderPlacedEvent)
);

var properties = channel.CreateBasicProperties();
properties.Persistent = true;

channel.BasicPublish(
    exchange: "orders",
    routingKey: "order.placed",
    basicProperties: properties,
    body: body
);

// Consumer
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var body = ea.Body.ToArray();
    var message = Encoding.UTF8.GetString(body);
    var @event = JsonSerializer
        .Deserialize<OrderPlacedEvent>(message);

    ProcessEvent(@event);

    channel.BasicAck(ea.DeliveryTag, false);
};

channel.BasicConsume(
    queue: "order.placed",
    autoAck: false,
    consumer: consumer
);

Azure Service Bus - Managed Cloud Service

Azure Service Bus is a fully managed message broker with enterprise features:

Key Features:

  • • Fully managed (no infrastructure)
  • • Topics & Subscriptions (pub/sub)
  • • Sessions (ordered processing)
  • • Dead-letter queues built-in
  • • Duplicate detection
  • • Azure integration (Managed Identity)

Best For:

  • • Azure-native applications
  • • Enterprise messaging
  • • Managed service preference
  • • Medium throughput needs
  • • .NET ecosystem
  • • Zero infrastructure management
// Azure Service Bus Sender (.NET)
var client = new ServiceBusClient(
    "Endpoint=sb://namespace.servicebus.windows.net/...",
    new DefaultAzureCredential() // Managed Identity
);

var sender = client.CreateSender("orders");

var message = new ServiceBusMessage(
    JsonSerializer.Serialize(orderPlacedEvent)
)
{
    ContentType = "application/json",
    MessageId = orderPlacedEvent.EventId.ToString(),
    SessionId = orderPlacedEvent.OrderId.ToString()
};

await sender.SendMessageAsync(message);

// Receiver
var processor = client.CreateProcessor(
    "orders",
    new ServiceBusProcessorOptions
    {
        AutoCompleteMessages = false,
        MaxConcurrentCalls = 10
    }
);

processor.ProcessMessageAsync += async args =>
{
    var body = args.Message.Body.ToString();
    var @event = JsonSerializer
        .Deserialize<OrderPlacedEvent>(body);

    await ProcessEvent(@event);

    // Complete message (remove from queue)
    await args.CompleteMessageAsync(args.Message);
};

processor.ProcessErrorAsync += args =>
{
    Console.WriteLine(args.Exception);
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();

Broker Comparison Table

FeatureKafkaRabbitMQAzure Service Bus
ThroughputVery high (millions/sec)Medium (100k/sec)Medium
Event RetentionDays/weeks (configurable)Transient (consumed = deleted)Transient (TTL available)
Event ReplayYes (offset reset)NoNo
OrderingPer partitionPer queueSessions (guaranteed)
ManagementSelf-hosted / Confluent CloudSelf-hosted / CloudAMQPFully managed (Azure)
CostInfrastructure + licensingLow (open source)Pay-per-operation
Best Use CaseEvent streaming, Event SourcingTask queues, RPCAzure-native apps

How to Choose the Right Broker?

Choose Kafka when: Implementing Event Sourcing, need very high throughput (millions msg/sec), must be able to replay events, or require long-term event storage.

Choose RabbitMQ when: Building task queues, need complex message routing, implementing RPC patterns, or throughput under 100k msg/sec is sufficient.

Choose Azure Service Bus when: Working in Azure ecosystem, prefer fully managed service (zero infrastructure operations), or need enterprise features like sessions and dead-letter queues. Many companies also use a hybrid approach: Kafka for event streaming, RabbitMQ for task queues.

Saga Pattern - Distributed Transactions Without Headaches

In microservices architecture, the toughest challenge is transactions spanning multiple services. Traditional ACID transactions simply don't work in distributed systems.

This is where Saga pattern comes to the rescue. It implements distributed transactions as a sequence of local transactions. Each step publishes an event after completion. If something goes wrong, compensating transactions rollback previous steps. It's an elegant solution to a problem that previously seemed unsolvable.

Saga Choreography vs Orchestration

Choreography (Decentralized)

Order Service
  ↓ OrderPlaced event
Payment Service
  → PaymentProcessed event
  ↓
Inventory Service
  → StockReserved event
  ↓
Shipping Service
  → OrderShipped event

// Each service listens to events
// and emits next events

Pros: No central point of failure, loose coupling. Cons: Complex flow tracking, debugging harder.

Orchestration (Centralized)

Saga Orchestrator
  ↓ ProcessPayment command
Payment Service
  → PaymentProcessed response
  ↓
Orchestrator
  ↓ ReserveStock command
Inventory Service
  → StockReserved response
  ↓
Orchestrator
  ↓ ShipOrder command
Shipping Service

// Orchestrator controls flow

Pros: Explicit flow, easier debugging. Cons: Central orchestrator dependency.

Saga Implementation - E-commerce Order

Order processing saga with compensating transactions:

public class OrderSagaOrchestrator
{
    private readonly IEventBus _eventBus;
    private readonly ISagaStateStore _stateStore;

    public async Task StartSaga(OrderPlacedEvent @event)
    {
        var sagaState = new OrderSagaState
        {
            SagaId = Guid.NewGuid(),
            OrderId = @event.OrderId,
            CurrentStep = SagaStep.Started,
            StartedAt = DateTime.UtcNow
        };

        await _stateStore.Save(sagaState);

        // Step 1: Process Payment
        await _eventBus.Send(new ProcessPaymentCommand
        {
            OrderId = @event.OrderId,
            Amount = @event.Total,
            SagaId = sagaState.SagaId
        });
    }

    // Payment successful
    public async Task Handle(PaymentProcessedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.PaymentProcessed;
        saga.PaymentId = @event.PaymentId;
        await _stateStore.Update(saga);

        // Step 2: Reserve Stock
        await _eventBus.Send(new ReserveStockCommand
        {
            OrderId = saga.OrderId,
            SagaId = saga.SagaId
        });
    }

    // Stock reserved
    public async Task Handle(StockReservedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.StockReserved;
        await _stateStore.Update(saga);

        // Step 3: Ship Order
        await _eventBus.Send(new ShipOrderCommand
        {
            OrderId = saga.OrderId,
            SagaId = saga.SagaId
        });
    }

    // Order shipped - saga complete
    public async Task Handle(OrderShippedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.Completed;
        saga.CompletedAt = DateTime.UtcNow;
        await _stateStore.Update(saga);
    }

    // FAILURE HANDLING - Compensating Transactions

    // Payment failed
    public async Task Handle(PaymentFailedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.Failed;
        saga.FailureReason = "Payment failed";
        await _stateStore.Update(saga);

        // Cancel order (no compensation needed - nothing done yet)
        await _eventBus.Publish(new OrderCancelledEvent
        {
            OrderId = saga.OrderId,
            Reason = "Payment failed"
        });
    }

    // Stock reservation failed
    public async Task Handle(StockReservationFailedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.Compensating;
        await _stateStore.Update(saga);

        // Compensating: Refund payment
        await _eventBus.Send(new RefundPaymentCommand
        {
            PaymentId = saga.PaymentId,
            Amount = saga.OrderTotal,
            Reason = "Stock unavailable"
        });
    }

    // Shipping failed
    public async Task Handle(ShippingFailedEvent @event)
    {
        var saga = await _stateStore.Get(@event.SagaId);
        saga.CurrentStep = SagaStep.Compensating;
        await _stateStore.Update(saga);

        // Compensating Step 1: Release stock
        await _eventBus.Send(new ReleaseStockCommand
        {
            OrderId = saga.OrderId
        });

        // Compensating Step 2: Refund payment
        await _eventBus.Send(new RefundPaymentCommand
        {
            PaymentId = saga.PaymentId,
            Amount = saga.OrderTotal,
            Reason = "Shipping failed"
        });
    }
}

// Saga State
public class OrderSagaState
{
    public Guid SagaId { get; set; }
    public Guid OrderId { get; set; }
    public SagaStep CurrentStep { get; set; }
    public Guid? PaymentId { get; set; }
    public decimal OrderTotal { get; set; }
    public DateTime StartedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
    public string? FailureReason { get; set; }
}

public enum SagaStep
{
    Started,
    PaymentProcessed,
    StockReserved,
    Completed,
    Compensating,
    Failed
}

Saga State Machine Visualization

                    ┌─────────────┐
                    │   Started   │
                    └──────┬──────┘
                           │
                    Process Payment
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
    ┌──────────────────┐      ┌─────────────────┐
    │ Payment Success  │      │  Payment Failed │
    └────────┬─────────┘      └────────┬────────┘
             │                         │
      Reserve Stock              Cancel Order
             │                         │
    ┌────────┴─────────┐               ▼
    ▼                  ▼         ┌──────────┐
┌─────────┐    ┌──────────────┐ │  Failed  │
│Reserved │    │Reserve Failed│ └──────────┘
└────┬────┘    └──────┬───────┘
     │                │
Ship Order      Refund Payment
     │                │
┌────┴─────┐          ▼
▼          ▼    ┌──────────┐
┌────┐  ┌───────────┐│ Failed  │
│Done│  │Ship Failed│└──────────┘
└────┘  └─────┬─────┘
              │
      Release + Refund
              │
              ▼
        ┌──────────┐
        │  Failed  │
        └──────────┘

Saga Pattern Best Practices

Idempotency is crucial: Each step must be idempotent (retry-safe) – multiple executions must produce the same result.

Timeout handling: Implement timeouts for each step to detect stuck operations.

Saga state persistence: Store saga state in durable storage – it must survive service restarts.

Compensating transactions: Compensating transactions must also be idempotent and reliable.

Monitoring and alerts: Track saga progress, set up alerts for stuck sagas. Use Saga pattern instead of distributed 2PC (two-phase commit) in microservices – 2PC simply doesn't work in distributed systems.

Real Case Study: E-commerce Platform Handling 100k Orders Daily

Theory is one thing, but how does it look in practice? Here's a case study of a real e-commerce platform that processes over 100,000 orders per day.

The system uses Event Sourcing for audit, CQRS for fast reads, Kafka as event bus, and Saga pattern for managing distributed transactions. Let's see how all of this works together in practice:

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        API Gateway                          │
└───────────────────────┬─────────────────────────────────────┘
                        │
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│Order Service │ │Product Svc   │ │Customer Svc  │
│              │ │              │ │              │
│ Event Store  │ │ Event Store  │ │ Event Store  │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
       │                │                │
       └────────────────┼────────────────┘
                        │
                        ▼
                ┌──────────────┐
                │     Kafka    │
                │ Event Stream │
                └───────┬──────┘
                        │
        ┌───────────────┼───────────────────┐
        ▼               ▼                   ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│Payment Svc   │ │Inventory Svc │ │Shipping Svc  │
└──────────────┘ └──────────────┘ └──────────────┘
        │               │                   │
        └───────────────┼───────────────────┘
                        │
                        ▼
                ┌──────────────┐
                │ Projections  │
                │              │
                │ - SQL Read   │
                │ - Redis Cache│
                │ - Elastic    │
                └──────────────┘

Event Flow - Order Placement

1. Command: PlaceOrder

User → API Gateway → Order Service (Command Handler)

2. Event: OrderPlaced

Order Service → Event Store → Kafka (orders topic)

3. Saga Orchestrator: ProcessOrderSaga

Consumes OrderPlaced, coordinates distributed transaction

4. Command: ProcessPayment

Saga → Payment Service

5. Event: PaymentProcessed

Payment Service → Kafka

6. Command: ReserveStock

Saga → Inventory Service

7. Event: StockReserved

Inventory Service → Kafka

8. Command: ShipOrder

Saga → Shipping Service

9. Event: OrderShipped

Shipping Service → Kafka → Multiple Consumers (Notification, Analytics, etc.)

10. Projections Update

OrderSummaryProjection consumes events, builds read models

Technology Stack

Backend:

  • • .NET 8 (Microservices)
  • • Event Store (Marten / EventStoreDB)
  • • Apache Kafka (Event Streaming)
  • • MassTransit (Saga Orchestration)
  • • PostgreSQL (Event Store, Projections)
  • • Redis (Cache, Read Models)

Infrastructure:

  • • Azure Kubernetes Service (AKS)
  • • Azure Container Registry
  • • Confluent Cloud / Azure Event Hubs
  • • OpenTelemetry (Distributed Tracing)
  • • Grafana / Prometheus (Monitoring)
  • • Elasticsearch (Search Projections)

Results & Benefits

99.9%

Availability

Isolated failures - payment service down doesn't block catalog browsing

10x

Scalability

Independent service scaling - inventory scales independently from orders

100%

Audit Trail

Event Sourcing provides complete history for compliance and debugging

50ms

Query Performance

CQRS read models optimized - Redis cache for hot data

Frequently Asked Questions about Event-Driven Architecture

What is Event-Driven Architecture and when should you use it?

Event-Driven Architecture (EDA) is an architectural pattern where components communicate through events instead of direct synchronous calls. Producers emit events representing facts ("order placed"), and consumers independently react to these events.

Use EDA when you need: loose coupling between services, asynchronous processing, event replay capability, scalability for high-throughput scenarios. Perfect use cases: e-commerce (order processing), IoT (sensor data), trading systems, real-time analytics, user notifications.

What is the difference between Event Sourcing and CQRS?

These are two different patterns that are often used together. Event Sourcing is a data storage approach – instead of current state you record the full sequence of events (all changes). CQRS is a pattern that separates write model from read model.

You can use them independently (CQRS without Event Sourcing or vice versa), but together they create a powerful combination: Event Sourcing for write side provides full audit trail and replay capability, CQRS for read side enables denormalized, optimized projections. In practice, Event Sourcing naturally leads to CQRS because you have event store (write) and projections (read).

Kafka vs RabbitMQ vs Azure Service Bus - which one to choose?

Each of these message brokers has its strengths:

Apache Kafka excels at event streaming and very high throughput (millions msg/sec). Key difference: Kafka stores events long-term and enables replay. Ideal for Event Sourcing, analytics pipelines, IoT sensor data.

RabbitMQ is a traditional message queue with flexible routing. Messages are transient (consumed = deleted). Better for task queues, RPC patterns, background jobs. Lower throughput (~100k msg/sec), but simpler setup.

Azure Service Bus is a fully managed service in Azure ecosystem. Zero infrastructure operations, enterprise features (sessions, dead-letter queues, duplicate detection). Natural choice for Azure-native applications and when you prefer managed services.

How does Saga pattern work in distributed transactions?

In microservices, you can't use traditional ACID transactions spanning multiple services. Saga pattern solves this by implementing distributed transactions as a sequence of local transactions.

Each step executes a local transaction and publishes an event upon completion. The next service listens to the event and executes its step. When everything works, the saga completes successfully. But what if a step fails?

Then compensating transactions kick in – special operations that rollback previous steps (e.g., payment refund, releasing reserved stock). You have two styles: Choreography (decentralized, services react to events) and Orchestration (central coordinator manages flow). Saga is a proven solution – use it instead of distributed 2PC (two-phase commit), which doesn't work in practice in distributed systems.

What are the main challenges of Event-Driven Architecture?

EDA is not a silver bullet – it brings challenges too. The most important:

Eventual consistency: Data is not immediately consistent between services. You must accept that for a brief period, different parts of the system may see different states. This is the trade-off for scalability.

Debugging complexity: In distributed systems, requests flow through multiple services. You need distributed tracing (e.g., OpenTelemetry) to track the flow.

Message ordering: Hard to guarantee global ordering in distributed systems. Solution: partitioning (events with the same key go to the same partition).

Duplicate messages: Network issues can cause duplicates. Every handler must be idempotent (multiple executions produce the same effect).

Solutions: Distributed tracing (OpenTelemetry, Jaeger), schema registry for event schema evolution, idempotent handlers, monitoring and alerting for stuck messages, automated testing (contract tests, chaos engineering).

Summary - Is Event-Driven Architecture Right for You?

Event-Driven Architecture is not just a trendy buzzword, but a proven solution for systems that need to scale under load and survive failures.

You now know the key patterns: Event Sourcing provides full change history, CQRS optimizes reads and writes separately, Saga pattern manages distributed transactions, and message brokers like Kafka, RabbitMQ, and Azure Service Bus ensure reliable event delivery.

Companies implementing EDA achieve better fault isolation (one service failure doesn't kill the entire system), independent service scaling, complete event history for audit and compliance, and flexible system evolution without downtime. It's an investment in long-term system maintainability.

If you're building a system that needs to process more than a few hundred requests per second or requires full audit of operations, EDA stops being an option and becomes a necessity. Also check our articles on microservices architecture and cloud solutions for the full picture of modern architectures.

Need Help Implementing Event-Driven Architecture?

I specialize in designing and implementing production-ready event-driven systems. I have experience with Event Sourcing, CQRS, Kafka, microservices architecture, and distributed transactions. I'll help you build a scalable and resilient system that will grow with your business.

Related articles

Event-Driven Architecture Patterns 2025 - Comprehensive Guide | Wojciechowski.app