Domain-Driven Design for AI Agents: Bounded Contexts, Tools, and Business 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, but an application service 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.
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.
The real problem is rule ownership
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 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. 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. 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. 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. 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 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. 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.
Worked example: add a task safely
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
class AddTaskProposal(BaseModel):
description: str = Field(min_length=1, max_length=200)
due_date: date | None = None
priority: Literal["low", "normal", "high"] = "normal"
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: dict[UUID, Task] = field(default_factory=dict)
_events: list[object] = field(default_factory=list)
@property
def tasks(self) -> tuple[Task, ...]:
return tuple(self._tasks.values())
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:
normalized = " ".join(description.casefold().split())
duplicate = any(
" ".join(task.description.casefold().split()) == normalized
and task.due_date == due_date
for task in self._tasks.values()
)
if duplicate:
raise ValueError("A matching task already exists for that date")
task = Task(uuid4(), description.strip(), due_date, priority)
self._tasks[task.task_id] = 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 exposes a tuple view rather than its mutable dictionary, so callers cannot append around add_task().
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,
)
# Implement both writes in one database transaction.
self.repository.save(task_list, expected_version)
self.outbox.add_all(task_list.pull_events())
return task
The comment about one transaction is essential: repository save plus outbox insert must succeed or fail together. A unit-of-work abstraction can own that transaction when the concrete repository and outbox share a database.
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 modify one aggregate or domain service rather than several prompts. A trace should use terms the owning team recognizes. A malformed or unauthorized proposal should fail before persistence, and a concurrent save should fail rather than silently overwrite state.
That is the practical value of DDD for agents. It 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