Step 17 of 24

Domain-driven design

Model complex business domains with a ubiquitous language, bounded contexts and context maps, then entities, value objects, aggregates, domain events and repositories.

20 min 10 sections

Most hard software problems aren't technical — they're about understanding a messy business well enough to encode it correctly. Domain-driven design (DDD), from Eric Evans's 2003 book, is a set of practices for exactly that: build a shared language with domain experts, split a large domain into models that are each consistent inside their own boundary, and express the rules in code that reads like the business. This lesson covers DDD's two halves — strategic design (language, subdomains, bounded contexts, context maps) and tactical design (entities, value objects, aggregates, domain events, repositories) — with Go examples from a ride-hailing and food-delivery domain.

Ubiquitous language

The first DDD practice is the simplest and the most often skipped: developers and domain experts agree on one vocabulary and use it everywhere — in conversations, tickets, documentation, and the code itself.

Without it, translation errors pile up. Operations says a driver is "on a trip"; the code calls it ActiveJob; the database has assignment_status = 3; the analytics team calls it a "booking". Every handoff between those words is a chance for a bug, and new engineers must learn four dictionaries.

With a ubiquitous language, if the business says "a driver accepts a booking, which becomes a trip when the passenger is picked up", the code says:

go
func (b *Booking) Accept(driverID DriverID, at time.Time) error
func (b *Booking) PickUp(at time.Time) (Trip, error)

Not UpdateStatus(2). When the language changes — the business starts saying "order" instead of "booking" — you rename the code too. The ubiquitous language note covers how to build one with domain experts, often in a workshop format called event storming: put every business event ("Booking requested", "Driver assigned", "Payment captured") on sticky notes on a timeline, and discuss until everyone agrees what each means.

Subdomains: where to spend your effort

A large business is many subdomains, and they don't deserve equal investment:

  • Core domain: what makes the business different and is worth building in-house with your best people — for a ride-hailing company, matching drivers to passengers and dynamic pricing.
  • Supporting subdomains: necessary and specific to you, but not a differentiator — driver onboarding, promotions administration.
  • Generic subdomains: solved problems every business has — authentication, email delivery, accounting ledgers. Buy or use open source.

Rich DDD modelling is expensive. Spend it on the core domain; build supporting subdomains simply; don't build generic ones at all if you can avoid it.

Bounded contexts

Here is DDD's most important idea. In a big business, the same word means different things to different departments, and trying to build one model that serves everyone produces a giant, contradictory Order type with 80 fields that nobody fully understands.

Consider "order" in a food-delivery company:

ContextWhat "order" meansWhat it cares about
OrderingWhat the customer choseitems, notes, restaurant, price shown
RestaurantA ticket to cookitems, prep time, ready status
DeliveryA parcel to movepickup and drop-off points, driver, ETA
BillingA financial transactionamounts, fees, tax, settlement to the restaurant

A bounded context is an explicit boundary — usually a module or a service, owned by one team — inside which a model and its language are consistent. Each context has its own Order (or better, its own words: Ticket, Delivery, Charge) containing only what that context needs. They share an identifier, not a model.

Bounded contexts are the natural lines along which to split a modular monolith into modules, or a system into services; most failed microservice migrations split by technical layer or by noun instead. See the bounded contexts note.

Context maps: how contexts relate

Contexts must still exchange information, and the relationship between their teams shapes the integration. A context map names those relationships:

  • Customer–supplier: an upstream context (Ordering) provides data a downstream one (Delivery) needs, and the downstream team's needs influence the upstream team's priorities.
  • Conformist: the downstream simply adopts the upstream model as-is, typically because it has no influence (a big third-party API).
  • Anti-corruption layer (ACL): the downstream translates the upstream model into its own language at the boundary, so a messy or foreign model can't leak in. Essential when integrating legacy systems or vendors.
  • Shared kernel: two contexts share a small piece of model (a Money type) and agree to change it together. Keep it tiny.
  • Published language / open host service: an upstream offers a well-documented, versioned API or event schema for everyone.

