Skip to content

Contratos entre Módulos (MODULE_CONTRACTS.md)

Estado: Contrato arquitectónico normativo para ninaku-app-api.

Este documento define las reglas estrictas de colaboración entre módulos de negocio dentro del monolito modular de Ninaku. Complementa a ARCHITECTURE.md y consolida la especificación definitiva para las fronteras entre módulos en tiempo de compilación.

El objetivo es preservar una propiedad inmutable de los datos y hechos de negocio, manteniendo la colaboración de forma explícita, fuertemente tipada, comprobable y comprensible. Ninaku es una sola aplicación desplegable por defecto; los límites entre módulos no implican llamadas HTTP internas ni una base de datos independiente por módulo.


1. Regla Principal

Un módulo de negocio puede exponer sus capacidades funcionales únicamente a través de contratos públicos explícitos bajo su carpeta contracts/. Ningún módulo tiene permitido acceder a repositorios internos, consultas SQL, servicios de infraestructura o manejadores de aplicación de otro módulo.

Cada hecho de negocio tiene un solo módulo propietario. La colaboración entre módulos transfiere información o solicita acciones, pero nunca transfiere la propiedad del dato.

ts
// ❌ INCORRECTO: Violación de fronteras (acceso directo a repositorio ajeno)
class ConfirmSaleUseCase {
  constructor(
    private readonly inventoryRepository: InventoryRepository,
    private readonly paymentRepository: PaymentRepository,
  ) {}
}

// ✅ PREFERIDO: Colaboración explícita mediante Contratos Públicos
class ConfirmSaleUseCase {
  constructor(
    private readonly inventory: InventoryContract,
    private readonly payments: PaymentsContract,
  ) {}
}

En este ejemplo, Sales solicita a Inventory que ejecute una capacidad de reserva. Sales nunca escribe ni muta directamente el estado de Inventory.


2. Disposición Interna de un Módulo

Un módulo de negocio evoluciona hacia la siguiente estructura cuando sus responsabilidades realmente existen:

text
src/modules/inventory/
├── domain/                  <-- Reglas de negocio puras, agregados y eventos de dominio
├── application/             <-- Casos de uso, orquestación y puertos internos
├── infrastructure/          <-- Adaptadores de persistencia, proveedores y servicios
├── presentation/            <-- Controladores HTTP, DTOs y catálogo de fallos públicos
├── contracts/               <-- SUPERFICIE PÚBLICA IN-PROCESS DEL MÓDULO
└── inventory.module.ts      <-- Módulo NestJS que declara providers y exports

La carpeta contracts/ representa la superficie pública que otros módulos tienen permitido consumir. Todo lo demás (domain/, application/, infrastructure/, presentation/) es privado e interno del módulo.

  • Módulos pequeños: Pueden mantener un directorio de contratos plano:
    text
    contracts/
    ├── inventory.contract.ts
    └── inventory.contract.types.ts
  • Módulos grandes: Pueden categorizar sus archivos de contrato:
    text
    contracts/
    ├── commands/
    ├── queries/
    ├── results/
    ├── events/
    └── inventory.contract.ts

Nota sobre errores HTTP: El catálogo de fallos públicos (PublicFailure) no vive en contracts/. Los códigos de error públicos son consumidos por clientes HTTP externos, no por otros módulos de NestJS. Por ende, residen en presentation/failures/.


3. Contrato vs. Puerto

Los términos Contrato (Contract) y Puerto (Port) no son intercambiables en la arquitectura de Ninaku Core.

A. Contrato Público de Módulo (Contract)

Un Contrato describe lo que un módulo ofrece a otros módulos de Ninaku.

text
Sales Application Use Case
    ↓ (consume)
PaymentsContract (Clase Abstracta bajo src/modules/payments/contracts/)
    ↓ (implementado por)
PaymentsApplicationService
ts
// src/modules/payments/contracts/payments.contract.ts
export abstract class PaymentsContract {
  abstract authorize(input: AuthorizePaymentInput): Promise<AuthorizePaymentResult>;
}

B. Puerto de Aplicación / Dominio (Port)

Un Puerto describe una dependencia que el propio módulo requiere de la infraestructura técnica o de un proveedor externo.

text
Payments Application
    ↓ (requiere)
PaymentGatewayPort (Interfaz bajo src/modules/payments/application/ports/)
    ↓ (implementado por)
DatafastAdapter (Servicio bajo src/modules/payments/infrastructure/)
ts
// src/modules/payments/application/ports/payment-gateway.port.ts
export interface PaymentGatewayPort {
  authorize(input: GatewayAuthorizationInput): Promise<GatewayAuthorizationResult>;
}

