Domain-Driven Design para AI Agents: Contextos e Regras

Tradução automática Este artigo foi traduzido automaticamente a partir da versão original em inglês.

Os projetos de agents tornam-se difíceis de alterar quando os prompts, o código e os processos de negócio usam termos diferentes. A equipa de compliance pede uma «verificação de política», enquanto a implementação expõe process_data(). O nome vago esconde qual é a regra aplicada, quem é responsável por ela e onde deve ser feita uma alteração.

O Domain-Driven Design (DDD) coloca essa linguagem de negócio e essa responsabilidade no centro. Num agent, o modelo pode interpretar um pedido e propor um comando tipado. Um serviço de aplicação fornece depois contexto fidedigno, e o modelo de domínio aceita ou rejeita a alteração de estado. Este guia liga o trabalho de definição de vocabulário e fronteiras a esse caminho de execução.

Este guia destina-se a engineers que desenvolvem agents capazes de alterar estado de negócio e que precisam de uma responsabilidade explícita pelas regras do domínio. Aprenderá a mapear uma proposta do modelo para um comando de aplicação autorizado e perceberá onde o fluxo ilustrativo ainda requer uma implementação concreta da transação.

TL;DR. Use DDD quando um agent altera estado de negócio num domínio com linguagem, responsabilidades e regras relevantes. Um schema valida a estrutura de uma proposta do modelo; o domínio impõe o seu significado. Não equipare agents a bounded contexts, nem JSON gerado a uma decisão de negócio válida.


A responsabilidade pelas regras é o problema

Os sistemas com agents distribuem frequentemente uma única regra por um system prompt, uma descrição de tool, um handler de API e uma constraint da base de dados. As cópias divergem. O limite de um reembolso muda, um prompt permanece desatualizado e um tool call sintaticamente válido chega à política errada.

O DDD começa por colocar perguntas diferentes:

  • Que equipa é responsável pela regra?
  • Que linguagem usam os especialistas do domínio para a descrever?
  • Em que fronteira tem esse termo um único significado?
  • Que alterações de estado têm de permanecer consistentes em conjunto?

Estas perguntas são úteis quando um workflow é suficientemente importante para ter políticas e um ciclo de vida. Um chatbot simples e apenas de leitura pode não precisar de aggregates, repositories e events. Use DDD para gerir a complexidade do domínio, não para decorar cada chamada a um LLM.

Design estratégico antes do código

Crie uma ubiquitous language

Uma ubiquitous language é um vocabulário partilhado pelos especialistas do domínio e pelos developers dentro de um bounded context. Se as operações de suporte dizem RefundRequest, approval limit e settlement, esses termos devem aparecer nos requisitos, no código, nos contratos das tools e nas avaliações.

Isto é mais do que escolher nomes descritivos para métodos. Os termos precisam de definições e exemplos. «Aprovado» significa que um manager clicou num botão, que o processador de pagamentos aceitou a transferência, ou ambas as coisas? A ambiguidade descoberta num glossário fica mais barata do que a ambiguidade descoberta num trace de um agent.

Desenhe bounded contexts em torno dos modelos e da responsabilidade

O mesmo substantivo pode ter significados diferentes em contextos diferentes. «Product» pode ser uma unidade de gestão de stock em Inventory, uma linha com preço em Billing e um compromisso de entrega em Order Management.

A palavra product modelada de forma diferente entre bounded contextsA palavra product modelada de forma diferente entre bounded contexts

Fowler descreve esta separação como uma forma de impedir que um único modelo abranja todos os significados de um termo (bounded contexts). Não corresponde automaticamente a um microservice, repository, agent ou team, embora essas fronteiras estejam frequentemente alinhadas.

Esta distinção é importante no design de agents:

  • um contexto pode utilizar internamente várias chamadas ao modelo ou agents especializados
  • um agent que atravesse vários contextos precisa de tradução explícita e de autoridade definida para cada um
  • a orquestração é uma preocupação da aplicação; não elimina a responsabilidade pelo domínio

Comece por um context map antes de desenhar um agent graph. Caso contrário, o grafo tende a reproduzir a disponibilidade das tools, em vez do negócio.

Classifique os subdomínios

O DDD separa habitualmente:

  • Core domain: a capacidade que cria valor diferenciado
  • Supporting subdomain: trabalho necessário e específico do negócio, mas que não é o diferenciador
  • Generic subdomain: uma capacidade resolvida, como identidade ou entrega de email

