Skip to content

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:

TierWhoImports fromNever imports
App developerBuilds applicationsContract packages, driver packages, @modularityjs/modularityinversify
Package developerBuilds extensions/drivers@modularityjs/di, the owning contract packageinversify, DI container internals
Framework developerBuilds 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():

typescript
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: inversify to createApp() 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:

typescript
@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:

typescript
@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 metadata

Key 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 — not HttpModule or CliModule.
  • Extensions import the contract, not the driver. An http-cli package imports HttpModule from @modularityjs/http. It never needs to know whether Fastify or Express is running.
  • The app is the only place that chooses the driver:
typescript
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/stderr

Transports filter by channel (routing key) and level (minimum severity):

typescript
// 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_PACKAGES allowlist (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.

typescript
// @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 imports

This 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 in dependencies or peerDependencies.
  • Every entry in dependencies/peerDependencies must be imported in production source.
  • Every @modularityjs/* in peerDependencies must have a devDependencies mirror.
  • devDependencies entries 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

DomainContractDriversHTTP ExtensionCLI Extension
Cachecachecache-memory, cache-rediscache-cli
Locklocklock-memory, lock-redislock-cli
Queuequeuequeue-memory, queue-redisqueue-cli
Eventseventsevents-memory, events-redisevents-cli
Storagestoragestorage-memory, storage-local, storage-s3http-storagestorage-cli
Loggerloggerlogger-console, logger-file
Sessionsessionsession-memory, session-redishttp-session
Databasedatabasedatabase-typeorm, database-prismadatabase-cli
Schedulerschedulerscheduler-cronerscheduler-cli
Healthhealthredis-healthhealth-cli
Authauthauth-jwt, auth-api-key, auth-oidc, auth-oidc-introspection, auth-local, auth-local-scrypt, auth-local-bcrypthttp-authauth-cli
Authzauthzauthz-rbac, authz-policyhttp-authzauthz-cli
MFAmfamfa-totp, mfa-sms, mfa-email, mfa-backup-codes, mfa-webauthnhttp-mfa
HTTPhttphttp-fastifyhttp-cli
CLIclicli-commander
DIdidi-inversify
Scopescope
Configconfigconfig-env
Secretssecretssecrets-memory, secrets-vault, secrets-aws, secrets-gcp
Encryptionencryptionencryption-aes
Rate Limitrate-limitrate-limit-memory, rate-limit-redishttp-rate-limitrate-limit-cli
i18ni18ni18n-jsonhttp-i18n
Validationvalidationvalidation-zod, validation-ajvhttp-validation
Webhookwebhookwebhook-memory, webhook-direct, webhook-queue, webhook-eventshttp-webhookwebhook-cli
Outboxoutboxoutbox-memory, outbox-typeorm, outbox-prismaoutbox-cli
Feature Flagsfeature-flagsfeature-flags-static, feature-flags-growthbookhttp-feature-flags
WebSocketwsws-fastifyws-cli
Mailmailmail-nodemailer, mail-memory, mail-queue
SMSsmssms-twilio, sms-memory, sms-queue
Notificationnotificationnotification-mail, notification-sms
Media (image)media-imagemedia-image-sharp
Media (video)media-videomedia-video-ffmpeg
Media (audio)media-audiomedia-audio-ffmpeg
Templatetemplatetemplate-handlebars, template-ejs
Assetsassetsassets-localhttp-assetsassets-cli

Several domains have additional extensions beyond HTTP:

  • Config: config-scope and config-env-scope adapt config to the scope system (ScopedConfigService / ScopedEnvConfigSource preferences).
  • Secrets: secrets-config contributes a ConfigSource (priority 1500) so secrets resolve through ConfigService.resolve().
  • Outbox: outbox-events (publishes through EventBus), outbox-scheduler (cron dispatch + retention).
  • Template: template-assets registers AssetUrlHelper in TemplateHelpersPool so layouts use the helper to resolve content-hashed URLs.
  • HTTP: abstract extensions http-htmx, http-csrf, http-validation, http-upload plus Fastify-driver extensions http-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.

KindPatternExamples
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>-clicache-cli, http-cli, auth-cli (kind: extension extending [<contract>, cli]; never cli-<name>)
Primitivebare nounmodularity, exception, retry, validation-schema — zero-dep utilities importable anywhere without module wiring
Toolingbare project nouncoding-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 concatenationActual nameCollapsed
http-auth × auth-jwthttp-auth-jwtauth
http-auth × auth-oidchttp-auth-oidcauth
http-auth × auth-api-keyhttp-auth-api-keyauth
http-auth × auth-localhttp-auth-localauth
http-auth × http-sessionhttp-auth-sessionhttp

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):

  1. Register — all module classes are collected
  2. Sort — topological sort based on imports
  3. Pre-bind validate — module metadata, duplicate names, version compatibility, import boundaries, and pool uniqueness are checked
  4. Bind providers and preferences — DI providers and preference overrides are registered in the container
  5. Post-bind validate — contract fulfilment, factory dependencies, and named injection sites are checked against what was bound
  6. Bind pool entries and overrides — pool contributions, slot bindings, and Override(...) dispatch tables are wired
  7. LifecycleafterLoad -> 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.

typescript
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.