Domain-Driven Design pour les agents AI : contextes et règles

Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.

Les projets d’agents deviennent difficiles à faire évoluer lorsque les prompts, le code et les processus métier utilisent des termes différents. La conformité demande un « contrôle de policy », tandis que l’implémentation expose process_data(). Ce nom vague dissimule la règle appliquée, sa responsabilité et l’endroit où effectuer une modification.

Le Domain-Driven Design (DDD) place ce langage métier et cette responsabilité au centre. Pour un agent, le modèle peut interpréter une demande et proposer une commande typée. Un application service fournit ensuite un contexte fiable, puis le modèle de domaine accepte ou rejette le changement d’état. Ce guide relie le travail sur le vocabulaire et les frontières à ce chemin d’exécution.

Ce guide s’adresse aux ingénieurs qui construisent des agents modifiant l’état métier et qui ont besoin d’une responsabilité explicite pour les règles du domaine. Vous apprendrez à faire correspondre une proposition du modèle à une commande applicative autorisée, ainsi qu’à identifier les parties du flux illustratif qui nécessitent encore une implémentation concrète de la transaction.

TL;DR. Utilisez le DDD lorsqu’un agent modifie l’état métier dans un domaine doté d’un langage, de responsabilités et de règles significatifs. Un schéma valide la structure d’une proposition du modèle ; le domaine en impose la signification. Ne confondez pas les agents avec les bounded contexts, ni un JSON généré avec une décision métier valide.


La responsabilité des règles est le vrai problème

Les systèmes d’agents répartissent souvent une même règle entre un system prompt, une description de tool, un gestionnaire d’API et une contrainte de base de données. Les copies divergent. Une limite de remboursement change, un prompt reste obsolète, et un tool call syntaxiquement valide atteint la mauvaise policy.

Le DDD commence par poser des questions différentes :

  • Quelle équipe est responsable de la règle ?
  • Quel langage les experts du domaine utilisent-ils pour la désigner ?
  • Dans quelle frontière ce terme possède-t-il un sens unique ?
  • Quels changements d’état doivent rester cohérents ensemble ?

Ces questions sont utiles lorsqu’un workflow est suffisamment important pour avoir des policies et un cycle de vie. Un chatbot simple, en lecture seule, n’a peut-être pas besoin d’aggregates, de repositories ni d’événements. Utilisez le DDD pour gérer la complexité métier, pas pour décorer chaque appel de LLM.

La conception stratégique avant le code

Construire un langage omniprésent

Un langage omniprésent est un vocabulaire partagé par les experts du domaine et les développeurs au sein d’un bounded context. Si les opérations de support utilisent RefundRequest, approval limit et settlement, ces termes doivent apparaître dans les exigences, le code, les contrats de tools et les évaluations.

Il ne suffit pas de choisir des noms de méthodes descriptifs. Les termes doivent avoir des définitions et des exemples. « Approuvé » signifie-t-il qu’un responsable a cliqué sur un bouton, que le prestataire de paiement a accepté le virement, ou les deux ? Une ambiguïté découverte dans un glossaire coûte moins cher qu’une ambiguïté découverte dans une trace d’agent.

Dessiner les bounded contexts autour des modèles et des responsabilités

Un même nom peut avoir des significations différentes selon les contextes. « Produit » peut désigner une unité de gestion des stocks dans Inventory, une ligne tarifée dans Billing et un engagement de livraison dans Order Management.

Le terme product modélisé différemment selon les bounded contextsLe terme product modélisé différemment selon les bounded contexts

Un bounded context possède son modèle et traduit les concepts à sa frontière. Fowler décrit cette séparation comme un moyen d’empêcher qu’un même modèle couvre toutes les significations d’un terme (bounded contexts). Il ne s’agit pas automatiquement d’un microservice, d’un repository, d’un agent ou d’une équipe, même si ces frontières s’alignent souvent.

Cette distinction est importante dans la conception d’agents :

  • un contexte peut utiliser plusieurs appels au modèle ou plusieurs agents spécialisés en interne
  • un agent qui couvre plusieurs contextes a besoin d’une traduction explicite et d’une autorité définie pour chacun
  • l’orchestration relève de l’application ; elle n’efface pas la responsabilité du domaine

Commencez par une context map avant de dessiner un graphe d’agents. Sinon, le graphe tend à reproduire les tools disponibles plutôt que le métier.

Classer les subdomains

Le DDD distingue généralement :

  • Core domain : la capacité qui crée une valeur différenciante
  • Supporting subdomain : un travail nécessaire et spécifique à l’entreprise, mais qui ne constitue pas le facteur de différenciation
  • Generic subdomain : une capacité résolue, comme la gestion des identités ou l’envoi d’e-mails

