Domain-Driven Design for AI Agents: Contexts and Rules

This guide is for Python engineers building AI agents that change business data. You will learn how Domain-Driven Design (DDD) organizes business vocabulary and rules, and how to turn a model proposal into an authorized operation.

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.

DDD puts that business language and ownership at the center. The language model interprets a request and proposes an operation with named, typed fields. Application code supplies trusted user information, checks permission, and invokes the business rules before saving a change.

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 the code patterns described later in this article. Use the code patterns when an agent changes state that has rules; skip them for read-only or single-step features.

Ask these four questions in order. Each yes adds one pattern that later sections explain.

  1. Does the agent change business data? If not, use domain terms in tools and prompts and stop.
  2. Do rules decide whether a change is allowed? Add an aggregate and an application service.
  3. Does one term mean different things to different teams? Add bounded contexts and a context map.
  4. Must another service react to the change? Add a domain event and a transactional outbox.

Strategic design before code

Build a ubiquitous language

A bounded context is a part of the system where business terms and rules have one agreed meaning. Its ubiquitous language is the vocabulary shared by domain experts and developers. 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

A domain model is the objects, data, and rules used to represent a business area; it is distinct from the language model. 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 figure compares one shared Product class with one Product model per context. In the lower half, each arrow is a mapping that code performs when an order line passes to another context.

One shared Product class holding the fields of Order Management, Billing, and Inventory, compared with a separate Product model in each context and mappings from an order line to a priced line and a stock reservationOne shared Product class holding the fields of Order Management, Billing, and Inventory, compared with a separate Product model in each context and mappings from an order line to a priced line and a stock reservation

Each context owns its domain model. When information passes to another context, code maps the terms and identifiers between them. 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.

In agent design this means:

  • 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

The next figure draws these three cases. Compare the second and third columns: in the second, one agent translates terms for two contexts; in the third, an orchestrator only sends commands, and each context applies its own rules.

Three columns: two agents inside the Billing context, one support agent mapping refund to Billing and Orders terms, and an orchestrator sending IssueRefund to Billing and RefundOrder to OrdersThree columns: two agents inside the Billing context, one support agent mapping refund to Billing and Orders terms, and an orchestrator sending IssueRefund to Billing and RefundOrder to Orders

Start with a context map showing the business areas and how they exchange information, 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 map places each context in its subdomain band. Scheduling supplies dates to Task Management, which reports each added task to Notifications through a TaskAdded event.

Task Management in the core band, Scheduling in the supporting band supplying dates, and Notifications in the generic band receiving the TaskAdded eventTask Management in the core band, Scheduling in the supporting band supplying dates, and Notifications in the generic band receiving the TaskAdded event

Spend modelling effort on the core domain and write its rules yourself. Use an existing service for generic subdomains, such as an email provider for notifications. The classification does not decide where an LLM goes.


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.

Agent state fits the same split. A ConversationSession with its message history is an entity, and so is each Task the agent plans. A recorded tool result, such as a ToolOutput with the call arguments and the response, is a value object: it does not change after it is stored. Once a Task is an object, it can refuse completion until its dependencies finish, and that rule lives in one method instead of several prompt templates.

Aggregates and invariants

An aggregate groups related objects whose changes must obey the same rules (Evans, Domain-Driven Design Reference). Other code changes those objects through one entry object, called the aggregate root.

In the example below, TaskList is the root and its Task objects are members. An invariant is a rule that must remain true after each operation. This list rejects a task with the same normalized description and due date as an existing task. Other domains have different invariants:

  • 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
  • a plan accepts at most 10 open tasks, even when the model proposes 50

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.

A unit of work groups the saves of one use case into one database transaction: it commits all of them or none (Percival and Gregory, Unit of Work). The application service opens it, makes its changes, and calls commit(). Leaving without calling commit() discards the uncommitted changes.

The domain should not call an LLM, HTTP client, or ORM. Those belong in adapters, the code that connects the application to outside systems.

Domain events are facts, not a message bus

