Backend del módulo¶
El backend de un módulo tiene dos mitades: el paquete puro (modules/<slug>/) con la lógica de dominio, y el host NestJS (apps/api/src/modules/<slug>/) que lo expone por HTTP y le cablea la infraestructura.
El contrato DidactaModule¶
src/index.ts del paquete exporta el módulo con sus lifecycle hooks:
import type { DidactaModule, ModuleContext } from '@didacta/core-kernel';
import { manifest } from './manifest';
export const miModulo: DidactaModule = {
manifest,
async onRegister(ctx: ModuleContext) {
// 1 vez por proceso, sin tenant. Aquí van las suscripciones al event bus.
},
async onEnable(tenantId: string, ctx: ModuleContext) {
// Cada vez que un tenant activa el módulo.
},
async onDisable(tenantId: string, ctx: ModuleContext) {
// El tenant lo desactiva. Los datos SE CONSERVAN.
},
async onUninstall(tenantId: string, ctx: ModuleContext) {
// Desinstalación. Los datos se archivan, no se borran.
},
};
export { manifest, MiModuloService };
Dos patrones de export, ambos en uso:
- Objeto directo (
export const coursesModule: DidactaModule = {...}) — cuando el módulo no necesita nada del host para construirse. - Factory (
export function buildAccessGroupsModule(deps): DidactaModule) — cuando necesita inyección desde el host (adapters de Stripe, resolvers de usuarios, funciones de IA…).
Las suscripciones de eventos no se apagan solas
onRegister corre una vez por proceso y sin tenant; onDisable no desuscribe del bus. Si tu handler reacciona a eventos, comprueba dentro si el módulo está activo para el tenantId del evento antes de tocar datos.
El ModuleContext¶
El módulo nunca crea infraestructura: la recibe.
| Servicio | Para qué |
|---|---|
eventBus |
Publicar y suscribirse a eventos de dominio (outbox transaccional). |
hookRegistry |
Registrar y disparar hooks síncronos entre módulos. |
storage |
Subir ficheros e imágenes (con optimización automática). |
auditLog |
Registrar acciones auditables. |
evidenceVault |
Evidencias con integridad (Fundae, cumplimiento). |
notificationHub |
Notificar por email / in-app / webhook respetando las preferencias del usuario. |
i18n |
Textos localizados. |
logger |
Logging estructurado (Pino). |
config |
Configuración por (tenantId, módulo, clave). |
Registro en el arranque¶
El registro de módulos es explícito (no hay autodiscovery de carpetas): añade tu módulo al array de registry.register([...]) en apps/api/src/modules/module-registry.service.ts. En ese mismo fichero el host construye tus dependencias (services de dominio, puertos de IA, publishers) si usas el patrón factory.
En el arranque, el registro:
- Valida
coreVersionRequiredcontra la versión del core. - Ordena los módulos topológicamente por dependencias (detecta ciclos y versiones incompatibles con errores tipados).
- Ejecuta
onRegisterde cada uno. - Persiste los manifests en la tabla
module(upsert por nombre).
El host NestJS¶
En apps/api/src/modules/<slug>/ viven los adaptadores HTTP:
apps/api/src/modules/access-groups/
├── access-groups.controller.ts # rutas bajo /modules/access-groups
├── access-groups.service.ts # service con Prisma real
├── access-groups-courses.bridge.ts # suscriptor de courses.course.published
└── access-groups-tiers.bridge.ts # suscriptor de payment_connections.user_tier.changed
Reglas del host:
- El controller usa
@Controller('modules/<slug>')— coherente con elapiNamespacedel manifest. El interceptor de acceso por módulo gatea esas rutas automáticamente cuando el tenant lo tiene desactivado. - Sin lógica de negocio en el controller: valida con Zod (
ZodValidationPipepor parámetro), delega en el service. - Los errores de dominio se mapean a HTTP con un exception filter propio del módulo (patrón
courses-error.filter.ts), registrado en el módulo NestJS correspondiente. - Declara el módulo NestJS en
apps/api/src/modules/modules.module.ts.
Módulos condicionados por configuración externa¶
Si tu módulo depende de una credencial externa (como mod.billing/mod.subscriptions de sus claves de Stripe), el patrón es: el módulo se registra siempre — nunca gatees el arranque completo por si falta una config, porque en multi-tenant esa config puede vivir POR TENANT (panel de Administración, tenant_setting cifrado) y no se sabe hasta la primera llamada real si un tenant concreto la tiene. En su lugar, resuelve la credencial dentro de cada método (un stripeFor(tenantId) o equivalente, nunca cacheado a nivel de instancia) y lanza un error de dominio tipado si falta — un exception filter propio del módulo lo mapea a 503 con un mensaje claro. Así un tenant sin configurar no tumba nada para el resto, y el mismo despliegue puede tener tenants con y sin la integración activa al mismo tiempo.