Num task assistant, a gestão de tarefas pode ser o core, o agendamento um supporting subdomain e a entrega de notificações um generic subdomain.

Gestão de tarefas, agendamento e notificações como contextos separadosGestão de tarefas, agendamento e notificações como contextos separados

A classificação orienta o investimento. Não significa que cada caixa precise de um LLM.


Os padrões táticos definem a fronteira do estado

Entities e value objects

Uma entity tem identidade e um ciclo de vida. Uma tarefa continua a ser a mesma tarefa depois de a sua descrição ser alterada. Um value object é definido pelos seus valores e é normalmente imutável: um endereço de email, um montante monetário ou uma janela temporal.

Aggregates e invariants

Um aggregate é uma fronteira de consistência em DDD (Evans, Domain-Driven Design Reference). A sua root expõe as operações que podem alterar os seus membros e protege invariants como:

  • uma tarefa concluída não pode voltar a ser concluída
  • um owner não pode ter reminders abertos duplicados para o mesmo dia
  • um reembolso não pode exceder o montante restante que pode ser reembolsado

Um aggregate não se torna seguro apenas porque existe uma lista de Python por detrás de um método add_task(). O código externo não deve receber uma referência mutável que contorne esse método. A persistência também precisa de controlo de concorrência; caso contrário, dois pedidos válidos podem violar um invariant quando são guardados em simultâneo.

Repositories e serviços de aplicação

Um repository carrega e guarda aggregates sem expor preocupações da base de dados ao domínio (Fowler, Repository). Um serviço de aplicação coordena um use case: carrega o estado, invoca a operação do domínio, guarda com uma versão esperada e publica os events resultantes.

O domínio não deve chamar um LLM, um cliente HTTP ou um ORM. Esses componentes são adapters em torno do use case.

Domain events são factos, não um message bus

TaskAdded é um facto no passado produzido pelo domínio. A aplicação pode persistir esse facto numa outbox juntamente com a atualização do aggregate e publicar depois um integration event após o commit. Este é o padrão transactional-outbox, não uma garantia fornecida pelo próprio objeto event (Richardson, Transactional Outbox). Enviar diretamente para um broker a partir de uma entity pode publicar um event relativo a uma transação que falha posteriormente.

Os events podem coordenar agents, mas não tornam a coordenação fiável por si só. A semântica de entrega, a idempotência, a ordenação e os contratos versionados continuam a ser trabalho da infraestrutura.


Trate a saída do modelo como uma proposta não fidedigna

Uma integração com um LLM assemelha-se a uma anti-corruption layer: traduz uma representação externa e probabilística para termos que o domínio compreende. A analogia é útil desde que a validação e a política permaneçam separadas.

O mapa de camadas torna explícita a fronteira de responsabilidade: o modelo permanece externo, os adapters traduzem a sua proposta, o serviço de aplicação autoriza um use case e o domínio mantém o invariant.

As camadas de DDD mantêm o modelo fora do domínioAs camadas de DDD mantêm o modelo fora do domínio

Fluxo ilustrativo pretendido, desde a saída do modelo até um comando do domínio e regras determinísticasFluxo ilustrativo pretendido, desde a saída do modelo até um comando do domínio e regras determinísticas

A fronteira tem quatro passos:

  1. Constrain e parse: exigir um contrato de saída tipado.
  2. Normalize: resolver datas, unidades, identificadores e locale utilizando contexto fidedigno.
  3. Authorize: decidir se este actor pode pedir a operação.
  4. Execute: invocar um método de um aggregate que impõe o invariant.

O Pydantic pode rejeitar um campo em falta ou um enum inválido através do seu modelo de validação tipado (documentação do Pydantic). Não pode decidir se «amanhã» corresponde à data correta, se o utilizador é owner da lista de tarefas ou se já existe uma tarefa semelhante em aberto.

Fluxo ilustrativo: adicionar uma tarefa

Os fragmentos seguintes mostram um pedido, não um módulo completo. O actor é actor_id, o estado-alvo é TaskList do owner e o efeito secundário no domínio é um event TaskAdded. O adapter da tool ou do modelo fornece AddTaskProposal; o serviço de aplicação autoriza o actor, altera o aggregate e é responsável pela fronteira de persistência. TaskAdded, TaskAuthorizer e Outbox são tipos omitidos. Nenhuma implementação complementar ou test neste repository torna estes fragmentos executáveis.