TaskAdded records a fact: a task was added. The application can store this event in an outbox table in the same database transaction as the task change. The transaction saves both changes together or neither. A separate publisher sends the stored event to other services only after the transaction succeeds. This is the transactional-outbox pattern; an event object alone does not provide it (Richardson, Transactional Outbox). Sending directly to a broker from an entity risks publishing an event for a transaction that later fails.

The figure compares three runs: the entity sends the event to the broker and the transaction then rolls back; the outbox with a rollback; and the outbox with a commit.

Three time lines: an event sent to the broker before a rollback reaches Notifications for a task never saved; with an outbox, a rollback sends nothing and a commit lets the publisher send the eventThree time lines: an event sent to the broker before a rollback reaches Notifications for a task never saved; with an outbox, a rollback sends nothing and a commit lets the publisher send the event

Events can coordinate agents, but they do not make coordination reliable by themselves. Delivery semantics, idempotency, ordering, and versioned contracts remain infrastructure work.

Choose the publisher’s target by who consumes the event. When the listeners run in the same process, an in-process signal library such as blinker can call them after the commit. When other services consume the event, send it to a broker such as RabbitMQ, or to Redis Streams if the team already runs Redis. Do not use Redis Pub/Sub for these events: it delivers each message at most once, and a subscriber that is disconnected loses it (Redis Pub/Sub delivery semantics).


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 translation layer checks only the shape and values of the proposal. Permission stays in the application service and business rules stay in the aggregate.

A port is an interface listing operations the application needs. The repository port lists how to load and save task lists; a database adapter implements those operations. The figure draws the four packages as rings and shows code dependencies, not execution order. The application uses the aggregate and repository port; the infrastructure adapter implements that port. The domain does not depend on either the database adapter or the model.

Four packages as nested rings, with interface and infrastructure outside, application inside them, and domain in the centre; every dependency between packages points toward domainFour packages as nested rings, with interface and infrastructure outside, application inside them, and domain in the centre; every dependency between packages points toward domain

In a Python project, the four layers can be four packages: domain/, application/, infrastructure/, and interface/. Swapping the database then touches infrastructure/; changing the model provider touches interface/.

The next diagram runs the four calls from the worked example below through the five steps of the list under it. Each row shows where a call stops and what the tool returns.

Four example calls through five steps: a valid task is saved, a duplicate stops at the invariant, priority urgent stops at parsing, and another user stops at authorizationFour example calls through five steps: a valid task is saved, a duplicate stops at the invariant, priority urgent stops at parsing, and another user stops at authorization

Code between the model and the domain runs these steps:

  1. Receive the proposal: the model returns AddTaskProposal; treat every value in it as untrusted.
  2. Parse and normalize: validate it against the typed contract, then resolve dates, units, identifiers, and locale using trusted context.
  3. Authorize: decide whether this actor may request the operation.
  4. Enforce the invariant: invoke an aggregate method that checks the rule and raises TaskAdded.
  5. Commit together: save the task list and the outbox event in one transaction.

Three tools can make the model return the typed output and validate it (steps 1 and 2):

  • Pydantic AI, when you want an agent framework. Set the agent’s output type to a Pydantic model; when the output fails validation, it sends the error back to the model and asks again.
  • Instructor, when you keep your provider SDK and only need parsed Pydantic objects. It wraps the client, and max_retries re-asks the model with the validation error.
  • The provider’s own structured-output mode, such as OpenAI Structured Outputs, when you want the provider to constrain generation to the JSON schema.

Send a validation error back to the model once or twice. If the proposal still fails, ask the user instead of retrying.

Pydantic can reject a missing field or a priority outside low, normal, high 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.

Worked example: add a task

Consider a request to add “Send the invoice” to a task list. The actor is the signed-in requester (actor_id); the owner is the person whose list will change (owner_id). They may be different people, so the application checks permission for that actor and list before making the change.

The model adapter supplies the task details as AddTaskProposal. The application service checks permission, asks TaskList to add the task, and commits the change and its TaskAdded event through a unit of work. Save the seven Python blocks from this section through “Run the example”, in order, as task_example.py. They form one module for Python 3.12 with Pydantic 2; the last block runs it.

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

TaskList.add_task() rejects blank descriptions and duplicate tasks before changing the list. Keeping those checks here makes them apply whether a request comes from an LLM or an ordinary form.

