Skip to content
Software Atlas

Domain-Driven Design

Code that speaks the business's language: bounded contexts and context maps, entities and value objects, aggregates and where to draw their boundaries, domain events, anti-corruption layers, and an honest look at when DDD is overkill.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceDomain-Driven Design means building your code around how the business actually works, in the business's own words, and splitting a big system wherever those words change meaning.

The 80-column Customer table

You join a company that sells furniture online. There is one customers table, and every department uses it:

  • Sales thinks of a customer as a lead: someone who might buy, with a sales stage and an owner.
  • The warehouse thinks of a customer as a delivery address: where the van goes, and when the loading dock is open.
  • Billing thinks of a customer as a payer: a legal name, a tax ID, a credit limit, unpaid invoices.

Over the years each department added its columns to the same table, and the table now has 80 of them. Pick a department below and look at what it actually uses:

One Customer table, three departments
Pick a department to see which columns it actually uses, then switch to one model per department.
Who's looking?
table customers (24 of its 80 columns shown)
idnameemailphonelead_sourcelead_stagesales_ownerlast_contactedship_streetship_cityship_zipdelivery_notesdock_hourslegal_nametax_idbilling_streetpayment_methodcredit_limitinvoice_termson_holdstatusnotesflag2type
Sales uses 11 of these 24 columns and has to scroll past the rest. The yellow ones are shared with other departments but mean something else here: status means “qualified”, “lost” (where the lead is in the sales funnel); notes holds things like “wants a demo in March”. Change what status means for Sales and you've changed it for everyone.

Nobody is happy. Every team scrolls past 60 columns that aren't theirs. The status column means three different things depending on who wrote it last. A change Billing needs ("split on_hold into two states") risks breaking Sales' reports. That one shared model serves nobody.

What DDD offers

Domain-Driven Design (DDD) is a set of ideas, collected by Eric Evans in his 2003 book of the same name, for keeping software understandable while the business keeps changing. The domain is the business area your software serves: here, selling and delivering furniture. The ideas come in two groups:

GroupThe question it answersMain ideas
Strategic (the big picture)Where do we split the system, and how do the pieces talk?Shared language, bounded contexts, context maps, which parts deserve the most effort
Tactical (code inside one piece)How do we write the code so the business rules can't be broken?Entities, value objects, aggregates, repositories, domain events

You can use the strategic ideas without the tactical ones, and that's often the best value for money. Section 11 covers when DDD is overkill.

The running example

The rest of this page builds one online shop with four parts: Catalog (products and photos), Ordering (orders and credit limits), Payments (charges and refunds) and Shipping (parcels and tracking). There is real Go and JavaScript code for it, tested against Postgres.

You'll learnSection
Using the business's words in code§02
Splitting where words change meaning, and drawing how the pieces relate§03–§04
Entities, value objects and aggregates, with code§05–§06
How big an aggregate should be (and why too big makes requests queue up)§07
Domain events, and keeping other parts in sync later§08
Protecting your model from someone else's§09
Where to spend effort, and when not to bother with DDD§10–§11
Foundations§02

Ubiquitous language: code that uses the business's words

In one sentenceIf the business says "place an order" and your code says updateRecord(rec, 2), every conversation needs a translator. Make the code say order.Place().

The translation tax

A domain expert is whoever knows how the business really works: the warehouse lead, the accountant, the support agent who handles angry calls. When they describe a rule, they use their own words: draft, placed, credit limit, back-ordered.

Developers often write the same rule in completely different words, picked on the day the code was written. Then every bug report and every new feature request has to be translated in someone's head, and the translation is where mistakes creep in. Flip the toggle:

Can the business read your code?
Each row is a rule as the business says it, next to the code that implements it.
“A customer can't change an order once it's placed.”
if (rec.st >= 2) throw new Error("E41");
🤔 What is st? Why 2? What is E41?
“An order can't go over the customer's credit limit.”
if (rec.amt + d.p * d.q > rec.u.lim) rec.flg = true;
🤔 It sets a flag and carries on. Is that the rule? Who reads flg?
“We ship an order only once.”
if (rec.st === 3 && !rec.dup) svc.process(rec, 4);
🤔 Is “process with 4” shipping? What does dup mean?
“When an order is placed, the warehouse gets to know.”
q.push({ t: 7, id: rec.id });
🤔 Message type 7 is…? You need to find the consumer to know.
Every rule needs a translation between how people talk and how the code is written, and the translation lives in someone's head. The second rule even has a bug nobody can spot without the translation: it flags the order instead of refusing it.

The rules didn't change between the two modes. Only the names did. And in developer-speak, the second rule was quietly wrong: it set a flag instead of refusing the line, and nobody could tell by reading it.

One language, used everywhere

DDD's answer is a ubiquitous language: one shared vocabulary that the business people and the developers both use, in conversation, in tickets, in tests and in the code. "Ubiquitous" just means "everywhere".

In practice that means:

  • Type names are business nouns: Order, Shipment, CreditLimit, not Record, DataItem, Manager.
  • Method names are business verbs: order.Place(), shipment.MarkDelivered(), not updateRecord(rec, 2) or setStatus(3).
  • Events are business facts, in the past tense: OrderPlaced, PaymentRefunded. (A domain event is a record that something meaningful happened; §08 covers them.)
  • Errors speak the language too: ErrOverLimit ("total would exceed the credit limit"), not "E41".

A before/after rename

Here's the second rule from the widget, before and after. The logic is the same size; the difference is that you can read the second version aloud to the accountant and they'll say "yes, that's the rule" (or, more usefully, "no, that's wrong").

