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.

The word product modeled differently across bounded contextsThe word product modeled differently across bounded contexts

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.

Task management, scheduling, and notifications as separate contextsTask management, scheduling, and notifications as separate contexts

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.

DDD layers keep the model outside the domainDDD layers keep the model outside the domain

Illustrative target flow from model output to a domain command and deterministic rulesIllustrative target flow from model output to a domain command and deterministic rules

The boundary has four steps:

  1. Constrain and parse: require a typed output contract.
  2. Normalize: resolve dates, units, identifiers, and locale using trusted context.
  3. Authorize: decide whether this actor may request the operation.
  4. 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