Skip to content

Domain-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.

  • 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-cruiser rule 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.

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.

StepArtifactWho approves itWhat it checks
1Glossary per bounded context (CLAUDE.md / AGENTS.md / Cursor project rule)Domain expert + tech leadNames in code, prompts and tests
2Invariant tests for each aggregateDeveloper who owns the contextThe aggregate’s behaviour
3Aggregate implementationNobody line by line: the tests from step 2—
4Boundary rule in CITech leadImports 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/.

A glossary that is precise enough for an agent names each term’s DDD role and states the invariants as rules, not prose:

src/contexts/ordering/AGENTS.md (or CLAUDE.md)
# 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):

Terminal window
npm i -D dependency-cruiser typescript@^6
.dependency-cruiser.cjs
module.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:

Terminal window
npx depcruise src --config .dependency-cruiser.cjs

Existing 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.

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.

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:

CheckCatchesRunsSigns off
Invariant spec (step 2)Broken business rulesEvery commitContext owner approves the spec once
Planted-bug runA spec that cannot failWhen the spec changesContext owner
Public-member list against glossary (step 3)Hallucinated behaviourEach implementation taskDeveloper, in minutes
depcruise rule (step 4)Cross-context importsEvery pull request, blockingTech lead owns the rule
Glossary drift auditNew synonyms, mutated value objects, leaked persistenceEvery pull request, advisoryReviewer 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.

  • 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 OrderService and leaves Order as 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 example from: { path: '^src/contexts/[^/]+/domain/.*\\.test\\.ts$' }, to: { path: '/services/' }. A rule that forbids domain/ from importing services/ 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.ts in the implementing session and add the CI check from protect the oracle.
  • The boundary gate is green because it checked nothing. A typescript 7 upgrade, a wrong from.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 into src/contexts/shipping/, runs depcruise, and fails the job if that run exits 0.
  • Existing code violates the new rule on day one. Turning the gate on at error blocks every pull request. Recovery: start at warn, fix one violation per pull request, and switch to error when the list is empty.

Where to go next with domain-driven design

Section titled “Where to go next with domain-driven design”