1. Defina a proposta virada para o modelo

Mantenha a proposta próxima do que o modelo consegue inferir. Não lhe peça para inventar IDs da base de dados ou identificadores de owners fidedignos.

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

Se o utilizador disser «amanhã», a aplicação deve fornecer ao modelo uma data local explícita ou resolver a expressão relativa com um date parser testado. Nunca use o relógio do inference server como contexto de negócio implícito.

2. Coloque o invariant no 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

O exemplo omite a definição de TaskAdded por uma questão de brevidade. Num módulo de domínio completo, seria um value object imutável. O aggregate mantém as tarefas numa tuple imutável, pelo que os callers não recebem uma coleção mutável que possam alterar ou à volta da qual possam contornar add_task(). Os seus métodos mutadores substituem essa tuple subjacente apenas depois de imporem a regra.

A deteção de duplicados é deliberadamente simples neste caso. As regras reais podem precisar de normalização sensível ao locale, semântica de recorrência ou uma constraint de unicidade na base de dados como mecanismo final e seguro contra race conditions.

3. Defina o port do 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: ...

O adapter de infraestrutura pode implementar optimistic concurrency através de uma coluna de versão. O contrato do domínio declara o que é importante sem depender do SQLAlchemy ou de uma base de dados específica.

4. Coordene o 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

O código acima não implementa a transação. O save do repository e a inserção na outbox têm de ser executados através de uma unit of work concreta que partilhe uma transação da base de dados. Caso contrário, uma falha depois de save pode deixar a tarefa armazenada sem o seu event. O diagrama mostra essa fronteira pretendida, não uma garantia fornecida por estes fragmentos.

O modelo está ausente deste serviço. Um adapter pode obter AddTaskProposal de um LLM, outro de um formulário HTTP e os tests podem construí-lo diretamente. O comportamento de negócio permanece idêntico.


Mapeie tools para comandos de aplicação

As tools de um agent devem expor use cases, não primitivas da base de dados. Prefira:

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

em vez de:

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

O primeiro conjunto fala a linguagem do domínio e dá à aplicação um local para autorizar e impor regras. O segundo permite ao modelo descrever mutações arbitrárias na persistência.

O resultado de uma tool deve distinguir as falhas sobre as quais o agent pode agir: proposta inválida, actor não autorizado, conflito de domínio, atualização concorrente e infraestrutura indisponível. Não transforme todas as falhas numa string que incentive retries cegos.

Teste a fronteira por camadas

Tests do domínio

Teste os aggregates sem modelo, rede ou base de dados:

  • as tarefas duplicadas são rejeitadas
  • as tarefas válidas produzem o event esperado
  • as coleções expostas não podem alterar o estado interno
  • as regras de transição mantêm-se após operações repetidas

Tests da aplicação

Use repositories e authorizers falsos para verificar o carregamento, a ordem da autorização, os saves com a versão esperada, o comportamento da outbox e o mapeamento de erros.

Avaliações do contrato do modelo

Avalie o adapter probabilístico separadamente:

  • precisão na extração da intenção e dos campos
  • resolução de datas relativas com o contexto de timezone fornecido
  • recusa ou pedido de esclarecimento quando faltam informações obrigatórias
  • resistência a prompt injection dentro do texto de uma tarefa entre aspas
  • taxa de propostas válidas segundo o schema, mas inutilizáveis semanticamente

Um test end-to-end deve depois confirmar que as propostas inválidas nunca contornam os mesmos métodos do domínio utilizados por interfaces fidedignas.

Quando o design está a funcionar

Deve conseguir alterar o provider do modelo sem alterar um test do domínio. Uma alteração de política deve afetar um único aggregate ou serviço de domínio, em vez de vários prompts. Os traces devem utilizar termos reconhecidos pela equipa responsável. As propostas malformadas ou não autorizadas devem falhar antes da persistência, e um save concorrente deve falhar em vez de substituir silenciosamente o estado.

O DDD não torna um modelo determinístico. Torna explícitas a autoridade, a linguagem e as fronteiras de consistência do sistema, de forma suficientemente clara para que o modelo não tenha de o fazer.

Referências