Post

Hands-on DDD and Event Sourcing [3/6]: Domain events, Event Sourcing and Saga

Hands-on DDD and Event Sourcing [3/6]: Domain events, Event Sourcing and Saga


In the previous post, I covered a bit more about bounded contexts and some of the building blocks of the implementation. Now, let’s extend the implementation to domain events.


Domain Events


Before we jump into Event Sourcing, let’s clarify a common confusion: there are many types of events in software architecture, but not all events are domain events, and domain events don’t necessarily imply Event Sourcing.

A domain event represents an immutable fact that has already occurred in the domain, the result of a business behavior. Ideally, your aggregates should expose explicit behaviors (rather than being anemic), and from those behaviors, domain events are born. Once again, ubiquitous language plays a key role in how these events are named and understood.

Domain events are always context-bound, and their meaning holds only within the bounded context where they originated.

💡 When naming events, look at the context and pick a name that carries real meaning, using the [Noun][PastTenseVerb] combination (e.g. CustomerRegistered, OrderShipped).

What about Integration events?

Although they look similar, Domain and Integration events serve different purposes and operate at different scopes.

  • Domain Events: Trigger reactions within the same bounded context, and are most often dispatched in-process. What makes an event a domain event is the scope of its meaning, not the transport it happens to travel on.
  • Integration Events: Trigger reactions across different bounded contexts or external systems, and are typically handled asynchronously using a messaging infrastructure. Since they fan out across service boundaries, they require a higher level of decoupling and have to tolerate unexpected response times. This will be clear when we advance to the complete order processing flow.

To stream integration events, you’ll usually use a message broker. There are many options available, and for this project, I’m using Kafka, although I’ve also had great experiences with RabbitMQ. We’ll cover the implementation when tackling the infrastructure.


Event Sourcing


In short, Event Sourcing is an architectural pattern in which state changes are represented as a sequence of events, and these events serve as the source of truth.

Logging events is not a new concept in software, but Greg Young shaped the technique into the form we call Event Sourcing nowadays:

  • Events are chronologically persisted in what’s called an Event Store.
  • For this to work consistently, the store has to be append-only: the events themselves are immutable facts, so they are always appended, but never changed or deleted.

Event Sourcing also allows us to shift from the conventional approach, which was storing, changing and fetching the last state of an object, to reading the object’s event history and then rehydrating it to its latest state. Also, writing and reading events are completely separate operations that can scale independently, which is why Event Sourcing pairs naturally with CQRS.

Event Sourcing is technology-agnostic, with some good players in the market, such as Event Store. Since I’m using Postgres as a document database, Marten was the natural choice.

With Event Sourcing:

  • Each state transition in an aggregate is captured as a domain event.
  • Events are appended chronologically in an event store, instead of overwrites of the current object’s state.
  • The system rebuilds aggregate state by rehydrating it from its stream of past events.

Why use Event Sourcing?

This approach gives you a few things for free:

  • The object-relational impedance mismatch stops being a problem on the write side. You store data as it was intended, event-based and serialized.
  • You get a natural audit trail. The complete event history reveals how and why the current state exists that way.
  • You get independent scalability between reads and writes, which leads to CQRS.

Embedded complexity

Not all that glitters is gold. This shift in approach carries a fairly steep learning curve, especially if you use an in-house implementation, which is great for learning but rarely worth it in a corporate environment. There are frameworks that absorb most of that complexity for you, and I recommend leaning on one, like I did with Marten here. More on that in Part 4.

Some aspects to consider when using Event Sourcing are:

  • Concurrency and optimistic locking, for when multiple users edit the same record at once and the writes must land in the right order. There’s a very nice article that covers it in depth, and I won’t try to do it better here.
  • Schema evolution, for when event structures change over time, usually handled through upcasting, by translating events written in an older shape into the current one as they are read, so old streams keep replaying without crashing.
  • Event versioning, for when a change to an event’s shape is breaking. You introduce a new version of the event instead of editing the old ones, so consumers and upcasters know which shape they are dealing with.
  • Performance tuning of long event streams and projections, for when a stream grows to thousands of events and full replay gets slow. Snapshots (a periodically stored state you replay forward from) cap that cost on the write side, while read models stay current by applying each new event as it arrives.

CQRS: Command Query Responsibility Segregation

CQRS is an architectural pattern that is often mentioned alongside Event Sourcing, and for good reason. They pair perfectly!

  • Commands express user intents and actions. Commands will be the triggers to change the state of our aggregate and emit events on the write side.
  • Queries retrieve the current state from the read model. CQRS itself doesn’t dictate how that model is stored, but once you pair it with Event Sourcing, a materialized projection becomes the natural fit, which is the route this project takes.

This separation allows your write model to focus purely on domain logic and emitting events, while your read model is optimized for performance and user experience. You must be wondering whether retrieving the current state means replaying every event in sequence, and how that would perform and scale. In the next post, I’ll explore Projections, a solution for this problem that builds and updates read models.


Hands-on


Let’s walk through a simple example using the Customer aggregate root. After the domain invariants are validated, the domain object is built, and the AppendEvent and the Apply methods are called in sequence:

