DEV Community

Tim Bright
Tim Bright

Posted on

The Repository Pattern with Rich Domain Models

This is an article written by Claude but guided by me. I hate all the explanations about combining repository pattern, domain models, and domain-driven design because IMO they all talk around the issue but it's never actionable (read: helpful).

Hopefully you think this is different.


Here's an example of the repository Pattern with rich domain models in the context of an airline's flight operations:

Most Go codebases handle persistence the same way: a struct full of exported fields, a db package that scans rows into it, and business rules scattered across handlers and services. It works, until the rules get interesting. Airline flight operations is a domain where the rules get interesting fast, so let's use it to show what two patterns each buy you — and how they meet in the DDD concept of an aggregate.

The division of labor is clean and worth stating up front. A rich domain model is about keeping the business rules inside the domain model — the flight decides whether it can depart. The Repository pattern is about how data gets loaded into that model and saved back out — without the model ever knowing where it came from. One governs behavior, the other governs movement. Confuse the two and you get models that know SQL, or repositories that make business decisions.

The anemic starting point

Here's the version you've seen a hundred times:

type Flight struct {
    ID          string
    Status      string
    Crew        []CrewAssignment
    DepartedAt  *time.Time
}
Enter fullscreen mode Exit fullscreen mode

Everything is exported, so anything can do this:

flight.Status = "DEPARTED" // with zero crew assigned, at a gate, in the past
Enter fullscreen mode Exit fullscreen mode

The struct is a bag of data — an anemic model. The rule "a flight cannot depart without a full crew complement" has to live somewhere else: a service, a handler, a stored procedure, or worst of all, three of those places with slight differences. Nothing in the type system stops a new teammate from writing the invalid assignment above. The business rules live outside the model — that's the disease a rich domain model cures.

Rich domain models: the rules live inside

A rich domain model inverts this. Data becomes unexported; the only way in is through methods that enforce the rules of the domain:

package flight

type Status int

const (
    Scheduled Status = iota
    Boarding
    Departed
    Cancelled
)

type Flight struct {
    id     ID
    status Status
    crew   []CrewAssignment
    events []DomainEvent
}

const minCrew = 4

// Depart is the ONLY way a flight departs.
func (f *Flight) Depart(now time.Time) error {
    if f.status != Boarding {
        return fmt.Errorf("flight %s: cannot depart from status %v", f.id, f.status)
    }
    if len(f.crew) < minCrew {
        return ErrInsufficientCrew
    }
    f.status = Departed
    f.events = append(f.events, FlightDeparted{FlightID: f.id, At: now})
    return nil
}

func (f *Flight) AssignCrew(m CrewMember) error {
    if f.status == Departed || f.status == Cancelled {
        return ErrFlightClosed
    }
    for _, c := range f.crew {
        if c.MemberID == m.ID {
            return ErrAlreadyAssigned
        }
    }
    f.crew = append(f.crew, CrewAssignment{MemberID: m.ID, Role: m.Role})
    return nil
}
Enter fullscreen mode Exit fullscreen mode

What this affords you: the business rules now live inside the model, and nowhere else. Because status and crew are unexported, code outside the flight package cannot construct or mutate a Flight that violates the rules — invalid states become unrepresentable. Depart isn't a setter with validation bolted on; it's the domain operation itself, encoding the legal state machine (Scheduled → Boarding → Departed, with Cancelled reachable until departure). Ask "can this flight depart?" and there is exactly one place the answer can come from. Go gives you this almost for free: package-level encapsulation via lowercase fields is the enforcement mechanism, no annotations or frameworks required.

Note what the model deliberately knows nothing about: databases, rows, transactions. It answers what is allowed to happen; it has no opinion on how it got into memory. That second question belongs to a different pattern.

The aggregate: drawing the consistency boundary

Notice that Flight owns its []CrewAssignment. That's deliberate, and it's what DDD calls an aggregate: a cluster of objects treated as a single unit for data changes, with one entry point — the aggregate root (Flight).

The boundary is chosen by asking one question: what has to be true at save time? Whatever must hold the instant a transaction commits goes inside the aggregate; everything else stays out. "Minimum four crew before departure" must be true the moment a departed flight hits the database — there is no acceptable window where a departed flight has three crew. So the flight and its crew list must change together, and crew assignments live inside the aggregate.

