[!NOTE] Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Domain-Driven Design voor AI Agents: Begrensde contexten, hulpmiddelen en bedrijfsregels

Wanneer prompts, code en bedrijfsprocessen verschillende terminologie gebruiken, worden Agent projecten moeilijk aan te passen. Voor compliance is een “policy check” vereist, terwijl de implementatie dit probleem juist blootlegt. process_data(). De vage benaming verhult voor welk regelgevingsprincipe er wordt gegrepen, wie daar verantwoordelijk voor is, en waar een wijziging thuishoort.

Domain-Driven Design (DDD) plaatst die bedrijfsstaal en eigenaarschap centraal. Voor een agent kan de model een verzoek interpreteren en een getypeerde opdracht voorstellen, maar een applicatieservice levert betrouwbare context, waarna de domein model de staatswijziging accepteert of afwijst. Deze gids brengt het vocabulaire en de grensdefinitie in verband met die uitvoeringsweg.

Kort samengevat. Gebruik DDD wanneer een agent de bedrijfsstate in een domein verandert, waarbij sprake is van betekenisvolle taal, eigenaarschap en regels. Een schema controleert de vorm van een voorstel voor een model; het domein zorgt ervoor dat de betekenis wordt nageleefd. Stel agents niet gelijk aan begrensde contexten, en beschouw gegenereerde JSON niet als een geldige bedrijfsbeslissing.


Het echte probleem is de eigendomsverhouding van regels

Agent-systemen verdelen vaak één regel over een system prompt, een beschrijving van een hulpprogramma, een API-handler en een databasebeperking. De kopieën komen uit elkaar te liggen. Wanneer de limiet voor restituties verandert, blijft één prompt achter met oude waarden, terwijl een syntactisch geldige tool call wordt toegepast op de verkeerde beleidsregels.

DDD begint met het stellen van verschillende vragen:

Die vragen zijn nuttig wanneer een workflow belangrijk genoeg is om beleidsregels en een levenscyclus te hebben. Een eenvoudige alleen-lezen chatbot heeft mogelijk geen aggregaten, repositories of events nodig. Gebruik DDD om de complexiteit van het domein te beheren, en niet om elke LLM-aanroep te versieren.

Strategisch ontwerp vóór de code

Een alomtegenwoordige taal bouwen

Een universele taal is een woordenschat die wordt gedeeld door domeinexperts en ontwikkelaars binnen één afgebakend context. Als de supportafdeling zegt RefundRequest, approval limit, en settlementDie termen moeten voorkomen in de specificaties, de code, de contracten voor tools en in de evaluaties.

Dit gaat verder dan het kiezen van beschrijvende methoondnamen. Termen vereisen definities en voorbeelden. Betekent “goedgekeurd” dat een manager op een knop heeft gedrukt, dat de betalingsprocessor de overboeking heeft geaccepteerd, of beide? Ambigiteit die al in een glossary wordt vastgesteld, is goedkoper dan ambigiteit die pas later bij agent trace naar voren komt.

Teken begrensde contexten rondom models en eigendom

Hetzelfde zelfstandig naamwoord kan in verschillende contexten verschillende betekenissen hebben. “Product” kan een voorraadhoudende eenheid zijn in Inventory, een geprijsde lijst in Billing, en een leveringsverplichting in Order Management.

Het woord ‘product’ wordt op verschillende manieren gemodelleerd binnen beperkte contexten.

Een begrensd context beheert zijn eigen model en voert vertaling uit aan de grenzen daarvan. Het is niet automatisch een microservice, repository, agent of team, hoewel deze grenzen vaak overeenkomen.

Deze onderscheiding is belangrijk bij het ontwerpen van agent:

Begin met een contextkaart voordat je een agent-grafiek tekenen. Anders heeft de grafiek de neiging om de beschikbaarheid van tools weer te geven in plaats van de bedrijfsactiviteiten.

Classificeer de subdomeinen

DDD scheidt doorgaans:

