DDD and event sourcing in Go · Part 1
Sum types in Go, or how to model events the compiler can check
Go has no sum types, and event sourcing really wants them. The sealed interface pattern, why type switches lie to you about exhaustiveness, and how to get the missing compiler checks back with a linter or a visitor.
This is the first post in a series about building domain-driven, event-sourced systems in Go. Before touching fun stuff like aggregates, event stores, or projections, I want to start with a type system problem, because it shapes everything that comes after: Go has no sum types, and event sourcing is the pattern that misses them the most. This is a subject that is close to my heart. I’ve done a lot of Haskell and TypeScript, where sum types are present and extremely useful. I find this topic underdiscussed in the Go space.
What a sum type is
A sum type (also called a tagged union or discriminated union) is a type with a fixed, closed set of variants. A value is exactly one of them, and the compiler knows the full list. TypeScript spells it as a discriminated union:
type OrderEvent =
| { kind: 'placed'; orderId: string; total: number }
| { kind: 'paid'; transactionId: string }
| { kind: 'cancelled'; reason: string }
function apply(order: Order, e: OrderEvent): Order {
switch (e.kind) {
case 'placed':
return { ...order, id: e.orderId, status: 'pending', total: e.total }
case 'paid':
return { ...order, status: 'paid' }
case 'cancelled':
return { ...order, status: 'cancelled' }
default: {
const unreachable: never = e
return unreachable
}
}
}
The closed set is the point. Inside each case, the compiler narrows e to that one variant, so e.transactionId only typechecks under case 'paid'. The never assignment in the default makes the switch exhaustive by construction: once every variant has a case, e narrows to never in the default branch and the assignment typechecks. Add a refunded variant next month and every switch with that guard becomes a compile error until you decide what refunded means there. The type system turns “I added a case” into a checklist the compiler walks for you.
Rust, OCaml, Haskell, Swift, Kotlin all have a native spelling for this. Go does not. Generics didn’t change that: a union in a type constraint (type Event interface{ Placed | Paid }) can only constrain a type parameter, you can’t declare a variable of that union type.
Why event sourcing wants this so badly
In event sourcing, instead of storing the current state of an order in a database row and updating it, you store everything that ever happened to it. OrderPlaced, OrderPaid, OrderShipped, each recorded as an event, appended to a list, and never modified afterwards. When you need the current state, you rebuild it by replaying that history: start from an empty order, apply each event in turn, and whatever you end up with is the order as it exists today. (DDD calls the rebuilt object an aggregate; more on that later in the series.)
state = apply(apply(apply(empty, e1), e2), e3)
If you write functional code you’ll recognize this as a fold over the event list. If you don’t, “replay the history through apply” is the whole idea.
That apply function needs to handle every event type that can appear in the stream. If an event type goes unhandled, the aggregate silently rebuilds into a wrong state. You paid, but the replay skipped OrderPaid, so the order still says pending. Nothing crashes, nothing warns. You have an invalid state that will make someone unhappy at some point, with no trace of why.
That is why we want a sum type: a closed list of variants, consumed by functions that must cover all of them. The whole correctness story of replay rests on exhaustiveness. Which is the one thing Go won’t check for us. The rest of this post is about getting as much of that guarantee back as possible.
The sealed interface pattern
The standard Go encoding is an interface with an unexported marker method:
type OrderEvent interface {
isOrderEvent()
}
type OrderPlaced struct {
OrderID string
CustomerID string
Total int64
PlacedAt time.Time
}
type OrderPaid struct {
TransactionID string
PaidAt time.Time
}
type OrderCancelled struct {
Reason string
CancelledAt time.Time
}
func (OrderPlaced) isOrderEvent() {}
func (OrderPaid) isOrderEvent() {}
func (OrderCancelled) isOrderEvent() {}
Because isOrderEvent is unexported, no type outside this package can implement OrderEvent. The set of variants is closed at the package boundary. That’s the “sealed” part, and it’s half of what a sum type gives you: nobody can sneak a new event into the system from elsewhere. In DDD terms this lines up nicely with the aggregate owning its events. The order package declares what can happen to an order, full stop.
Consumption is a type switch:
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
}
}
This reads well and it’s what most Go event sourcing code looks like. It is also where the encoding breaks down.
The type switch lies about exhaustiveness
Add OrderRefunded to the package. Give it the marker method. The code compiles. Every type switch in the codebase compiles. apply silently ignores the new event, replay produces wrong state, and the compiler had no opinion at any point.
This is the exact failure the TypeScript never guard would have caught, and it’s the failure mode that matters most in event sourcing, because events already written to the store don’t go away. The first defense is cheap and worth stating explicitly: never let the switch fall through silently.
default:
panic(fmt.Sprintf("unhandled event type %T", e))
}
A panic during replay is brutal, but the alternative is an aggregate that quietly rebuilt into a state that never happened. An event in the store is a fact. Code that receives a fact it doesn’t understand is running at the wrong version, and loud is the only acceptable failure. You want to catch this as soon as possible, and the signal to be as clear as possible. In command handling paths you may prefer returning an error, but the principle holds: unknown variant means stop, never skip.
The panic turns a silent bug into a runtime crash. Better, but it still needs the bad path to execute before you learn anything. The next two tools move the check earlier.
Getting the check back, option 1: go-check-sumtype
go-check-sumtype (a maintained fork of BurntSushi’s original, bundled in golangci-lint) implements exactly the missing analysis. You mark the interface:
//sumtype:decl
type OrderEvent interface {
isOrderEvent()
}
The declaration must be a sealed interface with an unexported method, which we already have. From then on, any type switch on OrderEvent that doesn’t cover every implementing type in the package gets flagged:
order.go:83:2: exhaustiveness check failed for sum type "OrderEvent":
missing cases for OrderRefunded
Two configuration details matter. First, a default clause normally counts as covering everything, which would neuter the whole check. Set default-signifies-exhaustive: false in your golangci config so you can keep the defensive default: panic and still get flagged on missing cases. Second, the linter understands pointer variants, which brings up a real gotcha: case OrderPlaced: does not match a *OrderPlaced value. If your events flow through the system as pointers, your cases must be pointer cases. Pick one representation and hold the line everywhere; I use plain values for events, since they’re immutable facts and small.
With the linter wired into CI, adding OrderRefunded breaks the build on every non-exhaustive switch, which is precisely the TypeScript behavior we wanted. The honest caveat: it’s a linter, so the guarantee is as strong as your CI pipeline. Anyone who builds with go build alone gets nothing. As long as CI gates every merge, that’s fine.
Getting the check back, option 2: the visitor
If you want the compiler itself to enforce exhaustiveness, with no linter involved, the visitor pattern does it:
type OrderEvent interface {
accept(v orderEventVisitor)
}
type orderEventVisitor interface {
visitPlaced(e OrderPlaced)
visitPaid(e OrderPaid)
visitCancelled(e OrderCancelled)
}
func (e OrderPlaced) accept(v orderEventVisitor) { v.visitPlaced(e) }
func (e OrderPaid) accept(v orderEventVisitor) { v.visitPaid(e) }
func (e OrderCancelled) accept(v orderEventVisitor) { v.visitCancelled(e) }
Now apply is a visitor implementation:
type applier struct{ o *Order }
func (a applier) visitPlaced(e OrderPlaced) {
a.o.id = e.OrderID
a.o.status = StatusPending
a.o.total = e.Total
}
func (a applier) visitPaid(e OrderPaid) { a.o.status = StatusPaid }
func (a applier) visitCancelled(e OrderCancelled) { a.o.status = StatusCancelled }
func (o *Order) apply(e OrderEvent) { e.accept(applier{o}) }
Add OrderRefunded, add visitRefunded to the visitor interface, and every visitor in the codebase fails to compile until it handles the new event. That’s a genuine compile-time guarantee from vanilla go build, no tooling.
The cost is visible in the snippet: three declarations per variant instead of one, an extra type per consumer, and clumsy ergonomics whenever the consumer needs to return a value (you end up stashing results in visitor fields, or reaching for a generic accept, and Go won’t let you add type parameters to methods anyway). For a projection that eight teams implement against your events, that cost buys real safety. For the two or three switches inside a single package, it’s a lot of ceremony.
My default: sealed interface plus type switch plus go-check-sumtype in CI, with the panicking default as the runtime backstop. The visitor comes out only when the consumers of an event set live outside the package that owns it and I can’t rely on their CI running my linters.
What this sets up
The sealed interface pattern gives the domain layer a vocabulary: each aggregate package exports its event set as a closed type, and every fold over history is checked for coverage. Commands fit the same shape, and so do the errors an aggregate can produce.
What it deliberately ignores is persistence. Type switches work on Go types, but an event store holds bytes, and encoding/json has no idea which struct {"reason": "customer request"} should become. Round-tripping a sum type through storage needs a type registry and versioning discipline. That’s a problem for a later post in the series.
Next, we will talk about what to do with those events, and how to produce them in the first place.