Domain-Driven Design für AI Agents: Kontexte und Regeln

Automatische Übersetzung Dieser Artikel wurde automatisch aus der englischen Originalversion übersetzt.

Agent-Projekte lassen sich nur schwer ändern, wenn Prompts, Code und Geschäftsprozesse unterschiedliche Begriffe verwenden. Die Compliance-Abteilung fordert einen „Policy Check“, während die Implementierung process_data() bereitstellt. Der vage Name verschleiert, welche Regel angewendet wird, wer dafür verantwortlich ist und wo eine Änderung hingehört.

Domain-Driven Design (DDD) stellt diese Geschäftssprache und die Verantwortlichkeiten ins Zentrum. Bei einem Agent kann das Model eine Anfrage interpretieren und einen typisierten Command vorschlagen. Ein Application Service stellt anschließend vertrauenswürdigen Kontext bereit, und das Domain Model akzeptiert oder verwirft die Zustandsänderung. Dieser Leitfaden verbindet die Arbeit an Vokabular und Grenzen mit diesem Ausführungspfad.

Dieser Leitfaden richtet sich an Engineers, die Agents bauen, welche Business State ändern und eine explizite Zuständigkeit für Domain Rules benötigen. Sie lernen, wie sich ein Model-Vorschlag auf einen autorisierten Application Command abbilden lässt und an welcher Stelle der beispielhafte Ablauf noch eine konkrete Transaktionsimplementierung benötigt.

TL;DR. Verwenden Sie DDD, wenn ein Agent Business State in einer Domain mit aussagekräftiger Sprache, klaren Verantwortlichkeiten und Regeln ändert. Ein Schema validiert die Struktur eines Model-Vorschlags; die Domain erzwingt dessen Bedeutung. Setzen Sie Agents nicht mit Bounded Contexts gleich und betrachten Sie generiertes JSON nicht als gültige Geschäftsentscheidung.


Regelverantwortung ist das Problem

Agent-Systeme verteilen eine einzelne Regel häufig über einen System Prompt, eine Tool-Beschreibung, einen API-Handler und eine Datenbank-Constraint. Die Kopien driften auseinander. Ein Refund-Limit wird geändert, ein Prompt bleibt veraltet, und ein syntaktisch gültiger Tool Call erreicht die falsche Policy.

DDD beginnt mit anderen Fragen:

  • Welches Team ist für die Regel verantwortlich?
  • Welche Sprache verwenden Domain-Experten dafür?
  • Innerhalb welcher Grenze hat dieser Begriff genau eine Bedeutung?
  • Welche Zustandsänderungen müssen gemeinsam konsistent bleiben?

Diese Fragen sind nützlich, wenn ein Workflow wichtig genug ist, um Policies und einen Lifecycle zu besitzen. Ein einfacher Read-only-Chatbot benötigt möglicherweise keine Aggregates, Repositories und Events. Verwenden Sie DDD, um Domain-Komplexität zu verwalten, nicht um jeden LLM-Aufruf aufzuwerten.

Strategisches Design vor dem Code

Eine Ubiquitous Language aufbauen

Eine Ubiquitous Language ist ein Vokabular, das Domain-Experten und Entwickler innerhalb eines Bounded Contexts gemeinsam verwenden. Wenn Support Operations von RefundRequest, approval limit und settlement sprechen, sollten diese Begriffe in Requirements, Code, Tool Contracts und Evals vorkommen.

Dabei geht es um mehr als aussagekräftige Methodennamen. Begriffe benötigen Definitionen und Beispiele. Bedeutet „approved“, dass ein Manager auf einen Button geklickt hat, der Payment Processor den Transfer akzeptiert hat oder beides? Eine in einem Glossar entdeckte Mehrdeutigkeit ist günstiger als eine Mehrdeutigkeit, die erst in einem Agent Trace auffällt.

Bounded Contexts um Models und Verantwortlichkeiten ziehen

