Skip to content

Modules

Defining a Module

Modules are classes decorated with @Module():

typescript
import { Module } from '@modularityjs/modularity';

@Module({
  name: 'my-module',
  imports: [OtherModule],
  providers: [MyService, MyConfig],
  contracts: [MyServicePool],
  preferences: [{ provide: AbstractService, useClass: ConcreteService }],
  pools: [{ pool: SomePool, key: 'my-entry', useClass: MyPoolEntry }],
})
class MyModule {}

Module Options

OptionDescription
nameUnique module name (required)
importsModules this module depends on
providersClasses to register in the DI container
contractsPool tokens or abstract classes this module owns
preferencesDI overrides (bind abstract to concrete)
poolsEntries to contribute to pool collections
overridesConstructor argument overrides for arbitrary classes (see Overrides)

Lifecycle Hooks

Modules can implement lifecycle interfaces to run code at specific points during boot and shutdown:

typescript
import type { OnModuleInit, OnModuleReady } from '@modularityjs/modularity';

@Module({ name: 'my-module' })
class MyModule implements OnModuleInit, OnModuleReady {
  onInit(): void {
    // Runs during createApp() — connect to databases, validate config
  }

  onReady(): void {
    // Runs during app.start() — start HTTP server, begin processing
  }
}

Hook Order

HookWhenUse for
afterLoadAfter all modules loaded, before initFramework-tier container.bind() and extension hook registrations
onInitDuring createApp()Connecting to external services, validating config
onReadyDuring app.start()Starting servers, schedulers, consumer loops
onShutdownDuring app.shutdown()Graceful stop of servers and consumers
onDestroyAfter shutdownClosing connections, cleanup

Hooks run in topological order (dependencies first for init, reverse for shutdown).

Module Ordering

The modules: [...] array you pass to createApp is treated as a set, not a sequence. The loader derives the actual load order itself, so you never maintain ordering by hand:

  1. Dependencies load first. Every relationship that requires ordering is expressed as a graph edge, and the loader sorts the graph topologically. Two kinds of edges exist:
    • imports: [OtherModule] — the imported module loads before the importer.
    • Manual providers — when a module declares preferences: [{ provide: T }] without useClass (meaning it binds T itself in afterLoad), every module whose constructor injects T loads after it. This edge is inferred automatically from the @Inject metadata; you don't declare it.
  2. Unrelated modules keep their array position. When two modules have no path between them in the graph, the sort is stable: they load in the order you listed them. This makes the order fully deterministic — the same array always produces the same boot sequence, on every machine, every run.
  3. Cycles fail the boot. If module A imports B and B imports A (directly or transitively), createApp throws with the traced cycle (A → B → A) instead of picking an arbitrary winner.

Why derived order

Hand-maintained ordering is the classic failure mode of module systems: it works at 5 modules, silently rots at 50, and produces bugs that only reproduce with one specific arrangement. Deriving order from declared relationships means:

  • Reordering the array is safe. As long as no two unrelated modules compete for the same preference token (see the tie-break below), any permutation of the same module set produces identical wiring — this property is pinned by a test that boots every permutation of a representative graph.
  • Ordering bugs become declaration bugs. If a module needs another to load first, the fix is declaring the relationship (imports), which the boundary checker and boot-time validation can then verify — not nudging array positions until it works.
  • Shutdown correctness comes for free. The reverse of a correct init order is a correct teardown order; onShutdown/onDestroy run in exact reverse without any extra declarations.

The one place array position matters

Because the sort is stable, array position is the tie-breaker for independent modules competing for the same preference token — typically two drivers of the same contract. The later entry wins:

typescript
modules: [
  CacheModule,
  CacheMemoryModule, // binds CacheService…
  CacheRedisModule, //  …and is overridden here: Redis wins
];

This is deliberate: the app's modules array is the composition root, and listing a replacement driver after the default is the intended way to override it. Between modules that are related, array position never decides anything — declare the relationship instead of relying on position.

Pools

Pools allow modules to contribute entries to a shared collection discovered at boot time.

Defining a Pool

typescript
import { createPool } from '@modularityjs/modularity';

export const HealthIndicatorsPool = createPool<HealthIndicator, 'class'>(
  'health:indicators',
);

Contributing to a Pool

typescript
@Module({
  name: 'my-health',
  pools: [
    {
      pool: HealthIndicatorsPool,
      key: 'database',
      useClass: DbHealthIndicator,
    },
  ],
})
class MyHealthModule {}

Consuming a Pool

typescript
import { InjectPool } from '@modularityjs/modularity';

@Injectable()
class HealthService {
  constructor(
    @InjectPool(HealthIndicatorsPool)
    private readonly indicators: HealthIndicator[],
  ) {}
}

Pool entries support five forms:

  • useClass — register a class; the container instantiates and injects it.
  • useValue — register a literal value for value-mode pools.
  • useFactory — register the result of a factory function (with an optional inject list of tokens).
  • override — deep-merge a Partial<T> into an existing value-pool entry contributed under the same key upstream.
  • remove: true — drop a previously contributed entry under the same key, in either class- or value-mode pools.

Preferences

Preferences override DI bindings. The last module to declare a preference for a token wins:

typescript
@Module({
  name: 'cache-redis',
  imports: [CacheModule],
  preferences: [{ provide: CacheService, useClass: RedisCacheService }],
})
class CacheRedisModule {}

This is how drivers replace abstract contracts with concrete implementations.

Transitive Chaining

Preferences chain transitively. A preference can target any bound class — not just a declared contract — and the resolution follows the full chain at resolve time:

typescript
// Module A declares the contract
@Module({ name: 'cache', contracts: [CacheService] })
class CacheModule {}

// Module B provides a base driver
@Module({
  name: 'cache-redis',
  imports: [CacheModule],
  providers: [RedisCacheService],
  preferences: [{ provide: CacheService, useClass: RedisCacheService }],
})
class CacheRedisModule {}

// Module C wraps the driver with instrumentation
@Module({
  name: 'cache-redis-instrumented',
  imports: [CacheRedisModule],
  providers: [InstrumentedRedisCacheService],
  preferences: [
    { provide: RedisCacheService, useClass: InstrumentedRedisCacheService },
  ],
})
class CacheRedisInstrumentedModule {}

When any service injects CacheService, the container follows the chain:

CacheService → RedisCacheService → InstrumentedRedisCacheService

This works because each preference creates a lazy alias that is resolved at injection time, not at registration time. This enables patterns like decorators, instrumentation wrappers, and multi-layer driver composition without modifying the original modules.

Configuration with forRoot()

Modules with configurable options expose a static forRoot() method:

typescript
@Module({
  name: 'http',
  providers: [HttpModuleConfig],
  contracts: [HttpControllersPool],
})
class HttpModule {
  static forRoot(config: DeepPartial<HttpModuleConfig>): ModuleDefinition {
    return configureModule(HttpModule, HttpModuleConfig, config);
  }
}

// Usage — contract configures, driver implements:
const modules = [HttpModule.forRoot({ port: 3000 }), HttpFastifyModule];

The configureModule() helper merges partial config into the config class defaults and calls validate() on the config if the method exists.