✕ Developer-speak
function updateRecord(rec, d) {
  if (rec.st >= 2) throw new Error("E41");
  if (rec.amt + d.p * d.q > rec.u.lim) rec.flg = true;
  rec.items.push(d);
  rec.amt += d.p * d.q;
}
✓ Ubiquitous language
class Order {
  addLine(productId, qty, unitPrice) {
    if (this.#status !== "draft")
      throw new DomainError("can only change a draft order");
    const newTotal = this.#total.add(unitPrice.times(qty));
    if (newTotal.cents > this.#limit.cents)
      throw new DomainError("total would exceed the credit limit");
    // … add the line, update the total
  }
}

(The # fields and the Money type with add and times are explained in §05 and §06. For now, read it as English.)

How the language gets built

The language isn't invented by developers and handed down. It's discovered by talking to domain experts, and it changes as everyone understands the business better.

  1. Listen for the words people already use, and notice when two people use different words for the same thing ("client" vs "customer") or the same word for different things ("order" meaning a purchase vs a purchase order to a supplier). Both are worth a conversation.
  2. Write down a short glossary in the repository, next to the code: "Placed: the customer confirmed the order; it can no longer be changed."
  3. Rename the code when the language changes. If the business starts saying "reservation" instead of "hold", rename Hold to Reservation. Modern editors make this a minute's work; the confusion of leaving it costs far more.
  4. Use the words in tests. A test called TestPlacedOrderCannotBeChanged doubles as documentation the domain expert could check.

Where this breaks down

A single company rarely has one language. Sales, warehouse and billing each used "customer" correctly, but differently. Trying to force one meaning of "customer" on the whole company is how the 80-column table happened.

So the language is shared within a boundary, not across the whole company. That boundary has a name, and it's the most important idea in DDD: the bounded context, next.

Foundations§03

Bounded contexts: one word, one meaning, per context

In one sentenceA bounded context is a part of the system inside which every word means exactly one thing. Different contexts may use the same word for different things, and that's fine.

A model per context

A domain model is the simplified picture of the business that your code is built around: which things exist, what rules they follow, what you can do with them. It deliberately leaves out what doesn't matter. A map of the subway leaves out the streets; that's what makes it useful.

A bounded context is the boundary around one such model. In our shop, the same everyday words mean different things in each of the four contexts. Pick a word:

One word, four contexts
Pick a word the whole company uses. Each bounded context gives it its own meaning, fields and behavior.
Catalog context
Product
has: title, description, photos, category, list price
can: publish, hide, rewrite the description
Ordering context
Order line
has: productId, name and price as they were when ordered
can: nothing: it's a snapshot that doesn't change when the catalog does
Payments context
Doesn't use this word at all. Nothing here needs it.
Shipping context
Parcel item
has: SKU, weight, size, fragile?
can: pack it, put it on a label. Photos and price don't matter here
One word, up to four different models. Trying to build one “Product” class that serves all four gives you every field and every rule in one place, and each team breaks the others. Inside each box, though, the word means exactly one thing.

Look at Product. Catalog cares about photos and descriptions. Shipping cares about weight and size, and has never heard of photos. Ordering keeps a snapshot of the name and price at the moment the line was added, so that a later price change in the catalog doesn't silently change what the customer agreed to pay.

If you built one Product class for all three, it would carry photos into the shipping code and weights into the catalog code, and every change would need three teams to agree. That's the 80-column table from §01, again.

What it looks like in code

Each context gets its own package (or folder, or module), with its own types named in its own language. The types can share a name, because they live in different packages:

three Products, three packages
package catalog

type Product struct {
	ID          ProductID
	Title       string
	Description string
	Photos      []URL
	ListPrice   Money
}

// ---------------------------------------------------
package ordering

type line struct {
	product   ProductID // Catalog's product, by ID only
	name      string    // copied when the line was added
	unitPrice Money     // frozen: later price changes don't apply
	qty       int
}

// ---------------------------------------------------
package shipping

type ParcelItem struct {
	SKU     string // the warehouse's stock code
	WeightG int
	Fragile bool
}

Notice what crosses a context boundary: only an ID (ProductID) and the few values copied at a meaningful moment. Ordering never imports catalog.Product and never reads Catalog's tables. If it needs something from Catalog, it asks Catalog through an API or listens to its events (§04 covers how).

What a context owns

A bounded context is more than a folder of types. It owns:

It ownsWhich means
Its languageInside Shipping, "item" means a parcel item. Nobody has to ask which kind.
Its codeOne team (or one clear owner) changes it. Other contexts can't reach inside.
Its dataIts own tables (or its own database). Other contexts don't query them directly.
Its interfaceA small API and/or a set of events that other contexts may use.

The data rule is the one teams break most often, because a quick JOIN across another context's table is so tempting. Every such query becomes a hidden dependency: the other team can't rename a column without breaking you.

Finding the boundaries

There's no formula, but the same clues show up again and again:

  1. The language changes. Where "product" stops meaning "a page with photos" and starts meaning "a box with a weight", you've crossed a boundary.
  2. Different people care. Marketing edits the catalog; warehouse staff handle shipments. Different experts usually mean different contexts.
  3. Different rules and different rates of change. Pricing rules change weekly; the carrier integration changes when the carrier forces it.
  4. A process hands off. "Once the order is paid, it goes to the warehouse." Hand-offs are natural seams.

§10 shows event storming, a workshop that surfaces these clues quickly.

A context is not (necessarily) a service

A bounded context is a boundary of meaning, not of deployment. All four contexts can live in one program as four packages with strict rules about who imports what, which is a modular monolith. Or each can be a separate microservice. Start with packages; Monolith vs microservices covers when splitting into services is worth its cost. §11 comes back to this.

Practitioner§04

Context maps: how contexts relate

In one sentenceContexts have to talk. A context map draws who depends on whom, and names the deal between each pair, because the deal decides who has to change when something changes.

Upstream and downstream

When one context uses something another provides, the provider is upstream and the user is downstream. Think of a river: if a factory upstream changes what it dumps into the water, the town downstream deals with it, never the other way round.

In our shop, Catalog is upstream of Ordering: Ordering needs product names and prices. If Catalog renames a field in its API, Ordering breaks. Ordering can rename anything it likes without bothering Catalog.

So the interesting question for each pair is: how much power does the downstream side have, and how does it protect itself? DDD gives the common answers names.

The shop's map

A context map is a drawing of every context and every relationship. Here is the shop's, with two external systems it depends on. Hover or tap each relationship:

The shop's context map
Hover or tap a relationship (or use the buttons) to see what it means, how it looks in code, and what it costs.
customer–suppliershared kernelopen host + published languageconformistanti-corruption layerCatalogproducts, photos, pricesOrderingorders, credit limitsPaymentscharges, refundsShippingshipments, trackingCard processorexternal APIShipFastcarrier, external API
Arrows point downstream: from the side that provides to the side that depends on it. Dashed boxes are systems you don't own.
anti-corruption layer
upstream: ShipFast · downstream: Shipping
ShipFast's API is cryptic (stat_cd: “DLV”, timestamps like 20261029T143000Z) and changes often. Shipping puts a translator at its edge that turns ShipFast's model into its own TrackingUpdate.
In code: One small package, shipping/shipfast, parses their JSON and maps their codes. Only it knows ShipFast's format; the rest of Shipping only ever sees its own TrackingUpdate (§09).
Watch out: Code to write and test. Worth it when the outside model is messy, unstable, or would warp your own.

The relationship patterns

PatternThe dealPick it when
PartnershipTwo teams plan and release changes together.Both succeed or fail together and can coordinate closely.
Shared kernelA small piece of model (a Money package) is co-owned. Changes need both teams.The shared part is tiny, stable, and both teams sit close together.
Customer–supplierDownstream can ask; upstream plans its needs in.Upstream is another team in your company that is willing to listen.
ConformistDownstream adopts upstream's model as-is.You can't influence upstream (a big vendor), and its model is good enough.
Anti-corruption layerDownstream translates upstream's model into its own at the edge.Upstream's model is messy, unstable, or would warp yours. Very common with legacy systems.
Open host serviceUpstream offers one documented API for all consumers.Many consumers; building custom integrations for each doesn't scale.
Published languageA documented, versioned data format between contexts.Usually paired with open host: the events or API payloads have a public schema.
Separate waysNo integration at all.Connecting costs more than duplicating a little work.

The patterns aren't exclusive. Ordering can be an open host with a published language for its events and a customer of Catalog at the same time.

A published language in practice

Ordering's events are read by Payments, Shipping, and soon by analytics. Instead of each consumer guessing the shape, Ordering publishes a schema and treats it as a promise. Inside Ordering, the Go struct can be refactored freely; the published shape is versioned separately:

ordering.OrderPlaced v1 (the published contract)
{
  "type": "ordering.OrderPlaced",
  "version": 1,
  "orderId": "o-1",
  "customerId": "c-7",
  "total": { "cents": 6000, "currency": "EUR" },
  "at": "2026-10-30T15:36:02Z"
}

The rules that keep it a promise: add optional fields freely; never rename or remove a field, or change what it means, without publishing a v2 alongside v1 and giving consumers time to move. This is the same discipline as evolving any public API.

A domain event re-shaped into a stable message like this is sometimes called an integration event, to separate it from the internal events that only Ordering's own code sees.

Who has the power?

The map is also a map of people. Upstream teams set the pace; downstream teams absorb changes. Three things to look for when you draw yours:

  1. Arrows into your core business from outside. If a vendor's model flows straight into the part of your system that makes you money, put an ACL there (§09).
  2. "Customer–supplier" on paper, "conformist" in practice. If the upstream team never actually takes your requests, admit it and protect yourself.
  3. Shared kernels that keep growing. A shared Money type is fine. A shared Customer type is the 80-column table again.
Practitioner§05

Entities and value objects

In one sentenceAn entity is the same thing even when all its data changes (customer c-7). A value object is its data: any 10 euros is as good as any other 10 euros.

From here on we're in tactical DDD: how to write the code inside one bounded context. We'll stay in the Ordering context.

Two kinds of things

An entity has an identity that stays the same over its whole life. Order o-1 is created as a draft, gets lines, is placed, is shipped; at every point it's the same order, and two orders with identical lines are still two orders. You compare entities by ID.

A value object has no identity. It's defined entirely by its values: an amount of money, an address, a date range. You compare value objects by value, and you never change one: to "change" it, you replace it with a new one. That makes it immutable.

Try sorting a few things from the shop:

Entity or value object?
Ask: “if every field changed, would it still be the same thing?” Yes → entity. “Is any copy with the same fields just as good?” Yes → value object.
Customer c-7, Ana Lee
Money: 10.00 EUR
Order o-1
Address: 1 Rue Neuve, Lyon
Date range: 1–7 Nov
Shipment shp-42
Email address
0 / 7 answered · 0 right

Why bother with value objects?

Because a plain number or string can't carry its rules. A price as float64 invites three bugs at once: rounding errors (0.1 + 0.2 isn't 0.3 in floating point), adding euros to dollars, and negative or nonsense values sneaking in. Using basic types for things with rules is called primitive obsession (Anti-patterns §05 covers it in depth).

A Money value object fixes all three in one place: it stores whole cents (an integer, so no rounding), always carries its currency, and refuses to add two different currencies.

Money in Go and JavaScript

ordering/money.go
type Money struct {
	cents    int64  // lowercase = unexported: other packages can't change it
	currency string // "EUR", "USD", …
}

func NewMoney(cents int64, currency string) (Money, error) {
	if !isCurrencyCode(currency) {
		return Money{}, fmt.Errorf("money: bad currency %q", currency)
	}
	return Money{cents: cents, currency: currency}, nil
}

// isCurrencyCode: exactly three ASCII capital letters, like "EUR".
func isCurrencyCode(s string) bool {
	if len(s) != 3 { // len counts bytes, so "€" (3 bytes) gets past this…
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] < 'A' || s[i] > 'Z' { // …but not past this
			return false
		}
	}
	return true
}

func (m Money) Cents() int64      { return m.cents }
func (m Money) Currency() string  { return m.currency }
func (m Money) Times(n int) Money { return Money{m.cents * int64(n), m.currency} }

// Add returns a NEW Money; m itself is never modified.
func (m Money) Add(o Money) (Money, error) {
	if m.currency != o.currency {
		return Money{}, ErrCurrencyMismatch
	}
	return Money{m.cents + o.cents, m.currency}, nil
}

Equality: Go gets it for free, JS doesn't

The two languages differ in one important way. Go's == on a struct compares every field, so two Money values with the same cents and currency are equal. JavaScript's === on objects compares references (is it the very same object in memory?), so you need an equals method. The tests show both, plus immutability:

ordering/money_test.go
func TestMoneyEqualityByValue(t *testing.T) {
	a, _ := NewMoney(1000, "EUR")
	b, _ := NewMoney(1000, "EUR")
	if a != b { // ← == compares every field: equal by value
		t.Fatal("same amount and currency should be equal")
	}
	sum, err := a.Add(b)
	if err != nil || sum.String() != "20.00 EUR" || a.String() != "10.00 EUR" {
		t.Fatalf("got %v %v, a=%v", sum, err, a) // a is unchanged after Add
	}
	usd, _ := NewMoney(1000, "USD")
	if _, err := a.Add(usd); err != ErrCurrencyMismatch {
		t.Fatalf("want ErrCurrencyMismatch, got %v", err)
	}
	for _, bad := range []string{"euro", "eur", "€", ""} {
		if _, err := NewMoney(5, bad); err == nil {
			t.Fatalf("bad currency %q accepted", bad)
		}
	}
}

Both pass (go test on Go 1.24, node --test on Node 22). Go's struct equality works because both fields are comparable types; if you ever add a slice field to a value object, == stops compiling and you'll need an Equal method there too.

(The full files also have a small String() / toString() method that formats 1000 cents as "10.00 EUR".)

Entities compare by ID

For entities, the rule flips: two Order objects are the same order exactly when they have the same ID, even if one was loaded a second ago and has stale lines. Never compare entities field by field. In Go, entities are usually handled through a pointer (*Order) because they change over time; in JS, through a class instance with an id getter.

Practitioner§06

Aggregates: one door, rules checked inside

In one sentenceAn aggregate is a small group of objects that must always be consistent with each other, guarded by one object, the root, which is the only door in. Every change goes through the root, so the root can check the rules first.

The problem: rules that span several objects

An order has lines. Some rules are about the order as a whole, not any one line:

