DEV Community

Cover image for DDD in Go Without the Bureaucracy: Aggregates, Not Abstractions
Gabriel Anhaia
Gabriel Anhaia

Posted on

DDD in Go Without the Bureaucracy: Aggregates, Not Abstractions


You don't need a ValueObjectFactoryStrategy. You need one struct, one invariant, and a method that returns an error.

The rest of this post shows you how to get there without dragging a decade of Java framework cosplay into your Go module.

Why Java-DDD Scares Go Developers

Open the average Go developer's first DDD tutorial and you'll see something like this:

  • An AbstractAggregateRoot<T> base class.
  • A DomainEventPublisherStrategy interface with three implementations.
  • A ValueObjectFactory that builds another factory.
  • A package tree five folders deep before you find anything that does work.

This is what happens when DDD gets trapped inside Spring. The patterns aren't wrong, but the ceremony around them is. You end up writing eight types to represent "a customer has an email address" and the actual domain rule (the email must be valid and unique within the tenant) is buried under three layers of injection plumbing.

Go developers see this and run. They were promised small binaries, flat packages, and if err != nil. They didn't sign up to recreate EnterpriseBeanFactoryInitializerImpl.

DDD did not invent the bureaucracy. The cargo-cult DDD cosplay did, by treating the patterns as ritual instead of design tools. Evans's own writing emphasizes building a ubiquitous language and letting the model reflect it, not wrapping every primitive in a class with three nested factories. The bureaucracy was added later, often by frameworks and tutorials that turned the patterns into checklists.

What DDD Actually Is at Its Core

Strip every framework, base class, and annotation away. What's left is one idea:

The structure and language of your code should match the business domain.

If the business talks about orders, line items, and fulfillment, your code has types called Order, LineItem, and Fulfillment. If a domain expert says "an order can't be confirmed without at least one paid item," there's a method on Order that enforces exactly that.

The DDD Reference Evans published in 2015, his own consolidated definition, fits in 50 pages. The original concepts are small:

  • Ubiquitous language: developers and domain experts use the same words.
  • Entities: things with identity that change over time (an Order).
  • Value objects: things defined by their attributes, immutable (a Money amount).
  • Aggregates: a cluster of entities and value objects with one root and a consistency boundary.
  • Domain events: things that happened and that other parts of the system care about.
  • Bounded context: where a particular model is valid.

That's the tactical pattern set most Go services need. Strategic DDD covers context maps, anti-corruption layers, and the big-picture stuff. It matters when you're carving up a monolith across teams. For a single service, you mostly need aggregates and value objects done well.

Three Dots Labs has been calling this "DDD Lite" since 2020 and the pattern has become the de facto standard for Go services that take their domain seriously. The "Lite" means DDD with the Java ceremony stripped out, not a watered-down version.

Aggregates as Go Structs With Invariant Methods

An aggregate in Go is a struct, a pointer receiver, and a handful of methods. Nothing more.

Two things separate an aggregate from a plain struct:

  1. The fields are unexported. Outside code can't reach in and mutate state.
  2. The methods enforce invariants: rules that must always hold true.

Vaughn Vernon's Effective Aggregate Design says it best: an invariant is a business rule that must be transactionally consistent. Not eventually. Not "we'll fix it on the next cron run." Inside the aggregate, the rule is always true.

Here's an Order aggregate with one invariant: an order can only be confirmed if it has at least one item and a non-zero total.

package order

import (
    "errors"
    "time"
)

type Status string

const (
    StatusDraft     Status = "draft"
    StatusConfirmed Status = "confirmed"
    StatusCancelled Status = "cancelled"
)

type Order struct {
    id        string
    status    Status
    items     []LineItem
    total     Money
    createdAt time.Time
}

var (
    ErrEmptyOrder       = errors.New("order has no items")
    ErrAlreadyConfirmed = errors.New("order already confirmed")
    ErrCancelled        = errors.New("order is cancelled")
)
Enter fullscreen mode Exit fullscreen mode

Errors get named after the rule that failed, not the state. The transition method follows.

