Architecture
Overview
ModularityJS is a modular framework for TypeScript. It provides structured module loading, dependency injection with override capabilities, pool-based service discovery, lifecycle hooks, and boot-time contract validation.
The framework has zero coupling to any specific DI container, HTTP server, or runtime. Pure TypeScript with an abstract DI contract.
Abstraction Boundary
The framework defines three consumer tiers with strict import rules:
| Tier | Who | Imports from | Never imports |
|---|---|---|---|
| App developer | Builds applications | Contract packages, driver packages, @modularityjs/modularity | inversify |
| Package developer | Builds extensions/drivers | @modularityjs/di, the owning contract package | inversify, DI container internals |
| Framework developer | Builds the framework itself | @modularityjs/di, @modularityjs/modularity | (full access) |
The golden rule: no code outside @modularityjs/di-inversify imports from inversify directly.
DI Architecture
Decorators (@Injectable, @Inject, etc.) write framework-owned metadata directly on classes at import time using Reflect.defineMetadata. No DI driver is needed at decorator time — decorators are self-contained.
The DI driver is passed explicitly to createApp():
import { inversify } from '@modularityjs/di-inversify';
const app = await createApp({
di: inversify,
modules: [...],
});At container binding time, the driver translates framework metadata into the underlying DI container's format. This means:
- No global state or side-effect imports
- Tests pass
di: inversifytocreateApp()explicitly - The driver is type-safe and swappable
Contract/Driver Pattern
Every pluggable service follows the same pattern:
contract package -> abstract class (e.g. CacheService)
driver package(s) -> concrete class extending the abstract (e.g. MemoryCacheService)Contracts are abstract classes decorated with @Injectable(). They serve as both TypeScript types and DI tokens:
@Injectable()
export abstract class CacheService {
abstract get<T = unknown>(key: string): Promise<T | undefined>;
abstract set<T = unknown>(
key: string,
value: T,
options?: CacheSetOptions,
): Promise<void>;
abstract delete(key: string): Promise<void>;
abstract has(key: string): Promise<boolean>;
abstract invalidateTag(tag: string): Promise<void>;
abstract invalidateTags(tags: string[]): Promise<void>;
// Atomic operations
abstract compareAndSet<T>(
key: string,
expected: T | undefined,
next: T,
options?: CacheSetOptions,
): Promise<boolean>;
abstract increment(
key: string,
delta?: number,
options?: CacheSetOptions,
): Promise<number>;
abstract decrement(
key: string,
delta?: number,
options?: CacheSetOptions,
): Promise<number>;
}Drivers register a preference — a DI override that binds the abstract contract to a concrete implementation:
@Module({
name: 'cache-memory',
imports: [CacheModule],
providers: [MemoryCacheService],
preferences: [{ provide: CacheService, useClass: MemoryCacheService }],
})
export class CacheMemoryModule {}The contract module never depends on any driver. Dependency flows one way: driver -> contract.
Service Contracts vs Module-Level Contracts
The pattern above covers service contracts — abstract classes resolved via @Inject(). Some packages use a more structured variant: module-level contracts with pool-based discovery.
Service Contract (Cache, Lock, Queue, Storage, Logger)
A single abstract class serves as both the TypeScript type and the DI token. Drivers provide a concrete implementation via a preference. Consumers inject the abstract class directly.
@modularityjs/cache -> CacheService (abstract class)
@modularityjs/cache-memory -> MemoryCacheService (preference: CacheService -> MemoryCacheService)Module-Level Contract (HTTP, CLI)
The contract package owns a Module class that declares a pool contract, provides configuration, and offers forRoot(). The driver package owns a separate Module that imports the contract module and adds the concrete implementation.
contract package (e.g. @modularityjs/http):
HttpModule — declares contracts: [HttpControllersPool], provides HttpModuleConfig
HttpControllersPool — pool token
@Controller, @Get — decorators writing framework-owned metadata
driver package (e.g. @modularityjs/http-fastify):
HttpFastifyModule — imports: [HttpModule], builds Fastify routes from metadataKey rules:
- The contract Module stays in the contract package. It declares the pool and provides config. No external implementation dependencies.
- The driver Module is named after the driver.
HttpFastifyModule,CliCommanderModule— notHttpModuleorCliModule. - Extensions import the contract, not the driver. An
http-clipackage importsHttpModulefrom@modularityjs/http. It never needs to know whether Fastify or Express is running. - The app is the only place that chooses the driver:
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
const app = await createApp({
di: inversify,
modules: [
HttpModule.forRoot({ port: 3000 }), // contract — config
HttpFastifyModule, // driver — implementation
MyControllersModule, // extension — depends on contract only
],
});This separation means you can swap HttpFastifyModule for a hypothetical HttpExpressModule without touching any extension or consumer module.
Transport-Pool Contract (Logger)
The logger uses a variant where the service is concrete in the contract and dispatches to pluggable transports via a pool. This applies when the dispatch logic is always the same — only the output destinations vary.
contract package (@modularityjs/logger):
LoggerService — concrete: dispatches to transports from pool
LoggerFactory — concrete: creates scoped/channel loggers
LogTransport — abstract: what transports implement
LogTransportPool — pool token
driver package (@modularityjs/logger-console):
ConsoleTransport — writes to stdout/stderrTransports filter by channel (routing key) and level (minimum severity):
// Default logger — routes to transports listening to 'default' channel
@Inject(LoggerService) logger: LoggerService;
logger.info('hello');
// Scoped logger — same channel, adds [label] prefix to output
const http = factory.create('http-server');
// Channel logger — routes to transports registered for 'payment' channel
const payment = factory.createChannel('payment');Multiple transports can coexist — a console transport for default logs, a file transport for payment logs, a wildcard transport for everything.
Module Import Boundaries
The framework enforces that a package's TypeScript imports match the module's declared dependencies. If a source file imports from @modularityjs/foo, then the package's module must list the corresponding module in its imports array.
This is checked statically by pnpm check-boundaries and enforced in CI.
Rules:
INFRASTRUCTURE_PACKAGESallowlist (di,modularity,exception,retry,validation-schema) is always allowed without module wiring.- All other
@modularityjs/*imports must correspond to a module in the@Module({ imports: [...] })decorator. - Test files (
__tests__/) are excluded.
Re-export rule: if a contract package uses a type from its dependency in a public interface, it must re-export that type. This way consumers import from their declared dependency rather than reaching through the boundary.
// @modularityjs/authz/src/index.ts — re-exports AuthIdentity used in AuthzChecker
export type { AuthIdentity } from '@modularityjs/auth';
// @modularityjs/authz-rbac/src/rbac-authz.service.ts — imports from declared dependency
import type { AuthIdentity, AuthzChecker } from '@modularityjs/authz';Example violation:
Module boundary violations:
@modularityjs/authz-rbac
src/rbac-authz.service.ts:1 — imports @modularityjs/auth
not declared in module importsThis means authz-rbac imports from @modularityjs/auth but only declares imports: [AuthzModule]. The fix is either to add AuthModule to imports, or (more commonly) to re-export the needed type from @modularityjs/authz.
Dependency Strictness
Every package's package.json must accurately reflect its imports — no missing deps, no unused deps. This is checked by pnpm check-dependencies and enforced in CI.
Rules:
- Every package imported in production source (
src/, excluding tests) must be independenciesorpeerDependencies. - Every entry in
dependencies/peerDependenciesmust be imported in production source. - Every
@modularityjs/*inpeerDependenciesmust have adevDependenciesmirror. devDependenciesentries must be peer-dep mirrors, standard tooling (eslint,typescript,vitest,@types/*), or imported in tests/config files.
Indirect test dependencies (packages needed at runtime during tests but not directly imported, like sql.js for TypeORM or fastify for HTTP integration tests) are accepted automatically — check-dependencies walks the dependencies and peerDependencies of every workspace package imported in tests, and the peerDependencies of every external peerDependency declared in the current package. The transitives that show up via either path are treated as legitimate devDeps.
Package Graph
Every pluggable domain follows the same shape: a contract defines the abstraction, one or more drivers implement it, and optional extensions decorate or adapt it for other systems (HTTP, CLI, scope, telemetry).
All Domains
| Domain | Contract | Drivers | HTTP Extension | CLI Extension |
|---|---|---|---|---|
| Cache | cache | cache-memory, cache-redis | cache-cli | |
| Lock | lock | lock-memory, lock-redis | lock-cli | |
| Queue | queue | queue-memory, queue-redis | queue-cli | |
| Events | events | events-memory, events-redis | events-cli | |
| Storage | storage | storage-memory, storage-local, storage-s3 | http-storage | storage-cli |
| Logger | logger | logger-console, logger-file | ||
| Session | session | session-memory, session-redis | http-session | |
| Database | database | database-typeorm, database-prisma | database-cli | |
| Scheduler | scheduler | scheduler-croner | scheduler-cli | |
| Health | health | redis-health | health-cli | |
| Auth | auth | auth-jwt, auth-api-key, auth-oidc, auth-oidc-introspection, auth-local, auth-local-scrypt, auth-local-bcrypt | http-auth | auth-cli |
| Authz | authz | authz-rbac, authz-policy | http-authz | authz-cli |
| MFA | mfa | mfa-totp, mfa-sms, mfa-email, mfa-backup-codes, mfa-webauthn | http-mfa | |
| HTTP | http | http-fastify | http-cli | |
| CLI | cli | cli-commander | ||
| DI | di | di-inversify | ||
| Scope | scope | |||
| Config | config | config-env | ||
| Secrets | secrets | secrets-memory, secrets-vault, secrets-aws, secrets-gcp | ||
| Encryption | encryption | encryption-aes | ||
| Rate Limit | rate-limit | rate-limit-memory, rate-limit-redis | http-rate-limit | rate-limit-cli |
| i18n | i18n | i18n-json | http-i18n | |
| Validation | validation | validation-zod, validation-ajv | http-validation | |
| Webhook | webhook | webhook-memory, webhook-direct, webhook-queue, webhook-events | http-webhook | webhook-cli |
| Outbox | outbox | outbox-memory, outbox-typeorm, outbox-prisma | outbox-cli | |
| Feature Flags | feature-flags | feature-flags-static, feature-flags-growthbook | http-feature-flags | |
| WebSocket | ws | ws-fastify | ws-cli | |
mail | mail-nodemailer, mail-memory, mail-queue | |||
| SMS | sms | sms-twilio, sms-memory, sms-queue | ||
| Notification | notification | notification-mail, notification-sms | ||
| Media (image) | media-image | media-image-sharp | ||
| Media (video) | media-video | media-video-ffmpeg | ||
| Media (audio) | media-audio | media-audio-ffmpeg | ||
| Template | template | template-handlebars, template-ejs | ||
| Assets | assets | assets-local | http-assets | assets-cli |
Several domains have additional extensions beyond HTTP:
- Config:
config-scopeandconfig-env-scopeadapt config to the scope system (ScopedConfigService/ScopedEnvConfigSourcepreferences). - Secrets:
secrets-configcontributes aConfigSource(priority 1500) so secrets resolve throughConfigService.resolve(). - Outbox:
outbox-events(publishes throughEventBus),outbox-scheduler(cron dispatch + retention). - Template:
template-assetsregistersAssetUrlHelperinTemplateHelpersPoolso layouts use thehelper to resolve content-hashed URLs. - HTTP: abstract extensions
http-htmx,http-csrf,http-validation,http-uploadplus Fastify-driver extensionshttp-fastify-formbody,http-fastify-upload,http-fastify-cors,http-fastify-compression,http-fastify-security-headers.
Package Naming
Every package name is @modularityjs/<kebab-tokens>. The token shape encodes the package's kind:
The framework has only 5 kinds. The contract+driver pair is the GoF Bridge pattern (abstraction + implementor), so there is no separate "bridge" kind — packages that decorate, adapt, or integrate contracts are all extensions.
| Kind | Pattern | Examples |
|---|---|---|
| Contract | <domain> | cache, auth, lock, queue, mfa, http, feature-flags, rate-limit, di, plugins, scope, redis, view |
| Driver | <contract>-<implementation> | cache-memory, cache-redis, cli-commander, database-prisma, auth-jwt, feature-flags-growthbook, di-inversify |
| Sub-driver | <driver>-<strategy> | auth-local-bcrypt, auth-local-scrypt, media-audio-ffmpeg, media-video-ffmpeg, media-image-sharp (canonical per strategy) |
| Extension | <X>-<Y> or <X>-<feature> | http-auth, http-csrf, http-htmx, cache-telemetry, cache-cli, secrets-config, template-assets, http-fastify-cors — decorators, adapters, integrations; extends lists every target (1 or more) |
| CLI extension | <contract>-cli | cache-cli, http-cli, auth-cli (kind: extension extending [<contract>, cli]; never cli-<name>) |
| Primitive | bare noun | modularity, exception, retry, validation-schema — zero-dep utilities importable anywhere without module wiring |
| Tooling | bare project noun | coding-standard, create, manifest, mcp, testing — build-time only, not framework runtime |
Prefix-collapsing rule. When a package combines two names that already share a token, the duplicated token is dropped — each segment earns its place once:
| Naive concatenation | Actual name | Collapsed |
|---|---|---|
http-auth × auth-jwt | http-auth-jwt | auth |
http-auth × auth-oidc | http-auth-oidc | auth |
http-auth × auth-api-key | http-auth-api-key | auth |
http-auth × auth-local | http-auth-local | auth |
http-auth × http-session | http-auth-session | http |
Compact form: concatenate participants left-to-right; drop any token that's already the previous name's suffix. Most cross-contract extensions have no overlap so the rule is invisible (http-storage, notification-mail); it only fires when the two halves meet at a shared token.
Naming a new package is therefore mechanical — pick the kind, identify the participants, concatenate, collapse duplicates. Avoid introducing a new shape unless the existing kinds can't express the relationship.
Boot-Time Contract Fulfillment
Every class contract must have a provider when the application boots. If not, boot fails immediately with a clear error.
This means:
- Consuming code never needs null fallbacks
- If boot succeeds, every
@Inject(ServiceClass)will resolve - Missing drivers are caught at startup, not at runtime
Module Loading
Modules are loaded in topological order based on their imports declarations. This ensures that when a module's lifecycle hooks run, all its dependencies are already initialized.
The loading sequence (validation runs in multiple passes interleaved with binding, not as a single trailing step):
- Register — all module classes are collected
- Sort — topological sort based on
imports - Pre-bind validate — module metadata, duplicate names, version compatibility, import boundaries, and pool uniqueness are checked
- Bind providers and preferences — DI providers and preference overrides are registered in the container
- Post-bind validate — contract fulfilment, factory dependencies, and named injection sites are checked against what was bound
- Bind pool entries and overrides — pool contributions, slot bindings, and
Override(...)dispatch tables are wired - Lifecycle —
afterLoad->onInit-> (app.start) ->onReady
Pools
Pools are a mechanism for modules to contribute entries to a shared collection. For example, HTTP controllers, event listeners, and queue consumers are all discovered via pools.
const HttpControllersPool = createPool<object, 'class'>(
'modularityjs:http:controllers-pool',
);
// Module contributes to the pool
@Module({
name: 'my-http',
imports: [HttpModule],
providers: [MyController],
pools: [
{ pool: HttpControllersPool, key: 'my-controller', useClass: MyController },
],
})
class MyHttpModule {}Any service can then inject all pool entries using @InjectPool(HttpControllersPool).
Preferences
Preferences are the framework's DI override mechanism. When multiple modules declare a preference for the same token, last wins — the module loaded later takes priority.
This enables the contract/driver pattern: the driver module imports the contract module and declares a preference that overrides the abstract binding with a concrete implementation.
Preferences also chain transitively. A preference can target any bound class, not just a declared contract. The resolution follows the full alias chain at injection time, so Contract → DriverA → DriverB resolves to DriverB. This enables decorator and instrumentation patterns without touching the original modules. See Preferences — Transitive Chaining for details and examples.