from dataclasses import dataclass, field
from datetime import date
from uuid import UUID, uuid4

class DomainError(Exception):
    """A business rule rejected the operation."""

class DuplicateTask(DomainError):
    pass

@dataclass(frozen=True)
class TaskAdded:
    task_id: UUID
    owner_id: UUID

@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[TaskAdded] = field(default_factory=list)

    @property
    def tasks(self) -> tuple[Task, ...]:
        return self._tasks

    def pull_events(self) -> tuple[TaskAdded, ...]:
        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 DomainError("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 DuplicateTask("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 domain raises its own exception types. pydantic.ValidationError is a subclass of ValueError, so a tool that caught ValueError to report a domain conflict would also catch invalid proposals.

TaskAdded is a frozen dataclass: a value object that records the fact. 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 ports

These Python protocols list what the application needs from storage and from permission checks. TaskListRepository loads and saves one task list. UnitOfWork holds that repository and the pending events, and commits both. TaskAuthorizer raises Unauthorized when the actor may not change the list.

from typing import Protocol, Self
from uuid import UUID

class ConcurrentUpdate(Exception):
    pass

class Unauthorized(Exception):
    pass

class TaskListRepository(Protocol):
    def get(self, owner_id: UUID) -> TaskList: ...

    def save(self, task_list: TaskList, expected_version: int) -> None: ...

class UnitOfWork(Protocol):
    task_lists: TaskListRepository

    def __enter__(self) -> Self: ...

    def __exit__(self, *exc_info: object) -> None: ...

    def add_events(self, events: tuple[TaskAdded, ...]) -> None: ...

    def commit(self) -> None: ...

class TaskAuthorizer(Protocol):
    def require_add_permission(self, actor_id: UUID, owner_id: UUID) -> None: ...

The database adapter must save only if the stored version still matches expected_version, the version loaded earlier. A successful save increments that version. If another request has changed the list, it raises ConcurrentUpdate instead of overwriting that request’s work. This is optimistic concurrency control; SQLAlchemy’s version counter documentation shows the underlying comparison. The protocol states the requirement; a concrete adapter must implement the check and update together.

4. Coordinate the use case

AddTaskService puts permission, loading, the domain operation, and persistence in order. Its actor and owner identifiers come from trusted application context, not the model proposal.

from uuid import UUID

class AddTaskService:
    def __init__(self, uow: UnitOfWork, authorizer: TaskAuthorizer) -> None:
        self.uow = uow
        self.authorizer = authorizer

    def execute(
        self,
        actor_id: UUID,
        owner_id: UUID,
        proposal: AddTaskProposal,
    ) -> Task:
        self.authorizer.require_add_permission(actor_id, owner_id)

        with self.uow:
            task_list = self.uow.task_lists.get(owner_id)
            expected_version = task_list.version
            task = task_list.add_task(
                description=proposal.description,
                due_date=proposal.due_date,
                priority=proposal.priority,
            )
            self.uow.task_lists.save(task_list, expected_version)
            self.uow.add_events(task_list.pull_events())
            self.uow.commit()
        return task

If a line before commit() raises, the unit of work discards the uncommitted task list change and event. A connection failure or timeout during commit() can leave its outcome unknown: the database may have committed before its reply was lost. Leaving the block cannot undo that committed transaction (Amazon Builders’ Library, Making retries safe with idempotent APIs).

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.

5. Add in-memory adapters

These adapters let the example run without a database. InMemoryUnitOfWork.commit() checks every version first and only then writes the task lists and the events, so it saves both or neither. AllowList is a fake authorizer for tests and demos.

import copy
from uuid import UUID

class InMemoryDatabase:
    def __init__(self) -> None:
        self.task_lists: dict[UUID, TaskList] = {}
        self.outbox: list[TaskAdded] = []

class InMemoryTaskLists:
    def __init__(self, db: InMemoryDatabase) -> None:
        self.db = db
        self.staged: dict[UUID, tuple[TaskList, int]] = {}

    def get(self, owner_id: UUID) -> TaskList:
        stored = self.db.task_lists.get(owner_id, TaskList(owner_id, version=0))
        return copy.deepcopy(stored)

    def save(self, task_list: TaskList, expected_version: int) -> None:
        self.staged[task_list.owner_id] = (task_list, expected_version)

class InMemoryUnitOfWork:
    def __init__(self, db: InMemoryDatabase) -> None:
        self.db = db

    def __enter__(self) -> "InMemoryUnitOfWork":
        self.task_lists = InMemoryTaskLists(self.db)
        self.events: list[TaskAdded] = []
        return self

    def __exit__(self, *exc_info: object) -> None:
        # Leaving the block without commit() discards every staged change.
        self.task_lists.staged.clear()
        self.events.clear()

    def add_events(self, events: tuple[TaskAdded, ...]) -> None:
        self.events.extend(events)

    def commit(self) -> None:
        staged = self.task_lists.staged
        for owner_id, (_, expected) in staged.items():
            stored = self.db.task_lists.get(owner_id)
            if (stored.version if stored else 0) != expected:
                raise ConcurrentUpdate(f"task list {owner_id} changed")
        for owner_id, (task_list, expected) in staged.items():
            task_list.version = expected + 1
            self.db.task_lists[owner_id] = task_list
        self.db.outbox.extend(self.events)
        staged.clear()
        self.events.clear()

class AllowList:
    def __init__(self, allowed: set[tuple[UUID, UUID]]) -> None:
        self.allowed = allowed

    def require_add_permission(self, actor_id: UUID, owner_id: UUID) -> None:
        if (actor_id, owner_id) not in self.allowed:
            raise Unauthorized(f"{actor_id} may not add tasks for {owner_id}")

A database adapter implements UnitOfWork with one SQL transaction; the in-memory version shows the contract.


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. The figure sends the same duplicate request through both designs: the database tool saves a second copy, and the domain-command tool refuses it.

A duplicate task request saved as a second row by insert_row, and refused by add_task when TaskList raises DuplicateTaskA duplicate task request saved as a second row by insert_row, and refused by add_task when TaskList raises DuplicateTask

Agent framework code, such as a LangGraph node or a Pydantic AI tool, belongs in the interface layer and calls application services, as add_task_tool() below does. If orchestration in the application layer uses a framework, put it behind a port the application owns. The domain never imports the framework.

A tool result should distinguish failures the agent can act on. Do not flatten all failures into a string that invites blind retries. Each failure has a different next action:

FailureRaised asTool returnsWhat the agent does
Invalid proposalpydantic.ValidationErrorinvalid_proposalFix the arguments from the error and call again, once or twice
Unauthorized actorUnauthorizedunauthorizedStop and tell the user; do not retry
Domain conflictDomainError, e.g. DuplicateTaskdomain_conflictTell the user and ask how to proceed
Concurrent updateConcurrentUpdateconcurrent_updateCall the tool again once; the service reloads the list
Unavailable infrastructureConnectionError, TimeoutErrorunavailableCheck the outcome before retrying; use the conditions below

Here, unavailable reports an infrastructure exception; it does not prove that nothing was saved. Retry with backoff only after the application confirms no write occurred, or when it atomically saves the write and its result under a request ID and returns that result on retries. Reuse the same ID and arguments within its retention period (Amazon Builders’ Library, Making retries safe with idempotent APIs). Otherwise, reconcile the outcome using the application’s records. If those records cannot resolve it, tell the user the outcome is unknown.

The demo rejects duplicate task content but does not implement request idempotency: a retry after a committed write returns domain_conflict rather than the original success.

This tool function wraps AddTaskService.execute() and returns those codes. The framework passes the actor and owner from the signed-in session, not from the model.

from uuid import UUID

from pydantic import ValidationError

def add_task_tool(
    service: AddTaskService,
    actor_id: UUID,
    owner_id: UUID,
    arguments: dict,
) -> dict:
    try:
        proposal = AddTaskProposal.model_validate(arguments)
        task = service.execute(actor_id, owner_id, proposal)
    except ValidationError as exc:
        return {"error": "invalid_proposal", "detail": str(exc)}
    except Unauthorized:
        return {"error": "unauthorized"}
    except DomainError as exc:
        return {"error": "domain_conflict", "detail": str(exc)}
    except ConcurrentUpdate:
        return {"error": "concurrent_update"}
    except (ConnectionError, TimeoutError):
        return {"error": "unavailable"}
    return {"ok": True, "task_id": str(task.task_id)}

Run the example

The last block calls the tool four times: one valid task, one duplicate with different case and spacing, one priority outside the allowed values, and one actor without permission.

from uuid import uuid4

if __name__ == "__main__":
    alice, bob = uuid4(), uuid4()
    db = InMemoryDatabase()
    service = AddTaskService(InMemoryUnitOfWork(db), AllowList({(alice, alice)}))

    def call(actor, arguments):
        result = add_task_tool(service, actor, alice, arguments)
        print(result.get("error", "ok"))

    invoice = {"description": "Send the invoice", "due_date": "2026-10-05"}
    call(alice, invoice)  # ok
    call(alice, {**invoice, "description": "send the  INVOICE"})  # domain_conflict
    call(alice, {**invoice, "priority": "urgent"})  # invalid_proposal
    call(bob, invoice)  # unauthorized

    stored = db.task_lists[alice]
    print(f"version={stored.version} tasks={len(stored.tasks)} outbox={len(db.outbox)}")

With Python 3.12 installed, run these commands in the directory containing task_example.py:

python3.12 -m venv .venv
.venv/bin/python -m pip install "pydantic>=2,<3"
.venv/bin/python task_example.py

The module prints:

ok
domain_conflict
invalid_proposal
unauthorized
version=1 tasks=1 outbox=1

Only the first call changed state. The stored list is at version 1 with one task, and the outbox holds one TaskAdded event.

Tests for each layer

Domain tests

Test aggregates with pytest, 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

The second call in the run above already checks the first item through the tool. A domain test makes the same call on TaskList directly and expects DuplicateTask.

Application tests

Use fake repositories and authorizers to verify loading, authorization order, expected-version saves, outbox behavior, and error mapping. The in-memory unit of work above is that fake. Save the following block separately as test_task_example.py, beside task_example.py. It opens two units of work on the same data to simulate two requests that load the same version:

from uuid import uuid4

import pytest

from task_example import ConcurrentUpdate, InMemoryDatabase, InMemoryUnitOfWork

def test_stale_save_raises_concurrent_update():
    db, owner = InMemoryDatabase(), uuid4()
    first, second = InMemoryUnitOfWork(db), InMemoryUnitOfWork(db)

    with first, second:
        mine = first.task_lists.get(owner)  # both requests load version 0
        theirs = second.task_lists.get(owner)

        mine.add_task("Call the bank", None, "normal")
        first.task_lists.save(mine, expected_version=0)
        first.commit()  # stored version is now 1

        theirs.add_task("Book travel", None, "normal")
        second.task_lists.save(theirs, expected_version=0)
        with pytest.raises(ConcurrentUpdate):
            second.commit()

    assert [t.description for t in db.task_lists[owner].tasks] == ["Call the bank"]

Install pytest in the same environment and run the test:

.venv/bin/python -m pip install pytest
.venv/bin/python -m pytest -q test_task_example.py

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

For tests that should not call a real model, Pydantic AI provides TestModel, which calls the agent’s tools and returns schema-valid output, and FunctionModel, which lets the test decide each response (Pydantic AI testing). Keep the evaluations above for the real model.

An end-to-end test should then confirm that bad proposals never bypass the same domain methods used by trusted interfaces.

Order of work for a new agent project

  1. Interview the domain experts and write the glossary, with a definition and an example for each term.
  2. Draw the context map. Start with one core context.
  3. Model the entities and value objects. Put each rule in the method that changes the state.
  4. Choose the aggregate roots and write down their invariants.
  5. Define the repository, unit-of-work, and authorizer ports. Use in-memory adapters until the domain tests pass.
  6. Raise domain events for changes other contexts need, and write them to an outbox in the same transaction.
  7. Define the model-facing proposal types and the parser that validates them.
  8. Expose each use case as a tool that calls an application service and maps its failures.
  9. Add orchestration last, sending commands between contexts.

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.

The model will still make wrong proposals; the aggregate rejects the ones that break a rule.

References