Pour un assistant de gestion des tâches, la gestion des tâches peut être le core domain, la planification un supporting subdomain et l’envoi de notifications un generic subdomain.

La gestion des tâches, la planification et les notifications comme contextes distinctsLa gestion des tâches, la planification et les notifications comme contextes distincts

Cette classification guide les investissements. Elle ne signifie pas que chaque bloc nécessite un LLM.


Les patterns tactiques définissent la frontière d’état

Entities et value objects

Une entity possède une identité et un cycle de vie. Une tâche reste la même tâche après la modification de sa description. Un value object est défini par ses valeurs et est généralement immuable : une adresse e-mail, un montant ou une plage horaire.

Aggregates et invariants

Un aggregate est une frontière de cohérence en DDD (Evans, Domain-Driven Design Reference). Sa racine expose les opérations capables de modifier ses membres et protège des invariants tels que :

  • une tâche terminée ne peut pas être terminée une deuxième fois
  • un propriétaire ne peut pas avoir plusieurs rappels ouverts pour le même jour
  • un remboursement ne peut pas dépasser le montant encore remboursable

Un aggregate ne devient pas sûr simplement parce qu’une liste Python se trouve derrière une méthode add_task(). Le code externe ne doit pas recevoir de référence mutable permettant de contourner cette méthode. La persistance doit également gérer la concurrence ; sinon deux requêtes valides peuvent violer un invariant lorsqu’elles sont enregistrées simultanément.

Repositories et application services

Un repository charge et enregistre les aggregates sans exposer les détails de la base de données au domaine (Fowler, Repository). Un application service coordonne un use case : charger l’état, invoquer l’opération du domaine, enregistrer avec une version attendue, puis publier les événements produits.

Le domaine ne doit appeler ni LLM, ni client HTTP, ni ORM. Ce sont des adapters autour du use case.

Les domain events sont des faits, pas un message bus

TaskAdded est un fait exprimé au passé, déclenché par le domaine. L’application peut le persister dans un outbox avec la mise à jour de l’aggregate, puis publier un événement d’intégration après le commit. C’est le pattern transactional-outbox, et non une garantie fournie par l’objet événement lui-même (Richardson, Transactional Outbox). Envoyer directement un événement à un broker depuis une entity risque de publier un événement pour une transaction qui échoue ensuite.

Les événements peuvent coordonner des agents, mais ils ne rendent pas cette coordination fiable à eux seuls. Les sémantiques de livraison, l’idempotence, l’ordre et le versionnage des contrats restent des tâches d’infrastructure.


Traiter la sortie du modèle comme une proposition non fiable

Une intégration de LLM ressemble à une anti-corruption layer : elle traduit une représentation externe et probabiliste dans des termes compréhensibles par le domaine. L’analogie est utile tant que la validation et la policy restent séparées.

La cartographie des couches rend explicite la frontière des responsabilités : le modèle reste externe, les adapters traduisent sa proposition, l’application service autorise un use case, et le domaine conserve l’invariant.

Les couches DDD maintiennent le modèle à l’extérieur du domaineLes couches DDD maintiennent le modèle à l’extérieur du domaine

Flux cible illustratif, de la sortie du modèle à une commande du domaine et à des règles déterministesFlux cible illustratif, de la sortie du modèle à une commande du domaine et à des règles déterministes

La frontière comporte quatre étapes :

  1. Contraindre et parser : exiger un contrat de sortie typé.
  2. Normaliser : résoudre les dates, les unités, les identifiants et la locale à l’aide d’un contexte fiable.
  3. Autoriser : déterminer si cet acteur peut demander l’opération.
  4. Exécuter : invoquer une méthode d’aggregate qui impose l’invariant.

Pydantic peut rejeter un champ manquant ou une valeur d’enum invalide grâce à son modèle de validation typé (documentation Pydantic). Il ne peut pas déterminer si « demain » correspond à la bonne date, si l’utilisateur possède la liste de tâches ou si une tâche similaire est déjà ouverte.

Flux illustratif : ajouter une tâche

Les fragments suivants montrent une requête, et non un module complet. L’acteur est actor_id, l’état cible est le TaskList du propriétaire, et l’effet de bord du domaine est un événement TaskAdded. Le tool ou l’adapter du modèle fournit AddTaskProposal ; l’application service autorise l’acteur, modifie l’aggregate et est responsable de la frontière de persistance. TaskAdded, TaskAuthorizer et Outbox sont des types omis. Aucun companion implementation ni test dans ce repository ne permet d’exécuter ces fragments.

