Domain-Driven Design para AI Agents: contextos y reglas
Traducción automática Este artículo se tradujo automáticamente a partir de la versión original en inglés.
Los proyectos de agentes se vuelven difíciles de modificar cuando los prompts, el código y los procesos de negocio utilizan términos distintos. El equipo de Compliance pide una «comprobación de políticas», mientras que la implementación expone process_data(). Este nombre ambiguo oculta qué regla se aplica, quién es responsable de ella y dónde debe hacerse un cambio.
Domain-Driven Design (DDD) sitúa ese lenguaje de negocio y la responsabilidad en el centro. En un agente, el modelo puede interpretar una petición y proponer un comando tipado. Después, un servicio de aplicación proporciona el contexto de confianza y el modelo de dominio acepta o rechaza el cambio de estado. Esta guía conecta el vocabulario y el trabajo de definición de límites con ese flujo de ejecución.
Esta guía está dirigida a ingenieros que construyen agentes capaces de cambiar el estado del negocio y que necesitan una responsabilidad explícita sobre las reglas de dominio. Aprenderás a transformar la propuesta de un modelo en un comando de aplicación autorizado y a identificar qué parte del flujo ilustrativo todavía requiere una implementación concreta de la transacción.
TL;DR. Usa DDD cuando un agente cambie el estado del negocio en un dominio con lenguaje, responsabilidades y reglas relevantes. Un esquema valida la estructura de una propuesta del modelo; el dominio impone su significado. No equipares los agentes con bounded contexts ni el JSON generado con una decisión de negocio válida.
El problema es quién es responsable de cada regla
Los sistemas de agentes suelen distribuir una misma regla entre un system prompt, la descripción de una herramienta, un controlador de API y una restricción de la base de datos. Las copias divergen. Cambia el límite de un reembolso, un prompt conserva la versión antigua y un tool call sintácticamente válido acaba aplicando la política equivocada.
DDD empieza planteando preguntas diferentes:
- ¿Qué equipo es responsable de la regla?
- ¿Qué lenguaje utilizan los expertos del dominio para describirla?
- ¿Dentro de qué límite tiene ese término un único significado?
- ¿Qué cambios de estado deben seguir siendo consistentes conjuntamente?
Estas preguntas resultan útiles cuando un workflow es lo bastante importante como para tener políticas y un ciclo de vida. Un chatbot sencillo y de solo lectura quizá no necesite aggregates, repositories ni eventos. Usa DDD para gestionar la complejidad del dominio, no para adornar cada llamada a un LLM.
Diseño estratégico antes del código
Construye un ubiquitous language
Un ubiquitous language es el vocabulario compartido por los expertos del dominio y los desarrolladores dentro de un bounded context. Si el equipo de operaciones de soporte utiliza RefundRequest, approval limit y settlement, esos términos deberían aparecer en los requisitos, el código, los contratos de herramientas y las evaluaciones.
Esto va más allá de elegir nombres descriptivos para los métodos. Los términos necesitan definiciones y ejemplos. ¿«Aprobado» significa que un manager ha hecho clic en un botón, que el procesador de pagos ha aceptado la transferencia o ambas cosas? Detectar la ambigüedad en un glosario es más barato que detectarla en un trace de un agente.
Dibuja bounded contexts alrededor de los modelos y la responsabilidad
El mismo sustantivo puede significar cosas distintas en contextos diferentes. «Product» puede ser una unidad de mantenimiento de stock en Inventory, una línea con precio en Billing y un compromiso de entrega en Order Management.
Un bounded context es responsable de su modelo y traduce los términos en su frontera. Fowler describe esta separación como una forma de evitar que un único modelo abarque todos los significados de un término (bounded contexts). No es automáticamente un microservice, repository, agente o equipo, aunque esas fronteras suelen coincidir.
Esta distinción es importante al diseñar agentes:
- un contexto puede utilizar internamente varias llamadas al modelo o agentes especializados
- un agente que abarque varios contextos necesita traducción explícita y autoridad para cada uno
- la orquestación es una responsabilidad de la aplicación; no elimina la responsabilidad del dominio
Empieza con un context map antes de dibujar un grafo de agentes. De lo contrario, el grafo tenderá a reproducir la disponibilidad de herramientas en lugar del negocio.
Clasifica los subdominios
DDD suele separar:
- Core domain: la capacidad que crea valor diferencial
- Supporting subdomain: trabajo necesario y específico del negocio que no constituye el diferenciador
- Generic subdomain: una capacidad resuelta, como la identidad o el envío de correo electrónico
Para un asistente de tareas, la gestión de tareas puede ser el core domain, la planificación un supporting subdomain y el envío de notificaciones un generic subdomain.
La clasificación orienta la inversión. No significa que cada bloque necesite un LLM.
Los patrones tácticos definen la frontera de estado
Entities y value objects
Una entity tiene identidad y un ciclo de vida. Una tarea sigue siendo la misma tarea después de cambiar su descripción. Un value object se define por sus valores y suele ser inmutable: una dirección de correo electrónico, una cantidad monetaria o una ventana temporal.
Aggregates e invariants
Un aggregate es una frontera de consistencia en DDD (Evans, Domain-Driven Design Reference). Su raíz expone las operaciones que pueden cambiar sus miembros y protege invariants como:
- una tarea completada no puede completarse de nuevo
- un propietario no puede tener recordatorios abiertos duplicados para el mismo día
- un reembolso no puede superar el importe restante reembolsable
Un aggregate no se vuelve seguro simplemente porque una lista de Python esté detrás de un método add_task(). El código externo no debe recibir una referencia mutable que permita saltarse ese método. La persistencia también necesita control de concurrencia; de lo contrario, dos peticiones válidas pueden infringir una invariant al guardarse simultáneamente.
Repositories y application services
Un repository carga y guarda aggregates sin filtrar las preocupaciones de la base de datos al dominio (Fowler, Repository). Un application service coordina un caso de uso: cargar el estado, invocar la operación de dominio, guardar con una versión esperada y publicar los eventos resultantes.
El dominio no debería llamar a un LLM, un cliente HTTP ni un ORM. Son adapters alrededor del caso de uso.
Los domain events son hechos, no un message bus
TaskAdded es un hecho expresado en pasado que genera el dominio. La aplicación puede persistirlo en un outbox junto con la actualización del aggregate y publicar un integration event después del commit. Este es el patrón transactional-outbox, no una garantía proporcionada por el propio objeto de evento (Richardson, Transactional Outbox). Enviar directamente a un broker desde una entity puede publicar un evento correspondiente a una transacción que falle posteriormente.
Los eventos pueden coordinar agentes, pero no hacen fiable la coordinación por sí solos. La semántica de entrega, la idempotencia, el orden y los contratos versionados siguen siendo trabajo de infraestructura.
Trata la salida del modelo como una propuesta no confiable
Una integración con un LLM se parece a una anti-corruption layer: traduce una representación externa y probabilística a términos que el dominio entiende. La analogía es útil siempre que la validación y las políticas sigan estando separadas.
El mapa de capas hace explícita la frontera de responsabilidad: el modelo permanece fuera, los adapters traducen su propuesta, el application service autoriza un caso de uso y el dominio conserva la invariant.
La frontera tiene cuatro pasos:
- Restringir y analizar: exige un contrato de salida tipado.
- Normalizar: resuelve fechas, unidades, identificadores y locale utilizando contexto de confianza.
- Autorizar: decide si este actor puede solicitar la operación.
- Ejecutar: invoca un método del aggregate que impone la invariant.
Pydantic puede rechazar un campo ausente o un enum no válido mediante su modelo de validación tipada (documentación de Pydantic). No puede decidir que «mañana» se refiere a la fecha correcta, que el usuario es propietario de la lista de tareas o que ya existe una tarea similar abierta.
Flujo ilustrativo: añadir una tarea
Los siguientes fragmentos muestran una petición, no un módulo completo. El actor es actor_id, el estado objetivo es el TaskList del propietario y el efecto secundario del dominio es un evento TaskAdded. El adapter de la herramienta o del modelo proporciona AddTaskProposal; el application service autoriza al actor, modifica el aggregate y es responsable de la frontera de persistencia. TaskAdded, TaskAuthorizer y Outbox son tipos omitidos. En este repositorio no hay ninguna implementación complementaria ni ningún test que permita ejecutar estos fragmentos.
1. Define la propuesta orientada al modelo
Mantén la propuesta cerca de lo que el modelo puede inferir. No le pidas que invente IDs de base de datos ni identificadores de propietarios de confianza.
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 el usuario dice «mañana», la aplicación debería proporcionar al modelo una fecha local explícita o resolver la expresión relativa mediante un parser de fechas probado. No utilices nunca el reloj del servidor de inferencia como contexto de negocio implícito.
2. Coloca la invariant en el 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
El ejemplo omite la definición de TaskAdded para abreviar. En un módulo de dominio completo sería un value object inmutable. El aggregate mantiene las tareas en una tupla inmutable, por lo que los llamadores no reciben ninguna colección mutable que puedan ampliar o modificar al margen de add_task(). Sus métodos mutadores sustituyen esa tupla subyacente únicamente después de imponer la regla.
La detección de duplicados es deliberadamente sencilla. Las reglas reales pueden necesitar normalización adaptada al locale, semántica de recurrencia o una restricción de unicidad en la base de datos como última protección segura frente a race conditions.
3. Define el port del 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: ...
El adapter de infraestructura puede implementar optimistic concurrency mediante una columna de versión. El contrato del dominio expresa lo importante sin depender de SQLAlchemy ni de una base de datos concreta.
4. Coordina el caso de uso
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
El código anterior no implementa la transacción. El guardado del repository y la inserción en el outbox deben ejecutarse mediante una única unidad de trabajo concreta que comparta una transacción de base de datos. De lo contrario, un fallo después de save puede dejar la tarea almacenada sin su evento. El diagrama muestra esa frontera prevista, no una garantía proporcionada por estos fragmentos.
El modelo no aparece en este servicio. Un adapter puede obtener AddTaskProposal de un LLM, otro de un formulario HTTP y los tests pueden construirlo directamente. El comportamiento de negocio permanece idéntico.
Asigna las herramientas a comandos de aplicación
Las herramientas de un agente deberían exponer casos de uso, no primitivas de base de datos. Es preferible:
add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)
que:
insert_row(table, values)
update_record(table, id, patch)
El primer conjunto utiliza el lenguaje del dominio y proporciona a la aplicación un lugar donde autorizar e imponer las reglas. El segundo permite al modelo describir mutaciones de persistencia arbitrarias.
Un resultado de herramienta debería distinguir los fallos sobre los que el agente puede actuar: propuesta no válida, actor no autorizado, conflicto de dominio, actualización concurrente e infraestructura no disponible. No conviertas todos los fallos en una cadena de texto que invite a reintentos ciegos.
Prueba la frontera por capas
Tests de dominio
Prueba los aggregates sin modelo, red ni base de datos:
- se rechazan las tareas duplicadas
- las tareas válidas generan el evento esperado
- las colecciones expuestas no pueden modificar el estado interno
- las reglas de transición se mantienen tras operaciones repetidas
Tests de aplicación
Utiliza repositories y authorizers falsos para verificar la carga, el orden de autorización, los guardados con la versión esperada, el comportamiento del outbox y el mapeo de errores.
Evaluaciones del contrato del modelo
Evalúa el adapter probabilístico por separado:
- precisión de la extracción de la intención y los campos
- resolución de fechas relativas con el contexto de zona horaria proporcionado
- rechazo o solicitud de aclaraciones cuando falta información obligatoria
- resistencia frente a prompt injection dentro del texto de una tarea entrecomillado
- proporción de propuestas válidas según el esquema pero inutilizables semánticamente
Después, un test end-to-end debería confirmar que las propuestas incorrectas nunca pueden saltarse los mismos métodos de dominio que utilizan las interfaces de confianza.
Cuándo funciona el diseño
Deberías poder cambiar el proveedor del modelo sin modificar un test de dominio. Un cambio de política debería afectar a un único aggregate o domain service, en lugar de a varios prompts. Los traces deberían utilizar términos que el equipo responsable reconozca. Las propuestas malformadas o no autorizadas deberían fallar antes de la persistencia, y un guardado concurrente debería fallar en lugar de sobrescribir el estado silenciosamente.
DDD no hace determinista a un modelo. Hace suficientemente explícitas la autoridad, el lenguaje y las fronteras de consistencia del sistema para que el modelo no tenga que serlo.
Referencias
- Eric Evans, Domain-Driven Design Reference — definiciones de patrones estratégicos y tácticos
- Martin Fowler, Bounded Context — por qué un único modelo no debería abarcar todos los significados de un término
- Martin Fowler, Repository — abstracción de colección de persistencia
- Chris Richardson, Transactional Outbox — publicar después de una transacción de base de datos sin perder eventos
- Documentación de Pydantic — validación tipada de propuestas