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 | @modularityjs/core, driver packages | Internal framework packages, inversify |
| Package developer | Builds extensions/drivers | @modularityjs/di, contract packages | 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>(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:
@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.
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:
- Register — all module classes are collected
- Sort — topological sort based on
imports - Bind — DI providers and preferences are registered in the container
- Validate — contracts, pools, and import boundaries are checked
- 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',
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.