1. Définir la proposition destinée au modèle

Gardez la proposition proche de ce que le modèle peut déduire. Ne lui demandez pas d’inventer des identifiants de base de données ni des identifiants de propriétaire fiables.

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

Si l’utilisateur dit « demain », l’application doit fournir au modèle une date locale explicite ou résoudre l’expression relative avec un date parser testé. N’utilisez jamais implicitement l’horloge du serveur d’inférence comme contexte métier.

2. Placer l’invariant dans l’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

L’exemple omet la définition de TaskAdded par souci de concision. Dans un module de domaine complet, il s’agirait d’un value object immuable. L’aggregate conserve les tâches dans un tuple immuable ; les appelants ne reçoivent donc aucune collection mutable qu’ils pourraient modifier ou à laquelle ils pourraient ajouter des éléments en contournant add_task(). Ses méthodes mutantes ne remplacent ce tuple interne qu’après avoir appliqué la règle.

La détection des doublons est volontairement simple ici. Les règles réelles peuvent nécessiter une normalisation tenant compte de la locale, la sémantique des récurrences ou une contrainte d’unicité en base de données comme dernier filet de sécurité contre les conditions de concurrence.

3. Définir le port du 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: ...

L’adapter d’infrastructure peut implémenter une concurrence optimiste avec une colonne de version. Le contrat du domaine exprime ce qui compte sans dépendre de SQLAlchemy ni d’une base de données particulière.

4. Coordonner le 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

Le code ci-dessus n’implémente pas la transaction. L’enregistrement dans le repository et l’insertion dans l’outbox doivent s’exécuter au sein d’une même unit of work concrète partageant une transaction de base de données. Sinon, une défaillance après save peut laisser la tâche enregistrée sans son événement. Le diagramme montre cette frontière souhaitée, et non une garantie fournie par ces fragments.

Le modèle est absent de ce service. Un adapter peut obtenir AddTaskProposal depuis un LLM, un autre depuis un formulaire HTTP, et les tests peuvent le construire directement. Le comportement métier reste identique.


Mapper les tools vers des commandes applicatives

Les tools d’un agent doivent exposer des use cases, et non des primitives de base de données. Préférez :

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

à :

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

Le premier ensemble parle le langage du domaine et fournit à l’application un endroit où autoriser et imposer les règles. Le second permet au modèle de décrire des mutations de persistance arbitraires.

Un tool result doit distinguer les erreurs sur lesquelles l’agent peut agir : proposition invalide, acteur non autorisé, conflit de domaine, mise à jour concurrente et infrastructure indisponible. Ne réduisez pas toutes les erreurs à une chaîne qui encourage les retries aveugles.

Tester la frontière par couches

Tests du domaine

Testez les aggregates sans modèle, réseau ni base de données :

  • les tâches en doublon sont rejetées
  • les tâches valides déclenchent l’événement attendu
  • les collections exposées ne peuvent pas modifier l’état interne
  • les règles de transition restent respectées lors d’opérations répétées

Tests applicatifs

Utilisez des repositories et des authorizers factices pour vérifier le chargement, l’ordre de l’autorisation, les enregistrements avec la version attendue, le comportement de l’outbox et la correspondance des erreurs.

Évaluations du contrat avec le modèle

Évaluez séparément l’adapter probabiliste :

  • précision de l’extraction de l’intention et des champs
  • résolution des dates relatives avec le contexte de fuseau horaire fourni
  • refus ou demande de clarification lorsque des informations nécessaires sont manquantes
  • résistance à la prompt injection dans le texte de tâche cité
  • taux de propositions valides selon le schéma mais inutilisables sémantiquement

Un test end-to-end doit ensuite confirmer que les mauvaises propositions ne contournent jamais les mêmes méthodes du domaine que celles utilisées par les interfaces de confiance.

Quand la conception fonctionne

Vous devez pouvoir changer de fournisseur de modèle sans modifier un test du domaine. Une modification de policy devrait toucher un seul aggregate ou domain service, plutôt que plusieurs prompts. Les traces doivent utiliser des termes reconnus par l’équipe responsable. Les propositions mal formées ou non autorisées doivent échouer avant la persistance, et un enregistrement concurrent doit échouer au lieu d’écraser silencieusement l’état.

Le DDD ne rend pas un modèle déterministe. Il rend les responsabilités, le langage et les frontières de cohérence du système suffisamment explicites pour que le modèle n’ait pas à les définir.

Références