  • The order's total must equal the sum of its lines.
  • The total may not exceed the customer's credit limit.
  • Once placed, nothing may change. And an order ships only once.

A rule that must always be true like this is an invariant. If any code anywhere can grab a line and change its quantity, then nobody is in a position to check these rules, and sooner or later something breaks them.

The definition

An aggregate is a cluster of objects treated as one unit for changes. Here: an Order plus its order lines. The aggregate root is the one entity outside code may hold: the Order. The rules:

  1. Outside code only talks to the root. It may call order.AddLine(…), never line.qty = 10.
  2. The root checks every invariant before changing anything. If a rule would break, nothing changes and the caller gets an error.
  3. The aggregate is loaded and saved as a whole, in one database transaction (§07).

Try both versions: one where the lines are private behind the root, one where they're ordinary public fields:

Try to break the Order aggregate
Credit limit 100.00 EUR. Use the methods, then try the back doors: editing a line directly, or rewinding a shipped order's status.
Order o-1 (aggregate root)draft
no lines yet
total (stored)0.00 EUR
recorded events: none
Invariants
✓ total (0.00 EUR) = sum of the lines (0.00 EUR)
✓ sum of the lines ≤ credit limit (100.00 EUR)
✓ nothing changed after the order was placed
✓ shipped at most once (shipped 0×)
call a method…

With public fields, the back door changes a quantity without asking anyone, and three invariants break at once: the stored total is wrong, the order is over the limit, and (if it was placed) it changed after placing. Nothing crashed. You'd only find out from an angry accountant.

The Order aggregate in code

Where does the credit limit come from? In the Ordering context a customer is a Buyer with a credit limit (the lens in §03 showed this, and the §04 map lists credit limits under Ordering). The Buyer is its own aggregate. When an order is created, the buyer's current limit is copied into the order, so the order can check it on its own without loading anything else (§07 revisits this choice).

ordering/order.go
type (
	OrderID    string
	CustomerID string
	ProductID  string
	Status     string
)

const (
	Draft   Status = "draft"
	Placed  Status = "placed"
	Shipped Status = "shipped"
)

var (
	ErrNotDraft       = errors.New("order: can only change a draft order")
	ErrOverLimit      = errors.New("order: total would exceed the credit limit")
	ErrAlreadyShipped = errors.New("order: already shipped")
	ErrInvalidOrder   = errors.New("order: needs an ID, a customer and a positive credit limit")
	// … ErrBadQuantity, ErrEmpty, ErrNotPlaced
)

type line struct { // unexported: code outside this package can't hold or edit one
	product   ProductID
	qty       int
	unitPrice Money
}

type Order struct { // the aggregate root
	id       OrderID
	customer CustomerID // another aggregate: referenced by ID only
	status   Status
	lines    []line
	total    Money
	limit    Money
	events   []Event // recorded here, published after a successful save (§08)
	version  int     // set when loaded, checked when saved (§07); 0 = never saved
}

func NewOrder(id OrderID, customer CustomerID, limit Money) (*Order, error) {
	if id == "" || customer == "" || limit.Cents() <= 0 { // also rejects a zero Money
		return nil, ErrInvalidOrder
	}
	zero := Money{0, limit.Currency()}
	return &Order{id: id, customer: customer, status: Draft, total: zero, limit: limit}, nil
}

func (o *Order) AddLine(p ProductID, qty int, unitPrice Money) error {
	if o.status != Draft {
		return ErrNotDraft
	}
	if qty < 1 {
		return ErrBadQuantity
	}
	newTotal, err := o.total.Add(unitPrice.Times(qty))
	if err != nil {
		return err
	}
	if newTotal.Cents() > o.limit.Cents() { // ← the invariant, checked before any change
		return ErrOverLimit
	}
	o.lines = append(o.lines, line{p, qty, unitPrice})
	o.total = newTotal
	return nil
}

func (o *Order) Ship(now time.Time) error {
	switch o.status {
	case Shipped:
		return ErrAlreadyShipped // ← can't ship twice
	case Placed:
		o.status = Shipped
		o.events = append(o.events, OrderShipped{OrderID: o.id, At: now})
		return nil
	default:
		return ErrNotPlaced
	}
}
// Place works like Ship: draft + at least one line → placed, records OrderPlaced.

Tested without a database

Because the rules live in plain code, testing them needs no database, no HTTP server and no mocks. This is the Go test (the JS one with node:test checks the same things); both pass:

ordering/order_test.go
func TestOrderInvariants(t *testing.T) {
	now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
	o, err := NewOrder("o-1", "c-7", eur(100_00)) // credit limit 100.00 EUR
	if err != nil {
		t.Fatal(err)
	}

	if err := o.AddLine("mug", 2, eur(30_00)); err != nil { // 60.00
		t.Fatal(err)
	}
	if err := o.AddLine("lamp", 1, eur(50_00)); !errors.Is(err, ErrOverLimit) { // 110.00 > 100.00
		t.Fatalf("want ErrOverLimit, got %v", err)
	}
	if o.Total() != eur(60_00) { // the failed AddLine changed nothing
		t.Fatalf("total = %v", o.Total())
	}
	if err := o.Place(now); err != nil {
		t.Fatal(err)
	}
	if err := o.Ship(now); err != nil {
		t.Fatal(err)
	}
	if err := o.Ship(now); !errors.Is(err, ErrAlreadyShipped) {
		t.Fatalf("second ship: %v", err)
	}
}

eur(…) is a three-line test helper that calls NewMoney(c, "EUR"). Go allows _ inside number literals, so 100_00 is just 10000 cents written to look like "100.00".

What makes a good aggregate method