Regla nemotécnica:

  • Contrato = Lo que el módulo OFRECE hacia afuera (contracts/).
  • Puerto = Lo que el módulo NECESITA desde adentro (application/ports/).

4. Modos de Colaboración Permitidos

Ninaku utiliza tres modos principales de colaboración en proceso. Resuelven problemas distintos y no se deben colapsar en un bus genérico de mensajería.

4.1 Contrato Síncrono en Proceso

Se utiliza cuando el módulo llamador necesita el resultado inmediato para completar su caso de uso.

text
Confirm Sale Use Case

InventoryContract.reserveStock(...)

Inventory Application
  • La ejecución se realiza dentro del mismo proceso de NestJS (sin llamadas HTTP internas).
  • Cuando la invariante de negocio exige atomicidad entre varios propietarios, la llamada puede participar en la misma transacción de PostgreSQL compartiendo un objeto opaco TransactionContext provisto por Foundation.

4.2 Contrato de Lectura (Read Contract)

Se utiliza cuando un módulo o un compositor de consultas de pantalla necesita información perteneciente a otro módulo.

text
POS Workspace Query Composer
   ├── CatalogReadContract
   ├── PricingReadContract
   ├── InventoryReadContract
   └── CustomerReadContract
  • Los contratos de lectura retornan DTOs de proyección orientados a tareas.
  • No exponen entidades del ORM, firmas de repositorios ni SQL builders.

4.3 Evento Durable / Outbox

Se utiliza para consecuencias que no necesitan completarse en forma síncrona con el caso de uso iniciador, o que cruzan un límite externo/recuperable.

text
Sales Transaction
    ├── Actualiza estado propiedad de Sales
    └── Inserta evento de integración SaleConfirmed en la tabla outbox
             ↓ COMMIT
      Outbox Dispatcher (BullMQ)

      Procesadores de eventos interesados (Notifications, Analytics, etc.)
  • Las llamadas a redes o servicios externos nunca se ejecutan dentro de la transacción SQL de PostgreSQL. Utilizan el patrón Outbox/Inbox para garantizar reintentos e idempotencia.

5. Diseño de Contratos Públicos

Un contrato público debe:

  • Usar lenguaje e intenciones de negocio, no nombres de tablas SQL.
  • Exponer una cantidad acotada y significativa de operaciones.
  • Utilizar tipos explícitos para entradas (Input), comandos y resultados (Result).
  • Hacer explícito el contexto de tenant (organizationId, businessUnitId) cuando sea necesario.
  • Evitar la fuga de detalles de tecnología de persistencia o SDKs de terceros.
ts
// ✅ PREFERIDO
inventory.reserveStock(input)
payments.authorize(input)
pricing.resolvePrice(input)

// ❌ RECHAZADO
inventory.save(entity)
payments.execute('action', payload)
repository.findAll(table, filters)

6. Lo Que Los Contratos NUNCA Deben Exponer

Los siguientes elementos son detalles internos de implementación y tienen prohibido formar parte de la superficie de un contrato público:

  1. Instancias de pg.Pool o pg.PoolClient.
  2. Constructores de consultas SQL (Kysely, Knex) o fragmentos de SQL crudo.
  3. Repositorios de persistencia pertenecientes al módulo.
  4. Entidades o modelos del ORM/base de datos.
  5. Tipos de peticiones/respuestas directos de SDKs de terceros.
  6. Operaciones CRUD genéricas que permitan a otro módulo mutar libremente tablas ajenas.

7. Convención de Composición en NestJS

Dado que las interfaces de TypeScript no existen en tiempo de ejecución, un contrato inyectable requiere un token de runtime. Ninaku utiliza clases abstractas como tipo de contrato y token de inyección de dependencias (DI):

ts
// src/modules/inventory/contracts/inventory.contract.ts
export abstract class InventoryContract {
  abstract reserveStock(input: ReserveStockInput): Promise<ReserveStockResult>;
}

El módulo propietario vincula el contrato público con su servicio de aplicación interno:

ts
// src/modules/inventory/inventory.module.ts
@Module({
  providers: [
    InventoryApplicationService,
    {
      provide: InventoryContract,
      useExisting: InventoryApplicationService,
    },
  ],
  exports: [InventoryContract],
})
export class InventoryModule {}

Los módulos consumidores importan InventoryModule e inyectan únicamente la clase abstracta InventoryContract.


8. Regla de Transacciones Compartidas Entre Módulos

El caso de uso que inicia la operación es el dueño del límite de la transacción. Cuando la atomicidad exige que varios módulos participen:

text
Application Use Case (Sales)

TenantTransactionRunnerService (Foundation)
    ├── SalesContract/Application escribe en tablas de Sales
    ├── InventoryContract/Application escribe en tablas de Inventory
    └── Outbox escribe el evento de integración

