DEV Community

Cover image for The Anaemic Domain Model in Go: 5 Anti-Patterns and Their Cures
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Anaemic Domain Model in Go: 5 Anti-Patterns and Their Cures


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
}
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Are the fields exported? If yes, the struct is a data bag.
  2. Does the type have methods named after business verbs (Confirm, Cancel, Pause)? If no, the rules live somewhere else.
  3. If you delete the service/ or manager/ 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.

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

Top comments (0)