An anti-corruption layer in Go is just an adapter that translates:

go
// Delivery's ACL over the legacy dispatch system
func (a *LegacyDispatchACL) DeliveryFor(ctx context.Context, id OrderID) (Delivery, error) {
	raw, err := a.client.GetJob(ctx, string(id)) // legacy: {"jb_st": 4, "lat1": ..., "lng1": ...}
	if err != nil {
		return Delivery{}, err
	}
	return Delivery{
		OrderID: id,
		Pickup:  Location{Lat: raw.Lat1, Lng: raw.Lng1},
		Dropoff: Location{Lat: raw.Lat2, Lng: raw.Lng2},
		Status:  translateLegacyStatus(raw.JbSt),
	}, nil
}

Tactical design: entities and value objects

Inside a bounded context, DDD gives you building blocks for the model.

An entity has an identity that persists while its attributes change. A driver changes phone number, vehicle and rating, but is still the same driver; two drivers with the same name are different drivers. Entities are compared by ID.

A value object has no identity; it's defined entirely by its attributes, and is immutable. Two Money{Amount: 15000, Currency: "IDR"} values are interchangeable. Value objects are where much of a domain's logic belongs, and replacing primitives with them removes whole classes of bugs:

go
type Money struct {
	amount   int64  // minor units
	currency string // ISO 4217
}

var ErrCurrencyMismatch = errors.New("currency mismatch")

func NewMoney(amount int64, currency string) (Money, error) {
	if len(currency) != 3 {
		return Money{}, fmt.Errorf("invalid currency %q", currency)
	}
	return Money{amount: amount, currency: currency}, nil
}

func (m Money) Add(o Money) (Money, error) {
	if m.currency != o.currency {
		return Money{}, ErrCurrencyMismatch
	}
	return Money{amount: m.amount + o.amount, currency: m.currency}, nil
}

func (m Money) Amount() int64    { return m.amount }
func (m Money) Currency() string { return m.currency }

Unexported fields plus a constructor mean a Money can't exist in an invalid state, and adding rupiah to ringgit is impossible by construction. Other good value objects: Email, PhoneNumber, DateRange, Location, Percentage. The value objects note covers equality and immutability in TypeScript too.

Aggregates: consistency boundaries

Business rules — invariants — often span several objects: "an order's total must equal the sum of its lines", "a paid order can't be modified", "an order has at most 50 items". An aggregate is a cluster of entities and value objects treated as one unit for changes, with one entity as its root. The rules:

  1. Outside code may only hold references to, and call methods on, the root. It never modifies an inner object directly.
  2. The root enforces all invariants of the aggregate in its methods.
  3. One transaction changes one aggregate. Consistency inside an aggregate is immediate; between aggregates it's eventual.
  4. Aggregates reference each other by ID, not by object pointer.
go
var (
	ErrOrderNotEditable = errors.New("order can no longer be changed")
	ErrTooManyItems     = errors.New("order has too many items")
)

const maxLines = 50

type Order struct { // aggregate root
	id         OrderID
	customerID CustomerID // reference by ID, not *Customer
	lines      []OrderLine
	status     OrderStatus
	events     []DomainEvent
}

func (o *Order) AddItem(menuItemID MenuItemID, price Money, qty int) error {
	if o.status != StatusDraft {
		return ErrOrderNotEditable
	}
	if len(o.lines) >= maxLines {
		return ErrTooManyItems
	}
	o.lines = append(o.lines, OrderLine{menuItemID: menuItemID, price: price, qty: qty})
	return nil
}

func (o *Order) Place(at time.Time) error {
	if o.status != StatusDraft || len(o.lines) == 0 {
		return ErrOrderNotEditable
	}
	o.status = StatusPlaced
	o.events = append(o.events, OrderPlaced{OrderID: o.id, At: at})
	return nil
}