1
2
AppendEvent(@event);
Apply(@event);
  • AppendEvent is defined in the AggregateRoot base class, and it adds the event to the uncommitted events Queue of IDomainEvent.
  • Apply is not declared in the base class. It’s a set of overloads on the aggregate itself, one per event type, picked by the argument type. That’s the same convention Marten relies on to rehydrate the aggregate, and each applied event mutates a corresponding part of it.

Take this example from the UpdateCustomerInformation command.

Instead of directly modifying customer fields like we would usually do, the handler executes an action on the customer (the customer.UpdateInformation method), which makes it emit a CustomerUpdated domain event:

The event is appended to the uncommitted events queue, then applied through Apply:

And with Customer now mutated (its data was updated, in memory), we’re ready to persist the event into the event store, through the IEventStoreRepository<Customer> in the UpdateCustomerInformationHandler.

The sequence here is:

  1. The handler fetches the stream and Marten rehydrates Customer by replaying its events, while staging an optimistic concurrency check for the commit.
  2. customer.UpdateInformation(customerData) validates the invariants and emits CustomerUpdated, appending it to the uncommitted events queue and applying it to mutate the in-memory aggregate.
  3. AppendEventsAndCommitAsync drains that queue into the stream and commits, appending the event to the event store and bumping the stream version.

Notice that nothing overwrote the customer row. The new state is the sum of CustomerRegistered + CustomerUpdated, and the next fetch will replay both.

⚠️ Important: an in-process domain event handler should only run after the aggregate is successfully persisted, never before a confirmed commit. Otherwise a side effect could fire for a state that never actually landed.


Saga


Domain events stay within a single bounded context, but a business process often spans several of them, with each bounded context eventually emitting integration events the others react to.

Coordinating a multi-step workflow like that, where each step depends on the previous one succeeding, needs a dedicated pattern: SAGA to keep data consistent across distributed services even without a single global transaction.

Choreography vs Orchestration

There are two ways to design it: choreography, where services react to each other’s events with no central coordinator, and orchestration, where a central saga instance tells each step what to do next. I went with orchestration because the order flow has a clear and sequential chain of steps that depend on the previous one being successful, with compensation logic to handle when any step fails. It keeps everything centralized and visible, instead of scattered across every microservice’s event handlers. However, trade-offs like a single point of failure and the need for resilience must be considered, and I explore some of them in Part 5 when talking about implementation.

Successful workflow

The successful ordering flow is handled in the OrderSaga.cs:

flowchart TB
    OP([OrderPlaced]):::event --> PO[ProcessOrder]:::cmd
    PO --> OPD([OrderProcessed]):::event
    OPD --> RP[RequestPayment]:::cmd
    RP -.->|async| PF([PaymentFinalized]):::event
    PF --> RCP[RecordPayment]:::cmd
    RCP --> OPA([OrderPaid]):::event
    OPA --> RS[RequestShipment]:::cmd
    RS -.->|async| SF([ShipmentFinalized]):::event
    SF --> RSH[RecordShipment]:::cmd

    classDef event fill:#f5a623,stroke:#c47f0e,color:#1a1a1a,font-weight:bold
    classDef cmd fill:#7db4de,stroke:#4d8cb8,color:#1a1a1a

Compensation workflow

However, there are failure cases you need to predict and compensate for. For example, what if you purchase more products than are available in stock? Or what happens if a customer exceeds the credit limit and can’t complete the payment?

I designed a couple of compensation integration events that are streamed by different microservices, and the OrderSaga.Compensation is there to handle them:

flowchart TB

    subgraph COMP[" "]
        direction LR
        PFail([PaymentFailed]):::event --> CA1[CancelOrder]:::cmd --> OC1([OrderCanceled]):::event
        CRL([CustomerReachedCreditLimit]):::event --> CA2[CancelOrder]:::cmd --> OC2([OrderCanceled]):::event
        SFail([ShipmentFailed]):::event --> CA3[CancelOrder]:::cmd --> OC3([OrderCanceled]):::event
        OOS([ProductWasOutOfStock]):::event --> CA4[CancelOrder]:::cmd --> OC4([OrderCanceled]):::event
        OCE([OrderCanceled]):::event -->|only if already paid| RCP[RequestCancelPayment]:::cmd --> PC([PaymentCanceled]):::event
    end

    classDef event fill:#f5a623,stroke:#c47f0e,color:#1a1a1a,font-weight:bold
    classDef cmd fill:#7db4de,stroke:#4d8cb8,color:#1a1a1a

Each failure event maps to a CancelOrder command, and the payment is only reversed when the order had already been charged.


Final thoughts


So far we’ve covered the moving parts: domain events for state changes inside a bounded context, integration events for reactions across services, and the SAGA pattern for multi-step distributed workflows. What we haven’t done yet is persist any of it: all the state lives in memory.

In the next post, I’ll walk you through persisting domain events to the write database and projecting them to a read-optimized database using Marten projections.


Check the project on GitHub


This post is licensed under CC BY 4.0 by the author.