Arquitectura de Software

Domain-Driven Design: Modules, cohesión y responsabilidades para organizar el dominio

Aprende a definir Modules a partir de responsabilidades del negocio, ofrecer interfaces públicas y controlar sus dependencias. Con un ejemplo ejecutable en TypeScript, distingue un módulo de una carpeta, una layer o un Bounded Context y establece límites útiles para trabajar con IA.

Cargando tus opciones de aprendizaje…

Una academia cambia cómo abre las inscripciones de sus cursos. Para hacerlo, el equipo revisa controllers, varios archivos de services y una carpeta repositories. Cada lugar contiene una parte del recorrido, mezclada con componentes de evaluaciones y certificados.

La organización permite encontrar controladores, pero no responder fácilmente quién controla la apertura de una convocatoria o qué debe utilizar el proceso de inscripción para consultarla. Una herramienta de IA encuentra una clase interna, la importa directamente y termina dependiendo de cómo otro componente guarda sus datos.

El problema no se resuelve cambiando todos los nombres de las carpetas. Necesitamos agrupaciones que expresen responsabilidades del negocio y límites que indiquen qué puede conocer cada consumidor. En DDD, los Modules ayudan a organizar el modelo con ese propósito.

Un Module debe contar una parte del modelo

Un Module agrupa conceptos relacionados para que puedas comprenderlos juntos sin cargar mentalmente con todo el sistema. Evans propone buscar agrupaciones con alta cohesión y bajo acoplamiento, y nombrarlas mediante el lenguaje del negocio, en la sección Modules de su referencia de DDD.

Cohesion describe cuánto se relacionan las responsabilidades internas de una agrupación. course-planning tiene una explicación coherente si reúne convocatorias, fechas y condiciones de apertura. Meter allí el envío de cualquier correo de la plataforma solo porque contiene nombres de cursos debilita esa explicación.

Coupling describe las dependencias entre componentes. Si Inscripciones necesita conocer columnas, clases internas y reglas de reconstrucción de Planificación, existe más acoplamiento que si consulta una capacidad pública concreta.

El objetivo no es eliminar toda dependencia: las partes deben colaborar. Se trata de que dependan de información y comportamientos que el propietario esté dispuesto a ofrecer, sin necesitar sus detalles internos.

Del lenguaje a un límite útil

Para proponer un Module, empieza por decisiones y cambios reales:

  • ¿Qué conceptos necesitas comprender juntos para explicar una regla?
  • ¿Quién decide sobre ellos?
  • ¿Qué suelen modificar juntos los cambios del negocio?
  • ¿Qué información necesita otra parte y cuál debería permanecer interna?

En nuestra academia, Planificación decide si una edición admite inscripciones. Inscripciones registra quién participa y comprueba que no exista una inscripción duplicada. Ambas responsabilidades colaboran, pero no son idénticas.

Esto no obliga a separar cada sustantivo. student, course, date y status no constituyen automáticamente cuatro Modules. Una clase por carpeta puede dispersar una única regla tanto como una carpeta gigantesca puede mezclar responsabilidades distintas.

Si para explicar una operación necesitas saltar constantemente entre dos supuestos Modules, revisa si el límite está cortando conceptos que deberían permanecer juntos.

Una organización técnica y una organización del negocio pueden convivir

Esta estructura parcial organiza principalmente por tipo de componente:

src/
  controllers/
    enrollment-controller.ts
    certificate-controller.ts
  services/
    enrollment-service.ts
    certificate-service.ts
  repositories/
    enrollment-repository.ts
    certificate-repository.ts

Puede servir en una aplicación pequeña. Su limitación aparece cuando esas carpetas crecen: cada responsabilidad queda distribuida entre archivos que comparten ubicación por su función técnica, no por el concepto que representan.

Una alternativa es agrupar primero por responsabilidad y conservar las layers dentro de cada agrupación. Esta propuesta de estructura es ilustrativa; no son archivos de un proyecto existente:

