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.
The 30-second version
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:
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:
| Group | The question it answers | Main 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 learn | Section |
|---|---|
| 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 |
Ubiquitous language: code that uses the business's words
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:
if (rec.st >= 2) throw new Error("E41");if (rec.amt + d.p * d.q > rec.u.lim) rec.flg = true;if (rec.st === 3 && !rec.dup) svc.process(rec, 4);q.push({ t: 7, id: rec.id });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, notRecord,DataItem,Manager. - Method names are business verbs:
order.Place(),shipment.MarkDelivered(), notupdateRecord(rec, 2)orsetStatus(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").
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;
}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.
- 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.
- Write down a short glossary in the repository, next to the code: "Placed: the customer confirmed the order; it can no longer be changed."
- Rename the code when the language changes. If the business starts saying "reservation" instead of "hold", rename
HoldtoReservation. Modern editors make this a minute's work; the confusion of leaving it costs far more. - Use the words in tests. A test called
TestPlacedOrderCannotBeChangeddoubles 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.
Bounded contexts: one word, one meaning, per context
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:
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:
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 owns | Which means |
|---|---|
| Its language | Inside Shipping, "item" means a parcel item. Nobody has to ask which kind. |
| Its code | One team (or one clear owner) changes it. Other contexts can't reach inside. |
| Its data | Its own tables (or its own database). Other contexts don't query them directly. |
| Its interface | A 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:
- The language changes. Where "product" stops meaning "a page with photos" and starts meaning "a box with a weight", you've crossed a boundary.
- Different people care. Marketing edits the catalog; warehouse staff handle shipments. Different experts usually mean different contexts.
- Different rules and different rates of change. Pricing rules change weekly; the carrier integration changes when the carrier forces it.
- 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.
Context maps: how contexts relate
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 relationship patterns
| Pattern | The deal | Pick it when |
|---|---|---|
| Partnership | Two teams plan and release changes together. | Both succeed or fail together and can coordinate closely. |
| Shared kernel | A 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–supplier | Downstream can ask; upstream plans its needs in. | Upstream is another team in your company that is willing to listen. |
| Conformist | Downstream adopts upstream's model as-is. | You can't influence upstream (a big vendor), and its model is good enough. |
| Anti-corruption layer | Downstream 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 service | Upstream offers one documented API for all consumers. | Many consumers; building custom integrations for each doesn't scale. |
| Published language | A documented, versioned data format between contexts. | Usually paired with open host: the events or API payloads have a public schema. |
| Separate ways | No 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:
{
"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:
- 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).
- "Customer–supplier" on paper, "conformist" in practice. If the upstream team never actually takes your requests, admit it and protect yourself.
- Shared kernels that keep growing. A shared
Moneytype is fine. A sharedCustomertype is the 80-column table again.
Entities and value objects
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:
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
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:
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.
Aggregates: one door, rules checked inside
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:
- Outside code only talks to the root. It may call
order.AddLine(…), neverline.qty = 10. - The root checks every invariant before changing anything. If a rule would break, nothing changes and the caller gets an error.
- 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:
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).
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:
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, notSetStatusorUpdate. - 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 isShip(). - 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.
Aggregate boundaries and repositories
Three rules of thumb
- 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.
- 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. - 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:
- 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:
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
);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:
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 noThe 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.)
Domain events and eventual consistency
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
1func PlaceOrder(ctx context.Context, id ordering.OrderID) error {2 o, err := orders.Get(ctx, id)3 if err != nil {4 return err5 }6 if err := o.Place(time.Now()); err != nil {7 return err // a rule said no: nothing saved, nothing sent8 }9 return orders.Save(ctx, o) // order + outbox row, one transaction10}
Three things in that walkthrough are doing the real work:
- The aggregate records, it doesn't send.
o.PlaceappendsOrderPlacedto 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. - 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.)
- 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:
// 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:
// 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:
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:
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.
Anti-corruption layer: translating someone else's model
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:
{ "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:
s.Status = ev.StatCd // "DLV"if ev.StatCd == "DLV" { sendDelivered() }if ev.StatCd == "RTS" { startReturn() }t, _ := time.Parse("20060102T150405Z", ev.EvtTs)WHERE status_code IN ('PU','IT','OFD')status_code text -- holds 'DLV', 'RTS', …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: 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:
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
Shipmentinto ShipFast's request format. Your code calls something likeshipfast.BookPickup(shipment)with a ShippingShipment; 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.
Strategic design: where to spend your effort
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:
| Kind | What it is | What to do |
|---|---|---|
| Core | Why customers choose you over competitors. | Build it in-house, with your best people, a rich model, tactical DDD, lots of conversation with experts. |
| Supporting | Specific to how you work, needed, but not what sets you apart. | Build it, but simply. Plain CRUD is often fine. |
| Generic | Every 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:
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:
- Everyone writes events, one per orange note, in the past tense: "Order placed", "Payment declined", "Parcel delivered". No discussion yet, just quantity.
- 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?"
- 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).
- Draw boundaries where the language shifts and where work hands off between groups of people.
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: when DDD pays, and how it looks in Go and JS
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:
| Signal | Tactical DDD probably pays | Probably overkill |
|---|---|---|
| Rules | "Can't ship twice", "limit across lines", rules that change monthly | "Name is required" |
| Experts | You have to ask someone how it works, and the answers surprise you | The spec fits in a ticket |
| Lifetime | Years, with the business changing under it | A prototype or a one-off tool |
| Concurrency | Several users change the same things | One 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) errorin the same package as anOrderwith unexported fields is not anemic in any way that matters: outside code still can't changestatuswithout going through it.- An
Orderwith exported fields and aShippingService.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:
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 aninternal/folder keeps other modules from importing them. - Constructors that validate:
NewMoneychecks the currency;NewOrderrejects an empty ID or customer and a credit limit that isn't positive, returningErrInvalidOrder. - Make the zero value harmless, because Go can't forbid it. Outside code can always write
var o ordering.Orderand 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:
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:
ErrOverLimitanderrors.Isinstead of exception hierarchies. - Small interfaces, defined where they're used: Ordering's application code declares the two-method
Repositoryit needs; it doesn't inherit from a genericRepository[T]base. Go idioms §03 covers why. - Skip what Go doesn't need: abstract base classes, an
Entitysuper-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:
- 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.
- 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.
- 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.
Pitfalls gallery
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.
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).
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).
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.
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).
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).
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).
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).
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.
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).
Check yourself
Predict the answer before you click. Each explanation points back to the section that covers it.
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)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?
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?
Placing an order must also reserve stock, and Order and Stock are separate aggregates. What's the standard DDD approach?
Your Shipping context integrates a carrier that reports statuses as "DLV", "RTS", "OFD". Where should those strings appear in your code?
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?
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?
Cheat sheet
| I need to… | Use |
|---|---|
| Stop three departments fighting over one table | Separate bounded contexts, each with its own model, sharing an ID (§03) |
| Represent money, quantities, addresses | Value 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 other | A version column checked in the repository's UPDATE (§07) |
| Keep a rule that spans two aggregates | Ask "is a few seconds late OK?" If yes: events + a handler. If no: one (small) aggregate (§07) |
| Tell other parts something happened | A domain event + the outbox; idempotent consumers (§08) |
| Use a messy partner or legacy API | An anti-corruption layer (§09) |
| Decide where to invest | Core / supporting / generic (§10) |
| Find boundaries with the business | Event storming (§10) |
| Decide modules vs services | Modular 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
}