Because lines is unexported and only AddItem changes it, no code anywhere can add an item to a placed order. Contrast this with an anemic domain model — structs with only public fields and all rules in "service" functions — where any code path can set order.Status = "PLACED" and skip the checks.

Keep aggregates small. A huge aggregate (a Restaurant containing every order ever placed) causes lock contention and loads far too much data. Design them around the invariants that truly must be consistent in one transaction. The aggregates note has more on sizing.

Domain events, repositories and services

Domain events record that something meaningful happened, named in the past tense with the ubiquitous language: OrderPlaced, DriverAssigned, PaymentCaptured. The aggregate records them; after the transaction commits, the application publishes them so other parts of the context — and other contexts — can react. Publishing reliably alongside the database write is what the transactional outbox is for.

A repository gives the illusion of an in-memory collection of aggregates — Get(ctx, id), Save(ctx, order) — and hides the persistence. There's one repository per aggregate root, not per table: you never load or save an OrderLine on its own. The interface lives in the domain or application layer, and the SQL implementation is an adapter, exactly as in clean architecture.

A domain service holds domain logic that doesn't naturally belong to one entity — calculating a delivery fee from distance, weather and demand, for example. It's stateless and named in the domain language (FeeCalculator), not an OrderService that swallows every rule.

When DDD is worth it

DDD's strategic tools — ubiquitous language and bounded contexts — help almost any non-trivial system, cost little, and prevent the expensive mistake of a single shared model. The tactical patterns pay off in a complex core domain with many interacting rules: pricing, matching, payments, logistics, insurance. For simple CRUD, a rich aggregate model is ceremony, and a direct mapping of tables to structs is fine.

Practice

You're modelling the core of a food-delivery app's ordering context. Identify: two value objects, the aggregate root and its invariants, two domain events, and one other bounded context that ordering integrates with, including what that context calls an "order" and which context-map relationship fits.

Show solution

A good answer (others are possible):

  • Value objects: Money (amount in minor units plus currency); DeliveryAddress (street, coordinates, notes); also Quantity (positive integer with a maximum).
  • Aggregate root: Order, containing OrderLine entities (menu item ID, price at time of ordering, quantity, notes). Invariants enforced by Order's methods:
    • Items can only be added or removed while the order is a draft.
    • All lines come from the same restaurant.
    • Total equals the sum of lines plus fees, in one currency.
    • At most 50 lines; quantity per line between 1 and 99.
    • It can only be placed if the restaurant was open at the time (checked via a port, and the result passed in).
  • Domain events: OrderPlaced, OrderCancelled (with reason), OrderPaid.
  • Integration: the Delivery context, where the same thing is a Delivery: pickup location, drop-off location, assigned driver, ETA — no menu items or prices. Ordering is upstream, and Delivery reacts to OrderPlaced events in a customer–supplier relationship (Delivery's needs shape what the event contains). If Delivery integrated with an old third-party dispatch system, it would wrap that in an anti-corruption layer.

The aggregate references the customer and restaurant by ID only; loading the full restaurant inside every order would make the aggregate huge and tie two contexts' models together.

Key takeaways

  • Build a ubiquitous language with domain experts and use it in code; rename code when the language changes.
  • Invest DDD effort in the core domain; keep supporting subdomains simple and buy generic ones.
  • A bounded context is a boundary inside which one model is consistent; the same word may mean different things in different contexts.
  • Context maps name how contexts integrate: customer–supplier, conformist, anti-corruption layer, shared kernel, published language.
  • Entities have identity; value objects are immutable and compared by value — use them to replace primitives.
  • Aggregates are small consistency boundaries: change them only through the root, one per transaction, referencing others by ID; repositories load and save whole aggregates.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Ordering, Delivery and Billing all talk about an 'order', but each needs different data and rules. What does DDD recommend?

  2. 2.Which is best modelled as a value object?

  3. 3.Which rule about aggregates is correct?

  4. 4.Your Delivery context must read jobs from a legacy dispatch system whose data uses cryptic field names and numeric status codes. Which context-map pattern protects your model?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.