By contrast, "a crew member can't work two flights the same day" spans many flights. Pulling every flight a crew member touches into one aggregate would mean one giant lock over half the schedule. So that rule is allowed to be true eventually: a domain service or an event handler detects the conflict after commit and triggers a reassignment. CrewMember itself is a separate aggregate that Flight references only by ID.

This is the real payoff of the aggregate concept: the boundary is your consistency dial. Inside the aggregate, invariants are strongly consistent — enforced in one transaction, never observable in a broken state. Across aggregates, consistency is eventual — reconciled by events, sagas, or background checks. Draw the boundary too wide and you've serialized your writes; too narrow and rules that must hold at commit time can't be enforced at all.

Two working rules fall out of this:

  1. Outside code holds a reference only to the root. Nobody reaches into a flight and edits crew[2] directly.
  2. The aggregate is the unit of loading and saving — which brings us to repositories.

The Repository: how aggregates get in and out of memory

If the rich model owns the rules, the repository owns the movement: it is the mechanism by which data is loaded from storage into a domain model, and by which a changed model gets back out. It presents aggregates as if they were an in-memory collection — you Get a flight, you Save a flight, and the fact that rows, documents, or a map were involved is invisible. The interface lives in the domain package, expressed purely in domain terms:

package flight

type Repository interface {
    Get(ctx context.Context, id ID) (*Flight, error)
    Save(ctx context.Context, f *Flight) error
    DepartingBetween(ctx context.Context, window TimeRange) ([]*Flight, error)
}
Enter fullscreen mode Exit fullscreen mode

No *sql.DB, no column names, no ORM types. What this affords you:

  • Dependency inversion. The domain defines the interface; the postgres package implements it. Your business logic imports nothing from your storage layer — the arrow points the other way. This is idiomatic Go anyway: interfaces are defined by the consumer.
  • One repository per aggregate, operating on whole aggregates. Save persists the flight and its crew assignments in one transaction — the repository is what makes "true at save time" literally true, because the aggregate's transactional boundary becomes the database's. There is no CrewAssignmentRepository: crew assignments aren't independently addressable, and giving them their own save path would let callers commit a partial aggregate.
  • Testability. A memoryRepository backed by a map makes domain and application tests trivial, no database container needed.

Equally important is what the repository must not do: decide anything. It never checks crew counts or flips statuses — it faithfully moves state between storage and model. All judgment stays inside the aggregate.

The application service ties it together, and stays thin because loading is the repository's job and deciding is the model's:

func (s *OpsService) DepartFlight(ctx context.Context, id flight.ID) error {
    f, err := s.flights.Get(ctx, id)
    if err != nil {
        return err
    }
    if err := f.Depart(s.clock.Now()); err != nil {
        return err // domain said no
    }
    return s.flights.Save(ctx, f)
}
Enter fullscreen mode Exit fullscreen mode

Repository loads, model decides, repository saves. Every use case reads like this, and each line stays in its lane. If you find business logic creeping into the service — or worse, into a repository implementation — it belongs on the aggregate.

The rehydration wrinkle

There's one honest tension in Go: if fields are unexported, how does the Postgres implementation rebuild a Flight from rows? Don't weaken encapsulation with setters. Instead, export a single reconstitution function from the domain package:

// Rehydrate rebuilds a Flight from persisted state.
// For repository use only; it bypasses creation rules
// because the state was already validated when written.
func Rehydrate(id ID, status Status, crew []CrewAssignment) *Flight {
    return &Flight{id: id, status: status, crew: crew}
}
Enter fullscreen mode Exit fullscreen mode

It's a trust boundary, documented as such — a small price for keeping every mutation path guarded.

How the three ideas lock together

Each concept answers a different question, and each depends on the next. The rich domain model answers where do the business rules live? — inside the model, enforced by encapsulation. The aggregate answers what has to be true at save time? — it draws the line between the strongly consistent (inside, one transaction) and the eventually consistent (across boundaries, reconciled later), and names a root to guard it. The repository answers how does data get loaded into that model and saved back out? — atomically, wholesale, behind a domain-owned interface.

Skip any one and the others sag. A repository over an anemic model is just a fancy DAO — the rules still leak. A rich model without an aggregate boundary can't tell you what a transaction should contain. An aggregate without a repository gets persisted piecemeal by whatever SQL each caller writes, and the boundary evaporates at the database.

Together, in a domain like flight ops, they give you the thing that actually matters at 4 a.m. during irregular operations: a codebase where an illegal flight state is not a bug you catch in review, but a program that doesn't compile — or an error the domain hands back before anything touches the database.

Top comments (0)