Voor een taakassistent kan taakbeheer kernfunctie zijn, terwijl planningondersteuning en notificatieretour generiek van aard zijn.

Taakbeheer, planning en notificaties als afzonderlijke contexten

De classificatie stuurt de investeringen richting. Dat betekent echter niet dat elke categorie een LLM nodig heeft.


Tactische patronen bepalen de grens van de toestand

Entiteiten en waardeobjecten

Een entity beschikt over een identiteit en een levenscyclus. Een taak blijft dezelfde taak, zelfs wanneer de beschrijving ervan verandert. Een value object wordt gedefinieerd door zijn waarden en is doorgaans onveranderlijk: bijvoorbeeld een e-mailadres, een bedrag of een tijdsinterval.

Agregaten en invarianten

Een aggregate is een consistentiegrens. De wortel ervan biedt toegang tot de operaties die leden kunnen wijzigen en beschermt invarianten zoals:

Een aggregate wordt niet automatisch veilig alleen omdat er een Python-list achter zit add_task() Methoden: externe code mag geen mutabele referentie ontvangen die deze methoden omzeilt. Voor persistente gegevens is bovendien controle op gelijktijdige toegang noodzakelijk, aangezien twee geldige verzoeken bij gelijktijdige opslag een invariant kunnen schenden.

Repositoren en applicatieservices

Een repository laadt en slaat aggregaten op, zonder dat databasegerelateerde zorgen worden overgedragen naar het domein. Een applicatieservice coördineert één gebruiksscenario: het laden van de staat, het uitvoeren van de domeinoperatie, het opslaan met de verwachte versie, en het publiceren van de resulterende gebeurtenissen.

Het domein mag geen LLM, HTTP-client of ORM aanroepen. Dit zijn adapters die dienen ter ondersteuning van de betreffende use case.

Domeingebeurtenissen zijn feiten, geen berichtbus

TaskAdded Het betreft een feit uit het verleden dat door het domein wordt gegenereerd. De applicatie kan dit feit samen met de samenvattende update opslaan in een outbox, en vervolgens een integratie‑event publiceren nadat de commit heeft plaatsgevonden. Het direct verzenden naar een broker vanuit een entiteit brengt het risico met zich mee dat er een event voor een transactie wordt gepubliceerd die later faalt.

Gebeurtenissen kunnen agents coördineren, maar ze zorgen op zichzelf niet voor betrouwbare coördinatie. Semantiek rondom levering, idempotentie, ordening en geversioneerde contracten blijven onderdeel van de infrastructuurontwikkeling.


Behandel de uitvoer van model als een onbetrouwbare voorstel.

Een integratie van LLM fungeert als een anticorruptielayer: het vertaalt een externe, probabilistische weergave naar termen die door het desbetreffende domein begrepen worden. Deze analogie is bruikbaar zolang validatie en beleidsregels gescheiden blijven.

Model uitvoer wordt vertaald naar een domeincommando en gecontroleerd volgens deterministische regels

De grens bestaat uit vier stappen:

  1. Beperken en analyseren: er moet een getypeerd uitvoercontract worden vereist.
  2. Normaliseren: datummen, eenheden, identificatoren en de lokale instellingen worden opgelost aan de hand van betrouwbare contextinformatie.
  3. Autoriseren: bepalen of deze actor toestemming heeft om de operatie aan te vragen.
  4. Uitvoeren: een aggregatemethode wordt aangeroepen die de invarianten handhaaft.

Pydantic kan een ontbrekend veld of een ongeldige enum weigeren. Het kan niet bepalen of ‘morgen’ overeenkomt met de juiste datum, of dat de gebruiker eigenaar is van de taaklijst, of dat er al een soortgelijke taak openstaat.

Voorbeeld: een taak veilig toevoegen

1. Definieer het voorstel dat gericht is op model

Houd het voorstel zo dicht mogelijk bij wat de model kan afleiden. Vraag het niet om database-ID’s of identificatoren van betrouwbare eigenaren te verzinnen.

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"

