An independent reading companion to Eric Evans’s Domain-Driven Design: Tackling Complexity in the Heart of Software. These notes draw on the supplied book text and chapter files, with emphasis on patterns you can apply to existing code. Each note points back to its chapter and section.
Explanations, diagrams, and TypeScript examples are original adaptations. Code blocks illustrate a design decision and may omit surrounding types, error handling, or persistence details. The three marked interactive examples run locally in your browser. No book text is hosted here.
This is a selected field guide, not a chapter-by-chapter summary. The chapter references are more useful than page numbers because the supplied text’s page markers do not reliably match print editions. For a first application, choose one troublesome rule or boundary and test the model against real examples.
01 / Specification CH. 09 · 3 min
One rule. Three jobs.
Validate a result, find matches, or build something that fits.
REACH FOR THIS WHEN
The form validator, search filter, and generator each have their own version of the same business rule.
Give the requirement its own value object. It describes what an acceptable result looks like. Validation asks whether one result fits; selection finds existing results that fit; building to order asks a generator to produce a result that fits.
Try the same rule in three placesLive example
01 / VALIDATE
Does this fit?
Existing report 450 rows · PDF
Yes, it satisfies the spec
02 / SELECT
Which ones fit?
03 / BUILD TO ORDER
Make one that fits.
Source has 780 rows. Generator splits it into files.
Here the rule applies to each output file. The generator preserves all 780 rows by splitting them, then validates every file. It does not discard extra rows.
The generator owns the method. The specification owns the acceptance criteria.
TYPESCRIPT · ILLUSTRATIVE
type Report = { rows: number; format: string };
class ReportSpec {
constructor(
readonly maxRows: number,
readonly format: string
) {}
isSatisfiedBy(r: Report) {
return r.rows <= this.maxRows && r.format === this.format;
}
}
const spec = new ReportSpec(500, "PDF");
spec.isSatisfiedBy(report); // validate
reports.filter(r => spec.isSatisfiedBy(r)); // select
const built = generator.build(spec); // build to order
if (!spec.isSatisfiedBy(built)) throw Error("Invalid output");
Watch the edge
A predicate does not magically become a SQL query or a generator. Those need implementations that preserve the same meaning. Keep table details in persistence code, and test that query results and in-memory checks agree. Pass time explicitly when a rule depends on today.
Try it in your code
Pick a rule duplicated in a validator and a query. Name it with a domain expert. Write one passing case, one failing case, and the exact boundary case. Give both callers that same definition.
AND, OR, and NOT produce another specification you can use anywhere.
REACH FOR THIS WHEN
Every new eligibility exception adds another nested conditional, and nobody can explain the whole expression.
Name the small predicates first. Compose them into a bigger specification. The result has the same interface as its parts, so callers do not need a second way to evaluate a compound rule. This is also an example of closure of operations.
Change the facts
AND
Eligible
Both named predicates must pass. Uncheck either condition to see the composed rule change.
A combined rule is still a rule. That small property makes composition useful.
Start with only the operators you need. Evans explicitly allows implementing just AND. A general rule language adds parsing, debugging, query translation, and explanation work. Name the concepts before building that machinery.
Try it in your code
Take one long boolean expression. Label its domain conditions, then write a tiny truth table before extracting them. If users need rejection reasons, add an explanation result alongside the boolean contract.
Requirements decide what qualifies. A strategy decides how to choose.
REACH FOR THIS WHEN
A routing or scheduling function mixes hard limits with preferences like cheapest, fastest, or fewest changes.
Keep hard requirements in a specification. Give the variable choice policy its own strategy. A route can satisfy the delivery deadline and still be a poor choice under the cheapest-route policy. Changing the preference should not quietly change what counts as valid.
Deadline: arrival within 3 days
Economy5 days · $90Fails the specification
Standard3 days · $140Valid · cheapest choice
Express1 day · $260Valid · fastest choice
Change the strategy and the preferred route changes. The deadline requirement stays the same.
Validity is a constraint. Preference is a choice among valid options.
Filtering all possibilities is a teaching example, not a scalable search algorithm. A real generator may apply constraints and strategy during search. Report an explicit no-solution result when nothing qualifies.
Try it in your code
In one optimizer or recommender, mark each condition as “must hold” or “prefer.” Pull the most changeable preference behind a named function or object.
Make previews safe and commands explicit about what they guarantee.
REACH FOR THIS WHEN
A method that sounds like a calculation also mutates an order. Calling it for a preview changes the next result.
Move the calculation into a side-effect-free function, often on a value object. Keep the command that applies the result small. Describe its preconditions, postconditions, and invariants so a caller can understand the outcome without following every internal call.
Order snapshotunchanged input
→
quote(snapshot, policy)calculation, no mutation
→
Quotenew value
A separate command applies it: order.acceptQuote(quote)
A preview should be repeatable. A command should make its changes obvious.
TYPESCRIPT · ILLUSTRATIVE
const before = order.snapshot();
const quote = pricing.quote(before, discountPolicy);
// quote() does not change the order.
assert.deepEqual(order.snapshot(), before);
order.acceptQuote(quote);
// Contract: accepted total equals this quote's total.
assert.equal(order.totalCents, quote.totalCents);
Watch the edge
Pure calculations do not remove the need for concurrency control. A quote may become stale before it is applied. Check its relevant inputs or version when committing the command. Time and randomness must be explicit inputs for repeatable results.
Try it in your code
Find a method used by both preview and submit. Separate the calculation from the mutation. Write an assertion about what must remain unchanged and one about the committed result.
Replace scattered primitives with a small immutable domain concept.
REACH FOR THIS WHEN
A start date and end date travel together everywhere, but each caller invents its own overlap and validity checks.
A value object is defined by its attributes, without an identity you need to track through change. Give a complete concept like DateRange its own behavior. Return a new value for a new result. Use an entity when continuity matters even as attributes change.
startendRepeated checks in every caller
→
DateRange[ start, end )Valid at creation · overlaps(other)
A useful value object owns both the data and the rules that make those data meaningful.
TYPESCRIPT · ILLUSTRATIVE
class DateRange {
// Integer day indexes; half-open interval [start, end).
constructor(readonly start: number, readonly end: number) {
if (!Number.isInteger(start) || !Number.isInteger(end)
|| end <= start) throw Error("Invalid date range");
Object.freeze(this);
}
overlaps(other: DateRange) {
return this.start < other.end && other.start < this.end;
}
}
Watch the edge
The domain decides identity. An address may be a replaceable delivery value in one model and a tracked location in another. TypeScript readonly is compile-time protection; nested mutable data needs its own protection. Define time zones when converting real dates.
Try it in your code
Look for two or three parameters that always travel together. Name the concept, define equality and boundary behavior, and move one repeated operation onto it.
Group the state that must remain consistent when a change commits.
REACH FOR THIS WHEN
Two updates each pass their own checks, but together they violate a business limit.
Start with an invariant: allocated seats must never exceed capacity. Put the state needed to enforce it inside one aggregate and route changes through its root. The boundary says which facts must agree when the transaction completes. It is more than a convenient object graph.
One boundary, one capacity rule10 seats total
EVENT AGGREGATE
7 / 10 seats reserved
All changes go through Event.reserveSeats().
This browser demo is sequential. In a database, two requests must not both commit against the same stale count. The aggregate boundary guides that transaction policy.
Choose the consistency rule before choosing the aggregate boundary.
TYPESCRIPT · ILLUSTRATIVE
// Illustrative application + persistence boundary.
await transaction(async tx => {
const event = await events.loadForUpdate(eventId, tx);
event.reserveSeats(requestedSeats); // checks the invariant
await events.save(event, tx);
});
// The root owns capacity and reservations.
// Locking, or version checks with retries, must prevent
// concurrent writes from committing incompatible states.
Watch the edge
A root method alone cannot stop concurrent database writes. The transaction and locking/version policy must enforce the promise. Huge aggregates cause contention. If a rule crosses boundaries, decide whether delayed agreement is acceptable; do not quietly assume it is.
Try it in your code
Write “After every committed change, ___ must be true.” List the smallest set of state needed to check that claim. Identify every path that can modify it, including imports and background jobs.
07 / Make implicit concepts explicit CH. 09 · 2 min
The missing class may be a missing idea.
Treat repeated conditionals and awkward conversations as modeling clues.
REACH FOR THIS WHEN
A calculator keeps growing special cases, while experts insist these are ordinary business situations.
Ask what distinction the model is failing to express. In the book, separating interest accrual from payment removes a false coupling: income can be earned before cash arrives. Extracting another helper would not reveal that distinction. A named concept gives the team something precise to discuss.
Interest calculatorearned + paid + exceptions
→
AccrualWhat was earned, and when?
PaymentWhat was received, and when?
Sometimes the best refactor changes what the code means, not just where it lives.
TYPESCRIPT · ILLUSTRATIVE
// Two different facts, with independent timing.
type Accrual = {
earnedOn: string; amountCents: number; ledger: string
};
type Payment = {
receivedOn: string; amountCents: number
};
// Accrual calculation no longer depends on when cash arrived.
const accruals = asset.accrueThrough(asOfDate);
Watch the edge
A noun is not proof that you need a class. Try the concept against real scenarios and awkward counterexamples. Keep it only if it explains the business and improves the implementation. Use domain literature as another source of candidate ideas.
Try it in your code
Bring one awkward case to an expert. Ask what they call the intermediate result, which facts vary independently, and whether your “special case” is actually normal. Try one small model change.
Use a real scenario to test whether the model can carry the conversation.
REACH FOR THIS WHEN
The expert talks about eligibility, but developers explain flags, rows, and endpoints. Everyone nods without agreeing.
Tell the same scenario using the objects and operations in the model. When the sentence becomes vague or needs a long technical aside, look for a missing concept or a misleading name. Update conversation, tests, and code together. The language is a working model, not just a glossary.
BEFORE
“When these fields change, delete the rows and repopulate the table.”
AFTER
“Keep the itinerary if it still meets the new route requirements.”
The second sentence gives an expert a behavior they can challenge. These are original paraphrases of the chapter's example.
A clearer sentence can reveal a better behavior: keep an itinerary that still satisfies the changed requirements.
TYPESCRIPT · ILLUSTRATIVE
// Mechanism hides the business decision.
if (changedFields.length) rebuildRows(recordId);
// An illustrative domain conversation in code.
if (!newRouteSpec.isSatisfiedBy(currentItinerary)) {
cargo.assignItinerary(router.find(newRouteSpec));
}
Watch the edge
Do not force the same vocabulary across contexts with genuinely different meanings. Domain experts must be able to challenge the language. Renaming without changing a mistaken model only makes the mistake sound better.
Try it in your code
Take a test title to a domain expert. Walk through its example together using the same names as the code. Rename or split the first term you both use differently.
Keep each model coherent within an explicit boundary.
REACH FOR THIS WHEN
A shared Customer type accumulates unrelated fields because sales, billing, and support all need different things.
Define where each model and language apply. A prospect in sales and an invoice recipient in billing may refer to the same person without being the same concept. Draw the existing contexts, their owners, and what crosses each boundary. Make translation explicit.
SALES CONTEXT
Customer
Prospect Pipeline stage Next conversation
explicit identity mapping↔
BILLING CONTEXT
Customer
Account holder Invoice address Payment terms
The same real-world person can participate in both models. Neither model needs all the other's fields.
Consistency inside a context lets different models coexist across contexts.
TYPESCRIPT · ILLUSTRATIVE
// Sales context
type SalesCustomer = { prospectId: string; stage: SalesStage };
// Billing context
type BillingCustomer = {
accountId: string; invoiceAddress: Address
};
// Link identities deliberately. Do not silently treat
// prospectId and accountId as interchangeable.
Watch the edge
A bounded context does not require a microservice. It can have explicit code and team boundaries in one application. A context map describes today's dependencies before it becomes a proposal for tomorrow's architecture.
Try it in your code
Choose one overloaded noun. Write its meaning, lifecycle, and owner in each part of the business. Draw two boxes if the definitions conflict, then label the data and identity mapping between them.
Keep a vendor or legacy model from dictating your own.
REACH FOR THIS WHEN
Provider status codes and oddly named legacy fields appear in business logic throughout the application.
Put a translation layer between models. Its public interface speaks your domain language; its implementation understands the other system. Translate meaning, units, identities, and failure cases. A transport wrapper alone does not do this job.
Providercode: "1"
→
Translatormeaning + unknown cases
→
Your modelavailability: "available"
The boundary earns its keep when a foreign concept changes and the change stops there.
TYPESCRIPT · ILLUSTRATIVE
type Availability = "available" | "unavailable" | "unknown";
function translateAvailability(code: string): Availability {
// Fictional provider contract: 1 = ready, 0 = not ready.
switch (code) {
case "1": return "available";
case "0": return "unavailable";
default: return "unknown";
}
}
// Provider codes stay inside this adapter.
Watch the edge
Translation has a maintenance cost. If the upstream model fits your needs, consciously adopting it may be simpler. Preserve important distinctions and unknown states instead of guessing a convenient boolean.
Try it in your code
Find one vendor field referenced in several modules. Define what your application actually needs to know. Move the mapping and examples of uncertain input into one adapter.
Hide assembly and retrieval behind interfaces that express the model.
REACH FOR THIS WHEN
Every caller assembles a half-valid object, runs several queries, and remembers which children to attach.
A factory creates a valid new object or aggregate and hides complex assembly. A repository provides access to existing aggregates through a collection-like interface. Returning a usable domain object keeps persistence details out of the business conversation.
Factoryassemble + check invariants
→
New bookingnew identity
Repositoryretrieve + reconstitute
→
Existing bookingpreserved identity
Creation establishes a valid beginning. Retrieval preserves an existing identity.
TYPESCRIPT · ILLUSTRATIVE
const booking = bookingFactory.create(request); // new identity
await bookings.add(booking);
const existing = await bookings.byId(booking.id); // same identity
existing.changeItinerary(replacement);
await bookings.save(existing);
// Transaction ownership stays explicit at the application edge.
Watch the edge
A simple constructor may be enough. Do not create a repository for every table or internal child. Reconstitution must preserve stored identity and state; it must not accidentally run every side effect of new-object creation.
Try it in your code
Find repeated creation or loading choreography. Put one complex creation behind a factory, or one root lookup behind a repository. Name the operation after what the caller needs.
Group concepts that change together for a business reason.
REACH FOR THIS WHEN
One feature change touches folders named entities, services, factories, and repositories. The tree says little about the business.
Within the domain layer, organize modules around cohesive concepts. A module name should help someone predict where a business change belongs. Observe which responsibilities repeatedly change together, and refine the boundaries as your understanding improves.
TECHNICAL CATEGORIES
entities/ services/ factories/ repositories/
→
DOMAIN MODULES
booking/ routing/ billing/ customer-agreements/
A module name is part of the model. Use it to explain a piece of the business.
Moving files alone does not remove coupling. Keep genuinely shared concepts deliberate, and keep infrastructure concerns separate. Module boundaries organize one model; bounded contexts distinguish models that may disagree.
Try it in your code
Look at the last three feature changes. Which files moved together, and why? Name one domain concept that describes that cluster. Check its dependencies before moving it.
Express the problem in domain terms, then delegate the computation.
REACH FOR THIS WHEN
Graph traversal, scoring, and search internals swamp the code that explains a business decision.
Extract a coherent computational mechanism behind an intention-revealing interface. The domain still owns the question and its rules. The mechanism carries out the calculation. A narrow graph solver or constraint solver is a better boundary than a general-purpose Utils or Calculator class.
Routing modeldeadline, policy, constraints
→
Search mechanismgraph, traversal, scoring
→
Itinerarycheck against requirements
The domain formulates the problem. The mechanism solves the computation.
TYPESCRIPT · ILLUSTRATIVE
const problem = routingModel.toSearchProblem(request);
const solution = solver.findPath(problem);
if (!solution) return { kind: "no-route" };
const itinerary = routingModel.toItinerary(solution);
if (!request.spec.isSatisfiedBy(itinerary)) {
throw Error("Solver returned an invalid itinerary");
}
return { kind: "found", itinerary };
Watch the edge
First ask whether a better domain model can simplify the algorithm. Extraction is useful when the mechanism has its own coherent concepts; moving tangled code into a service just relocates the tangle.
Try it in your code
Mark the business statements and algorithm steps in one large method. Extract a computation only if you can describe its inputs and result without burying business policy inside it.
Protect the part of the model that makes the product valuable.
REACH FOR THIS WHEN
The team perfects generic infrastructure while the distinctive business behavior remains difficult to change.
Identify the core domain: the specialized capability that justifies this system. Separate supporting and generic concerns so the core is visible. Invest modeling effort and strong developers there. A small core makes the project's priorities easier to defend.
CORE DOMAINScarce-skill allocationThe reason to build this product
IdentityCalendaringEmailImports
An illustrative scheduling product. Different products have different cores.
The most technically interesting component is not automatically the core domain.
DECISION SKETCH
// Example for a specialized scheduling product.
Core: matching scarce skills to urgent work
Generic: identity, email delivery, basic calendaring
Supporting: operational imports and local admin workflows
Refactor first: the allocation model blocking new policies
Keep adequate: the already-working email template engine
Watch the edge
These categories depend on your product. Identity is generic for many products and central to an identity platform. A generic subdomain need not become a reusable framework. Supporting code still needs enough quality to be dependable.
Try it in your code
Write a short sentence explaining what this product does unusually well. Name the model concepts responsible. Use that sentence to choose between two competing refactors.
15 / Model-driven working prototypes CH. 09 · 2 min
Unblock the system with a modest implementation.
Keep the contract real while the clever algorithm is still being built.
REACH FOR THIS WHEN
The interface team cannot test a real workflow until the optimization team finishes a difficult algorithm.
Build a simple implementation behind the intended domain interface. It should respect the agreed constraints even if it finds worse answers or misses feasible solutions. The rest of the application can integrate, users can try the workflow, and discoveries can improve the contract early.
Application
STABLE AGREEMENTPlanner + acceptance criteria
Simple plannerworks today · limited search
→
Improved plannersame contract · better search
A small working implementation can test an interface more honestly than a canned response.
TYPESCRIPT · ILLUSTRATIVE
const result = simplePlanner.tryBuild(spec);
if (result.kind === "found") {
assert(spec.isSatisfiedBy(result.plan));
return result;
}
return { kind: "no-answer-found" };
// A heuristic finding no answer does not prove impossibility.
// Build a candidate separately; commit only an accepted plan.
Watch the edge
State the prototype's limits. The book's greedy packer can fail to find an answer and can leave partial mutations after failure. In your implementation, define failure semantics and choose rollback or staged results deliberately.
Try it in your code
For one blocked dependency, agree on the input, successful output, invariant, and failure result. Build the least sophisticated implementation that honors that contract, then exercise one end-to-end scenario.
Separate configurable definitions from the everyday objects they govern.
REACH FOR THIS WHEN
Every new customer workflow or role relationship requires another almost-identical subclass and a deployment.
If configuration is itself meaningful in the domain, give it a model. A workflow definition describes allowed steps; a workflow run records one execution under that definition. Keep the definition model focused on the variations users actually need.
KNOWLEDGE LEVELWorkflow definition v2Allowed steps and transitions
↓ governs
Run A · v2Run B · v2Run C · v1
When v3 appears, decide whether existing runs stay pinned or migrate. A definition change does not make that decision for you.
The definition is domain data too. Give it a lifecycle and clear limits.
TYPESCRIPT · ILLUSTRATIVE
type WorkflowDefinition = {
id: string; version: number;
allowedSteps: readonly StepDefinition[];
};
type WorkflowRun = {
id: string; definitionId: string;
definitionVersion: number; currentStep: string;
};
// A definition change needs an explicit rule for existing runs.
Watch the edge
This is an advanced option. Indirection makes behavior harder to trace, and configuration authors can end up programming. Start with a fixed model unless real variability demands this. Version binding here is a practical example; the book requires thinking through changes to existing instances.
Try it in your code
List three real variations users need. If they change the same structural rules, sketch a tiny definition model and one runtime instance. Decide what happens to that instance when its definition changes.
Validate a result, find matches, or build something that fits.
REACH FOR THIS WHEN
The form validator, search filter, and generator each have their own version of the same business rule.
Give the requirement its own value object. It describes what an acceptable result looks like. Validation asks whether one result fits; selection finds existing results that fit; building to order asks a generator to produce a result that fits.
Try the same rule in three placesLive example
01 / VALIDATE
Does this fit?
Existing report 450 rows · PDF
Yes, it satisfies the spec
02 / SELECT
Which ones fit?
03 / BUILD TO ORDER
Make one that fits.
Source has 780 rows. Generator splits it into files.
Here the rule applies to each output file. The generator preserves all 780 rows by splitting them, then validates every file. It does not discard extra rows.
The generator owns the method. The specification owns the acceptance criteria.
TYPESCRIPT · ILLUSTRATIVE
type Report = { rows: number; format: string };
class ReportSpec {
constructor(
readonly maxRows: number,
readonly format: string
) {}
isSatisfiedBy(r: Report) {
return r.rows <= this.maxRows && r.format === this.format;
}
}
const spec = new ReportSpec(500, "PDF");
spec.isSatisfiedBy(report); // validate
reports.filter(r => spec.isSatisfiedBy(r)); // select
const built = generator.build(spec); // build to order
if (!spec.isSatisfiedBy(built)) throw Error("Invalid output");
Watch the edge
A predicate does not magically become a SQL query or a generator. Those need implementations that preserve the same meaning. Keep table details in persistence code, and test that query results and in-memory checks agree. Pass time explicitly when a rule depends on today.
Try it in your code
Pick a rule duplicated in a validator and a query. Name it with a domain expert. Write one passing case, one failing case, and the exact boundary case. Give both callers that same definition.
02 / Composite specifications CH. 10 · 2 min
Build rules out of smaller rules.
AND, OR, and NOT produce another specification you can use anywhere.
REACH FOR THIS WHEN
Every new eligibility exception adds another nested conditional, and nobody can explain the whole expression.
Name the small predicates first. Compose them into a bigger specification. The result has the same interface as its parts, so callers do not need a second way to evaluate a compound rule. This is also an example of closure of operations.
Change the facts
AND
Eligible
Both named predicates must pass. Uncheck either condition to see the composed rule change.
A combined rule is still a rule. That small property makes composition useful.
Start with only the operators you need. Evans explicitly allows implementing just AND. A general rule language adds parsing, debugging, query translation, and explanation work. Name the concepts before building that machinery.
Try it in your code
Take one long boolean expression. Label its domain conditions, then write a tiny truth table before extracting them. If users need rejection reasons, add an explanation result alongside the boolean contract.
03 / Strategy + Specification CH. 12 · 2 min
Separate “acceptable” from “preferred.”
Requirements decide what qualifies. A strategy decides how to choose.
REACH FOR THIS WHEN
A routing or scheduling function mixes hard limits with preferences like cheapest, fastest, or fewest changes.
Keep hard requirements in a specification. Give the variable choice policy its own strategy. A route can satisfy the delivery deadline and still be a poor choice under the cheapest-route policy. Changing the preference should not quietly change what counts as valid.
Deadline: arrival within 3 days
Economy5 days · $90Fails the specification
Standard3 days · $140Valid · cheapest choice
Express1 day · $260Valid · fastest choice
Change the strategy and the preferred route changes. The deadline requirement stays the same.
Validity is a constraint. Preference is a choice among valid options.
Filtering all possibilities is a teaching example, not a scalable search algorithm. A real generator may apply constraints and strategy during search. Report an explicit no-solution result when nothing qualifies.
Try it in your code
In one optimizer or recommender, mark each condition as “must hold” or “prefer.” Pull the most changeable preference behind a named function or object.
04 / Functions + assertions CH. 10 · 2 min
Calculate first. Change state second.
Make previews safe and commands explicit about what they guarantee.
REACH FOR THIS WHEN
A method that sounds like a calculation also mutates an order. Calling it for a preview changes the next result.
Move the calculation into a side-effect-free function, often on a value object. Keep the command that applies the result small. Describe its preconditions, postconditions, and invariants so a caller can understand the outcome without following every internal call.
Order snapshotunchanged input
→
quote(snapshot, policy)calculation, no mutation
→
Quotenew value
A separate command applies it: order.acceptQuote(quote)
A preview should be repeatable. A command should make its changes obvious.
TYPESCRIPT · ILLUSTRATIVE
const before = order.snapshot();
const quote = pricing.quote(before, discountPolicy);
// quote() does not change the order.
assert.deepEqual(order.snapshot(), before);
order.acceptQuote(quote);
// Contract: accepted total equals this quote's total.
assert.equal(order.totalCents, quote.totalCents);
Watch the edge
Pure calculations do not remove the need for concurrency control. A quote may become stale before it is applied. Check its relevant inputs or version when committing the command. Time and randomness must be explicit inputs for repeatable results.
Try it in your code
Find a method used by both preview and submit. Separate the calculation from the mutation. Write an assertion about what must remain unchanged and one about the committed result.
05 / Value objects CH. 05 · 2 min
Keep a whole idea together.
Replace scattered primitives with a small immutable domain concept.
REACH FOR THIS WHEN
A start date and end date travel together everywhere, but each caller invents its own overlap and validity checks.
A value object is defined by its attributes, without an identity you need to track through change. Give a complete concept like DateRange its own behavior. Return a new value for a new result. Use an entity when continuity matters even as attributes change.
startendRepeated checks in every caller
→
DateRange[ start, end )Valid at creation · overlaps(other)
A useful value object owns both the data and the rules that make those data meaningful.
TYPESCRIPT · ILLUSTRATIVE
class DateRange {
// Integer day indexes; half-open interval [start, end).
constructor(readonly start: number, readonly end: number) {
if (!Number.isInteger(start) || !Number.isInteger(end)
|| end <= start) throw Error("Invalid date range");
Object.freeze(this);
}
overlaps(other: DateRange) {
return this.start < other.end && other.start < this.end;
}
}
Watch the edge
The domain decides identity. An address may be a replaceable delivery value in one model and a tracked location in another. TypeScript readonly is compile-time protection; nested mutable data needs its own protection. Define time zones when converting real dates.
Try it in your code
Look for two or three parameters that always travel together. Name the concept, define equality and boundary behavior, and move one repeated operation onto it.
06 / Aggregates CH. 06 · 3 min
Draw the boundary around the promise.
Group the state that must remain consistent when a change commits.
REACH FOR THIS WHEN
Two updates each pass their own checks, but together they violate a business limit.
Start with an invariant: allocated seats must never exceed capacity. Put the state needed to enforce it inside one aggregate and route changes through its root. The boundary says which facts must agree when the transaction completes. It is more than a convenient object graph.
One boundary, one capacity rule10 seats total
EVENT AGGREGATE
7 / 10 seats reserved
All changes go through Event.reserveSeats().
This browser demo is sequential. In a database, two requests must not both commit against the same stale count. The aggregate boundary guides that transaction policy.
Choose the consistency rule before choosing the aggregate boundary.
TYPESCRIPT · ILLUSTRATIVE
// Illustrative application + persistence boundary.
await transaction(async tx => {
const event = await events.loadForUpdate(eventId, tx);
event.reserveSeats(requestedSeats); // checks the invariant
await events.save(event, tx);
});
// The root owns capacity and reservations.
// Locking, or version checks with retries, must prevent
// concurrent writes from committing incompatible states.
Watch the edge
A root method alone cannot stop concurrent database writes. The transaction and locking/version policy must enforce the promise. Huge aggregates cause contention. If a rule crosses boundaries, decide whether delayed agreement is acceptable; do not quietly assume it is.
Try it in your code
Write “After every committed change, ___ must be true.” List the smallest set of state needed to check that claim. Identify every path that can modify it, including imports and background jobs.
07 / Make implicit concepts explicit CH. 09 · 2 min
The missing class may be a missing idea.
Treat repeated conditionals and awkward conversations as modeling clues.
REACH FOR THIS WHEN
A calculator keeps growing special cases, while experts insist these are ordinary business situations.
Ask what distinction the model is failing to express. In the book, separating interest accrual from payment removes a false coupling: income can be earned before cash arrives. Extracting another helper would not reveal that distinction. A named concept gives the team something precise to discuss.
Interest calculatorearned + paid + exceptions
→
AccrualWhat was earned, and when?
PaymentWhat was received, and when?
Sometimes the best refactor changes what the code means, not just where it lives.
TYPESCRIPT · ILLUSTRATIVE
// Two different facts, with independent timing.
type Accrual = {
earnedOn: string; amountCents: number; ledger: string
};
type Payment = {
receivedOn: string; amountCents: number
};
// Accrual calculation no longer depends on when cash arrived.
const accruals = asset.accrueThrough(asOfDate);
Watch the edge
A noun is not proof that you need a class. Try the concept against real scenarios and awkward counterexamples. Keep it only if it explains the business and improves the implementation. Use domain literature as another source of candidate ideas.
Try it in your code
Bring one awkward case to an expert. Ask what they call the intermediate result, which facts vary independently, and whether your “special case” is actually normal. Try one small model change.
08 / Ubiquitous language CH. 02 · 2 min
Say the rule without mentioning a table.
Use a real scenario to test whether the model can carry the conversation.
REACH FOR THIS WHEN
The expert talks about eligibility, but developers explain flags, rows, and endpoints. Everyone nods without agreeing.
Tell the same scenario using the objects and operations in the model. When the sentence becomes vague or needs a long technical aside, look for a missing concept or a misleading name. Update conversation, tests, and code together. The language is a working model, not just a glossary.
BEFORE
“When these fields change, delete the rows and repopulate the table.”
AFTER
“Keep the itinerary if it still meets the new route requirements.”
The second sentence gives an expert a behavior they can challenge. These are original paraphrases of the chapter's example.
A clearer sentence can reveal a better behavior: keep an itinerary that still satisfies the changed requirements.
TYPESCRIPT · ILLUSTRATIVE
// Mechanism hides the business decision.
if (changedFields.length) rebuildRows(recordId);
// An illustrative domain conversation in code.
if (!newRouteSpec.isSatisfiedBy(currentItinerary)) {
cargo.assignItinerary(router.find(newRouteSpec));
}
Watch the edge
Do not force the same vocabulary across contexts with genuinely different meanings. Domain experts must be able to challenge the language. Renaming without changing a mistaken model only makes the mistake sound better.
Try it in your code
Take a test title to a domain expert. Walk through its example together using the same names as the code. Rename or split the first term you both use differently.
09 / Bounded contexts CH. 14 · 2 min
One word can have two valid meanings.
Keep each model coherent within an explicit boundary.
REACH FOR THIS WHEN
A shared Customer type accumulates unrelated fields because sales, billing, and support all need different things.
Define where each model and language apply. A prospect in sales and an invoice recipient in billing may refer to the same person without being the same concept. Draw the existing contexts, their owners, and what crosses each boundary. Make translation explicit.
SALES CONTEXT
Customer
Prospect Pipeline stage Next conversation
explicit identity mapping↔
BILLING CONTEXT
Customer
Account holder Invoice address Payment terms
The same real-world person can participate in both models. Neither model needs all the other's fields.
Consistency inside a context lets different models coexist across contexts.
TYPESCRIPT · ILLUSTRATIVE
// Sales context
type SalesCustomer = { prospectId: string; stage: SalesStage };
// Billing context
type BillingCustomer = {
accountId: string; invoiceAddress: Address
};
// Link identities deliberately. Do not silently treat
// prospectId and accountId as interchangeable.
Watch the edge
A bounded context does not require a microservice. It can have explicit code and team boundaries in one application. A context map describes today's dependencies before it becomes a proposal for tomorrow's architecture.
Try it in your code
Choose one overloaded noun. Write its meaning, lifecycle, and owner in each part of the business. Draw two boxes if the definitions conflict, then label the data and identity mapping between them.
10 / Anticorruption layer CH. 14 · 2 min
Translate meaning at the edge.
Keep a vendor or legacy model from dictating your own.
REACH FOR THIS WHEN
Provider status codes and oddly named legacy fields appear in business logic throughout the application.
Put a translation layer between models. Its public interface speaks your domain language; its implementation understands the other system. Translate meaning, units, identities, and failure cases. A transport wrapper alone does not do this job.
Providercode: "1"
→
Translatormeaning + unknown cases
→
Your modelavailability: "available"
The boundary earns its keep when a foreign concept changes and the change stops there.
TYPESCRIPT · ILLUSTRATIVE
type Availability = "available" | "unavailable" | "unknown";
function translateAvailability(code: string): Availability {
// Fictional provider contract: 1 = ready, 0 = not ready.
switch (code) {
case "1": return "available";
case "0": return "unavailable";
default: return "unknown";
}
}
// Provider codes stay inside this adapter.
Watch the edge
Translation has a maintenance cost. If the upstream model fits your needs, consciously adopting it may be simpler. Preserve important distinctions and unknown states instead of guessing a convenient boolean.
Try it in your code
Find one vendor field referenced in several modules. Define what your application actually needs to know. Move the mapping and examples of uncertain input into one adapter.
11 / Factories + repositories CH. 06 · 2 min
Separate making from finding.
Hide assembly and retrieval behind interfaces that express the model.
REACH FOR THIS WHEN
Every caller assembles a half-valid object, runs several queries, and remembers which children to attach.
A factory creates a valid new object or aggregate and hides complex assembly. A repository provides access to existing aggregates through a collection-like interface. Returning a usable domain object keeps persistence details out of the business conversation.
Factoryassemble + check invariants
→
New bookingnew identity
Repositoryretrieve + reconstitute
→
Existing bookingpreserved identity
Creation establishes a valid beginning. Retrieval preserves an existing identity.
TYPESCRIPT · ILLUSTRATIVE
const booking = bookingFactory.create(request); // new identity
await bookings.add(booking);
const existing = await bookings.byId(booking.id); // same identity
existing.changeItinerary(replacement);
await bookings.save(existing);
// Transaction ownership stays explicit at the application edge.
Watch the edge
A simple constructor may be enough. Do not create a repository for every table or internal child. Reconstitution must preserve stored identity and state; it must not accidentally run every side effect of new-object creation.
Try it in your code
Find repeated creation or loading choreography. Put one complex creation behind a factory, or one root lookup behind a repository. Name the operation after what the caller needs.
12 / Modules + conceptual contours CH. 05 · 2 min
Let the folders tell the domain story.
Group concepts that change together for a business reason.
REACH FOR THIS WHEN
One feature change touches folders named entities, services, factories, and repositories. The tree says little about the business.
Within the domain layer, organize modules around cohesive concepts. A module name should help someone predict where a business change belongs. Observe which responsibilities repeatedly change together, and refine the boundaries as your understanding improves.
TECHNICAL CATEGORIES
entities/ services/ factories/ repositories/
→
DOMAIN MODULES
booking/ routing/ billing/ customer-agreements/
A module name is part of the model. Use it to explain a piece of the business.
Moving files alone does not remove coupling. Keep genuinely shared concepts deliberate, and keep infrastructure concerns separate. Module boundaries organize one model; bounded contexts distinguish models that may disagree.
Try it in your code
Look at the last three feature changes. Which files moved together, and why? Name one domain concept that describes that cluster. Check its dependencies before moving it.
13 / Cohesive mechanisms CH. 15 · 2 min
Keep the algorithm out of the business story.
Express the problem in domain terms, then delegate the computation.
REACH FOR THIS WHEN
Graph traversal, scoring, and search internals swamp the code that explains a business decision.
Extract a coherent computational mechanism behind an intention-revealing interface. The domain still owns the question and its rules. The mechanism carries out the calculation. A narrow graph solver or constraint solver is a better boundary than a general-purpose Utils or Calculator class.
Routing modeldeadline, policy, constraints
→
Search mechanismgraph, traversal, scoring
→
Itinerarycheck against requirements
The domain formulates the problem. The mechanism solves the computation.
TYPESCRIPT · ILLUSTRATIVE
const problem = routingModel.toSearchProblem(request);
const solution = solver.findPath(problem);
if (!solution) return { kind: "no-route" };
const itinerary = routingModel.toItinerary(solution);
if (!request.spec.isSatisfiedBy(itinerary)) {
throw Error("Solver returned an invalid itinerary");
}
return { kind: "found", itinerary };
Watch the edge
First ask whether a better domain model can simplify the algorithm. Extraction is useful when the mechanism has its own coherent concepts; moving tangled code into a service just relocates the tangle.
Try it in your code
Mark the business statements and algorithm steps in one large method. Extract a computation only if you can describe its inputs and result without burying business policy inside it.
14 / Core domain + distillation CH. 15 · 2 min
Spend your best design effort unevenly.
Protect the part of the model that makes the product valuable.
REACH FOR THIS WHEN
The team perfects generic infrastructure while the distinctive business behavior remains difficult to change.
Identify the core domain: the specialized capability that justifies this system. Separate supporting and generic concerns so the core is visible. Invest modeling effort and strong developers there. A small core makes the project's priorities easier to defend.
CORE DOMAINScarce-skill allocationThe reason to build this product
IdentityCalendaringEmailImports
An illustrative scheduling product. Different products have different cores.
The most technically interesting component is not automatically the core domain.
DECISION SKETCH
// Example for a specialized scheduling product.
Core: matching scarce skills to urgent work
Generic: identity, email delivery, basic calendaring
Supporting: operational imports and local admin workflows
Refactor first: the allocation model blocking new policies
Keep adequate: the already-working email template engine
Watch the edge
These categories depend on your product. Identity is generic for many products and central to an identity platform. A generic subdomain need not become a reusable framework. Supporting code still needs enough quality to be dependable.
Try it in your code
Write a short sentence explaining what this product does unusually well. Name the model concepts responsible. Use that sentence to choose between two competing refactors.
15 / Model-driven working prototypes CH. 09 · 2 min
Unblock the system with a modest implementation.
Keep the contract real while the clever algorithm is still being built.
REACH FOR THIS WHEN
The interface team cannot test a real workflow until the optimization team finishes a difficult algorithm.
Build a simple implementation behind the intended domain interface. It should respect the agreed constraints even if it finds worse answers or misses feasible solutions. The rest of the application can integrate, users can try the workflow, and discoveries can improve the contract early.
Application
STABLE AGREEMENTPlanner + acceptance criteria
Simple plannerworks today · limited search
→
Improved plannersame contract · better search
A small working implementation can test an interface more honestly than a canned response.
TYPESCRIPT · ILLUSTRATIVE
const result = simplePlanner.tryBuild(spec);
if (result.kind === "found") {
assert(spec.isSatisfiedBy(result.plan));
return result;
}
return { kind: "no-answer-found" };
// A heuristic finding no answer does not prove impossibility.
// Build a candidate separately; commit only an accepted plan.
Watch the edge
State the prototype's limits. The book's greedy packer can fail to find an answer and can leave partial mutations after failure. In your implementation, define failure semantics and choose rollback or staged results deliberately.
Try it in your code
For one blocked dependency, agree on the input, successful output, invariant, and failure result. Build the least sophisticated implementation that honors that contract, then exercise one end-to-end scenario.
16 / Knowledge level CH. 16 · 2 min
Model the rules that shape the instances.
Separate configurable definitions from the everyday objects they govern.
REACH FOR THIS WHEN
Every new customer workflow or role relationship requires another almost-identical subclass and a deployment.
If configuration is itself meaningful in the domain, give it a model. A workflow definition describes allowed steps; a workflow run records one execution under that definition. Keep the definition model focused on the variations users actually need.
KNOWLEDGE LEVELWorkflow definition v2Allowed steps and transitions
↓ governs
Run A · v2Run B · v2Run C · v1
When v3 appears, decide whether existing runs stay pinned or migrate. A definition change does not make that decision for you.
The definition is domain data too. Give it a lifecycle and clear limits.
TYPESCRIPT · ILLUSTRATIVE
type WorkflowDefinition = {
id: string; version: number;
allowedSteps: readonly StepDefinition[];
};
type WorkflowRun = {
id: string; definitionId: string;
definitionVersion: number; currentStep: string;
};
// A definition change needs an explicit rule for existing runs.
Watch the edge
This is an advanced option. Indirection makes behavior harder to trace, and configuration authors can end up programming. Start with a fixed model unless real variability demands this. Version binding here is a practical example; the book requires thinking through changes to existing instances.
Try it in your code
List three real variations users need. If they change the same structural rules, sketch a tiny definition model and one runtime instance. Decide what happens to that instance when its definition changes.