- 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 open internal/domain/order.go expecting rules. You find a struct with eight public fields, no methods, and a comment: // fields are set by OrderService. Then you open internal/service/order_service.go and find a 600-line file called OrderManager doing all the thinking.
That is the anaemic domain model. Martin Fowler named the smell in 2003 and called it an anti-pattern because it imitates a domain model on the outside while keeping all the behaviour somewhere else. You wrote a procedural script in a folder that was supposed to be a domain.
Go developers fall into this more than the Java crowd, and not because Go is hostile to DDD. Go's syntax makes the anaemic shape easy. Public fields are the default. A struct with no methods compiles and ships. Nothing in the language pushes back.
Below are the five smells that show up most in Go DDD code, with the fix for each. None of these ask you to do more DDD. They ask you to stop pretending you already do.
Smell 1: Public fields, zero methods, all logic in services
The classic shape. The struct holds the data. A "service" reaches in and computes things over it.
package domain
type Order struct {
ID string
CustomerID string
Status string
Items []Item
TotalCents int64
}
package service
func (s *OrderService) Confirm(o *domain.Order) error {
if len(o.Items) == 0 {
return errors.New("empty order")
}
if o.Status == "confirmed" {
return errors.New("already confirmed")
}
o.Status = "confirmed"
return nil
}
The rule "an order with no items cannot be confirmed" lives in OrderService.Confirm. Anyone with a *Order in scope can write o.Status = "shipped" and the compiler will let them. The invariant is a suggestion.
The fix: the rule belongs on the type that owns the data.
package domain
type Order struct {
id string
customerID string
status Status
items []Item
totalCents int64
}
func (o *Order) Confirm() error {
if len(o.items) == 0 {
return ErrEmptyOrder
}
if o.status == StatusConfirmed {
return ErrAlreadyConfirmed
}
o.status = StatusConfirmed
return nil
}
Fields are unexported. The transition is a method. The compiler now enforces what the comment used to ask for. A caller cannot write o.status = ... from outside the package.
Smell 2: "Manager", "Helper", and "Util" packages
You see these in import paths. internal/manager/order_manager.go. pkg/util/order_helper.go. internal/services/order_orchestrator.go. The names are interchangeable because the contents are: a flat collection of functions that mutate domain structs.
package manager
func ApplyDiscount(o *domain.Order, pct int) {
o.TotalCents = o.TotalCents * int64(100-pct) / 100
}
func MarkPaid(o *domain.Order, ref string) {
o.Status = "paid"
o.PaymentRef = ref
}
Two functions, two domain rules, zero of them on the type they belong to. The manager package becomes a junk drawer. Six months in, no one knows if the discount logic lives in OrderManager, OrderService, BillingHelper, or all three.
Cure: delete the manager package and put the methods on the aggregate.
package domain
func (o *Order) ApplyDiscount(pct int) error {
if pct < 0 || pct > 100 {
return ErrInvalidDiscount
}
if o.status != StatusDraft {
return ErrNotDraft
}
o.totalCents = o.totalCents * int64(100-pct) / 100
return nil
}
func (o *Order) MarkPaid(ref PaymentRef) error {
if o.status != StatusConfirmed {
return ErrNotConfirmed
}
o.status = StatusPaid
o.paymentRef = ref
return nil
}
The code is the same; it just lives on the type now. There is now exactly one place to look for "what can happen to an order" — the Order type's method set. go doc prints it for you.
The application service still exists; it shrinks. It loads the aggregate, calls one method, persists, emits the event. It does not own the rule.
Smell 3: Getters and setters for every field
This one is dressed-up anaemia. The fields are unexported, so the team thinks the encapsulation is fixed. Then someone writes a method per field.
func (o *Order) GetStatus() string { return string(o.status) }
func (o *Order) SetStatus(s string) { o.status = Status(s) }
func (o *Order) GetTotalCents() int64 { return o.totalCents }
func (o *Order) SetTotalCents(t int64) { o.totalCents = t }
func (o *Order) GetItems() []Item { return o.items }
func (o *Order) SetItems(items []Item) { o.items = items }
A caller can still write o.SetStatus("cancelled") from anywhere. The setter is a public field with two extra characters. The unexported fields bought you nothing.
Worse, the methods have CRUD verbs (Set, Get, Update) instead of business verbs (Confirm, Cancel, MarkPaid). The ubiquitous language is gone the moment you ship SetStatus. That is the vocabulary your domain expert uses to describe the system.
The fix: replace setters with named transitions; keep getters only where the outside world has a reason to read.
func (o *Order) Status() Status { return o.status }
func (o *Order) Total() Money { return o.total }
func (o *Order) Cancel(reason string) error {
if o.status == StatusCancelled {
return ErrAlreadyCancelled
}
if o.status == StatusShipped {
return ErrAlreadyShipped
}
o.status = StatusCancelled
o.cancelReason = reason
return nil
}
Read methods take the name of the thing being read. Mutations take the name of the business event that caused them. A new engineer reading the file learns the domain from the method names.
Smell 4: Validation in the handler, not in the constructor
The HTTP handler decodes JSON, validates fields, then drops the values into a struct.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string
Age int
}
json.NewDecoder(r.Body).Decode(&req)
if !strings.Contains(req.Email, "@") {
http.Error(w, "bad email", 400)
return
}
if req.Age < 0 || req.Age > 150 {
http.Error(w, "bad age", 400)
return
}
u := &domain.User{Email: req.Email, Age: req.Age}
h.repo.Save(r.Context(), u)
}
Two problems. The rule "an email must contain @" lives in the HTTP layer, so the CLI tool, the gRPC handler, and the CSV import all need to repeat that check or they ship bad data. And the next time someone constructs a domain.User directly, the validation just doesn't run.
Cure: invalid states should be impossible to construct. Move the rule to the constructor.
package domain
type User struct {
id string
email Email
age int
}
func NewUser(id string, email Email, age int) (*User, error) {
if id == "" {
return nil, ErrEmptyID
}
if age < 0 || age > 150 {
return nil, ErrInvalidAge
}
return &User{id: id, email: email, age: age}, nil
}
type Email struct{ v string }
func NewEmail(v string) (Email, error) {
if !strings.Contains(v, "@") {
return Email{}, ErrInvalidEmail
}
return Email{v: v}, nil
}
Now the handler is small:
email, err := domain.NewEmail(req.Email)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
u, err := domain.NewUser(req.ID, email, req.Age)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
Every entry point — HTTP, CLI, batch import, test fixture — goes through the same constructor. The handler just translates errors to status codes.
Smell 5: State transitions as field assignments
The aggregate exposes a method that takes the new state as an argument, or worse, sets it inline.
func (s *Service) Cancel(ctx context.Context, id string) error {
o, _ := s.repo.GetByID(ctx, id)
o.Status = "cancelled"
o.CancelledAt = time.Now()
return s.repo.Save(ctx, o)
}
Two field assignments. Zero rules. Can you cancel an already-shipped order? An already-cancelled one? Does the timestamp need a reason? The struct does not know. The service does not check. The next bug ticket will tell you.
This is the anaemic shape at its purest: the service is the state machine, the struct is the storage. A year later you will find three services that all set Status = "cancelled" slightly differently.
The cure: every transition is one named method on the aggregate, with the rules guarding it inside.
func (o *Order) Cancel(reason string, now time.Time) error {
switch o.status {
case StatusCancelled:
return ErrAlreadyCancelled
case StatusShipped:
return ErrAlreadyShipped
case StatusPaid:
return ErrPaidNeedsRefund
}
if reason == "" {
return ErrCancelReasonRequired
}
o.status = StatusCancelled
o.cancelledAt = now
o.cancelReason = reason
return nil
}
The application service collapses to three lines: load, call Cancel(reason, clock.Now()), save. The state machine lives on the aggregate, next to the state it gates. The switch over o.status is the state machine made literal, and there is exactly one of them in the codebase.
The honest test for whether your model is anaemic
Open the file with the most state changes in your codebase — Order, Subscription, Booking, whichever. Ask three questions.
- Are the fields exported? If yes, the struct is a data bag.
- Does the type have methods named after business verbs (
Confirm,Cancel,Pause)? If no, the rules live somewhere else. - If you delete the
service/ormanager/package, does the domain still know how to do its job? If no, the type is anaemic.
Three nos means you wrote a procedural program in a domain/ folder. That's fine, just don't call it DDD.
What this is not
This is not an argument for ceremony. The cures above are the same number of lines as the smells, often fewer. No factories, no abstract base classes, no AggregateRoot[T] generics. The whole shift is two moves: put the rule next to the data it guards, and name the method after the business event. Once those land, invalid states stop being reachable from outside the package on their own.
That is the cheapest version of DDD that still earns the name. If your codebase is two of those three, this week's PR can make it three.
If this hit close to home
The five smells above each get their own chapter in Hexagonal Architecture in Go — the worked aggregates, the boundary between the application service and the domain, where validation belongs when you have multiple inbound adapters, and how the import graph keeps you honest. The Complete Guide to Go Programming covers the language-level pieces (unexported fields, value vs pointer receivers, package boundaries) the cures depend on.

Top comments (0)