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 applications@modularityjs/core, driver packagesInternal framework packages, inversify
Package developerBuilds extensions/drivers@modularityjs/di, contract packagesinversify, 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>(key: string): Promise<T | undefined>;
  abstract set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
  abstract delete(key: string): Promise<void>;
  abstract has(key: string): Promise<boolean>;
}

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.

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:

  1. Register — all module classes are collected
  2. Sort — topological sort based on imports
  3. Bind — DI providers and preferences are registered in the container
  4. Validate — contracts, pools, and import boundaries are checked
  5. 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',
  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.