Derselbe Begriff kann in unterschiedlichen Kontexten etwas anderes bedeuten. „Product“ kann in Inventory eine Stock-Keeping Unit, in Billing eine bepreiste Position und in Order Management eine Lieferzusage sein.

Das Wort „Product“ in unterschiedlichen Bounded Contexts modelliertDas Wort „Product“ in unterschiedlichen Bounded Contexts modelliert

Fowler beschreibt diese Trennung als Möglichkeit, zu verhindern, dass sich ein Model über jede Bedeutung eines Begriffs erstreckt (Bounded Contexts). Ein Bounded Context ist nicht automatisch ein Microservice, Repository, Agent oder Team, auch wenn diese Grenzen oft zusammenfallen.

Diese Unterscheidung ist für das Agent-Design wichtig:

  • Ein Context kann intern mehrere Model Calls oder spezialisierte Agents verwenden.
  • Ein Agent, der mehrere Contexts umfasst, benötigt für jeden davon eine explizite Übersetzung und Autorität.
  • Orchestration ist eine Application-Angelegenheit; sie hebt Domain-Verantwortlichkeiten nicht auf.

Beginnen Sie mit einer Context Map, bevor Sie einen Agent Graph zeichnen. Andernfalls bildet der Graph tendenziell die Verfügbarkeit von Tools statt das Business ab.

Subdomains klassifizieren

DDD unterscheidet üblicherweise:

  • Core Domain: die Fähigkeit, die differenzierten Mehrwert schafft
  • Supporting Subdomain: notwendige, business-spezifische Arbeit, die nicht den Differenzierungsfaktor darstellt
  • Generic Subdomain: eine gelöste Fähigkeit wie Identity oder die Zustellung von E-Mails

Bei einem Task Assistant kann Task Management die Core Domain, Scheduling eine Supporting Subdomain und Notification Delivery eine Generic Subdomain sein.

Task Management, Scheduling und Notifications als getrennte ContextsTask Management, Scheduling und Notifications als getrennte Contexts

Die Klassifizierung steuert die Investitionen. Sie bedeutet nicht, dass jede Box ein LLM benötigt.


Taktische Patterns definieren die Zustandsgrenze

Entities und Value Objects

Eine Entity besitzt eine Identität und einen Lifecycle. Ein Task bleibt derselbe Task, nachdem sich seine Beschreibung geändert hat. Ein Value Object wird durch seine Werte definiert und ist üblicherweise unveränderlich: eine E-Mail-Adresse, ein Geldbetrag oder ein Zeitfenster.

Aggregates und Invariants

Ein Aggregate ist eine Consistency Boundary in DDD (Evans, Domain-Driven Design Reference). Seine Root Entity stellt die Operationen bereit, die Members ändern können, und schützt Invariants wie:

  • Ein abgeschlossener Task kann nicht erneut abgeschlossen werden.
  • Ein Owner darf keine doppelten offenen Reminders für denselben Tag haben.
  • Ein Refund darf den noch erstattungsfähigen Betrag nicht überschreiten.

Ein Aggregate wird nicht allein dadurch sicher, dass hinter einer add_task()-Methode eine Python-Liste liegt. Externer Code darf keine veränderbare Referenz erhalten, die diese Methode umgeht. Auch die Persistenz benötigt Concurrency Control, da zwei gültige Requests beim gleichzeitigen Speichern andernfalls eine Invariant verletzen können.

Repositories und Application Services

Ein Repository lädt und speichert Aggregates, ohne Datenbankbelange in die Domain durchsickern zu lassen (Fowler, Repository). Ein Application Service koordiniert einen Use Case: State laden, die Domain-Operation aufrufen, mit einer erwarteten Version speichern und daraus entstehende Events veröffentlichen.

Die Domain sollte kein LLM, keinen HTTP Client und kein ORM aufrufen. Diese Komponenten sind Adapter um den Use Case herum.

Domain Events sind Fakten, keine Message Bus

