Domain-Driven Design en la Práctica: Cómo Trazamos las Fronteras de Módulos
Patrones DDD prácticos para TypeScript y NestJS. Trazar fronteras de módulos, EventBus como capa anti-corrupción, cuándo DDD es excesivo, shared kernels y refactoring hacia fronteras.
DDD no va del Blue Book
La mayoría de artículos sobre DDD empiezan con agregados, objetos valor y eventos de dominio del libro de Eric Evans. Eso es teoría. En la práctica, el concepto más valioso del DDD se resume en una cosa: fronteras.
¿Dónde termina el módulo A y dónde empieza el módulo B? ¿Qué datos posee cada módulo? ¿Cómo se comunican? Cuando trazas bien las fronteras, el codebase se mantiene limpio mientras crece. Cuando las trazas mal, cada funcionalidad toca cada módulo, y el refactoring se vuelve imposible.
Hemos trazado fronteras de módulos en múltiples sistemas en producción: un plugin de Vendure con 6 módulos, una plataforma de ticketing con 8 servicios, y un PIM con 13 subscribers de eventos y orquestación de workers compleja. Este artículo cubre los patrones prácticos. Para la comunicación event-driven entre fronteras, consulta nuestra guía de arquitectura event-driven. Para patrones específicos de Vendure, consulta nuestra guía de arquitectura de plugins Vendure.
La regla de dependencia
La forma más simple de identificar fronteras de módulos: dibuja un grafo de dependencias. Si el módulo A importa algo del módulo B y el módulo B importa algo del módulo A, tienes una dependencia circular. La frontera está mal trazada.
// MAL: dependencias circulares
WishlistService importa LoyaltyService (para otorgar puntos)
LoyaltyService importa WishlistService (para verificar la wishlist para un bonus)
// BIEN: comunicación por eventos
WishlistService emite WishlistItemAddedEvent
LoyaltyService se suscribe a WishlistItemAddedEvent (otorga puntos)
LoyaltyService emite PointsAwardedEvent
WishlistService no le importa (no necesita suscripción)
La regla: las dependencias fluyen en una sola dirección. Si dos módulos necesitan comunicarse bidireccionalmente, usa eventos. Los eventos son unidireccionales por diseño. El emisor no sabe (ni le importa) quién se suscribe.
Cómo verificar
# Encontrar importaciones circulares en un proyecto TypeScript
npx madge --circular src/
# Encontrar importaciones entre módulos
grep -rn "from '.*wishlist.*'" src/modules/loyalty/
grep -rn "from '.*loyalty.*'" src/modules/wishlist/
Si estos comandos devuelven resultados, tienes violaciones de fronteras. Corrígelas extrayendo la preocupación compartida en un módulo separado o reemplazando la importación por un evento.
EventBus como capa anti-corrupción
El EventBus no es solo un sistema de mensajería. Es la capa anti-corrupción entre módulos. Cada módulo publica eventos que describen lo que pasó en su dominio. Otros módulos se suscriben y traducen esos eventos a los conceptos de su propio dominio.
// Módulo wishlist: publica evento de dominio
export class WishlistItemAddedEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public wishlistId: string,
public productVariantId: string,
public customerId: string,
) {
super();
}
}
// Servicio wishlist: emite evento después de la lógica de negocio
@Injectable()
export class WishlistService {
constructor(private eventBus: EventBus) {}
async addItem(ctx: RequestContext, input: AddItemInput): Promise<WishlistItem> {
// Lógica de negocio: validar, comprobar duplicados, guardar
const item = await this.saveItem(ctx, input);
// Publicar evento: "algo ocurrió en mi dominio"
await this.eventBus.publish(
new WishlistItemAddedEvent(ctx, input.wishlistId, input.productVariantId, ctx.activeUserId)
);
return item;
}
}
// Módulo loyalty: se suscribe y traduce a su propio dominio
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
// Suscribirse: traducir evento de wishlist a concepto de loyalty
this.eventBus.ofType(WishlistItemAddedEvent).subscribe(async event => {
await this.loyaltyService.awardPoints(
event.ctx,
event.customerId,
'wishlist_add', // Concepto propio de loyalty, no de wishlist
10, // Cantidad de puntos (decisión de loyalty)
);
});
}
}
Lo que la convierte en capa anti-corrupción
- El módulo wishlist no sabe que loyalty existe. Publica
WishlistItemAddedEventindependientemente de los suscriptores. - El módulo loyalty no importa servicios de wishlist. Solo depende de la clase de evento.
- La cantidad de puntos (10) es una decisión del dominio loyalty, no una preocupación de wishlist.
- Si loyalty se elimina, wishlist sigue funcionando sin cambios.
- Si un nuevo módulo (analytics) quiere reaccionar a los añadidos a wishlist, se suscribe al mismo evento. Cero cambios en wishlist.
Estructura de módulo en TypeScript/NestJS
Un módulo bien estructurado tiene una organización interna clara:
src/modules/wishlist/
├── entities/
│ ├── wishlist.entity.ts
│ └── wishlist-item.entity.ts
├── services/
│ └── wishlist.service.ts
├── resolvers/
│ ├── wishlist-shop.resolver.ts
│ └── wishlist-admin.resolver.ts
├── events/
│ └── wishlist.events.ts
├── schemas/
│ ├── wishlist-shop.schema.ts
│ └── wishlist-admin.schema.ts
├── types/
│ └── wishlist.types.ts
└── wishlist.module.ts
Registro del módulo
// wishlist.module.ts
@Module({
imports: [TypeOrmModule.forFeature([CiWishlist, CiWishlistItem])],
providers: [WishlistService, WishlistShopResolver, WishlistAdminResolver],
exports: [WishlistService], // Solo si otros módulos lo necesitan legítimamente
})
export class WishlistModule {}
El array exports es la API pública del módulo. Exporta solo lo que otros módulos realmente necesitan. La mayoría de módulos no deberían exportar nada (comunicarse vía eventos en su lugar).
Qué cruza las fronteras de módulos
| Cruza la frontera | Cómo |
|---|---|
| Eventos | Publicados vía EventBus, a los que otros módulos se suscriben |
| IDs de entidades | Pasados como valores primitivos (string/number), no como referencias de entidades |
| DTOs/interfaces | Tipos compartidos para payloads de eventos |
| NO cruza la frontera | Por qué |
|---|---|
| Instancias de servicios | Crea acoplamiento. Usa eventos. |
| Referencias de entidades | El módulo A no debería consultar las tablas del módulo B. |
| Acceso a repositorios | Cada módulo es dueño de sus propios datos. |
| Estado interno | Privado del módulo. |
Cuándo DDD es excesivo
Los conceptos DDD aportan valor cuando el dominio es complejo y el equipo es grande. Para muchas aplicaciones, patrones más simples funcionan mejor.
| Situación | ¿DDD? | Mejor enfoque |
|---|---|---|
| Aplicación CRUD con reglas de negocio simples | No | Patrón MVC/servicio-repositorio estándar |
| 2-3 ingenieros, un solo bounded context | No | Monolito bien organizado con carpetas claras |
| MVP de startup (product-market fit desconocido) | No | Avanza rápido, refactoriza después cuando el dominio se estabilice |
| Sistema empresarial con 6+ dominios de negocio distintos | Sí | Bounded contexts con fronteras de módulos claras |
| Varios equipos trabajando en el mismo codebase | Sí | La propiedad de módulos se alinea con la propiedad de equipos |
| Reglas de negocio complejas que varían según el contexto | Sí | Los modelos de dominio capturan las reglas explícitamente |
El punto medio pragmático
No necesitas agregados, eventos de dominio y capas anti-corrupción para un blog con comentarios. SÍ necesitas fronteras de módulos y comunicación por eventos para una plataforma de comercio con wishlist, reseñas, fidelización, recuperación de carrito y alertas de vuelta en stock.
El punto medio: organiza el código en módulos con fronteras claras y comunicación por EventBus. No implementes patrones DDD completos a menos que la complejidad del dominio lo justifique. Tres líneas de código similares son mejor que una abstracción prematura.
Shared Kernel: el código que no es de nadie
Algo de código es genuinamente compartido entre módulos. Constantes, funciones utilitarias, tipos base e interfaces comunes. Esto es el shared kernel.
src/shared/
├── constants/
│ └── index.ts # TABLE_NAMES, permissions, etc.
├── types/
│ └── common.types.ts # DTOs compartidos, tipos de paginación
├── utils/
│ └── date.utils.ts # Formato de fechas, helpers de zonas horarias
└── errors/
└── common.errors.ts # Clases de error base
Reglas para el shared kernel:
- Mínimo: solo lo que genuinamente necesita compartirse. Si lo usa un solo módulo, pertenece a ese módulo.
- Estable: los cambios en el shared kernel afectan a todos los módulos. Debería cambiar raramente.
- Sin lógica de negocio: solo utilidades, tipos y constantes. Las reglas de negocio pertenecen a los módulos de dominio.
- Propiedad del equipo de plataforma (o explícitamente de nadie). Si el shared kernel no tiene dueño, se convierte en un cajón de sastre.
Refactoring hacia fronteras
La mayoría de codebases no empiezan con fronteras de módulos limpias. Evolucionan desde un monolito donde todo importa todo. El refactoring hacia fronteras es un proceso gradual.
Paso 1: mapear las dependencias actuales
# Generar grafo de dependencias
npx madge --image dependency-graph.svg src/
# Encontrar los archivos más importados (puntos calientes de acoplamiento)
grep -rn "import.*from" src/ --include="*.ts" | awk -F"from " '{print $2}' | sort | uniq -c | sort -rn | head 20
Paso 2: identificar las fronteras naturales
Busca grupos de archivos que cambian juntos. Si wishlist.service.ts, wishlist.entity.ts y wishlist.resolver.ts siempre cambian en la misma PR, son un módulo natural.
Paso 3: extraer un módulo a la vez
Iteración 1: Mover los archivos de wishlist a src/modules/wishlist/
Iteración 2: Reemplazar las importaciones directas con eventos
Iteración 3: Mover los archivos de loyalty a src/modules/loyalty/
Iteración 4: Reemplazar las importaciones directas con eventos
...
No intentes extraer todos los módulos a la vez. Extrae uno, verifica que funciona, luego extrae el siguiente. Cada extracción debería ser una PR separada con sus propios tests.
Paso 4: hacer cumplir las fronteras
// Regla ESLint para prevenir importaciones entre módulos
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['*/modules/loyalty/*'],
message: 'El módulo wishlist no puede importar de loyalty. Usa EventBus.',
},
],
}],
},
};
Haz cumplir las fronteras con linting, no con documentación. La documentación se ignora. Los errores de lint bloquean las PR.
Errores comunes
-
Empezar con DDD en lugar de refactorizar hacia él. En un proyecto nuevo, empieza simple. Añade fronteras de módulos cuando la complejidad del dominio lo exija. No diseñes agregados para una app CRUD.
-
Exportar todo. Si un módulo exporta todos sus servicios, otros módulos los importarán. No exportes nada por defecto. Usa eventos para la comunicación entre módulos.
-
Consultas compartidas de base de datos entre módulos. El módulo A no debería escribir SQL que haga joins con las tablas del módulo B. Cada módulo es dueño de sus datos. Si A necesita datos de B, B los expone vía un evento o un método de servicio público.
-
Eventos con demasiados datos. Un evento debería llevar IDs y contexto mínimo, no entidades enteras. El suscriptor obtiene lo que necesita de su propio data store.
-
Sin cumplimiento. Fronteras de módulos sin reglas de lint son sugerencias. Las sugerencias se violan bajo presión de deadlines. Las reglas de lint bloquean las violaciones.
-
Abstracción prematura. Tres funciones similares en tres módulos está bien. Extraer una abstracción compartida antes de entender el patrón crea la abstracción equivocada. Espera hasta la tercera vez.
-
Vocabulario DDD sin comprensión DDD. Nombrar cosas "aggregate" y "value object" sin entender su propósito es arquitectura cargo cult. El propósito es capturar reglas del dominio, no impresionar a los reviewers de código.
Puntos clave
-
DDD trata de fronteras, no de patrones. ¿Dónde termina un módulo y empieza otro? ¿Qué posee cada módulo? ¿Cómo se comunican? Responde bien a esto y el codebase se mantiene limpio.
-
Los eventos son la capa anti-corrupción. El EventBus previene dependencias circulares y mantiene los módulos independientes. Los emisores no conocen a los suscriptores. La comunicación es unidireccional.
-
No exportes nada por defecto. Los servicios de un módulo son privados a menos que haya necesidad legítima de exportarlos. La mayoría de la comunicación entre módulos debería ir por eventos.
-
Empieza simple, refactoriza hacia fronteras. No diseñes agregados el primer día. Construye la funcionalidad, observa las fronteras naturales, luego extrae módulos.
-
Haz cumplir con linting, no con documentación. Las reglas ESLint que previenen importaciones entre módulos son más efectivas que las páginas wiki que describen la estructura de módulos.
-
El shared kernel es mínimo y estable. Solo constantes, tipos y utilidades. Sin lógica de negocio. Los cambios en el shared kernel afectan a cada módulo.
Aplicamos estos patrones en nuestros proyectos de software a medida y nuestro desarrollo de plugins Vendure. Si necesitas ayuda con la arquitectura de tu codebase, habla con nuestro equipo o solicita un presupuesto. Consulta también nuestra guía de ingeniería de software para nuestros principios de ingeniería más amplios.
Temas cubiertos
Guías relacionadas
Guía Empresarial de Sistemas de IA Agéntica
Guia tecnica de sistemas de IA agentica en entornos empresariales. Descubre la arquitectura, capacidades y aplicaciones de agentes IA autonomos.
Leer guíaComercio Agéntico: Cómo Dejar que los Agentes IA Compren de Forma Segura
Cómo diseñar comercio iniciado por agentes IA con gobernanza. Motores de políticas, puertas de aprobación HITL, recibos HMAC, idempotencia, aislamiento de tenants y el Agentic Checkout Protocol completo.
Leer guíaLos 9 Puntos Donde Tu Sistema de IA Filtra Datos (y Cómo Sellar Cada Uno)
Un mapa sistemático de cada lugar donde se filtran datos en sistemas de IA. Prompts, embeddings, logs, llamadas a herramientas, memoria de agentes, mensajes de error, caché, datos de fine-tuning y handoffs entre agentes.
Leer guía¿Listo para construir sistemas de IA listos para producción?
Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.
Iniciar una conversación