src/
  learning/
    course-planning/
      domain/
      application/
      public.ts
    enrollments/
      domain/
      application/
      infrastructure/
      presentation/
      public.ts

Una layer separa tipos de responsabilidad, como coordinación y mecanismos técnicos. Un Module da nombre a una parte coherente del modelo. Son dos ejes compatibles, pero no equivalentes.

En sentido estricto, el Module de Domain organiza conceptos del modelo; agrupar además sus componentes de aplicación e infraestructura bajo la misma raíz es una elección de organización del código. No todo archivo de esa raíz se convierte por ello en Domain.

No necesitas crear carpetas vacías para completar la estructura. Un Domain Service puede vivir junto a los conceptos de Domain con los que colabora; su ubicación debe ayudar a entender la responsabilidad que expresa.

Ejemplo completo: Planificación e Inscripciones colaboran

Los acuerdos que guían los imports

Usaremos dos Modules dentro del mismo Bounded Context educativo: comparten un modelo coherente de edición e inscripción. Permitiremos imports entre ellos únicamente por sus archivos public.ts.

Planificación ofrece consultar una edición. Inscripciones define el contrato mínimo que necesita para conocer su disponibilidad. Un punto de ensamblaje adapta ambos contratos; Inscripciones no importa ni siquiera la implementación pública de Planificación.

El grafo será:

Ensamblaje -> course-planning/public.ts
Ensamblaje -> enrollments/public.ts

Adaptador de ensamblaje:
  consulta Planificación -> traduce a contrato de Inscripciones

Dentro de cada Module:
  public.ts -> Application o implementación propia -> Domain propio

Esta inversión de dependencia es una elección del ejemplo para mostrar un contrato del consumidor. No exige introducir un adaptador para cada llamada pequeña dentro de un modelo compartido.

Las reglas serán: solo se admite la inscripción si la edición existe y está abierta; no se repite el identificador de inscripción ni la pareja estudiante-edición. No modelaremos cupos, pagos ni listas de espera.

Archivos del programa

Este proyecto es autocontenido y no requiere dependencias externas. Guarda los archivos indicados con esta estructura:

modules-example/
  package.json
  course-planning/
    domain/course-edition.ts
    public.ts
  enrollments/
    domain/enrollment.ts
    application/enroll-student.ts
    public.ts
  modules.test.ts

Contenido de package.json:

{
  "name": "modules-example",
  "private": true,
  "type": "module"
}

Usaremos Node.js 24.21.0, que puede ejecutar esta sintaxis TypeScript mediante type stripping. Esa ejecución no incluye comprobación estática de tipos. Los datos del ejemplo se construyen localmente; las entradas externas necesitarían validación de estructura en su límite de entrada.

Planificación mantiene su representación interna

Archivo course-planning/domain/course-edition.ts:

export class CourseEdition {
  #id: string;
  #title: string;
  #enrollmentOpen: boolean;

  constructor(id: string, title: string, enrollmentOpen: boolean) {
    if (!id.trim() || !title.trim() || typeof enrollmentOpen !== 'boolean') {
      throw new Error('Definición de edición inválida');
    }
    this.#id = id;
    this.#title = title;
    this.#enrollmentOpen = enrollmentOpen;
  }

