DDD and event sourcing in Go · Part 2
Four ways to pay an order in Go
The same feature written four times, each rewrite moving the business rules further from the database, until they are two pure functions you can test with no infrastructure at all. What each step buys, and what it costs.
Part 1 ended on a promise: we had a closed set of events and a checked way to fold them into state, but no answer to where those events come from in the first place. That is this post. I want to get there by refactoring, because the destination looks strange if you arrive at it cold. Same Order domain as part 1, four versions of the same feature: pay an order.
Version 0: the way everyone writes it
Here is the function as it exists in most Go services I have read, including several I wrote.
func (s *OrderService) PayOrder(ctx context.Context, orderID, txID string, amount int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var status string
var total int64
err = tx.QueryRowContext(ctx,
`SELECT status, total FROM orders WHERE id = $1 FOR UPDATE`, orderID,
).Scan(&status, &total)
if errors.Is(err, sql.ErrNoRows) {
return ErrOrderNotFound
}
if err != nil {
return err
}
if status == "cancelled" {
return ErrOrderCancelled
}
if status == "paid" {
return nil
}
if amount != total {
return ErrAmountMismatch
}
if _, err := tx.ExecContext(ctx,
`UPDATE orders SET status = 'paid', transaction_id = $1, paid_at = $2 WHERE id = $3`,
txID, time.Now(), orderID,
); err != nil {
return err
}
return tx.Commit()
}
Nothing here is wrong. It ships, and a new hire understands it in thirty seconds. What it does badly is one thing: the interesting part of this function is four if statements, and they are welded to a Postgres connection.
Those four ifs are what we call in DDD a “domain”. “A cancelled order cannot be paid”, “paying twice is not an error”, “the amount must match the total” are rules a business person would recognize and argue about. To execute them, you need a database, a transaction, and a row that already exists. Testing “can you pay a cancelled order” means standing up Postgres or mocking *sql.DB, and mocking *sql.DB is a punishment.
The second problem is that the order has a lifecycle, pending to paid to cancelled, and there is no place in the codebase where that lifecycle is written down. It is spread across every handler that touches the status column. PayOrder checks for cancelled. Does ShipOrder? You find out in production.
Version 1: the classic DDD aggregate
The standard fix is to give the order an object, put the rules on it, and let it produce events. Reusing the event set from part 1:
type Status int
const (
StatusNone Status = iota // zero value: nothing has happened to this order yet
StatusPending
StatusPaid
StatusCancelled
)
type Order struct {
id string
status Status
total int64
version int
changes []OrderEvent
}
func (o *Order) Pay(txID string, amount int64) error {
if o.status == StatusCancelled {
return ErrOrderCancelled
}
if o.status == StatusPaid {
return nil
}
if amount != o.total {
return ErrAmountMismatch
}
o.raise(OrderPaid{TransactionID: txID, PaidAt: time.Now()})
return nil
}
func (o *Order) raise(e OrderEvent) {
o.apply(e)
o.changes = append(o.changes, e)
}
func (o *Order) apply(e OrderEvent) {
switch e := e.(type) {
case OrderPlaced:
o.id = e.OrderID
o.status = StatusPending
o.total = e.Total
case OrderPaid:
o.status = StatusPaid
case OrderCancelled:
o.status = StatusCancelled
default:
panic(fmt.Sprintf("unhandled event type %T", e))
}
}
apply is unchanged from part 1, panicking default included, so a replay can never silently skip an event it does not recognize. The repository loads an order by replaying its stream and saves it by draining changes:
func (r *OrderRepo) Load(ctx context.Context, id string) (*Order, error) {
history, version, err := r.store.Read(ctx, "order-"+id)
if err != nil {
return nil, err
}
o := &Order{version: version}
for _, e := range history {
o.apply(e)
}
return o, nil
}
func (s *OrderService) PayOrder(ctx context.Context, orderID, txID string, amount int64) error {
o, err := s.repo.Load(ctx, orderID)
if err != nil {
return err
}
if err := o.Pay(txID, amount); err != nil {
return err
}
return s.repo.Save(ctx, o)
}
I find this better already. And to be fair, we could stop here and be fine. The rules now live in one file, they have names, and you can test Pay without a database. Most DDD-in-Go stops here, and stopping here is defensible.
But, four things still bother me, in rough order of how much.
raise does two jobs, mutating state and recording the change, and the correctness of the whole aggregate depends on every method remembering to go through it. Inside the package, o.status = StatusPaid compiles perfectly well and produces an order that is paid with no record of a payment. The compiler has no opinion and neither does the linter. You find out months later, from a customer asking where their receipt went.
Pay decides and mutates in the same breath, so there is no way to ask “what would happen if” without producing a changed object. That sounds academic until you want to validate a command before queuing it, or preview a cancellation for a support agent.
time.Now() sits inside the domain, so the aggregate is no longer a function of its inputs. Any test that asserts on PaidAt now needs a clock injected into your business logic.
And changes is infrastructure living in a domain type. Load must not fill it, Save must clear it, and nothing but attention enforces that.
Each of these is independently small. Together they mean the aggregate is a stateful object with a protocol. And this is often where you find most bugs.
Version 2: two functions
So let us take the object apart, by asking what its two halves actually are.
The first half is used to answer “given what has happened so far, is this request allowed”, and “what new facts does it produce?”. The other half is used to answer “given what has happened so far and one more fact, what is true now?”
The first half is the four ifs. The second half is the apply method you just read.
Start with the request. Part 1 made events a sealed sum type, and commands get exactly the same treatment:
//sumtype:decl
type OrderCommand interface {
isOrderCommand()
}
type PlaceOrder struct {
OrderID string
CustomerID string
Total int64
Now time.Time
}
type PayOrder struct {
TransactionID string
Amount int64
Now time.Time
}
type CancelOrder struct {
Reason string
Now time.Time
}
func (PlaceOrder) isOrderCommand() {}
func (PayOrder) isOrderCommand() {}
func (CancelOrder) isOrderCommand() {}
Yes, every command carries its own Now. It looks bureaucratic. It is also the entire trick: the clock is an input like any other, supplied by the caller at the edge, and the domain never reaches out to the world. On one side, a command is a request that may be refused by the domain. On the other, an event is a fact that already happened for sure and need to be stored. That is why they get two separate type sets.
The state becomes a plain struct with no methods and no secrets:
type OrderState struct {
ID string
Status Status
Total int64
}
StatusNone is the zero value, meaning no events yet. That single fact replaces every SELECT ... ErrNoRows check in version 0. An order that was never placed is a stream with no events, and an empty stream folds to the zero state.
Now the two functions.
func decide(s OrderState, c OrderCommand) ([]OrderEvent, error) {
switch c := c.(type) {
case PlaceOrder:
if s.Status != StatusNone {
return nil, ErrAlreadyPlaced
}
if c.Total <= 0 {
return nil, ErrInvalidTotal
}
return []OrderEvent{OrderPlaced{
OrderID: c.OrderID,
CustomerID: c.CustomerID,
Total: c.Total,
PlacedAt: c.Now,
}}, nil
case PayOrder:
switch s.Status {
case StatusNone:
return nil, ErrOrderNotFound
case StatusCancelled:
return nil, ErrOrderCancelled
case StatusPaid:
return nil, nil
}
if c.Amount != s.Total {
return nil, ErrAmountMismatch
}
return []OrderEvent{OrderPaid{
TransactionID: c.TransactionID,
PaidAt: c.Now,
}}, nil
case CancelOrder:
switch s.Status {
case StatusNone:
return nil, ErrOrderNotFound
case StatusPaid:
return nil, ErrAlreadyPaid
case StatusCancelled:
return nil, nil
}
return []OrderEvent{OrderCancelled{
Reason: c.Reason,
CancelledAt: c.Now,
}}, nil
default:
panic(fmt.Sprintf("unhandled command type %T", c))
}
}
func evolve(s OrderState, e OrderEvent) OrderState {
switch e := e.(type) {
case OrderPlaced:
s.ID = e.OrderID
s.Status = StatusPending
s.Total = e.Total
case OrderPaid:
s.Status = StatusPaid
case OrderCancelled:
s.Status = StatusCancelled
default:
panic(fmt.Sprintf("unhandled event type %T", e))
}
return s
}
evolve is the apply method from version 1 with the receiver turned into a parameter and a return value. That one change is what makes it pure (ie, depends only of its inputs, no side effect). It now takes state by value and returns it, so those assignments hit a local copy and the caller’s state is never touched. Value semantics do the immutability work that a language with real immutable records would do for you.
Look at case StatusPaid: return nil, nil in the PayOrder branch. Paying an already-paid order produced no new fact, so it produces no new event. Idempotence falls out of the return type.
More importantly, neither function touches the database, the clock, or the network. Given the same arguments they return the same answer on any machine. They are pure. And pure is dead simple to test.
The service that wires them is the only place left that knows about infrastructure, and it now handles every command instead of one:
func (s *OrderService) Handle(ctx context.Context, orderID string, c OrderCommand) error {
stream := "order-" + orderID
history, version, err := s.store.Read(ctx, stream)
if err != nil {
return err
}
state := OrderState{}
for _, e := range history {
state = evolve(state, e)
}
events, err := decide(state, c)
if err != nil || len(events) == 0 {
return err
}
return s.store.Append(ctx, stream, version, events)
}
Read, fold, decide, append. Four steps, no branches that belong to the order.
Plot twist: the pattern has a name
What we just built is the decider pattern. Jérémie Chassaing formalized it in Functional Event Sourcing Decider. I held the name back until here on purpose, because some of us (me included sometimes) put named patterns under “things I will never need”, but I find the two functions above are worth writing whether or not you ever say the word.
The name does buy something, though: the shape is precise enough to be reusable. A decider is five members:
- a command type
C, a state typeS, an event typeE - an initial state
decide: (S, C) -> []E(plus an error, in a language with errors)evolve: (S, E) -> SisTerminal: S -> bool
In Go, generics express it almost directly:
type Decider[C, S, E any] struct {
Initial S
Decide func(S, C) ([]E, error)
Evolve func(S, E) S
IsTerminal func(S) bool
}
var OrderDecider = Decider[OrderCommand, OrderState, OrderEvent]{
Initial: OrderState{},
Decide: decide,
Evolve: evolve,
IsTerminal: func(s OrderState) bool {
return s.Status == StatusPaid || s.Status == StatusCancelled
},
}
Once the shape is a type, the handler from version 2 is written once for the whole codebase:
type EventStore[E any] interface {
Read(ctx context.Context, stream string) ([]E, int, error)
Append(ctx context.Context, stream string, expected int, events []E) error
}
func Handle[C, S, E any](
ctx context.Context,
store EventStore[E],
d Decider[C, S, E],
stream string,
cmd C,
) error {
history, version, err := store.Read(ctx, stream)
if err != nil {
return err
}
state := d.Initial
for _, e := range history {
state = d.Evolve(state, e)
}
events, err := d.Decide(state, cmd)
if err != nil || len(events) == 0 {
return err
}
return store.Append(ctx, stream, version, events)
}
That is the entire application layer for every aggregate you will ever write (until the first command that needs to look at another stream). A new aggregate is just two pure functions and a struct literal, and it gets stream loading and optimistic concurrency for free.
Two honest notes on this code. EventStore[E] being generic over the event type hides the real problem, which is that the store holds bytes and something has to turn {"reason": "customer request"} back into an OrderCancelled. That is the type registry, and it is part 3. And IsTerminal is unused by Handle: I keep it because it is part of the definition and it earns its place with process managers and with archiving closed streams, but for a plain aggregate you can leave it out and lose nothing today.
What the split buys
Now, a domain test is given a history, when a command, then some events or an error:
func TestOrderDecider(t *testing.T) {
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
placed := OrderPlaced{OrderID: "o-1", CustomerID: "c-1", Total: 4200, PlacedAt: now}
tests := []struct {
name string
given []OrderEvent
when OrderCommand
then []OrderEvent
err error
}{
{
name: "paying a pending order records the payment",
given: []OrderEvent{placed},
when: PayOrder{TransactionID: "tx-1", Amount: 4200, Now: now},
then: []OrderEvent{OrderPaid{TransactionID: "tx-1", PaidAt: now}},
},
{
name: "paying a cancelled order is refused",
given: []OrderEvent{placed, OrderCancelled{Reason: "changed mind", CancelledAt: now}},
when: PayOrder{TransactionID: "tx-1", Amount: 4200, Now: now},
err: ErrOrderCancelled,
},
{
name: "paying twice records nothing new",
given: []OrderEvent{placed, OrderPaid{TransactionID: "tx-1", PaidAt: now}},
when: PayOrder{TransactionID: "tx-2", Amount: 4200, Now: now},
then: nil,
},
{
name: "the amount must match the total",
given: []OrderEvent{placed},
when: PayOrder{TransactionID: "tx-1", Amount: 100, Now: now},
err: ErrAmountMismatch,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := OrderDecider.Initial
for _, e := range tt.given {
state = OrderDecider.Evolve(state, e)
}
got, err := OrderDecider.Decide(state, tt.when)
if !errors.Is(err, tt.err) {
t.Fatalf("err = %v, want %v", err, tt.err)
}
if diff := cmp.Diff(tt.then, got); diff != "" {
t.Errorf("events (-want +got):\n%s", diff)
}
})
}
}
That test needs no mocks and no database. The test also never builds an OrderState by hand, it builds one out of events, which keeps the whole file in domain vocabulary. You can put that table in front of whoever owns the business rules and they can tell you whether row three is right. I will talk in a futur article of this series is about how far this goes, including property tests over event sequences.
As we saw in part 1, sum types are usefull for exhaustiveness. Now, we apply this to both parts of the decider. decide switches on the command sum type, evolve switches on the event sum type, both sealed, both marked //sumtype:decl. Add RefundOrder and the linter tells you decide is incomplete. Add OrderRefunded and it tells you evolve is incomplete. The type system walks the checklist, which is exactly what part 1 was fighting for, applied to the two places where forgetting a case is most expensive.
Append takes an expected version, so two concurrent payments race and the loser gets a conflict. Because decide is pure, recovering means running the whole thing again from the top. There is no partially-applied mutation to undo and no side effect that already escaped. Making retries safer and more predictable.
Nothing in decide or evolve requires an event store. Fold the resulting events into the state and UPDATE a row, and you have a plain CRUD service whose domain logic happens to be a decider. Keep the events instead and you have event sourcing. You can take the good part without signing up for the operational part on day one.
Because the shape is closed and mechanical, two deciders combine into one with a sum of their commands and a product of their states. Chassaing’s paper does this properly. In Go it is expressible but verbose enough that I mostly compose at the handler level instead, and I would rather say that than pretend the generic version is pleasant.
When not to do this
The decider earns its keep when a thing has a lifecycle and rules that depend on history. An order does. A user profile with an email and a display name does not: there is no invariant to protect, and wrapping it in commands and events buys you three extra type declarations and a worse afternoon. Building an event-sourced UpdateUserSettings is how DDD gets its reputation.
The cost is real even when it is worth paying. Every aggregate means a command set, an event set, a state struct, two functions, and a fair amount of ceremony before the first rule gets written. The Now on every command will look silly until the first test that fails at midnight. And decide returning ([]E, error) gives you two ways to say no, refusal and emptiness, so you have to be deliberate about which one a given rule uses. My convention: an error means the caller asked for something wrong, empty means the caller asked for something already true.
Next
We now have events produced by a pure function and appended to a store. The store, however, holds bytes, and encoding/json has no idea which struct {"reason": "customer request"} should become. Part 3 will be the type registry: envelope format, type names as a public API you cannot rename, and upcasting old event versions on read. It is where “events are immutable facts” grows operational teeth.