  • Named in the ubiquitous language: Place, Ship, AddLine, not SetStatus or Update.
  • Check first, change second. Every check runs before the first field is touched, so a failed call leaves the aggregate exactly as it was. The test above verifies this.
  • No setters for things with rules. A SetStatus(s) method is a public field with extra typing. If shipping has rules, the method is Ship().
  • No database, no HTTP, no clock lookups inside. Ship(now) takes the time as an argument, so tests can pass any time they like.

Some operations don't fit any single entity, like "calculate the shipping cost for this cart to this address". Those go in a domain service: a plain function in the domain package, which takes the objects it needs as arguments.

Practitioner§07

Aggregate boundaries and repositories

In one sentenceWhatever is inside one aggregate is always consistent, and is locked together on every change. So draw the boundary around what truly must be consistent at every instant, and nothing more.

Three rules of thumb

  1. One transaction changes one aggregate. Loading an aggregate, calling a method, and saving it is one database transaction. If a use case seems to need two aggregates changed together, that's a design question (below), not a reason for a bigger transaction.
  2. Reference other aggregates by ID. The Order holds a CustomerID, not a *Customer. Holding the object would tempt code to change the customer through the order, and would force loading it every time.
  3. Keep aggregates small. Put inside only what the invariants need. Everything inside is loaded, locked and saved together on every single change.

Rule 3 is the one people get wrong, because a bigger aggregate feels safer: more rules can be checked instantly. The cost shows up under load.

Big aggregates cause contention

Two transactions that change the same aggregate can't both succeed at once. Either the second one waits for the first one's row lock, or (with optimistic locking, below) the second one fails and must retry. Either way, everything that shares an aggregate is serialized: done one at a time. That's lock contention, and the bigger the aggregate, the more unrelated requests get caught in it.

Pick a boundary and a traffic pattern:

Draw the aggregate boundary
Choose what lives inside one aggregate, then a traffic pattern. Each bar is one “place order” transaction; the striped part is time spent waiting for another transaction on the same aggregate.
Boundary
Traffic
12 different customers all buy the same lamp within 0.7 s. Inside the aggregate: Order, Order lines. Each order locks: 1 order row.
ana → lamp
ben → lamp
chen → lamp
dia → lamp
eli → lamp
fay → lamp
gus → lamp
hana → lamp
ivo → lamp
jun → lamp
kim → lamp
lea → lamp
0 ms800 ms1600 ms
No two transactions touched the same aggregate, so nobody waited. Every order took 120 ms.
Which rules can be checked instantly, inside one transaction?
✓ instant · One order's total ≤ its credit limit; shipped only once
◷ eventual · All of a customer's open orders together ≤ their credit limit (another step checks it a moment later, see §08)
◷ eventual · Never sell more lamps than are in stock (another step checks it a moment later, see §08)
  • Order alone: every order is its own aggregate; nothing waits, whatever the traffic. But two rules can now only be checked eventually.
  • Customer + all their Orders: you can check "all open orders together stay under the credit limit" instantly, but every order from one big business customer queues behind the others. The customer row also grows with every order ever placed, and is loaded in full each time.
  • Order + Stock: you can never oversell, but a flash sale turns into a single-file queue on one stock row.

The timings in the widget are illustrative (each transaction is assumed to take 120 ms). Here's the same effect measured for real: 32 goroutines each placing 25 orders for one customer, against Postgres 16 on a 4-core machine, using version-checked updates:

$ go run ./cmd/contention
big aggregate (Customer+Orders):   800 orders in 1.169s, 4471 version conflicts (6.6 tries per order)
small aggregates (Order each):     800 orders in 83ms, 0 version conflicts (1.0 tries per order)

Measured on this machine, three runs, similar each time: the big aggregate was about 14× slower, and the database did more than five times the necessary work, because most attempts were thrown away and retried. The numbers will differ on your hardware; the shape won't.

"But that rule spans two aggregates!"

Say the business wants "a customer's open orders, together, may not exceed their credit limit". Before making Customer + Orders one aggregate, ask the domain expert a question: "What happens if it's briefly wrong?"

Often the answer is "not much: if someone goes 30 euros over for a minute, we hold the order and call them". Then the rule doesn't need the instant consistency an aggregate provides. Keep Order small, and let a separate step check the total a moment later and react (put the order on hold, cancel it). That step runs off a domain event, which is §08. The result is eventual consistency: correct a moment later, rather than correct at every instant. (The term is usually about copies of data catching up with each other; here it's separate aggregates that catch up.)

If the answer is "it must never happen, not even for a second" (say, selling the last seat on a plane twice), then the rule really is an invariant, and the objects belong in one aggregate. Make that aggregate as small as you can: a Seat or Stock aggregate per item, not "the whole flight" or "the whole warehouse".

Repositories: load whole, save whole

A repository is the object that loads and saves aggregates, so that the rest of the code never writes SQL for them. Its interface talks in aggregates, not rows: Get(id) returns a complete Order with all its lines; Save(order) stores all of it. There's exactly one repository per aggregate: an OrderRepository, never an OrderLineRepository, because a line on its own is not a unit you may load or change.

Where the interface lives and how it's wired into the app is Clean Architecture's subject. Here's what one looks like inside, with Postgres. The whole aggregate is stored as one row with the lines in a jsonb column (a column holding JSON that Postgres can index and query), plus a version number:

schema.sql
CREATE TABLE orders (
  id          text PRIMARY KEY,
  customer_id text NOT NULL,   -- a reference by ID, no foreign key into another context
  data        jsonb NOT NULL,  -- the whole aggregate: status, lines, totals
  version     int  NOT NULL
);
store/orders.go
var ErrConflict = errors.New("store: order was changed by someone else; reload and retry")