COMMIT / ROLLBACK

Reglas obligatorias:

  • La transacción la abre y gestiona Foundation mediante TenantTransactionRunnerService.
  • Los módulos participantes reciben un contexto de transacción opaco (TransactionContext).
  • Un contrato público nunca recibe ni expone pg.PoolClient.
  • Un módulo convierte internamente el TransactionContext opaco en una sesión de consulta mediante la función guardián asDatabaseSession(context) dentro de sus propios repositorios en infrastructure/.
  • Los repositorios no crean transacciones anidadas ocultas.

9. Eventos de Dominio vs. Eventos de Integración

  • Evento de Dominio: Mensaje interno del módulo que notifica un cambio de estado dentro del mismo límite del agregado. No se expone a otros módulos.
  • Evento de Integración: Mensaje público, durable e inmutable que se emite a través de la tabla outbox para ser consumido por otros módulos o procesos asíncronos.

Los eventos de integración deben ser minimalistas, versionados, serializables en JSON y libres de clases del dominio interno o tipos de drivers de base de datos.


10. Fronteras de Importación y Reglas de Linter R1–R9

El script de verificación estática tooling/repository/check-module-boundaries.ts (ejecutado por npm run lint:module-boundaries y por npm run verify) evalúa y hace cumplir nueve reglas innegociables (R1–R9) en cada compilación:

