Domain-Driven Design voor AI agents: contexten en regels
Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.
Agent-projecten worden moeilijk te wijzigen wanneer prompts, code en bedrijfsprocessen verschillende termen gebruiken. Compliance vraagt om een “policy check”, terwijl de implementatie process_data() blootstelt. Die vage naam verbergt welke regel wordt toegepast, wie eigenaar is en waar een wijziging thuishoort.
Domain-Driven Design (DDD) plaatst die business language en ownership centraal. Voor een agent kan het model een request interpreteren en een typed command voorstellen. Een application service levert vervolgens vertrouwde context aan, waarna het domain model de state change accepteert of afwijst. Deze guide verbindt de vocabulary- en boundary-werkzaamheden met dat execution path.
Deze guide is bedoeld voor engineers die agents bouwen die business state wijzigen en expliciet ownership van domain rules nodig hebben. Je leert hoe je een modelvoorstel mapt naar een geautoriseerde application command en waar de illustratieve flow nog een concrete transaction implementation vereist.
TL;DR. Gebruik DDD wanneer een agent business state wijzigt in een domain met betekenisvolle taal, ownership en rules. Een schema valideert de vorm van een modelvoorstel; het domain handhaaft de betekenis ervan. Stel agents niet gelijk aan bounded contexts en gegenereerde JSON niet aan een geldige business decision.
Rule ownership is het probleem
Agent-systemen verspreiden één regel vaak over een system prompt, een tool description, een API handler en een database constraint. De kopieën lopen uiteen. Een refund limit verandert, één prompt blijft oud en een syntactisch geldige tool call bereikt de verkeerde policy.
DDD begint met andere vragen:
- Welk team is eigenaar van de regel?
- Welke taal gebruiken domain experts hiervoor?
- Binnen welke boundary heeft die term één betekenis?
- Welke state changes moeten samen consistent blijven?
Deze vragen zijn nuttig wanneer een workflow belangrijk genoeg is om policies en een lifecycle te hebben. Een eenvoudige read-only chatbot heeft mogelijk geen aggregates, repositories en events nodig. Gebruik DDD om domain complexity te beheren, niet om elke LLM call te versieren.
Strategic design vóór code
Bouw een ubiquitous language
Een ubiquitous language is vocabulary die domain experts en developers binnen één bounded context delen. Als support operations RefundRequest, approval limit en settlement zeggen, moeten die termen terugkomen in requirements, code, tool contracts en evals.
Dit gaat verder dan het kiezen van beschrijvende method names. Termen hebben definities en voorbeelden nodig. Betekent “approved” dat een manager op een knop heeft geklikt, dat de payment processor de transfer heeft geaccepteerd, of beide? Ambiguity die in een glossary wordt ontdekt, is goedkoper dan ambiguity die in een agent trace wordt ontdekt.
Teken bounded contexts rond models en ownership
Hetzelfde noun kan in verschillende contexten iets anders betekenen. “Product” kan in Inventory een stock-keeping unit zijn, in Billing een geprijsde line en in Order Management een delivery commitment.
Fowler beschrijft deze separation als een manier om te voorkomen dat één model alle betekenissen van een term omvat (bounded contexts). Het is niet automatisch een microservice, repository, agent of team, hoewel die boundaries vaak op elkaar aansluiten.
Dit onderscheid is belangrijk bij agent design:
- één context kan intern meerdere model calls of gespecialiseerde agents gebruiken
- één agent die meerdere contexts overspant, heeft expliciete translation en authority voor elke context nodig
- orchestration is een application concern; het wist domain ownership niet uit
Begin met een context map voordat je een agent graph tekent. Anders reproduceert de graph vooral de beschikbare tools in plaats van het businessdomein.
Classificeer de subdomains
DDD maakt doorgaans onderscheid tussen:
- Core domain: de capability die differentiated value creëert
- Supporting subdomain: noodzakelijk, business-specific werk dat niet de differentiator is
- Generic subdomain: een opgeloste capability zoals identity of email delivery
Voor een task assistant kan task management core zijn, scheduling supporting en notification delivery generic.
De classificatie stuurt investeringen. Dit betekent niet dat elk vakje een LLM nodig heeft.
Tactical patterns definiëren de state boundary
Entities en value objects
Een entity heeft identity en een lifecycle. Een task blijft dezelfde task nadat de description is gewijzigd. Een value object wordt gedefinieerd door zijn waarden en is meestal immutable: een email address, geldbedrag of time window.
Aggregates en invariants
Een aggregate is een consistency boundary in DDD (Evans, Domain-Driven Design Reference). De root exposeert de operations die members kunnen wijzigen en beschermt invariants zoals:
- een voltooide task kan niet opnieuw worden voltooid
- een owner kan niet twee open reminders voor dezelfde dag hebben
- een refund kan het resterende refundable amount niet overschrijden
Een aggregate wordt niet veilig enkel omdat er een Python list achter een add_task()-method zit. Externe code mag geen mutable reference ontvangen waarmee die method wordt omzeild. Persistence heeft ook concurrency control nodig, anders kunnen twee geldige requests een invariant schenden wanneer ze gelijktijdig worden opgeslagen.
Repositories en application services
Een repository laadt en bewaart aggregates zonder database concerns naar het domain te laten lekken (Fowler, Repository). Een application service coördineert één use case: state laden, de domain operation aanroepen, opslaan met een expected version en resulterende events publiceren.
Het domain mag geen LLM, HTTP client of ORM aanroepen. Dat zijn adapters rond de use case.
Domain events zijn facts, geen message bus
TaskAdded is een fact in de verleden tijd die door het domain wordt raised. De application kan deze samen met de aggregate update in een outbox persisteren en na de commit een integration event publiceren. Dit is het transactional-outbox pattern, geen garantie die het event object zelf levert (Richardson, Transactional Outbox). Rechtstreeks vanuit een entity naar een broker sturen kan een event publiceren voor een transaction die later faalt.
Events kunnen agents coördineren, maar maken coordination op zichzelf niet betrouwbaar. Delivery semantics, idempotency, ordering en versioned contracts blijven infrastructure work.
Behandel modeloutput als een untrusted proposal
Een LLM integration lijkt op een anti-corruption layer: deze vertaalt een externe, probabilistische representatie naar termen die het domain begrijpt. De analogie is nuttig zolang validation en policy gescheiden blijven.
De layer map maakt de ownership boundary expliciet: het model blijft extern, adapters vertalen het proposal, de application service autoriseert één use case en het domain behoudt de invariant.
De boundary bestaat uit vier stappen:
- Constrain en parse: vereist een typed output contract.
- Normalize: resolve dates, units, identifiers en locale met trusted context.
- Authorize: bepaal of deze actor de operation mag aanvragen.
- Execute: roep een aggregate method aan die de invariant handhaaft.
Pydantic kan via zijn typed validation model een ontbrekend veld of een ongeldige enum afwijzen (Pydantic documentation). Het kan niet bepalen of “morgen” naar de juiste datum resolveert, of de user eigenaar is van de task list, of dat een vergelijkbare task al openstaat.
Illustratieve flow: een task toevoegen
De volgende fragments tonen één request, geen complete module. De actor is actor_id, de target state is de TaskList van de owner en het domain side effect is een TaskAdded event. De tool- of modeladapter levert AddTaskProposal; de application service autoriseert de actor, wijzigt de aggregate en is verantwoordelijk voor de persistence boundary. TaskAdded, TaskAuthorizer en Outbox zijn omitted types. Geen companion implementation of test in deze repository maakt deze fragments runnable.
1. Definieer het model-facing proposal
Houd het proposal dicht bij wat het model kan infereren. Vraag het model niet om database IDs of trusted owner identifiers te verzinnen.
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
Als de user “morgen” zegt, moet de application het model een expliciete lokale datum geven of de relative expression resolven met een geteste date parser. Gebruik nooit de klok van de inference server als impliciete business context.
2. Plaats de invariant in de 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
Het voorbeeld laat de TaskAdded-definition voor de beknoptheid weg. In een complete domain module zou dit een immutable value object zijn. De aggregate bewaart tasks in een immutable tuple, zodat callers geen mutable collection ontvangen waaraan ze kunnen appenden of die ze buiten add_task() om kunnen wijzigen. De mutating methods vervangen die backing tuple pas nadat ze de rule hebben afgedwongen.
Duplicate detection is hier bewust eenvoudig. Reële rules kunnen locale-aware normalization, recurrence semantics of een database uniqueness constraint als laatste race-safe backstop vereisen.
3. Definieer de 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: ...
De infrastructure adapter kan optimistic concurrency implementeren met een version column. Het domain contract beschrijft wat van belang is zonder afhankelijk te zijn van SQLAlchemy of een specifieke database.
4. Coördineer de 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
De bovenstaande code implementeert de transaction niet. Repository save en outbox insert moeten via één concrete unit of work worden uitgevoerd die één database transaction deelt. Anders kan een failure na save ertoe leiden dat de task wordt opgeslagen zonder bijbehorend event. Het diagram toont die bedoelde boundary, niet een garantie die deze fragments bieden.
Het model ontbreekt in deze service. Eén adapter kan AddTaskProposal uit een LLM ophalen, een andere uit een HTTP form en tests kunnen het direct construeren. Het business behavior blijft identiek.
Map tools naar application commands
Agent tools moeten use cases exposen, geen database primitives. Geef de voorkeur aan:
add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)
boven:
insert_row(table, values)
update_record(table, id, patch)
De eerste set spreekt de language van het domain en geeft de application een plaats om rules te autoriseren en af te dwingen. De tweede laat het model willekeurige persistence mutations beschrijven.
Een tool result moet failures onderscheiden waarop de agent kan handelen: invalid proposal, unauthorized actor, domain conflict, concurrent update en unavailable infrastructure. Maak niet van alle failures één string die blind retries uitlokt.
Test de boundary in layers
Domain tests
Test aggregates zonder model, network of database:
- duplicate tasks worden afgewezen
- geldige tasks genereren het verwachte event
- geëxposeerde collections kunnen de internal state niet wijzigen
- transition rules blijven gelden bij herhaalde operations
Application tests
Gebruik fake repositories en authorizers om loading, de volgorde van authorization, expected-version saves, outbox behavior en error mapping te verifiëren.
Model-contract evaluations
Evalueer de probabilistische adapter afzonderlijk:
- accuracy van intent- en field extraction
- relative-date resolution met meegeleverde timezone context
- refusal of clarification wanneer vereiste informatie ontbreekt
- resistance tegen prompt injection in quoted task text
- rate van schema-valid maar semantically unusable proposals
Een end-to-end test moet vervolgens bevestigen dat bad proposals nooit de domain methods kunnen omzeilen die ook door trusted interfaces worden gebruikt.
Wanneer het design werkt
Je moet de model provider kunnen wijzigen zonder een domain test te veranderen. Een policy change hoort één aggregate of domain service te raken in plaats van meerdere prompts. Traces moeten termen gebruiken die het owning team herkent. Malformed of unauthorized proposals moeten vóór persistence falen en een concurrent save moet falen in plaats van state stilzwijgend te overschrijven.
DDD maakt een model niet deterministisch. Het maakt de authority, language en consistency boundaries van het systeem expliciet genoeg dat het model dat niet hoeft te zijn.
References
- Eric Evans, Domain-Driven Design Reference — definities van strategic en tactical patterns
- Martin Fowler, Bounded Context — waarom één model niet elke betekenis van een term moet omvatten
- Martin Fowler, Repository — abstraction voor persistence collections
- Chris Richardson, Transactional Outbox — publiceren na een database transaction zonder events te verliezen
- Pydantic documentation — validation van typed proposals