// Save writes the whole aggregate (and its new events) in ONE transaction.
func (r Orders) Save(ctx context.Context, o *ordering.Order) error {
	s := o.Snapshot() // plain data: id, status, lines, totals, version
	data, err := json.Marshal(s)
	if err != nil {
		return err
	}
	tx, err := r.DB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() // does nothing if Commit already succeeded

	if s.Version == 0 { // never saved before
		_, err = tx.ExecContext(ctx,
			`INSERT INTO orders (id, customer_id, data, version) VALUES ($1, $2, $3, 1)`,
			s.ID, s.Customer, data)
	} else {
		var res sql.Result
		res, err = tx.ExecContext(ctx,
			`UPDATE orders SET data = $1, version = version + 1
			  WHERE id = $2 AND version = $3`, // ← only if nobody saved in between
			data, s.ID, s.Version)
		if err == nil {
			var n int64
			if n, err = res.RowsAffected(); err == nil && n == 0 {
				return ErrConflict
			}
		}
	}
	if err != nil {
		return err
	}
	// … insert the order's new events into the outbox (§08), same tx
	return tx.Commit()
}

The version is the private field from §06: FromSnapshot fills it in when Get loads the row, and Save checks it. Save doesn't update the in-memory order's version afterwards, so the rule is one load, one change, one save per request; to change the same order again, load it again.

The WHERE … AND version = $3 is optimistic locking. Two requests load order o-1 at version 2. The first saves and bumps it to 3. The second's UPDATE now matches zero rows, so it gets ErrConflict instead of silently overwriting the first one's change (a lost update; Race conditions §05 shows that bug in detail). The integration test, run against Postgres 16, does exactly that:

store/orders_test.go (excerpt)
a, _ := repo.Get(ctx, "o-1") // two requests load the same order
b, _ := repo.Get(ctx, "o-1")
a.Ship(time.Now())
repo.Save(ctx, a)            // ✓ version 2 → 3

b.Ship(time.Now())           // b still thinks the order is "placed"
err := repo.Save(ctx, b)     // ✗ ErrConflict: nothing overwritten

c, _ := repo.Get(ctx, "o-1") // reload and retry…
err = c.Ship(time.Now())     // ✗ ErrAlreadyShipped: now the aggregate itself says no

The version check and the aggregate's own rule work as a pair: the version check catches the race between two requests, and once the retry reloads fresh data, the invariant ("ships only once") decides. (In the real test every error is checked; they're trimmed here to show the sequence.)

Practitioner§08

Domain events and eventual consistency

In one sentenceA domain event is a fact, in the past tense: OrderPlaced. The aggregate records it, the same transaction stores it, and other aggregates and contexts react to it later in their own transactions.

Why events?

§07 left us with small aggregates, each changed in its own transaction. But business processes span several of them: placing an order should charge the card (Payments) and book a pickup (Shipping). Ordering could call both directly, but then:

  • Ordering must know about Payments and Shipping, and every new reaction ("also email the customer", "also update the sales dashboard") means changing Ordering's code.
  • If Shipping is down, placing an order fails, even though shipping could happen ten minutes later without anyone noticing.

Instead, Ordering just announces what happened. A domain event is that announcement: a record that something meaningful happened in the business, named in the past tense, in the ubiquitous language. Whoever cares subscribes to it. Ordering doesn't know or care who's listening.

Step by step

Follow OrderPlaced from one aggregate to the next
Step through placing an order: one transaction per aggregate, with events carrying the news.
Code
1func PlaceOrder(ctx context.Context, id ordering.OrderID) error {
2 o, err := orders.Get(ctx, id)
3 if err != nil {
4 return err
5 }
6 if err := o.Place(time.Now()); err != nil {
7 return err // a rule said no: nothing saved, nothing sent
8 }
9 return orders.Save(ctx, o) // order + outbox row, one transaction
10}
In memoryOrdering app
Order o-1 loaded: draft
Ordering DB
orders o-1: draft, v1
Message broker
no messages
Payments context
idle
Shipping context
idle
A customer clicks Place order. The application code loads the whole Order aggregate through its repository.
1 / 7

Three things in that walkthrough are doing the real work:

  1. The aggregate records, it doesn't send. o.Place appends OrderPlaced to a list inside the order. If the save fails, the event disappears with it, so the world never hears about an order that doesn't exist.
  2. The outbox. The repository writes the order and its events into the same database, in one transaction. A separate relay process publishes them to the message broker afterwards. (Queues covers the outbox and relay in depth.)
  3. Consumers are idempotent. Most brokers, as usually configured, deliver at least once, so the same event can arrive twice. The consumer must be idempotent: handling it twice has the same effect as once. (Idempotency shows the techniques.)

The code

The event is a plain struct. The aggregate records it; PullEvents hands the list to the repository and clears it:

ordering/events.go
// Event is anything that happened to an aggregate. Names are past tense.
// These are INTERNAL: refactor them freely. The published shapes come below.
type Event interface{ EventName() string }

type OrderPlaced struct {
	OrderID  OrderID
	Customer CustomerID
	Total    Money
	At       time.Time
}

func (OrderPlaced) EventName() string { return "ordering.OrderPlaced" }

// in order.go
func (o *Order) Place(now time.Time) error {
	if o.status != Draft {
		return ErrNotDraft
	}
	if len(o.lines) == 0 {
		return ErrEmpty
	}
	o.status = Placed
	o.events = append(o.events, OrderPlaced{o.id, o.customer, o.total, now}) // ← recorded, not sent
	return nil
}

func (o *Order) PullEvents() []Event {
	ev := o.events
	o.events = nil
	return ev
}

func (OrderPlaced) EventName() has a receiver with no variable name, because the method doesn't need to look at the value; it exists so that OrderPlaced satisfies the Event interface.

Internal event in, published event out

The internal OrderPlaced is Ordering's private business. What other contexts receive is the published v1 shape from §04, with its type and version. A small translation function maps one to the other, so Ordering can rename internal fields without breaking anybody:

ordering/published.go
// The published language: the v1 shapes other contexts rely on. Change with care.
type MoneyV1 struct {
	Cents    int64  `json:"cents"`
	Currency string `json:"currency"`
}

type OrderPlacedV1 struct {
	Type       string    `json:"type"`    // always "ordering.OrderPlaced"
	Version    int       `json:"version"` // always 1
	OrderID    string    `json:"orderId"`
	CustomerID string    `json:"customerId"`
	Total      MoneyV1   `json:"total"`
	At         time.Time `json:"at"`
}

// Published translates an internal event into its published v1 shape.
func Published(e Event) (any, error) {
	switch e := e.(type) {
	case OrderPlaced:
		return OrderPlacedV1{
			Type: e.EventName(), Version: 1,
			OrderID: string(e.OrderID), CustomerID: string(e.Customer),
			Total: MoneyV1{e.Total.cents, e.Total.currency}, At: e.At,
		}, nil
	case OrderShipped:
		return OrderShippedV1{Type: e.EventName(), Version: 1, OrderID: string(e.OrderID), At: e.At}, nil
	default:
		return nil, fmt.Errorf("ordering: no published form for %T", e)
	}
}

The missing piece of Save from §07 is a loop that translates each event and writes it into the outbox table inside the same transaction:

