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.
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.
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.
A fronteira tem quatro passos:
- Constrain e parse: exigir um contrato de saída tipado.
- Normalize: resolver datas, unidades, identificadores e locale utilizando contexto fidedigno.
- Authorize: decidir se este actor pode pedir a operação.
- 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
- Eric Evans, Domain-Driven Design Reference — definições de padrões estratégicos e táticos
- Martin Fowler, Bounded Context — por que razão um único modelo não deve abranger todos os significados de um termo
- Martin Fowler, Repository — abstração de coleção de persistência
- Chris Richardson, Transactional Outbox — publicação após uma transação da base de dados sem perder events
- Documentação do Pydantic — validação tipada de propostas