TaskAdded ist ein Fakt in der Vergangenheitsform, der von der Domain ausgelöst wird. Die Application kann ihn gemeinsam mit dem Aggregate-Update in einer Outbox persistieren und anschließend nach dem Commit ein Integration Event veröffentlichen. Das ist das Transactional-Outbox-Pattern und keine Garantie, die das Event Object selbst liefert (Richardson, Transactional Outbox). Wird direkt aus einer Entity an einen Broker gesendet, besteht das Risiko, ein Event für eine Transaktion zu veröffentlichen, die später fehlschlägt.

Events können Agents koordinieren, machen diese Koordination aber nicht automatisch zuverlässig. Delivery Semantics, Idempotency, Ordering und versionierte Contracts bleiben Infrastructure-Arbeit.


Model Output als nicht vertrauenswürdigen Vorschlag behandeln

Eine LLM-Integration ähnelt einer Anti-Corruption Layer: Sie übersetzt eine externe, probabilistische Repräsentation in Begriffe, die die Domain versteht. Die Analogie ist nützlich, solange Validation und Policy getrennt bleiben.

Das Layer Mapping macht die Ownership Boundary explizit: Das Model bleibt extern, Adapter übersetzen seinen Vorschlag, der Application Service autorisiert einen Use Case, und die Domain behält die Invariant.

DDD-Layer halten das Model außerhalb der DomainDDD-Layer halten das Model außerhalb der Domain

Beispielhafter Zielfluss vom Model Output über einen Domain Command zu deterministischen RegelnBeispielhafter Zielfluss vom Model Output über einen Domain Command zu deterministischen Regeln

Die Boundary umfasst vier Schritte:

  1. Constrain und parsen: Einen typisierten Output Contract verlangen.
  2. Normalisieren: Dates, Units, Identifiers und Locale anhand vertrauenswürdigen Kontexts auflösen.
  3. Autorisieren: Entscheiden, ob dieser Actor die Operation anfordern darf.
  4. Ausführen: Eine Aggregate-Methode aufrufen, die die Invariant erzwingt.

Pydantic kann über sein typisiertes Validation Model ein fehlendes Feld oder einen ungültigen Enum-Wert ablehnen (Pydantic-Dokumentation). Es kann nicht entscheiden, auf welches korrekte Datum „tomorrow“ verweist, ob der User Eigentümer der Task-Liste ist oder ob ein ähnlicher Task bereits offen ist.

Beispielhafter Ablauf: Einen Task hinzufügen

Die folgenden Fragmente zeigen einen einzelnen Request, kein vollständiges Modul. Der Actor ist actor_id, der Zielzustand ist das TaskList des Owners, und der Domain Side Effect ist ein TaskAdded-Event. Der Tool- oder Model-Adapter stellt AddTaskProposal bereit; der Application Service autorisiert den Actor, ändert das Aggregate und ist für die Persistence Boundary verantwortlich. TaskAdded, TaskAuthorizer und Outbox sind ausgelassene Types. Keine ergänzende Implementierung oder kein Test in diesem Repository macht diese Fragmente ausführbar.

1. Den Model-facing Proposal definieren

Halten Sie den Proposal nahe an dem, was das Model inferieren kann. Fordern Sie es nicht auf, Datenbank-IDs oder vertrauenswürdige Owner-Identifiers zu erfinden.

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

Wenn der User „tomorrow“ sagt, sollte die Application dem Model ein explizites lokales Datum geben oder den relativen Ausdruck mit einem getesteten Date Parser auflösen. Verwenden Sie niemals die Uhr des Inference Servers als impliziten Business Context.

2. Die Invariant im Aggregate platzieren

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

Das Beispiel lässt die Definition von TaskAdded der Kürze halber aus. In einem vollständigen Domain-Modul wäre dies ein unveränderliches Value Object. Das Aggregate hält Tasks in einem unveränderlichen Tuple, sodass Caller keine veränderbare Collection erhalten, die sie um add_task() herum erweitern oder bearbeiten können. Seine mutierenden Methoden ersetzen dieses zugrunde liegende Tuple erst, nachdem sie die Regel durchgesetzt haben.