  describe(): { id: string; title: string; enrollmentOpen: boolean } {
    return { id: this.#id, title: this.#title, enrollmentOpen: this.#enrollmentOpen };
  }
}

Archivo course-planning/public.ts:

import { CourseEdition } from './domain/course-edition.ts';

export interface CoursePlanningApi {
  findEdition(id: string): {
    id: string;
    title: string;
    enrollmentOpen: boolean;
  } | null;
}

export function createCoursePlanningModule(
  initial: readonly { id: string; title: string; enrollmentOpen: boolean }[],
): CoursePlanningApi {
  const editions = new Map<string, CourseEdition>();
  for (const definition of initial) {
    if (editions.has(definition.id)) throw new Error('Edición duplicada');
    editions.set(definition.id, new CourseEdition(
      definition.id, definition.title, definition.enrollmentOpen,
    ));
  }
  return {
    findEdition(id) {
      return editions.get(id)?.describe() ?? null;
    },
  };
}

El consumidor recibe una descripción, no el objeto CourseEdition ni el Map interno. Planificación podría sustituir después el booleano por una política de fechas manteniendo el significado del contrato público. Si cambia su significado o su sincronía, sí habrá que revisar a sus consumidores.

Inscripciones define lo que necesita conocer

Archivo enrollments/domain/enrollment.ts:

export class Enrollment {
  #id: string;
  #studentId: string;
  #editionId: string;

  constructor(id: string, studentId: string, editionId: string) {
    if (![id, studentId, editionId].every(value => value.trim().length > 0)) {
      throw new Error('La inscripción necesita identificadores no vacíos');
    }
    this.#id = id;
    this.#studentId = studentId;
    this.#editionId = editionId;
  }

  describe(): { id: string; studentId: string; editionId: string } {
    return { id: this.#id, studentId: this.#studentId, editionId: this.#editionId };
  }
}

Archivo enrollments/application/enroll-student.ts:

import { Enrollment } from '../domain/enrollment.ts';

export interface EditionAvailabilityReader {
  read(editionId: string): Promise<{ exists: boolean; acceptingEnrollments: boolean }>;
}

export interface EnrollmentApi {
  enroll(id: string, studentId: string, editionId: string): Promise<{
    id: string; studentId: string; editionId: string;
  }>;
}

export class EnrollStudent implements EnrollmentApi {
  #availability: EditionAvailabilityReader;
  #registered = new Map<string, Enrollment>();

  constructor(availability: EditionAvailabilityReader) {
    this.#availability = availability;
  }

