Domain-Driven Design for AI Agents: Contexts and Rules
Agent projects become difficult to change when prompts, code, and business processes use different terms. Compliance asks for a “policy check,” while the implementation exposes process_data(). The vague name hides which rule is being applied, who owns it, and where a change belongs.
Domain-Driven Design (DDD) puts that business language and ownership at the center. For an agent, the model can interpret a request and propose a typed command. An application service then supplies trusted context, and the domain model accepts or rejects the state change. This guide connects the vocabulary and boundary work to that execution path.
This guide is for engineers building agents that change business state and need explicit ownership of domain rules. You will learn how to map a model proposal to an authorized application command and where the illustrative flow still requires a concrete transaction implementation.
TL;DR. Use DDD when an agent changes business state in a domain with meaningful language, ownership, and rules. A schema validates the shape of a model proposal; the domain enforces its meaning. Do not equate agents with bounded contexts or generated JSON with a valid business decision.
Rule ownership is the problem
Agent systems often distribute one rule across a system prompt, a tool description, an API handler, and a database constraint. The copies drift. A refund limit changes, one prompt remains old, and a syntactically valid tool call reaches the wrong policy.
DDD starts by asking different questions:
- Which team owns the rule?
- What language do domain experts use for it?
- Within which boundary does that term have one meaning?
- Which state changes must remain consistent together?
Those questions are useful when a workflow is important enough to have policies and a lifecycle. A simple read-only chatbot may not need aggregates, repositories, and events. Use DDD to manage domain complexity, not to decorate every LLM call.
Strategic design before code
Build a ubiquitous language
A ubiquitous language is vocabulary shared by domain experts and developers inside one bounded context. If support operations say RefundRequest, approval limit, and settlement, those terms should appear in requirements, code, tool contracts, and evaluations.
This is more than choosing descriptive method names. Terms need definitions and examples. Does “approved” mean a manager clicked a button, the payment processor accepted the transfer, or both? Ambiguity discovered in a glossary is cheaper than ambiguity discovered in an agent trace.
Draw bounded contexts around models and ownership
The same noun can mean different things in different contexts. “Product” may be a stock-keeping unit in Inventory, a priced line in Billing, and a delivery commitment in Order Management.
A bounded context owns its model and translates at its boundary. Fowler describes this separation as a way to keep one model from spanning every meaning of a term (bounded contexts). It is not automatically a microservice, repository, agent, or team, although those boundaries often align.
This distinction matters in agent design:
- one context may use several model calls or specialized agents internally
- one agent that spans several contexts needs explicit translation and authority for each
- orchestration is an application concern; it does not erase domain ownership
Start with a context map before drawing an agent graph. Otherwise the graph tends to reproduce tool availability rather than the business.
Classify the subdomains
DDD commonly separates:
- Core domain: the capability that creates differentiated value
- Supporting subdomain: necessary, business-specific work that is not the differentiator
- Generic subdomain: a solved capability such as identity or email delivery
For a task assistant, task management may be core, scheduling supporting, and notification delivery generic.
The classification guides investment. It does not mean every box needs an LLM.
Tactical patterns define the state boundary
Entities and value objects
An entity has identity and a lifecycle. A task remains the same task after its description changes. A value object is defined by its values and is usually immutable: an email address, money amount, or time window.
Aggregates and invariants
An aggregate is a consistency boundary in DDD (Evans, Domain-Driven Design Reference). Its root exposes the operations that can change members and protects invariants such as:
- a completed task cannot be completed again
- an owner cannot have duplicate open reminders for the same day
- a refund cannot exceed the remaining refundable amount
An aggregate does not become safe merely because a Python list sits behind an add_task() method. External code must not receive a mutable reference that bypasses that method. Persistence also needs concurrency control, or two valid requests can violate an invariant when saved simultaneously.
Repositories and application services
A repository loads and saves aggregates without leaking database concerns into the domain (Fowler, Repository). An application service coordinates one use case: load state, invoke the domain operation, save with an expected version, and publish resulting events.
The domain should not call an LLM, HTTP client, or ORM. Those are adapters around the use case.
Domain events are facts, not a message bus
TaskAdded is a past-tense fact raised by the domain. The application can persist it in an outbox with the aggregate update, then publish an integration event after commit. This is the transactional-outbox pattern, not a guarantee supplied by the event object itself (Richardson, Transactional Outbox). Sending directly to a broker from an entity risks publishing an event for a transaction that later fails.
Events can coordinate agents, but they do not make coordination reliable by themselves. Delivery semantics, idempotency, ordering, and versioned contracts remain infrastructure work.
Treat model output as an untrusted proposal
An LLM integration resembles an anti-corruption layer: it translates an external, probabilistic representation into terms the domain understands. The analogy is useful as long as validation and policy remain separate.
The layer map makes the ownership boundary explicit: the model stays external, adapters translate its proposal, the application service authorizes one use case, and the domain retains the invariant.
The boundary has four steps:
- Constrain and parse: require a typed output contract.
- Normalize: resolve dates, units, identifiers, and locale using trusted context.
- Authorize: decide whether this actor may request the operation.
- Execute: invoke an aggregate method that enforces the invariant.
Pydantic can reject a missing field or invalid enum through its typed validation model (Pydantic documentation). It cannot decide that “tomorrow” resolves to the correct date, that the user owns the task list, or that a similar task is already open.
Illustrative flow: add a task
The following fragments show one request, not a complete module. The actor is actor_id, the target state is the owner’s TaskList, and the domain side effect is a TaskAdded event. The tool or model adapter supplies AddTaskProposal; the application service authorizes the actor, changes the aggregate, and is responsible for the persistence boundary. TaskAdded, TaskAuthorizer, and Outbox are omitted types. No companion implementation or test in this repository makes these fragments runnable.
1. Define the model-facing proposal
Keep the proposal close to what the model can infer. Do not ask it to invent database IDs or trusted owner identifiers.
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class AddTaskProposal(BaseModel):
description: str = Field(min_length=1, max_length=200)
due_date: date | None = None
priority: Literal["low", "normal", "high"] = "normal"
@field_validator("description")
@classmethod
def description_must_contain_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("description must contain non-whitespace characters")
return value
If the user says “tomorrow,” the application should give the model an explicit local date or resolve the relative expression with a tested date parser. Never use the inference server’s clock as implicit business context.
2. Put the invariant in the aggregate
from dataclasses import dataclass, field
from datetime import date
from uuid import UUID, uuid4
@dataclass(frozen=True)
class Task:
task_id: UUID
description: str
due_date: date | None
priority: str
@dataclass
class TaskList:
owner_id: UUID
version: int
_tasks: tuple[Task, ...] = ()
_events: list[object] = field(default_factory=list)
@property
def tasks(self) -> tuple[Task, ...]:
return self._tasks
def pull_events(self) -> tuple[object, ...]:
events = tuple(self._events)
self._events.clear()
return events
def add_task(
self,
description: str,
due_date: date | None,
priority: str,
) -> Task:
description = description.strip()
if not description:
raise ValueError("Task description must contain non-whitespace characters")
normalized = " ".join(description.casefold().split())
duplicate = any(
" ".join(task.description.casefold().split()) == normalized
and task.due_date == due_date
for task in self._tasks
)
if duplicate:
raise ValueError("A matching task already exists for that date")
task = Task(uuid4(), description, due_date, priority)
self._tasks += (task,)
self._events.append(TaskAdded(task.task_id, self.owner_id))
return task
The example omits the TaskAdded definition for brevity. In a complete domain module it would be an immutable value object. The aggregate keeps tasks in an immutable tuple, so callers receive no mutable collection that they can append to or edit around add_task(). Its mutating methods replace that backing tuple only after enforcing the rule.
Duplicate detection here is deliberately simple. Real rules may need locale-aware normalization, recurrence semantics, or a database uniqueness constraint as a final race-safe backstop.
3. Define the repository port
from typing import Protocol
from uuid import UUID
class ConcurrentUpdate(Exception):
pass
class TaskListRepository(Protocol):
def get(self, owner_id: UUID) -> TaskList: ...
def save(self, task_list: TaskList, expected_version: int) -> None: ...
The infrastructure adapter can implement optimistic concurrency with a version column. The domain contract states what matters without depending on SQLAlchemy or a particular database.
4. Coordinate the use case
from uuid import UUID
class AddTaskService:
def __init__(
self,
repository: TaskListRepository,
authorizer: TaskAuthorizer,
outbox: Outbox,
) -> None:
self.repository = repository
self.authorizer = authorizer
self.outbox = outbox
def execute(
self,
actor_id: UUID,
owner_id: UUID,
proposal: AddTaskProposal,
) -> Task:
self.authorizer.require_add_permission(actor_id, owner_id)
task_list = self.repository.get(owner_id)
expected_version = task_list.version
task = task_list.add_task(
description=proposal.description,
due_date=proposal.due_date,
priority=proposal.priority,
)
# Illustrative only: these two calls are not atomic through these ports.
self.repository.save(task_list, expected_version)
self.outbox.add_all(task_list.pull_events())
return task
The code above does not implement the transaction. Repository save and outbox insert must execute through one concrete unit of work that shares a database transaction. Otherwise a failure after save can leave the task stored without its event. The diagram shows that intended boundary, not a guarantee provided by these fragments.
The model is absent from this service. One adapter may obtain AddTaskProposal from an LLM, another from an HTTP form, and tests can construct it directly. The business behavior remains identical.
Map tools to application commands
Agent tools should expose use cases, not database primitives. Prefer:
add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)
over:
insert_row(table, values)
update_record(table, id, patch)
The first set speaks the domain language and gives the application a place to authorize and enforce rules. The second lets the model describe arbitrary persistence mutations.
A tool result should distinguish failures the agent can act on: invalid proposal, unauthorized actor, domain conflict, concurrent update, and unavailable infrastructure. Do not flatten all failures into a string that invites blind retries.
Test the boundary in layers
Domain tests
Test aggregates without a model, network, or database:
- duplicate tasks are rejected
- valid tasks raise the expected event
- exposed collections cannot mutate internal state
- transition rules hold over repeated operations
Application tests
Use fake repositories and authorizers to verify loading, authorization order, expected-version saves, outbox behavior, and error mapping.
Model-contract evaluations
Evaluate the probabilistic adapter separately:
- intent and field extraction accuracy
- relative-date resolution with supplied timezone context
- refusal or clarification when required information is missing
- resistance to prompt injection inside quoted task text
- rate of schema-valid but semantically unusable proposals
An end-to-end test should then confirm that bad proposals never bypass the same domain methods used by trusted interfaces.
When the design is working
You should be able to change the model provider without changing a domain test. A policy change should touch one aggregate or domain service instead of several prompts. Traces should use terms the owning team recognizes. Malformed or unauthorized proposals should fail before persistence, and a concurrent save should fail instead of silently overwriting state.
DDD does not make a model deterministic. It makes the system’s authority, language, and consistency boundaries explicit enough that the model does not need to be.
References
- Eric Evans, Domain-Driven Design Reference — strategic and tactical pattern definitions
- Martin Fowler, Bounded Context — why one model should not span every meaning of a term
- Martin Fowler, Repository — persistence collection abstraction
- Chris Richardson, Transactional Outbox — publishing after a database transaction without losing events
- Pydantic documentation — typed proposal validation