func (o *Order) Confirm() error {
    if o.status == StatusConfirmed {
        return ErrAlreadyConfirmed
    }
    if o.status == StatusCancelled {
        return ErrCancelled
    }
    if len(o.items) == 0 || o.total.IsZero() {
        return ErrEmptyOrder
    }
    o.status = StatusConfirmed
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Look at what's not there. No AggregateRoot base class. No @Aggregate annotation. No event-bus dependency injected at construction. No factory.

The struct is small. The method enforces the rule. Callers either get nil and a confirmed order, or they get an error and an unchanged one.

Value Objects as Small Immutable Structs (No Factory)

A value object has no identity. Two Money values of EUR 10.00 are interchangeable. You don't need to track which one is which.

In Go, that's a struct with unexported fields and a constructor function. The constructor enforces validity at the door, and after that, the value is immutable because nothing outside the package can mutate it.

package order

import (
    "errors"
    "fmt"
)

type Money struct {
    amountCents int64
    currency    string
}

func NewMoney(amountCents int64, currency string) (Money, error) {
    if amountCents < 0 {
        return Money{}, errors.New("money cannot be negative")
    }
    if len(currency) != 3 {
        return Money{}, fmt.Errorf(
            "currency must be ISO 4217 code, got %q", currency,
        )
    }
    return Money{amountCents: amountCents, currency: currency}, nil
}

func (m Money) IsZero() bool { return m.amountCents == 0 }

func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf(
            "cannot add %s and %s", m.currency, other.currency,
        )
    }
    return Money{
        amountCents: m.amountCents + other.amountCents,
        currency:    m.currency,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

That's the entire value object. No MoneyFactory. No MoneyBuilder. No MoneyValidationStrategy. The constructor is the factory and the validator. The Java pattern split those into three types because Java couldn't return tuples; Go can. Don't import a problem you don't have.

The same shape works for EmailAddress, OrderID, CustomerID, Quantity, anything where invalid states should be impossible. The pattern is always:

type X struct { /* unexported fields */ }
func NewX(...) (X, error) { /* validate, return value or error */ }
func (x X) SomeQuery() T { /* read-only methods */ }
Enter fullscreen mode Exit fullscreen mode

Methods on X use value receivers, not pointer receivers. That's how you communicate immutability in Go.

Domain Events as Values Returned From Aggregate Methods

Java DDD usually plugs an event publisher into the aggregate's constructor and lets methods call publish(event) mid-mutation. That couples the aggregate to infrastructure. Tests need a fake bus. Transactions get tangled with side effects.

In Go, do the boring thing: return events from the method.

type Event interface {
    Name() string
    OccurredAt() time.Time
}

type OrderConfirmed struct {
    OrderID    string
    Total      Money
    ConfirmedAt time.Time
}

func (e OrderConfirmed) Name() string         { return "order.confirmed" }
func (e OrderConfirmed) OccurredAt() time.Time { return e.ConfirmedAt }

func (o *Order) Confirm() (Event, error) {
    if o.status == StatusConfirmed {
        return nil, ErrAlreadyConfirmed
    }
    if o.status == StatusCancelled {
        return nil, ErrCancelled
    }
    if len(o.items) == 0 || o.total.IsZero() {
        return nil, ErrEmptyOrder
    }
    o.status = StatusConfirmed
    return OrderConfirmed{
        OrderID:     o.id,
        Total:       o.total,
        ConfirmedAt: time.Now(),
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The aggregate method now returns (Event, error). The caller (usually an application service) decides what to do with the event after the transaction commits. Persist it to an outbox table, push it to Kafka, log it, ignore it. The aggregate doesn't care.

This keeps the aggregate pure: it takes input, mutates state, and reports what happened. The unit test needs no fake bus and no I/O.

Commit to one shape per package: (Event, error), with nil meaning "nothing emitted." Stay consistent. The worked example below uses that shape end to end.

When DDD Is Too Much

A CRUD endpoint is not DDD. It is also not a problem.

If your service exists to write a row to a table when someone POSTs and read it back when someone GETs, you don't have a domain. You have a data forwarder. Wrapping it in aggregates and value objects is theater.

Signs that DDD is the wrong tool for this part of your codebase:

  • The "rules" are all NOT NULL and UNIQUE constraints.
  • Every method on the type is a getter or a setter.
  • The product owner describes the feature in terms of forms and tables, not behaviors.
  • There are no state transitions. The entity is created, read, occasionally deleted.

Use DDD where the language is rich and the rules matter. A User table with id, email, created_at does not need an aggregate. A Subscription can be paused, resumed, upgraded, expired, and reactivated, with rules about what's allowed when. That absolutely needs an aggregate.

Mix the two in the same service. Hexagonal architecture lets you. The user-management corner can be a thin handler over a query. The subscription corner can be a full aggregate with invariants and events. Both ship from the same main.go.

The mistake to avoid is the inverse: forcing aggregates onto CRUD because someone read a book, or skipping aggregates on a complex domain because someone read a different book.

Worked Example: The Order Aggregate, End to End

The full picture: one aggregate, one invariant, two transitions, one event. No imports beyond the standard library. Start with the package, the status enum, and the supporting LineItem type.

package order

import (
    "errors"
    "time"
)

type Status string

const (
    StatusDraft     Status = "draft"
    StatusConfirmed Status = "confirmed"
    StatusCancelled Status = "cancelled"
)

type LineItem struct {
    sku      string
    quantity int
    price    Money
}

func NewLineItem(sku string, qty int, price Money) (LineItem, error) {
    if sku == "" {
        return LineItem{}, errors.New("sku is required")
    }
    if qty <= 0 {
        return LineItem{}, errors.New("quantity must be positive")
    }
    return LineItem{sku: sku, quantity: qty, price: price}, nil
}

func (li LineItem) Subtotal() Money {
    return Money{
        amountCents: li.price.amountCents * int64(li.quantity),
        currency:    li.price.currency,
    }
}
Enter fullscreen mode Exit fullscreen mode

LineItem is a value object: validated at construction, then immutable. Now the aggregate root and its constructor.

type Order struct {
    id        string
    status    Status
    items     []LineItem
    total     Money
    createdAt time.Time
}

func NewOrder(id string, currency string) (*Order, error) {
    if id == "" {
        return nil, errors.New("order id is required")
    }
    zero, err := NewMoney(0, currency)
    if err != nil {
        return nil, err
    }
    return &Order{
        id:        id,
        status:    StatusDraft,
        total:     zero,
        createdAt: time.Now(),
    }, nil
}

var (
    ErrEmptyOrder       = errors.New("order has no items")
    ErrAlreadyConfirmed = errors.New("order already confirmed")
    ErrCancelled        = errors.New("order is cancelled")
    ErrNotDraft         = errors.New("only draft orders accept items")
)
Enter fullscreen mode Exit fullscreen mode

Two state transitions, each guarded by the invariant that fits it.

func (o *Order) AddItem(item LineItem) error {
    if o.status != StatusDraft {
        return ErrNotDraft
    }
    newTotal, err := o.total.Add(item.Subtotal())
    if err != nil {
        return err
    }
    o.items = append(o.items, item)
    o.total = newTotal
    return nil
}

func (o *Order) Confirm() (Event, error) {
    if o.status == StatusConfirmed {
        return nil, ErrAlreadyConfirmed
    }
    if o.status == StatusCancelled {
        return nil, ErrCancelled
    }
    if len(o.items) == 0 || o.total.IsZero() {
        return nil, ErrEmptyOrder
    }
    o.status = StatusConfirmed
    return OrderConfirmed{
        OrderID:     o.id,
        Total:       o.total,
        ConfirmedAt: time.Now(),
    }, nil
}

func (o *Order) ID() string     { return o.id }
func (o *Order) Status() Status { return o.status }
func (o *Order) Total() Money   { return o.total }
Enter fullscreen mode Exit fullscreen mode

The accessors are read-only. Anything that mutates state goes through a named transition.

The unit test runs in microseconds with no database, no mock framework, no fixture builder:

package order_test

import (
    "testing"

    "example.com/shop/order"
)

func TestConfirmEmptyOrderFails(t *testing.T) {
    o, err := order.NewOrder("ord-1", "EUR")
    if err != nil {
        t.Fatal(err)
    }

    if _, err := o.Confirm(); err != order.ErrEmptyOrder {
        t.Fatalf("expected ErrEmptyOrder, got %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The empty-order test proves the invariant: a draft with no items refuses to confirm. The happy path checks the same method emits the right event.

func TestConfirmEmitsEvent(t *testing.T) {
    o, _ := order.NewOrder("ord-1", "EUR")
    price, _ := order.NewMoney(2500, "EUR")
    item, _ := order.NewLineItem("SKU-1", 2, price)
    if err := o.AddItem(item); err != nil {
        t.Fatal(err)
    }

    evt, err := o.Confirm()
    if err != nil {
        t.Fatal(err)
    }
    if evt.Name() != "order.confirmed" {
        t.Fatalf("unexpected event: %s", evt.Name())
    }
    if o.Status() != order.StatusConfirmed {
        t.Fatalf("expected status confirmed, got %s", o.Status())
    }
}
Enter fullscreen mode Exit fullscreen mode

No factory hierarchy, no AbstractOrder<T extends OrderRoot>, no reflection, and no annotations. The package is one file (or two if you want to split events out), and a junior engineer can read the whole thing in five minutes and tell you what an order is allowed to do.

That's DDD in Go. The patterns are all there: entities, value objects, invariants, events, and the consistency boundary around the aggregate. They are just spelled in Go instead of Spring.

What to Cut, What to Keep

Cut:

  • Base classes and abstract aggregate roots.
  • Per-field value objects when one struct does the job.
  • Factories that wrap a constructor that already validates.
  • Event publishers wired into aggregate constructors.
  • Layered package trees that exist only to mirror a Java tutorial.

Keep:

  • Unexported fields on aggregates and value objects.
  • One constructor per type that enforces validity at the door.
  • Methods named in the ubiquitous language (Confirm, Cancel, AddItem), not CRUD verbs (SetStatus, UpdateItems).
  • Errors that name the rule that failed (ErrEmptyOrder, not ErrInvalidState).
  • Events as plain values returned from the methods that cause them.

There's a quick check for "am I doing this right": read your aggregate file out loud to a domain expert. If they recognize the words and agree with the rules, that's DDD. If you have to explain what an AbstractEventPublisherStrategyImpl is, that's Java cosplay.

Try it on the next subscription, billing, or onboarding aggregate you touch this week: pick the noisiest type in the package, delete the factory and the abstract base, and see what's left. Usually it's a struct with unexported fields and a method or two with real names. Ship that.


If this lined up with how you want to structure Go services, the long version of the argument lives in Hexagonal Architecture in Go — aggregate design, port layout, where to put the application service, how to wire main() so the domain stays clean, and where DDD-Lite stops being enough. Pairs with The Complete Guide to Go Programming if the language itself is still settling in.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)