Wanneer de gebruiker ‘morgen’ zegt, moet de applicatie aan de model een expliciete lokale datum geven of de relatieve expressie verwerken met een geteste datumparser. Gebruik nooit de klok van de inference-server als impliciete bedrijfscontext.

2. Plaats de invariant in het aggregaat

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

Het voorbeeld laat dit weg. TaskAdded Definitie voor de kortheid: in een compleet domeinmodule zou dit een immutable value object zijn. Het aggregate biedt een tuple-weergave in plaats van zijn mutable dictionary, zodat aanroepers geen elementen kunnen toevoegen. add_task().

De detectie van duplicaten is hier opzettelijk eenvoudig. Echte regels vereisen mogelijk normalisatie die rekening houdt met de lokale context, semantiek rondom recurrencie, of een uniekheidsbeperking in de database als definitieve veiligheidsmaatregel tegen race conditions.

3. Definieer de poort van het repository

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 infrastructuuradapter kan optimistische concurrentie realiseren met behulp van een versienummerkolom. Het domeincontract specificeert wat belangrijk is, zonder afhankelijk te zijn van SQLAlchemy of een bepaalde database.

4. Coördineren van het gebruikscase

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

Een opmerking over één transactie is essentieel: het opslaan in de repository en het invoegen in de outbox moeten ofwel samen slagen, ofwel samen falen. Een abstractie van een eenheid van werk kan die transactie beheren wanneer de concrete repository en de outbox toegang hebben tot dezelfde database.

De model is afwezig in deze service. Eén adapter kan deze wel verkrijgen. AddTaskProposal Een komt van een LLM, een andere van een HTTP-formulier, en tests kunnen het direct opbouwen. Het bedrijfsbehaviour blijft identiek.


Hernoem hulpmiddelen naar applicatiecommando’s

Agent Tools moeten gebruiksscenario’s blootleggen, en niet de primitieven van een database. Gebruik liever:

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)

De eerste set spreekt de domeinstaal en biedt de applicatie een manier om regels te autoriseren en af te dwingen. De tweede set stelt model in staat om willekeurige mutaties in de persistente gegevens te beschrijven.

Een tool result dient fouten te onderscheiden waarop de agent kan ingrijpen: een ongeldige voorstel, een onbevoegde actor, een domeinconflict, gelijktijdige updates en onbeschikbare infrastructuur. Zorg ervoor dat niet alle fouten worden samengevoegd tot één string, waardoor blinde herproberingen worden uitgelokt.

De grens op verschillende lagen testen

Domeintests

Test aggregaten zonder een model, netwerk of database:

Toepassingstests

Gebruik valse repositories en autorisatoren om het laden, de volgorde van autorisatie, het opslaan van de verwachte versie, het gedrag van de uitgaande berichtenlijst en de foutmapping te verifiëren.

Model-evaluatie van contracten

Evalueer de probabilistische adapter apart:

Een end-to-end-test dient vervolgens te bevestigen dat slechte voorstellen nooit de dezelfde domeinmethoden omzeilen die door betrouwbare interfaces worden gebruikt.

Wanneer het ontwerp goed functioneert

Je moet in staat zijn om de model-provider te wijzigen zonder een domeintest aan te passen. Een beleidswijziging dient één geaggregeerd of domeinservice te modificeren in plaats van meerdere prompts. Een trace moet termen gebruiken die door het beheerteam worden herkend. Een ongeldige of niet-geautoriseerde voorstel moet al vóór opslag worden afgewezen, en een gelijktijdige opslag moet falen in plaats van de staat stilletjes te overschrijven.

Dat is de praktische waarde van DDD voor agents. Het maakt een model niet deterministisch. Het zorgt er juist voor dat de grenzen met betrekking tot de autoriteit, het taalgebruik en de consistentie van het systeem voldoende expliciet zijn, zodat een model daartoe niet nodig is.

Referenties