The module's backend¶
A module's backend has two halves: the pure package (modules/<slug>/) holding the domain logic, and the NestJS host (apps/api/src/modules/<slug>/) that exposes it over HTTP and wires up its infrastructure.
The DidactaModule contract¶
The package's src/index.ts exports the module with its lifecycle hooks:
import type { DidactaModule, ModuleContext } from '@didacta/core-kernel';
import { manifest } from './manifest';
export const miModulo: DidactaModule = {
manifest,
async onRegister(ctx: ModuleContext) {
// Once per process, with no tenant. Event bus subscriptions go here.
},
async onEnable(tenantId: string, ctx: ModuleContext) {
// Every time a tenant enables the module.
},
async onDisable(tenantId: string, ctx: ModuleContext) {
// The tenant disables it. Data IS PRESERVED.
},
async onUninstall(tenantId: string, ctx: ModuleContext) {
// Uninstall. Data is archived, not deleted.
},
};
export { manifest, MiModuloService };
Two export patterns, both in use:
- A plain object (
export const coursesModule: DidactaModule = {...}) — when the module needs nothing from the host to be constructed. - A factory (
export function buildAccessGroupsModule(deps): DidactaModule) — when it needs injection from the host (Stripe adapters, user resolvers, AI functions…).
Event subscriptions do not switch themselves off
onRegister runs once per process and with no tenant; onDisable does not unsubscribe from the bus. If your handler reacts to events, check inside it whether the module is active for the event's tenantId before touching data.
The ModuleContext¶
A module never creates infrastructure: it receives it.
| Service | What it is for |
|---|---|
eventBus |
Publishing and subscribing to domain events (transactional outbox). |
hookRegistry |
Registering and firing synchronous hooks between modules. |
storage |
Uploading files and images (with automatic optimisation). |
auditLog |
Recording auditable actions. |
evidenceVault |
Evidence with integrity guarantees (Fundae, compliance). |
notificationHub |
Notifying by email / in-app / webhook, honouring the user's preferences. |
i18n |
Localised copy. |
logger |
Structured logging (Pino). |
config |
Configuration per (tenantId, module, key). |
Registration at startup¶
Module registration is explicit (there is no folder autodiscovery): add your module to the registry.register([...]) array in apps/api/src/modules/module-registry.service.ts. In that same file the host builds your dependencies (domain services, AI ports, publishers) if you use the factory pattern.
At startup, the registry:
- Validates
coreVersionRequiredagainst the core version. - Orders the modules topologically by dependency (detecting cycles and incompatible versions with typed errors).
- Runs each module's
onRegister. - Persists the manifests in the
moduletable (upsert by name).
The NestJS host¶
apps/api/src/modules/<slug>/ is where the HTTP adapters live:
apps/api/src/modules/access-groups/
├── access-groups.controller.ts # routes under /modules/access-groups
├── access-groups.service.ts # service with real Prisma
├── access-groups-courses.bridge.ts # subscriber to courses.course.published
└── access-groups-tiers.bridge.ts # subscriber to payment_connections.user_tier.changed
Host rules:
- The controller uses
@Controller('modules/<slug>')— consistent with the manifest'sapiNamespace. The per-module access interceptor gates those routes automatically when the tenant has the module disabled. - No business logic in the controller: validate with Zod (
ZodValidationPipeper parameter) and delegate to the service. - Domain errors are mapped to HTTP with the module's own exception filter (the
courses-error.filter.tspattern), registered in the corresponding NestJS module. - Declare the NestJS module in
apps/api/src/modules/modules.module.ts.
Modules that depend on external configuration¶
If your module depends on an external credential (as mod.billing/mod.subscriptions depend on their Stripe keys), the pattern is: the module always registers itself — never gate the whole startup on a missing configuration, because in a multi-tenant setup that configuration may live PER TENANT (the admin panel, an encrypted tenant_setting) and you cannot know until the first real call whether a given tenant has it. Instead, resolve the credential inside each method (a stripeFor(tenantId) or equivalent, never cached at instance level) and throw a typed domain error if it is missing — the module's own exception filter maps it to a 503 with a clear message. That way an unconfigured tenant brings nothing down for the rest, and the same deployment can have tenants with and without the integration enabled at the same time.