- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
DomainEventPublisherStrategyinterface with three implementations. - A
ValueObjectFactorythat 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
Moneyamount). - 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:
- The fields are unexported. Outside code can't reach in and mutate state.
- 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")
)
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
}
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
}
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 */ }
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
}
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 NULLandUNIQUEconstraints. - 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,
}
}
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")
)
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 }
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)
}
}
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())
}
}
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, notErrInvalidState). - 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.

Top comments (0)