← Writing

DDD in Go: the questions I actually get asked

After a few years of go-ddd being out there, the same handful of questions come up again and again. Here are honest answers, including when not to use any of this.

I open-sourced go-ddd as the template I wished I’d inherited: business rules that live in the domain, not scattered across HTTP handlers. It found an audience, and with an audience came the same questions on repeat. In the repo’s Q&A, in a r/golang thread, in my inbox. Here are the ones worth answering in public.

A caveat before I start: this is opinion, formed by shipping this style into production at a bank and in a few startups. It is not the one true way. Where I sound certain, read “this has worked for me and here is why”, not “everyone is wrong but me”.

“Isn’t this overkill?”

Often, yes. If your service is a thin layer over CRUD, read a row, write a row, no invariants worth protecting, then DDD is overhead you will resent. You’ll write three mapping functions to move one field. I say this in the guide and I’ll say it here: reach for this when the domain is the hard part, not the plumbing.

The tell is invariants. The moment “this can never happen” shows up in a requirement (a balance that can’t go negative, an order that can’t ship before it’s paid, a state machine that can’t skip a step), you have a domain worth modelling. Below that bar, a handler and a query are fine, and pretending otherwise is just ceremony.

The honest failure mode of DDD is a team applying all of it to a problem that needed none of it, then blaming the pattern when everything takes three times as long. Match the ceremony to the stakes. A CRUD admin panel and a payments engine do not deserve the same architecture, and it is not a character flaw to write the simple one simply.

“Where do I actually start?”

With the language, not the folders. Sit with whoever owns the business problem and write down the nouns and verbs they use. Not the ones you’d invent, theirs. If they say “settle”, don’t call it finalizeTransaction. The whole point of the domain layer is that someone non-technical could read the method names and agree they describe the business.

Only once the language is stable do the packages matter. And when they do, split by domain concept, not by technical layer. A billing package that contains its own entities, value objects, and repository interface beats an entities package that contains every entity in the system. The first tells you what the app does. The second tells you what Java tutorial the author read.

“Do commands give me transactional guarantees?”

Not by themselves. A command is an intent; it doesn’t know what a database is. ACID lives at the boundary where you execute it, typically the application service that opens a transaction, loads the aggregate, calls the method, and commits. The aggregate enforces the business invariant in memory. The transaction enforces the storage invariant on disk. Keep those two jobs separate and neither has to lie about the other.

Concretely, the application service is the only place that knows about transactions:

func (s *AccountService) Withdraw(ctx context.Context, cmd WithdrawCommand) error {
    return s.tx.Do(ctx, func(ctx context.Context) error {
        acc, err := s.repo.FindByID(ctx, cmd.AccountID)
        if err != nil {
            return err
        }
        if err := acc.Withdraw(cmd.Amount); err != nil { // invariant lives here
            return err
        }
        return s.repo.Save(ctx, acc)
    })
}

The Withdraw method on the aggregate refuses to overdraw. The transaction makes the load-check-save atomic. Neither one is doing the other’s job.

If a single command has to touch two aggregates atomically, that’s usually a sign the boundary is drawn in the wrong place, or that you want an event and eventual consistency instead of one fat transaction. An aggregate is meant to be the unit of consistency. When you find yourself locking two of them together to keep a rule true, the rule is telling you they might be one aggregate, or that the link between them tolerates being slightly late.

“How do I reach another bounded context?”

You don’t reach into it. You talk to it through a defined port, an interface it owns, and you translate at the edge. The whole point of a context boundary is that the other side can change its internals without breaking you. The instant one context imports another’s entities directly, you’ve deleted the boundary and kept the folder structure.

In practice that’s an interface in the calling context, implemented by an adapter that calls the other context (in-process, or over the wire) and returns your types, not theirs. If billing needs to know a customer’s tier, billing defines what it needs:

// in the billing context
type CustomerTiers interface {
    TierFor(ctx context.Context, id CustomerID) (Tier, error)
}

The customer context, or an adapter over it, implements that. Billing never imports a customer entity. It asks its own narrow question and gets its own type back. Six months later the customer team rewrites their storage and billing never notices, which is the entire return on the ceremony.

“Where does validation go, entity or value object?”

Both, but different validation. A value object guards its own shape: an Email that can’t be constructed invalid, a Money that can’t hold a negative amount or mix currencies. Once it exists, it’s correct by definition, everywhere.

func NewEmail(raw string) (Email, error) {
    addr, err := mail.ParseAddress(raw)
    if err != nil {
        return Email{}, fmt.Errorf("invalid email: %w", err)
    }
    return Email{value: addr.Address}, nil
}

After that call succeeds, no code anywhere has to re-check that the email is shaped correctly. It cannot be otherwise. That is the payoff of pushing validation down: you check once, at construction, and the type carries the proof.

The entity guards rules that span its fields and its lifecycle: this order can’t ship before it’s paid, this account can’t be closed with a non-zero balance. That isn’t one field’s problem, it’s the aggregate’s. Push everything you can down into value objects, so the entity only has to reason about the rules that genuinely need the whole picture. A good sign you’ve got the split right: your entities are short, because most of the “is this legal” work already happened in the types they’re built from.

“Why the mapping tax? Repositories return domain objects, not rows.”

Because the alternative is letting your database schema dictate your domain model, and they are not the same shape and shouldn’t be. The repository’s job is to hide persistence entirely: the domain asks for an aggregate, gets an aggregate, and never learns whether it came from Postgres, a cache, or a test double.

Yes, that’s a mapping layer between your domain struct and your database row. It feels like boilerplate on day one. It is also the seam that lets you change the schema, swap the store, or test the domain with zero infrastructure. I’ve done all three under production load, and each time the mapping layer was the thing that made it a contained change instead of a rewrite.

The mistake is letting an ORM’s model be your domain model. The instant your entity has gorm tags on it, your business logic and your table layout are welded together, and every schema migration is now a domain change. Keep them apart. The domain struct knows nothing about SQL. The repository implementation does the translation. That’s the tax, and it buys real freedom, but (back to the first question) only if the domain is worth the freedom.

“How do I test this?”

This is the quiet reward, and the reason I keep coming back to the style. Because the domain has no idea what a database is, you test the interesting logic with no database at all. Construct an aggregate in memory, call the method, assert on the result. The overdraw rule, the state machine, the rounding: all of it is a pure function of inputs you can build in a test in microseconds.

The repository interface makes an in-memory fake trivial, so application-service tests run without spinning anything up. Then, and only then, you write a smaller set of integration tests that prove the real repository maps to and from the database correctly. I use testcontainers for those, against a real Postgres, because the one thing an in-memory fake can’t tell you is whether your SQL is right. Fast unit tests for the logic, a few honest integration tests for the edges. You stop needing a running world to check a business rule, and the test suite stops being the reason nobody wants to touch the code.

The short version

DDD in Go is not a framework you adopt, it’s a set of decisions about where truth lives: business rules in the domain, transactions in the application service, translation in the repository, and nothing leaking across those lines. Do it when the domain earns it, skip it when it doesn’t, and be honest with yourself about which situation you’re in.

If you want the long version, it’s all in the guide.