ReglaRegla EnforzadaQué Prohíbe ConcretamentePor Qué ExisteError Concreto que Evita
R1Un archivo bajo src/modules/<a>/** solo puede importar de src/modules/<b>/** a través de src/modules/<b>/contracts/**.Importar repositorios, entidades, servicios de aplicación o controladores internos de otro módulo.Preserva el aislamiento modular dentro del monolito.Evita que refactorizaciones internas en el módulo B rompan la compilación o lógica del módulo A.
R2Los archivos bajo src/modules/*/{domain,application,contracts,presentation}/** solo pueden importar el TIPO TransactionContext desde foundation/database/transaction-context.ts; no pueden importar pg, database-session.ts, ni los nombres DatabaseSession, asDatabaseSession o TRANSACTION_CONTEXT_BRAND.Importar el driver de PostgreSQL (pg) o funciones de ejecución SQL directa en capas que no sean infrastructure/.Mantiene la abstracción transaccional opaca en el dominio y la aplicación.Evita que casos de uso o controladores ejecuten consultas SQL crudas o administren transacciones directamente.
R3Los archivos bajo src/modules/*/domain/** no pueden importar @nestjs/*, zod, pg, foundation/http/**, foundation/database/** ni foundation/transactions/**.Importar frameworks web, ORMs, librerías de validación o infraestructura dentro del Dominio.Preserva la pureza y portabilidad del Dominio en Clean Architecture / DDD.Evita que las reglas puras de negocio queden acopladas a NestJS o PostgreSQL, imposibilitando tests unitarios puros.
R4Los archivos bajo src/foundation/** no pueden importar src/modules/** ni src/verticals/**.Importar cualquier código de negocio o vertical desde la capa base de infraestructura.foundation/ provee capacidades genéricas y debe ser completamente agnóstico al dominio.Evita acoplamientos circulares donde la infraestructura base depende de módulos comerciales.
R5Un archivo bajo src/verticals/<v>/** solo puede importar de otro dueño (src/modules/<m>/** o src/verticals/<other>/**) únicamente a través de su contracts/**.Que una vertical acceda a capas internas de un módulo o de otra vertical.Garantiza que los productos verticales compongan capacidades públicas estándar.Evita mutaciones directas en tablas de módulos compartidos sin pasar por las validaciones del módulo.
R6TenantTransactionRunnerService y SystemTransactionRunnerService solo se importan desde src/modules/*/application/** y src/verticals/*/application/**.Importar los ejecutores de transacciones desde domain/, presentation/ o infrastructure/.La capa de aplicación es la única responsable de coordinar la unidad de trabajo transaccional.Evita transacciones abiertas desde controladores HTTP o repositorios administrando su propio ciclo de vida.
R7Los archivos bajo src/modules/<m>/{domain,application,contracts,presentation}/** no pueden importar src/modules/<m>/infrastructure/**.Importaciones apuntando hacia capas exteriores de infraestructura dentro del mismo módulo.Aplica el principio de Inversión de Dependencias (Inward Dependency Rule).Evita el acoplamiento rígido de casos de uso a implementaciones técnicas concretas o adaptadores de base de datos.
R8Los archivos bajo src/modules/** no pueden importar src/verticals/**, ni siquiera su contracts/**.Que un módulo de negocio compartido conozca o importe una vertical específica.Los módulos del Core son agnósticos y reutilizables por cualquier vertical.Evita contaminar el Core con condicionales por vertical (ej. if (vertical === 'restaurant')).
R9SystemTransactionRunnerService solo se importa desde los directorios permitidos explícitamente (SYSTEM_TRANSACTION_RUNNER_ALLOWED_DIRECTORIES).Usar transacciones sin contexto de tenant en flujos ordinarios de la aplicación.Obliga al uso de TenantTransactionRunnerService para proteger el aislamiento RLS por defecto.Evita fugas de aislamiento tenant donde una operación de negocio olvida aplicar el contexto de la organización.

Excepción Permitida en R9 (SYSTEM_TRANSACTION_RUNNER_ALLOWED_DIRECTORIES)

SystemTransactionRunnerService abre transacciones sin aplicar contexto de tenant (organization_id), por lo que su uso está restringido mediante una lista blanca explícita:

  • src/modules/identity/application: Permitido porque las operaciones de registro inicial de usuario, autenticación de credenciales y búsqueda global de identidades ocurren antes de que exista o se seleccione un contexto de organización activo.

11. Compatibilidad y Cambios Rompedores

Los contratos in-process residen en la misma unidad de despliegue. Si un contrato público necesita un cambio incompatible:

  1. Se refactorizan todas las llamadas en los módulos consumidores dentro de la misma Pull Request.
  2. Se ejecutan las pruebas de integración del sistema.
  3. No se dejan capas de compatibilidad intermedias ni código deprecado dentro del repositorio.

12. Colaboración en Verticales

Las verticales (restaurant, retail, hospitality) son consumidoras de módulos del Core compartidos y deben respetar exactamente las mismas reglas de fronteras:

text
src/verticals/restaurant/
    ├── CatalogContract    (de src/modules/catalog/contracts/)
    ├── SalesContract      (de src/modules/sales/contracts/)
    ├── PaymentsContract   (de src/modules/payments/contracts/)
    └── PrintingContract   (de src/modules/printing/contracts/)

Código específico de restaurante (ej. comandas de cocina o asignación de mesas) nunca modifica directamente las tablas SQL de sales o catalog.


13. Antipatrones Rechazados Explícitamente

  1. Llamadas HTTP internas: Prohibido realizar peticiones HTTP entre módulos del mismo monolito.
  2. Importar repositorios ajenos: Un módulo nunca inyecta un repositorio de otro módulo.
  3. Exponer clientes SQL: Prohibido exportar pg.PoolClient o instancias de base de datos en contratos.
  4. Bus genérico no tipado: Prohibido usar patrones como eventBus.publish('any_string', payload) para colaboración síncrona ordinaria.
  5. Condicionales por vertical en el Core: Prohibido escribir bifurcaciones de código basadas en la vertical dentro de módulos compartidos.

14. Definición de Hecho (Definition of Done) para una Colaboración entre Módulos

Antes de dar por completada una integración entre módulos, se debe verificar:

  1. ¿Se identifica claramente cuál módulo es el propietario único del dato?
  2. ¿La llamada síncrona utiliza un contrato abstracto bajo contracts/?
  3. ¿El contrato expone únicamente DTOs y tipos de negocio sin fugas de persistencia?
  4. Si la operación requiere atomicidad, ¿se utiliza TenantTransactionRunnerService con un TransactionContext opaco?
  5. Si la operación es asíncrona, ¿se utiliza la tabla outbox y un procesador idempotente?
  6. ¿El comando npm run verify (y en particular npm run lint:module-boundaries) pasa en verde sin violaciones de R1–R9?

15. Índice de Contratos Publicados

Cada contrato real, una vez implementado, se agrega a esta tabla en el mismo cambio que lo publica.

ContratoMódulo propietarioArchivoConsumidores actuales
PlatformAuditContractauditsrc/modules/audit/contracts/platform-audit.contract.tsidentity (diez transiciones de seguridad de plataforma: cambio y recuperación de contraseña, alta/baja de método de inicio de sesión, bloqueo de cuenta, cierre de sesión y cierre de sesión en todas partes, alta/baja de segundo factor, emisión de códigos de recuperación)

16. Modelo Mental Práctico

text
Lo que mi módulo OFRECE a otros        → src/modules/<m>/contracts/
Lo que mi módulo NECESITA externamente  → src/modules/<m>/application/ports/
Cómo lo implemento técnicamente        → src/modules/<m>/infrastructure/
Cómo la API HTTP me alcanza            → src/modules/<m>/presentation/
Cómo protejo las reglas de negocio     → src/modules/<m>/domain/ + application/

Application Foundation in progress. Tracked in issue #13.