  async enroll(id: string, studentId: string, editionId: string) {
    const enrollment = new Enrollment(id, studentId, editionId);
    const availability = await this.#availability.read(editionId);
    if (!availability.exists) throw new Error('Edición inexistente');
    if (!availability.acceptingEnrollments) throw new Error('Inscripciones cerradas');

    const duplicate = this.#registered.has(id) || [...this.#registered.values()]
      .some(existing => {
        const view = existing.describe();
        return view.studentId === studentId && view.editionId === editionId;
      });
    if (duplicate) throw new Error('Inscripción duplicada');
    this.#registered.set(id, enrollment);
    return enrollment.describe();
  }
}

Archivo enrollments/public.ts:

import { EnrollStudent } from './application/enroll-student.ts';
import type {
  EditionAvailabilityReader, EnrollmentApi,
} from './application/enroll-student.ts';

export type { EditionAvailabilityReader, EnrollmentApi };

export function createEnrollmentModule(
  availability: EditionAvailabilityReader,
): EnrollmentApi {
  return new EnrollStudent(availability);
}

No exportamos Enrollment ni EnrollStudent desde la entrada pública. Ofrecemos una capacidad y el contrato que necesita para funcionar. Usar exportaciones explícitas permite revisar esa decisión; export * sobre todos los archivos haría públicas más dependencias de las necesarias.

El Map de inscripciones pertenece al estado en memoria de esta demostración. Está junto al Use Case para evitar introducir persistencia ajena al tema; no es una propuesta para organizar una base de datos en Application. Una implementación real separaría ese mecanismo y definiría sus garantías de guardado.

Ensamblaje, entradas y resultados comprobables

Archivo modules.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import { createCoursePlanningModule } from './course-planning/public.ts';
import {
  createEnrollmentModule, type EditionAvailabilityReader,
} from './enrollments/public.ts';

function createExample() {
  const planning = createCoursePlanningModule([
    { id: 'typescript-october', title: 'TypeScript', enrollmentOpen: true },
    { id: 'architecture-november', title: 'Arquitectura', enrollmentOpen: false },
  ]);

  const availability: EditionAvailabilityReader = {
    async read(editionId) {
      const edition = planning.findEdition(editionId);
      return {
        exists: edition !== null,
        acceptingEnrollments: edition?.enrollmentOpen ?? false,
      };
    },
  };
  return { planning, enrollments: createEnrollmentModule(availability) };
}

test('una edición abierta admite una inscripción y rechaza duplicados', async () => {
  const { enrollments } = createExample();
  assert.deepEqual(await enrollments.enroll('enrollment-1', 'ana', 'typescript-october'), {
    id: 'enrollment-1', studentId: 'ana', editionId: 'typescript-october',
  });
  await assert.rejects(
    () => enrollments.enroll('enrollment-2', 'ana', 'typescript-october'), /duplicada/,
  );
});

test('el consumidor usa disponibilidad y no detalles internos', async () => {
  const { enrollments } = createExample();
  await assert.rejects(
    () => enrollments.enroll('enrollment-1', 'ana', 'architecture-november'), /cerradas/,
  );
  await assert.rejects(
    () => enrollments.enroll('enrollment-2', 'ana', 'missing'), /inexistente/,
  );
});

test('la descripción de Planificación no permite modificar su estado', () => {
  const { planning } = createExample();
  const view = planning.findEdition('typescript-october');
  assert.ok(view);
  view.enrollmentOpen = false;
  assert.equal(planning.findEdition('typescript-october')?.enrollmentOpen, true);
});

test('las entradas públicas exponen solo las funciones acordadas en ejecución', async () => {
  const planning = await import('./course-planning/public.ts');
  const enrollments = await import('./enrollments/public.ts');
  assert.deepEqual(Object.keys(planning), ['createCoursePlanningModule']);
  assert.deepEqual(Object.keys(enrollments), ['createEnrollmentModule']);
});

Desde la carpeta del proyecto, ejecuta:

node --test modules.test.ts

El recorrido empieza consultando Planificación, traduce su descripción a disponibilidad y registra una inscripción. La segunda solicitud para la misma pareja se rechaza. Las ediciones cerradas o inexistentes no producen una inscripción.

Las cuatro pruebas se ejecutaron y pasaron con Node.js 24.21.0. Usan entradas públicas. La última comprueba exportaciones presentes durante la ejecución; los contratos de tipos se eliminan al ejecutar y no se realizó comprobación estática con TypeScript. Ninguna de estas pruebas demuestra por sí sola que todos los imports del proyecto respeten los límites.

El programa conserva datos solo durante la ejecución. La consulta de apertura y el registro no son una operación atómica con Planificación; el ejemplo no resuelve un cierre concurrente, cupos ni coordinación entre procesos. Esas garantías requieren acuerdos e implementación adicionales.

Una interfaz pública no vuelve privados todos los archivos

Un archivo con imports o exports es un módulo en el sistema de TypeScript y JavaScript, como explica el handbook de TypeScript. Ese significado técnico no equivale al Module conceptual de DDD: nuestro Module agrupa varios archivos.

En este proyecto, public.ts establece una convención. Otro archivo aún podría escribir esta dependencia no permitida por el diseño:

// Fragmento que muestra una violación del límite, no una instrucción de uso.
import { CourseEdition } from './course-planning/domain/course-edition.ts';

El runtime no la prohíbe por el nombre domain. Para controlar ese límite necesitas revisión y, según el proyecto, reglas automáticas que detecten imports entre Modules a través de rutas internas. Deben considerar también reexportaciones y dependencias de tipos, no solo llamadas durante la ejecución.

Si conviertes una agrupación en un paquete, el campo exports de Node.js puede limitar sus entradas mediante resolución de paquetes. La documentación de Node.js también advierte que esa encapsulación no es una barrera absoluta frente a accesos por rutas directas. No necesitas introducir paquetes ahora para explicar las reglas de acceso.

Module, carpeta, layer, paquete y Bounded Context

Una carpeta agrupa archivos. Puede representar un Module si refleja una responsabilidad y sus límites, pero no los crea por existir.

Una layer separa tipos de responsabilidad técnica o de diseño. Puede organizar componentes dentro de un Module sin describir por sí misma qué parte del negocio representan.

Un paquete es una unidad de organización y distribución de código. Puede contener uno o varios Modules; no necesita corresponder a un despliegue independiente.

Un Bounded Context delimita dónde un modelo y su lenguaje mantienen un significado consistente. Un Module organiza conceptos dentro de ese modelo. Fowler explica esa delimitación en su artículo sobre Bounded Context.

Nuestro ejemplo permite colaboración directa porque ambos Modules pertenecen al mismo Bounded Context. Si Planificación e Inscripciones pertenecieran a contextos distintos, habría que revisar la integración. En un diseño que exige separación entre contextos, no importarían sus implementaciones: podrían colaborar mediante una Anticorruption Layer, o ACL, HTTP con contratos del consumidor. Esa consulta y el guardado local no constituyen una transacción compartida.

Comercio: compartir una clase no siempre reduce el acoplamiento

Una tienda coloca todo lo reutilizable en shared: Money, cliente, pedido, descuentos y acceso a facturación. Al principio evita imports largos; después cualquier cambio afecta a todos los consumidores.

La decisión importante es distinguir un concepto pequeño con significado estable de responsabilidades que solo comparten nombre. Un Money común podría ser razonable si sus reglas son iguales para los consumidores. Un Customer universal con atributos comerciales, fiscales y de entrega puede obligarlos a evolucionar juntos.

Antes de extraer, identifica quién es propietario del concepto y qué compatibilidad promete. Duplicar una representación pequeña puede ser preferible a crear una dependencia común equivocada. Reutilizar código es útil cuando reutilizas también el mismo significado.

Este escenario enseña a evaluar el coste de un Module compartido, no a prohibirlo por principio.

Logística: un ciclo de imports puede revelar un límite incorrecto

Supón que shipments importa reglas internas de routes y routes importa estados internos de shipments. Una modificación necesita cambios coordinados en ambos sentidos.

No se resuelve necesariamente moviendo tipos a shared o usando imports dinámicos. Primero pregunta si las reglas forman una sola responsabilidad, si una dependencia puede invertirse mediante un contrato concreto o si una operación de Application debe coordinar ambas sin mezclarlas.

Un ciclo de imports es una señal para investigar, no una prueba automática de que debas fusionar los Modules. También puede existir acoplamiento excesivo sin ningún ciclo.

Este ejemplo aporta otra decisión: controlar la dirección del conocimiento, además de reducir lo que cada consumidor necesita conocer.

Evolucionar los límites sin romper el comportamiento

Empieza por una responsabilidad y sus escenarios. Define la entrada pública, mueve los componentes relacionados y cambia a sus consumidores para que utilicen esa entrada. Revisa imports residuales y conserva pruebas de comportamiento durante el cambio.

La evolución interna puede volverse más localizada, pero no equivale a independencia absoluta: cambiar un contrato público sigue afectando a consumidores. Si modificas la sincronía, los errores o el significado de una respuesta, revisa esas consecuencias explícitamente.

Evita un index.ts global que reexporte toda la aplicación. Puede ocultar de dónde procede cada dependencia y facilitar ciclos. Un archivo de entrada por Module es útil cuando expresa una selección deliberada, no cuando funciona como inventario automático de archivos.

Cuándo basta una organización más sencilla

Una aplicación pequeña con una sola responsabilidad puede empezar con pocos archivos y una entrada clara. No necesita un Module por Entity ni cuatro layers repetidas por cada concepto.

Introduce nuevos límites cuando permiten comprender reglas juntas, aislar cambios reales o controlar dependencias que ya resultan problemáticas. Si cada operación trivial atraviesa cinco Modules y todos cambian juntos, revisa si la separación está fragmentando una responsabilidad coherente.

Prompts para orientar a la IA con límites explícitos

Proponer agrupaciones a partir de evidencia

Analiza esta estructura y los tres cambios de negocio adjuntos. Propón Modules con responsabilidades, componentes incluidos, consumidores y dudas de límites. No edites archivos ni dividas automáticamente por Entity. Distingue agrupaciones dentro de un Bounded Context de posibles fronteras entre contextos. Una persona validará el significado de los términos y contrastará qué componentes cambian juntos.

Implementar sin abrir detalles internos

Añade la comprobación de apertura a Inscripciones. Planificación solo se consume mediante su entrada pública; Inscripciones define la disponibilidad que necesita y el ensamblaje adapta ambos contratos. No importes clases internas ni añadas paquetes o frameworks. Entrega el cambio y pruebas de edición abierta, cerrada e inexistente. Una persona revisará todos los imports nuevos y ejecutará las pruebas del contrato.

Revisar el acoplamiento del cambio

Revisa el diff contra el mapa de Modules y dependencias adjunto. Busca imports internos entre Modules, reexportaciones que amplíen la interfaz pública, ciclos y conceptos de negocio enviados a shared sin propietario. No modifiques archivos. Devuelve evidencia, efecto y alternativas; marca incertidumbres. Una persona comprobará también los imports de tipos y decidirá si el límite sigue representando una responsabilidad coherente.

No basta con pedir «código modular». Debes proporcionar responsabilidades y dependencias permitidas. Después revisa la implementación: una herramienta puede respetar nombres de carpetas mientras crea imports que anulan sus límites.

Tres ejercicios con solución razonada

1. Cambiar la representación de apertura

Planificación reemplaza #enrollmentOpen por fechas internas, pero sigue ofreciendo el mismo resultado en findEdition(). ¿Qué debería cambiar en Inscripciones?

Solución razonada: nada, si el significado del contrato permanece igual y el adaptador sigue obteniendo los mismos hechos. Las pruebas deberían confirmar sus escenarios. Si ahora la apertura depende del reloj, Planificación necesita pruebas de sus límites temporales; ocultar el detalle no elimina esa responsabilidad.

2. Reexportar todas las Entities

La IA propone export * para evitar que otros Modules necesiten rutas internas. ¿Es suficiente?

Solución razonada: convierte detalles internos en dependencias públicas. Revisa qué capacidad necesita realmente cada consumidor y exporta solo lo acordado. Cambiar la ruta de un import no reduce el conocimiento que ese consumidor tiene sobre la implementación.

3. Crear un Module de fechas

Dos responsabilidades usan fechas y se propone agrupar sus reglas en dates. Una calcula plazos de entrega; otra, vigencia de certificados. ¿Las agruparías?

Solución razonada: compartir el tipo de dato no demuestra cohesión. Los plazos logísticos y la vigencia académica tienen decisiones distintas. Una utilidad de formato podría compartirse, pero las políticas deben conservar su significado y propietario. Si una modificación exige alterar ambas, investiga si existe una regla común real antes de extraerla.

Cómo reconocer Modules que ayudan a comprender el sistema

Un Module útil tiene una explicación breve en términos del negocio, reúne conceptos que necesitas entender juntos y ofrece una entrada deliberada a sus consumidores. Sus límites aparecen tanto en la estructura como en los imports y las pruebas.

Elige una responsabilidad concreta, define qué necesita ofrecer y revisa qué conocimiento puede permanecer dentro. Esa combinación de cohesión y dependencias controladas hace que la organización del código represente decisiones, no solo ubicaciones de archivos.

Cargando conversación…

Sigue aprendiendo