store/orders.go (inside Save, before Commit)
for _, e := range o.PullEvents() { // the outbox: events commit with the order, or not at all
	pub, err := ordering.Published(e) // internal event → published v1 shape
	if err != nil {
		return err
	}
	payload, err := json.Marshal(pub)
	if err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO outbox (type, payload) VALUES ($1, $2)`, e.EventName(), payload); err != nil {
		return err
	}
}
return tx.Commit()

After placing and saving order o-1 (Go integration test, Postgres 16), the outbox row's payload, read back with SELECT payload::text, is the §04 contract:

{"at": "2026-10-30T16:07:18.891879287Z", "type": "ordering.OrderPlaced", "total": {"cents": 6000, "currency": "EUR"}, "orderId": "o-1", "version": 1, "customerId": "c-7"}

(Postgres's jsonb stores keys sorted by length, then alphabetically, which is why at comes first. The JS repository produces the same shape, with the time in milliseconds.)

On the other side, Shipping's handler reads only the fields it needs, in its own model, and stays idempotent:

shipping/on_order_placed.go
type orderPlacedV1 struct { // the published shape, as Ordering promises to send it
	OrderID    string `json:"orderId"`
	CustomerID string `json:"customerId"`
}

func OnOrderPlaced(ctx context.Context, repo Shipments, payload []byte) error {
	var e orderPlacedV1
	if err := json.Unmarshal(payload, &e); err != nil {
		return fmt.Errorf("bad OrderPlaced: %w", err) // after a few retries: the dead-letter queue
	}
	_, err := repo.CreateOnce(ctx, Shipment{ // the message may arrive twice: stay idempotent
		ID: "shp-" + e.OrderID, OrderID: e.OrderID,
		Status: "awaiting_payment", // a PaymentCaptured handler makes it ready for pickup
	})
	return err
}

Shipping doesn't import Ordering's package. It declares its own small struct with just the published fields it reads, and ignores the rest. A message it can't parse is retried a few times and then parked in a dead-letter queue for a person to look at. A test delivers the same message twice and checks that exactly one shipment exists.

The shipment starts as "awaiting payment": Shipping won't book a pickup until it also hears PaymentCaptured from Payments, the same flow as the event-storming wall in §10.

Eventual consistency, honestly

Between the order's commit and Shipping's reaction, the system is inconsistent: there's a placed order with no shipment. Usually that's milliseconds; when Shipping is down, it's minutes. Design for it:

  • Show in-between states to users. "Order placed, preparing shipment" is honest; "Shipped" before it is not.
  • Decide what happens when a reaction fails. If Payments declines the card, it publishes PaymentDeclined, and Ordering cancels the order. A chain of steps with undo actions like this is a saga (Sagas covers it).
  • Read models catch up too. A dashboard built by listening to events (a read model) lags slightly behind. CQRS covers building those.
Practitioner§09

Anti-corruption layer: translating someone else's model

In one sentenceWhen you must use a system whose model you don't like, put a translator at your border. Their names, codes and quirks stop there; everything inside speaks your language.

The problem: their model leaks into yours

Shipping uses a carrier, ShipFast, that sends a webhook (an HTTP request to your server) every time a parcel's status changes. Their payload looks like this:

what ShipFast sends
{ "trk_no": "SF9921", "stat_cd": "DLV", "evt_ts": "20261029T143000Z", "ref_1": "shp-42", "wt_g": 1200 }

stat_cd: "DLV" means delivered; "RTS" means returned to sender; ref_1 is where they put your shipment ID; the timestamp is in a compact format of their own. None of this is how Shipping talks about shipments.

The easy path is to use their fields directly wherever you need them. Within a year, "DLV" appears in the email code, the returns code, a dashboard query, and the shipments table. Then ShipFast ships a new API version:

ShipFast changes its API
ShipFast v2 renames stat_cd to status, replaces codes like “DLV” with words like “DELIVERED”, and switches timestamps to the standard ISO 8601 format (2026-10-29T14:30:00Z). Which of Shipping's files must change?
tracking.go
updates shipment status
s.Status = ev.StatCd // "DLV"
notify.go
emails “your parcel arrived”
if ev.StatCd == "DLV" { sendDelivered() }
returns.go
handles parcels sent back
if ev.StatCd == "RTS" { startReturn() }
late_report.go
finds late deliveries
t, _ := time.Parse("20060102T150405Z", ev.EvtTs)
dashboard.sql
ops dashboard
WHERE status_code IN ('PU','IT','OFD')
shipments table
stored status
status_code text -- holds 'DLV', 'RTS', …
Everything works with v1.

That's the "corruption" in the name: not malice, just someone else's model slowly taking over your code, until you can't change carriers (or even upgrade to their next version) without touching everything.

The anti-corruption layer

An anti-corruption layer (ACL) is one package at the edge of your context that converts the outside model into yours. Only the ACL knows ShipFast's format. The webhook handler calls shipfast.ToTrackingUpdate(body) and only ever sees a TrackingUpdate, which is Shipping's own type: a shipment ID, one of three statuses, and a real timestamp. The ACL lives in its own small package inside Shipping, shipping/shipfast/:

shipping/tracking.go + shipping/shipfast/shipfast.go
// ---- shipping/tracking.go: our model, in the Shipping context's own words ----
package shipping

type Status string

const (
	InTransit Status = "in_transit"
	Delivered Status = "delivered"
	Returned  Status = "returned"
)

type TrackingUpdate struct {
	ShipmentID string
	Status     Status
	At         time.Time
}

// ---- shipping/shipfast/shipfast.go: the anti-corruption layer ----
package shipfast // the ONLY code that knows ShipFast's format

import "shop/shipping" // (plus encoding/json, fmt, time)

type shipFastEvent struct { // their shape, private to this package
	TrkNo  string `json:"trk_no"`
	StatCd string `json:"stat_cd"`
	EvtTs  string `json:"evt_ts"` // "20261029T143000Z"
	Ref1   string `json:"ref_1"`  // they put OUR shipment ID here
}

var shipFastStatus = map[string]shipping.Status{
	"PU": shipping.InTransit, "IT": shipping.InTransit, "OFD": shipping.InTransit, // picked up, in transit, out for delivery
	"DLV": shipping.Delivered,
	"RTS": shipping.Returned, // return to sender
}

func ToTrackingUpdate(body []byte) (shipping.TrackingUpdate, error) {
	var e shipFastEvent
	if err := json.Unmarshal(body, &e); err != nil {
		return shipping.TrackingUpdate{}, fmt.Errorf("shipfast: %w", err)
	}
	status, ok := shipFastStatus[e.StatCd]
	if !ok { // ← an unknown code is an error, not a silent default
		return shipping.TrackingUpdate{}, fmt.Errorf("shipfast: unknown status code %q", e.StatCd)
	}
	at, err := time.Parse("20060102T150405Z", e.EvtTs)
	if err != nil {
		return shipping.TrackingUpdate{}, fmt.Errorf("shipfast: bad timestamp: %w", err)
	}
	if e.Ref1 == "" {
		return shipping.TrackingUpdate{}, fmt.Errorf("shipfast: tracking %s has no shipment reference", e.TrkNo)
	}
	return shipping.TrackingUpdate{ShipmentID: e.Ref1, Status: status, At: at}, nil
}

The tests feed in a real-looking payload, check the translation, and check that an unknown code fails loudly. Both pass:

shipping/shipfast/shipfast_test.go
func TestToTrackingUpdate(t *testing.T) {
	body := []byte(`{"trk_no":"SF9921","stat_cd":"DLV","evt_ts":"20261029T143000Z","ref_1":"shp-42","wt_g":1200}`)
	got, err := ToTrackingUpdate(body)
	if err != nil {
		t.Fatal(err)
	}
	want := shipping.TrackingUpdate{ShipmentID: "shp-42", Status: shipping.Delivered,
		At: time.Date(2026, 10, 29, 14, 30, 0, 0, time.UTC)}
	if got != want {
		t.Fatalf("got %+v", got)
	}
	_, err = ToTrackingUpdate([]byte(`{"trk_no":"SF9921","stat_cd":"XX9","evt_ts":"20261029T143000Z","ref_1":"shp-42"}`))
	if err == nil { // err: shipfast: unknown status code "XX9"
		t.Fatal("unknown code accepted")
	}
}

Design notes

  • Fail loudly on what you don't understand. If ShipFast invents a new code, mapping it to "in transit" by default would quietly hide parcels that are actually lost. An error lands the message in a dead-letter queue where a person sees it.
  • Translate both ways. When Shipping books a pickup, the ACL also turns your Shipment into ShipFast's request format. Your code calls something like shipfast.BookPickup(shipment) with a Shipping Shipment; only the ACL knows their endpoint and field names.
  • Keep the raw payload for debugging, not for logic. Logging or storing the original JSON is fine. Making decisions on it brings the corruption back.
  • One ACL per outside model. If you add a second carrier, it gets its own translator producing the same TrackingUpdate, and the rest of Shipping doesn't change at all.

ACLs and legacy systems

The most common place for an ACL isn't a partner API; it's your own old system. When you build a new context next to a legacy application, an ACL lets the new code use its own clean model while still reading from and writing to the old one. Over time you move features across one by one until the old system can be switched off. That's the strangler fig approach; Monolith vs microservices covers it as a migration strategy.

Practitioner§10

Strategic design: where to spend your effort

In one sentenceNot every part of the system deserves the same care. Put your best people and your richest model into the core, the part that makes the business different; keep the rest simple, and buy what every company needs.

Three kinds of subdomain

A subdomain is one area of the business problem: pricing, payments, shipping. DDD sorts them by how much they matter to this business:

KindWhat it isWhat to do
CoreWhy customers choose you over competitors.Build it in-house, with your best people, a rich model, tactical DDD, lots of conversation with experts.
SupportingSpecific to how you work, needed, but not what sets you apart.Build it, but simply. Plain CRUD is often fine.
GenericEvery company has this exact problem.Buy it, or use a well-known library or service.

Which subdomain is core depends entirely on the business. Payments is generic for a furniture shop and core for a payments company. Try sorting the capabilities of a shop that sells refurbished phones:

Where should the effort go?
Our shop sells refurbished phones. Customers pick it because of honest grading and fair prices. Sort each capability.
Grading each used phone (A/B/C) after inspection
Pricing that tracks the second-hand market daily
Card payments
Login, passwords, two-factor
Warehouse returns flow for your grading process
Sales tax / VAT calculation
Order checkout and the order life cycle

Why this matters for DDD

Everything in §05–§09 costs effort: modeling conversations, aggregates, events, translation layers. That effort pays off where the rules are complex and valuable. So:

  • Spend the tactical patterns on the core. That's where a model that matches the business, in the business's words, keeps paying back as the rules change every month.
  • Don't build generic things. A team that writes its own authentication or tax engine is spending its best effort where it can at most equal what's available off the shelf.
  • Don't over-model supporting parts. A returns form with three fields doesn't need aggregates and events.
  • Protect the core from generic tools' models. A bought service is often fine to conform to (§04), unless its model would leak into your core. That's exactly where an ACL (§09) is worth it.

Event storming: finding it all in a room

How do you discover the subdomains, the language and the context boundaries in the first place? A popular technique is event storming, a workshop invented by Alberto Brandolini. Developers and domain experts stand at a long wall with sticky notes, and:

  1. Everyone writes events, one per orange note, in the past tense: "Order placed", "Payment declined", "Parcel delivered". No discussion yet, just quantity.
  2. Put them in time order along the wall. Gaps and duplicates start arguments, which is the point: "wait, does placed happen before or after the stock check?"
  3. Add what causes each event: the command, meaning a request like "Place order" that the system may accept or refuse (blue) and the thing that decided whether it was allowed (yellow, usually a future aggregate). Add automatic rules, "whenever X, then Y" (lilac), and open questions (pink).
  4. Draw boundaries where the language shifts and where work hands off between groups of people.
A small event-storming wall
Time runs left to right (top to bottom on a phone). Orange = something that happened, blue = what someone asked for, yellow = the thing that decides, lilac = an automatic “whenever… then…” rule, pink = an open question.
Ordering (a boundary that emerged)
Place order
Order
Order placed
What if the price changed since the cart was filled?
Payments (a boundary that emerged)
Whenever an order is placed → charge the card
Charge card
Payment
Payment captured
Payment declined
Shipping (a boundary that emerged)
Whenever a payment is captured → book a pickup
Book pickup
Shipment
Parcel picked up
Parcel delivered
The boundaries were drawn after the events went up: where the vocabulary changes (an “order” becomes a “payment”, then a “shipment”) and where one group of people hands work to another, there's probably a bounded context edge.

Two hours with the right people often reveals more about the business than two weeks of reading requirement documents. The yellow notes become aggregate candidates, the orange ones become domain events (§08), the lilac ones become event handlers, and the boundaries become bounded contexts (§03).

Staff-level§11

Staff level: when DDD pays, and how it looks in Go and JS

In one sentenceDDD is a tool for complex, changing business rules. Its strategic half (language, contexts) is cheap and almost always worth it; its tactical half (aggregates, events, repositories) pays off only where there are rules to protect.

When DDD is overkill

Most of an average codebase is CRUD (create, read, update, delete): forms that save rows and pages that show them. Wrapping that in aggregates, repositories and domain events adds files and indirection without protecting anything. Some honest signals:

SignalTactical DDD probably paysProbably overkill
Rules"Can't ship twice", "limit across lines", rules that change monthly"Name is required"
ExpertsYou have to ask someone how it works, and the answers surprise youThe spec fits in a ticket
LifetimeYears, with the business changing under itA prototype or a one-off tool
ConcurrencySeveral users change the same thingsOne admin edits a list
Core?It's the core domain (§10)It's generic or a simple supporting part

Even in a CRUD app, the cheap parts still help: using the business's names, and not letting one table serve three departments. And a CRUD area can grow rules later; when you notice rules spreading across handlers, that's the moment to pull them into an aggregate, not before.

The anemic domain model debate

An anemic domain model is one where the domain types are just data (fields, getters, setters) and all the rules live in separate "service" functions. Martin Fowler named it as an anti-pattern: anyone who can set a field can skip the rules, as the back door in §06 showed.

The debate is worth being precise about, because Go and modern JS lean toward data plus functions rather than classes full of methods. The real question isn't "are the rules methods on the struct?" but "can the rules be bypassed?":

  • func Ship(o *Order) error in the same package as an Order with unexported fields is not anemic in any way that matters: outside code still can't change status without going through it.
  • An Order with exported fields and a ShippingService.Ship(o) that usually gets called is the problem, whatever the syntax.

For genuinely simple logic, a transaction script (one plain function per use case: load rows, check, update, commit, with no domain objects) is clearer than an aggregate. Fowler describes that one as a perfectly good pattern too; it just stops scaling when many scripts must enforce the same rules.

DDD in idiomatic Go

DDD was first described with Java examples, and copying Java's shapes into Go produces something nobody enjoys. What carries over well:

one module, one package per context
shop/
  internal/
    catalog/            # bounded context: its own types, its own tables
    ordering/           # Order aggregate, Money, events, Repository interface
      postgres/         # ordering's repository implementation
    payments/
    shipping/
      shipfast/         # the anti-corruption layer for the carrier
  cmd/shop/main.go      # wires everything together
  • Package = context boundary. Unexported fields and types (line, status) are the aggregate's privacy, enforced by the compiler. Placing contexts under an internal/ folder keeps other modules from importing them.
  • Constructors that validate: NewMoney checks the currency; NewOrder rejects an empty ID or customer and a credit limit that isn't positive, returning ErrInvalidOrder.
  • Make the zero value harmless, because Go can't forbid it. Outside code can always write var o ordering.Order and get an order with every field empty. It can't set those fields, though, so the job is to make sure an empty order can't do damage. Here that comes almost for free: a zero order's status is "", not "draft", so every method refuses:
ordering/order_test.go (excerpt, passes)
if _, err := NewOrder("", "c-7", eur(100_00)); !errors.Is(err, ErrInvalidOrder) {
	t.Fatalf("empty ID: %v", err)
}
var o Order // the zero value: Go can't forbid this…
if err := o.AddLine("mug", 1, eur(30_00)); !errors.Is(err, ErrNotDraft) {
	t.Fatalf("zero order AddLine: %v", err) // …but status is "", not "draft", so it refuses
}
if err := o.Ship(time.Now()); !errors.Is(err, ErrNotPlaced) {
	t.Fatalf("zero order Ship: %v", err)
}

Where a zero value could slip through silently, give the type an explicit check (for example, a repository's Save refusing an order whose ID is empty). The same goes for a zero Money: its currency is "", so adding it to real euros returns ErrCurrencyMismatch.

  • Value receivers for value objects, pointer receivers for aggregates. That one choice expresses "never changes" vs "changes over time".
  • Errors as values: ErrOverLimit and errors.Is instead of exception hierarchies.
  • Small interfaces, defined where they're used: Ordering's application code declares the two-method Repository it needs; it doesn't inherit from a generic Repository[T] base. Go idioms §03 covers why.
  • Skip what Go doesn't need: abstract base classes, an Entity super-type, factories for things a function can build, getters for every field.

In JavaScript, the same ideas map to modules that export only the root (export class Order, never the line type), #private fields, Object.freeze for value objects, and plain functions for domain services. TypeScript's branded types play the role of Go's named OrderID type.

Contexts, modules and services

A bounded context is a boundary of meaning; how you deploy it is a separate decision:

  1. Start with a modular monolith: one deployable program, one package per context, each context owning its own tables (or schema), talking through small APIs and in-process events. Moving a boundary is a refactor, not a migration.
  2. Split a context into its own service when it has a real reason: a different team that needs to deploy independently, very different scaling needs, or different reliability requirements. Monolith vs microservices covers that decision and its costs.
  3. Never split one context across services, and avoid one service per aggregate. Both put a network between objects that must change together, which is a distributed monolith with extra latency.

Inside each context, Clean Architecture describes how to keep the domain code free of HTTP, SQL and framework details, so that the Order aggregate from §06 can be tested in milliseconds without a database.

Practitioner§12

Pitfalls gallery

In one sentenceMost DDD failures come from copying the vocabulary (aggregate, repository, event) without the reason behind each one.
01

The god aggregate

"Customer" holds addresses, orders, payments, wish lists and reviews, because they all "belong to the customer". Every change loads and locks all of it, and two browser tabs conflict with each other. Fix: ask which rules must be true at every instant (§07). Usually Customer, Order and Review are separate aggregates that point at each other by ID.

02

Holding other aggregates by reference

order.Customer.CreditLimit -= total changes two aggregates in one call, and the repository has to decide which one to save (often: neither, correctly). Fix: hold CustomerID. If the customer must react, publish an event (§08).

03

Setters with business names

order.SetStatus("shipped") is a public field with extra typing: nothing checks "only placed orders ship" or "ship once". Fix: methods that are business actions (Ship()), which check the rules before changing anything (§06).

04

One shared database, many “contexts”

Four nicely separated packages that all JOIN each other's tables are one context in disguise: no team can rename a column without a meeting. Fix: each context owns its tables; others use its API or its events (§03). Read-only copies built from events are fine.

05

Publishing your internal events as the contract

Serializing the internal OrderPlaced struct straight onto the broker means every refactor of Ordering's code breaks consumers. Fix: a separate, versioned published shape (§04), translated from the internal event, and consumers that declare only the fields they read (§08).

06

Sending events before the save commits

Publishing OrderPlaced from inside Place(), or before COMMIT, announces orders that may then fail to save; publishing after COMMIT without an outbox loses events when the process crashes in between. Fix: record in the aggregate, store in the outbox in the same transaction, publish from the outbox (§08).

07

A generic repository for everything

Repository[T] with FindAll, FindBy(field, value) and Update(T) for every table brings back row-level access: any code can load half an aggregate or update a line on its own. Fix: one repository per aggregate, with only the methods the use cases need (§07).

08

Letting a partner's model in

Their status codes in your if statements, their field names in your tables. Every change on their side becomes a project on yours. Fix: an anti-corruption layer that translates at the edge, and fails loudly on unknown values (§09).

09

Full DDD on a CRUD screen

Aggregates, factories, repositories and three layers for an admin form that edits office opening hours. Every simple change touches six files. Fix: reserve tactical DDD for the core and for areas with real rules (§10, §11); keep the good names everywhere.

10

Developers inventing the language alone

A team picks "Engagement" for what the business calls a "booking", in a design meeting with no business people in it. Now every conversation needs translating again. Fix: discover the words with domain experts, write them down, and rename the code when the business's words change (§02).

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Two Money values

In Go, Money is a struct with two unexported fields, cents int64 and currency string. What does this print?

a, _ := NewMoney(1000, "EUR")
b, _ := NewMoney(1000, "EUR")
fmt.Println(a == b)
QUESTION 2 / 7
Where does the rule go?

Rule: “an order's total may not exceed the customer's credit limit”. Your team puts the check in the HTTP handler before calling repo.Save. What's the main risk?

QUESTION 3 / 7
The flash sale

You made Product Stock part of the Order aggregate so you can never oversell. During a flash sale, 2,000 people buy the same lamp in a minute. What happens?

QUESTION 4 / 7
Changing two aggregates

Placing an order must also reserve stock, and Order and Stock are separate aggregates. What's the standard DDD approach?

QUESTION 5 / 7
The carrier's codes

Your Shipping context integrates a carrier that reports statuses as "DLV", "RTS", "OFD". Where should those strings appear in your code?

QUESTION 6 / 7
Is DDD worth it here?

An internal tool lets staff edit a list of office locations: name, address, opening hours. No rules beyond “name is required”. What's the right amount of DDD?

QUESTION 7 / 7
Same word, two meanings

Sales says a customer is “active” if they bought something in the last 90 days. Billing says “active” means the account isn't suspended. Both teams want an isActive field on the shared Customer type. Best move?

Answered 0 / 7
Foundations§14

Cheat sheet

🗣️
Ubiquitous languageThe business's words in types, methods, events and errors. Rename the code when the words change.
🗺️
Bounded contextWhere a word means exactly one thing. Its own model, code and data. Others refer to it by ID.
🔗
Context mapName the deal between each pair: customer–supplier, shared kernel, conformist, ACL, open host + published language.
💶
Value objectEqual by value, never changes, validates itself. Most things are values: Money, Address, DateRange.
🪪
EntityEqual by ID; same thing over its whole life even when every field changes.
🏦
AggregateOne root, one door. Rules checked before anything changes. Loaded and saved whole, one transaction.
📏
Keep it smallOnly what must be consistent every instant. Bigger aggregates = more lock contention.
📣
Domain eventsPast-tense facts. Recorded in the aggregate, stored via the outbox, handled idempotently elsewhere.
🛡️
Anti-corruption layerTranslate outside models at the edge. Fail loudly on unknown values.
🎯
Core firstModel the core carefully, keep supporting parts simple, buy generic ones. CRUD stays CRUD.
I need to…Use
Stop three departments fighting over one tableSeparate bounded contexts, each with its own model, sharing an ID (§03)
Represent money, quantities, addressesValue objects: immutable, validated in the constructor (§05)
Guarantee "total ≤ credit limit" and "ship once"Rules inside the aggregate root's methods (§06)
Prevent two requests overwriting each otherA version column checked in the repository's UPDATE (§07)
Keep a rule that spans two aggregatesAsk "is a few seconds late OK?" If yes: events + a handler. If no: one (small) aggregate (§07)
Tell other parts something happenedA domain event + the outbox; idempotent consumers (§08)
Use a messy partner or legacy APIAn anti-corruption layer (§09)
Decide where to investCore / supporting / generic (§10)
Find boundaries with the businessEvent storming (§10)
Decide modules vs servicesModular monolith first; one context per service at most (§11)
// The shape of a use case, in one screen
func PlaceOrder(ctx context.Context, id ordering.OrderID) error {
	o, err := orders.Get(ctx, id) // load the whole aggregate
	if err != nil {
		return err
	}
	if err := o.Place(time.Now()); err != nil { // the root checks the rules
		return err
	}
	return orders.Save(ctx, o) // aggregate + outbox events, one transaction, version-checked
}
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Software ArchitectureCQRS & Event Sourcing
Read →