Domain-driven design with AI coding agents
PLDomain-driven design (DDD) with AI coding agents works when three things live in the repository: the ubiquitous language in the agent’s instructions file, aggregate invariants as tests a human approves before implementation, and bounded-context boundaries as a dependency rule that fails CI. The agent writes the code; those three artifacts prove it still matches the domain.
Six months in, “Order” means three things across the codebase: a row in orders, a DTO in checkout, and a React view model. The agent invents fields that do not exist on the real entity, each refactor drags persistence back into the domain layer, and the Shipping module quietly imports OrderRepository. Nobody reads every diff anymore, so nobody sees the model and the language drift apart until a bug report does.
This page is for developers who model domains with an agent and tech leads who own the boundaries between contexts. It sits in the plan stage: the spec says what to build, and this page makes sure the agent builds it in your domain’s terms.
What a DDD setup for agents gives you
Section titled “What a DDD setup for agents gives you”- A glossary file each tool loads automatically, scoped to one bounded context.
- An invariant test suite for one aggregate that you approve before any implementation exists, plus a planted-bug check that proves the suite can fail.
- A
dependency-cruiserrule that turns “Shipping must not reach into Ordering” into a red CI build. - Five copy-paste prompts: extract the language, write invariant tests, implement the aggregate, draft the boundary rule, and audit glossary drift.
In what order do the DDD artifacts come?
Section titled “In what order do the DDD artifacts come?”The order matters more than any single prompt. Each artifact is the check for the next step, so an agent that skips ahead produces code nobody can verify without reading it.
| Step | Artifact | Who approves it | What it checks |
|---|---|---|---|
| 1 | Glossary per bounded context (CLAUDE.md / AGENTS.md / Cursor project rule) | Domain expert + tech lead | Names in code, prompts and tests |
| 2 | Invariant tests for each aggregate | Developer who owns the context | The aggregate’s behaviour |
| 3 | Aggregate implementation | Nobody line by line: the tests from step 2 | — |
| 4 | Boundary rule in CI | Tech lead | Imports between contexts |
Steps 1 and 4 are architecture decisions; record why a boundary exists in an architecture decision record so the agent can read the reason, not only the rule.
Step 1: Put the ubiquitous language where the agent reads it
Section titled “Step 1: Put the ubiquitous language where the agent reads it”The ubiquitous language is the vocabulary that developers and domain experts share. Put it in the instructions file of the bounded context it belongs to, not in one giant root file, so the agent working in Shipping never sees Billing’s definition of “Account”. Keep each glossary under a screen; pruning context files explains why long instruction files get ignored.
The content is the same in every tool. The file and how it loads differ:
Create a project rule under .cursor/rules/ for the Ordering context and commit it. The Cursor project rules guide covers scoping a rule to the context’s paths, so the glossary applies only when the agent works on src/contexts/ordering/.
Put the glossary in src/contexts/ordering/CLAUDE.md. Claude Code loads CLAUDE.md files above the working directory at launch and loads a subdirectory’s file on demand when it reads files in that directory, so the Ordering glossary arrives exactly when the agent touches Ordering code. Since v2.1.277 (on both release channels), Claude Code reads AGENTS.md in a project that has no CLAUDE.md; if your repo has a root CLAUDE.md, name each context’s glossary CLAUDE.md too.
Put the glossary in src/contexts/ordering/AGENTS.md. Codex 0.157.1 concatenates every AGENTS.md from the project root down to the current working directory only, so a nested file loads when you start Codex in that directory: codex -C src/contexts/ordering or codex exec -C src/contexts/ordering "…". Since 0.150.0, an untrusted project does not supply its AGENTS.md. A fresh CI checkout may be untrusted, so the drift audit below names the glossary files in its prompt and does not depend on AGENTS.md loading automatically; for project trust, see permissions and sandboxing.
A glossary that is precise enough for an agent names each term’s DDD role and states the invariants as rules, not prose:
# Ordering context — ubiquitous language
- **Order**: a customer's request to buy products. Aggregate root.- **LineItem**: one product and its quantity inside an Order. Value object, immutable.- **OrderStatus**: Pending | Paid | Shipped | Delivered | Cancelled.- **Customer**: referenced by `CustomerId` only; owned by the Customers context.
Invariants:- An Order has at most 10 distinct LineItems.- Status moves only forward: Pending → Paid → Shipped → Delivered. Cancelled is reachable from Pending and Paid only.- `total` is derived from LineItems and never set directly.
Rules for agents:- No persistence code (ORM decorators, `save()`) in `domain/`.- Other contexts use `index.ts` and `events.ts` only.- A term not in this list does not exist. Ask before inventing one.If the language exists only in meeting notes and old tickets, have the agent draft it and let a domain expert correct it. Review the draft, not the code that will use it.
Step 2: Approve the aggregate’s invariants as tests first
Section titled “Step 2: Approve the aggregate’s invariants as tests first”An aggregate exists to protect its invariants, so the invariants are the acceptance criteria. Ask for the tests before the implementation and approve them yourself: this is the contract you review in place of the diff. The approach is the same one executable acceptance criteria uses for user stories.
Item 3 asks for all 25 pairs on purpose. A small state machine is cheaper to test exhaustively than randomly. Random command sequences reach a late state such as Shipped only occasionally, so a property test can miss a bad transition out of it at the default run count; a 25-row table checks every pair on every run. Use property-based tests for invariants over large input spaces, such as totals and quantities, and tables for transitions.
A table test is short enough to review in full:
const STATUSES: OrderStatus[] = ['Pending', 'Paid', 'Shipped', 'Delivered', 'Cancelled'];
const ALLOWED: Record<OrderStatus, OrderStatus[]> = { Pending: ['Paid', 'Cancelled'], Paid: ['Shipped', 'Cancelled'], Shipped: ['Delivered'], Delivered: [], Cancelled: [],};
for (const from of STATUSES) { for (const to of STATUSES) { const allowed = ALLOWED[from].includes(to); it(`${from} → ${to} is ${allowed ? 'allowed' : 'rejected'}`, () => { const order = orderIn(from); // test helper: builds an Order in that status if (allowed) { order.changeStatus(to); expect(order.status).toBe(to); } else expect(() => order.changeStatus(to)).toThrow(OrderInvariantError); }); }}Once you approve the spec, keep the implementing agent from editing it: protect the oracle shows the permission rules and the CI check for each tool.
Step 3: Let the agent implement the aggregate against the tests
Section titled “Step 3: Let the agent implement the aggregate against the tests”With the tests fixed, the implementation prompt can be short. What it must add is the DDD shape the tests cannot see: behaviour on the aggregate, no setters, and no persistence.
The last line of that prompt is your review shortcut. You compare a short list of public members with the glossary instead of reading the class. A member with no glossary line behind it, such as applyDiscount(), is a hallucinated behaviour: delete it or add the term to the glossary on purpose.
Then prove the tests can fail. Plant one bug by hand, for example change >= 10 to >= 11 in the line-count check, and run the spec. If it stays green, the spec is weaker than it looks; oracle strength covers mutation testing for doing this at scale.
Step 4: Enforce bounded contexts with a dependency rule in CI
Section titled “Step 4: Enforce bounded contexts with a dependency rule in CI”A prompt that asks an agent to “check for boundary violations” is a review, not a gate: its exit code does not depend on what it finds. Make the boundary deterministic with dependency-cruiser and use the agent to write and maintain the rule. Install it with a typescript version it supports (see the caution below):
npm i -D dependency-cruiser typescript@^6module.exports = { forbidden: [ { name: 'shipping-uses-ordering-public-api-only', severity: 'error', comment: 'Shipping reacts to Ordering events; see docs/adr/0007-context-map.md', from: { path: '^src/contexts/shipping/' }, to: { path: '^src/contexts/ordering/', pathNot: '^src/contexts/ordering/(index|events)\\.ts$', }, }, ], options: { tsPreCompilationDeps: true },};Run it in CI next to your tests. It exits non-zero on any error violation:
npx depcruise src --config .dependency-cruiser.cjsExisting violations are a decision for the tech lead, not the agent. Either fix them first, or start the rule at severity: 'warn', fix them one per pull request, and switch to error when the list is empty. The fitness functions page covers layering rules, complexity budgets, and the same gate in other stacks.
How does each tool audit glossary drift?
Section titled “How does each tool audit glossary drift?”The dependency rule catches imports. It cannot catch a new class called Purchase that duplicates Order, or a LineItem mutated after construction. A read-only agent run catches those, reports them, and a human decides. Each workflow writes the diff to .agents/tmp/pr.diff inside the repository, so a read-only agent can open it without extra permissions; add .agents/tmp/ to .gitignore. The workflows differ by tool:
Check out the pull request’s branch, run mkdir -p .agents/tmp && git diff origin/main...HEAD > .agents/tmp/pr.diff in the terminal, and paste the prompt below into the agent. To run a review on every pull request instead, compare the options in AI code review bots, Cursor’s Bugbot among them.
Run it headless and read-only. --permission-mode dontAsk denies every tool call that would prompt, so nothing outside the allowlist can edit files or run a command that is not read-only:
mkdir -p .agents/tmpgit diff origin/main...HEAD > .agents/tmp/pr.diffclaude -p "$(cat .agents/prompts/glossary-drift.md)" \ --permission-mode dontAsk \ --allowedTools "Read,Grep,Glob" \ --output-format json > drift.jsonRun it from the repository root with a read-only sandbox. The prompt names the glossary files, so they load even though they sit below the working directory:
mkdir -p .agents/tmpgit diff origin/main...HEAD > .agents/tmp/pr.diffcodex exec --sandbox read-only \ -o drift.md "$(cat .agents/prompts/glossary-drift.md)"Store the prompt in .agents/prompts/glossary-drift.md so every tool and every CI job runs the same text. The diff sits inside the repository on purpose: in dontAsk mode, Claude Code denies a read outside the working directory without asking, and the audit would then report “No drift found” without having seen the diff. In CI, run it only on trusted branches; for pull requests from forks, follow permissions and sandboxing so no project instructions or secrets reach untrusted code.
How do you know the model still matches the domain?
Section titled “How do you know the model still matches the domain?”You verify the result through artifacts, not by reading the aggregate:
| Check | Catches | Runs | Signs off |
|---|---|---|---|
| Invariant spec (step 2) | Broken business rules | Every commit | Context owner approves the spec once |
| Planted-bug run | A spec that cannot fail | When the spec changes | Context owner |
| Public-member list against glossary (step 3) | Hallucinated behaviour | Each implementation task | Developer, in minutes |
depcruise rule (step 4) | Cross-context imports | Every pull request, blocking | Tech lead owns the rule |
| Glossary drift audit | New synonyms, mutated value objects, leaked persistence | Every pull request, advisory | Reviewer triages findings |
A pull request is ready when the first four are green and every drift finding is either fixed or answered with a glossary change in the same pull request.
What breaks when agents do DDD?
Section titled “What breaks when agents do DDD?”- The glossary drifts from the business. A term changes in a meeting and the agent keeps the old one. Recovery: change the glossary in the same pull request as the rename, and run the drift audit with item 5 on that pull request.
- One prompt spans several contexts. Ask for “the Order model” without naming the context, and the agent merges Ordering, Shipping, and Billing into one class. Recovery: start the session in the context’s directory (Codex
-C, or open only that folder), and name the context in the first line of the prompt. - An anemic model passes the tests. The agent puts rules in an
OrderServiceand leavesOrderas getters and setters; the invariant tests still pass because they call the service. Recovery: make the spec call methods on the aggregate only, and add a dependency-cruiser rule that forbids the aggregate’s spec from importing a service, for examplefrom: { path: '^src/contexts/[^/]+/domain/.*\\.test\\.ts$' }, to: { path: '/services/' }. A rule that forbidsdomain/from importingservices/stays green here, because the service imports the domain, not the other way round. Item 2 of the drift audit catches the other half: public getters and setters that no glossary behaviour justifies. - The agent weakens a failing invariant test. Faced with a red spec, it loosens the assertion instead of fixing the aggregate. Recovery: deny edits to
*.test.tsin the implementing session and add the CI check from protect the oracle. - The boundary gate is green because it checked nothing. A
typescript7 upgrade, a wrongfrom.path, or a moved folder makes dependency-cruiser cruise 0 modules or match nothing. Recovery: add a canary CI step that writes a known-bad import intosrc/contexts/shipping/, runsdepcruise, and fails the job if that run exits 0. - Existing code violates the new rule on day one. Turning the gate on at
errorblocks every pull request. Recovery: start atwarn, fix one violation per pull request, and switch toerrorwhen the list is empty.
Where to go next with domain-driven design
Section titled “Where to go next with domain-driven design”- Behaviour-driven development turns the ubiquitous language into Given/When/Then scenarios.
- Architecture fitness functions extend the boundary gate to layering, complexity, and duplication budgets.
- Microservices with agents covers bounded contexts that are separate services with their own contracts.
- Legacy modernization uses the same glossary-first approach to carve contexts out of a monolith.