Die Duplicate Detection ist hier bewusst einfach gehalten. Reale Regeln benötigen möglicherweise Locale-aware Normalization, Recurrence Semantics oder eine Database Uniqueness Constraint als letzte race-sichere Absicherung.

3. Den Repository Port definieren

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: ...

Der Infrastructure Adapter kann Optimistic Concurrency mit einer Versionsspalte implementieren. Der Domain Contract beschreibt, worauf es ankommt, ohne von SQLAlchemy oder einer bestimmten Datenbank abzuhängen.

4. Den Use Case koordinieren

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

Der obige Code implementiert die Transaktion nicht. Repository Save und Outbox Insert müssen über eine konkrete Unit of Work ausgeführt werden, die dieselbe Datenbanktransaktion verwendet. Andernfalls kann ein Fehler nach save dazu führen, dass der Task ohne sein Event gespeichert bleibt. Das Diagramm zeigt diese beabsichtigte Boundary, nicht eine Garantie, die durch die Fragmente bereitgestellt wird.

Das Model ist in diesem Service nicht vorhanden. Ein Adapter kann AddTaskProposal von einem LLM beziehen, ein anderer aus einem HTTP-Formular, und Tests können ihn direkt erzeugen. Das Business-Verhalten bleibt identisch.


Tools auf Application Commands abbilden

Agent-Tools sollten Use Cases statt Datenbankprimitiven bereitstellen. Bevorzugen Sie:

add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)

gegenüber:

insert_row(table, values)
update_record(table, id, patch)

Das erste Set spricht die Domain-Sprache und gibt der Application einen Ort, um Regeln zu autorisieren und durchzusetzen. Das zweite ermöglicht es dem Model, beliebige Persistence Mutations zu beschreiben.

Ein Tool Result sollte Fehler unterscheiden, auf die der Agent reagieren kann: ungültiger Proposal, nicht autorisierter Actor, Domain Conflict, Concurrent Update und nicht verfügbare Infrastructure. Reduzieren Sie nicht alle Fehler auf einen String, der zu blinden Retries einlädt.

Die Boundary in Schichten testen

Domain-Tests

Testen Sie Aggregates ohne Model, Network oder Datenbank:

  • Doppelte Tasks werden abgelehnt.
  • Gültige Tasks lösen das erwartete Event aus.
  • Exponierte Collections können den internen State nicht verändern.
  • Transition Rules bleiben auch bei wiederholten Operationen gültig.

Application-Tests

Verwenden Sie Fake Repositories und Authorizers, um Loading, die Reihenfolge der Autorisierung, Saves mit Expected Version, Outbox-Verhalten und Error Mapping zu überprüfen.

Model-Contract-Evals

Evaluieren Sie den probabilistischen Adapter separat:

  • Genauigkeit bei Intent- und Field Extraction
  • Auflösung relativer Dates mit bereitgestelltem Timezone Context
  • Ablehnung oder Rückfrage, wenn erforderliche Informationen fehlen
  • Resistenz gegen Prompt Injection innerhalb eines zitierten Task-Texts
  • Rate von schema-validen, aber semantisch nicht verwendbaren Proposals

Ein End-to-End-Test sollte anschließend bestätigen, dass schlechte Proposals niemals dieselben Domain-Methoden umgehen, die auch von vertrauenswürdigen Interfaces verwendet werden.

Woran Sie erkennen, dass das Design funktioniert

Sie sollten den Model Provider wechseln können, ohne einen Domain-Test zu ändern. Eine Policy-Änderung sollte ein einziges Aggregate oder einen Domain Service betreffen statt mehrere Prompts. Traces sollten Begriffe verwenden, die das verantwortliche Team erkennt. Fehlformatierte oder nicht autorisierte Proposals sollten vor der Persistenz fehlschlagen, und ein Concurrent Save sollte fehlschlagen, statt den State stillschweigend zu überschreiben.

DDD macht ein Model nicht deterministisch. Es macht die Autorität, Sprache und Consistency Boundaries des Systems so explizit, dass das Model sie nicht sein muss.

Referenzen