Ditsmod core architecture
Core concepts of Ditsmod architecture: Modules, Dependency Injection, Metadata Reflector, and Application Lifecycle & Graceful Shutdown. Guidance on module composition, DI hierarchies, providers, scopes, and custom decorator design.From its SKILL.md
npx -y skills add ditsmod/agent-skills --skill ditsmod-core-architectureAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
27.9 KB, ~6.1k tokens by cl100k_base, as published. Nobody here has run it
Ditsmod Core Architecture
This skill covers the three fundamental pillars of the Ditsmod framework: the Metadata Reflector, Dependency Injection (DI) and Modules.
Part 1: Modules
Choose The Module Type
Ditsmod applications have two module roles:
- Root module — exactly one per application; entry point for DI composition. Recommended class name:
AppModule. - Feature module — encapsulates a cohesive set of providers, controllers, or extensions. Reusable and importable.
@ditsmod/core provides rootModule and featureModule decorators. Both accept the same base metadata properties:
import { rootModule, featureModule } from '@ditsmod/core';
@featureModule({
imports: [], // Imported modules
providersPerApp: [],
providersPerMod: [],
providersPerRou: [],
providersPerReq: [],
exports: [], // Exported tokens or modules
extensions: [],
extensionsMeta: {},
resolvedCollisionsPerMod: [],
resolvedCollisionsPerRou: [],
resolvedCollisionsPerReq: [],
})
export class SomeModule {}
@rootModule({
// All featureModule properties, plus:
resolvedCollisionsPerApp: [], // Collision resolution at app level
})
export class AppModule {}
@ditsmod/rest and @ditsmod/trpc provide specialized module decorators with extended metadata:
@restModule/@restRootModule(@ditsmod/rest): Supports all base properties, pluscontrollers: []andappends: [](attaches module controllers under route prefixes without consuming provider exports).@trpcModule/@trpcRootModule(@ditsmod/trpc): Supports all base properties, pluscontrollers: [](registers tRPC controllers).
Do not mix
@ditsmod/restand@ditsmod/trpcentities in the same application. Check a package'speerDependenciesto confirm architectural style compatibility.
Static vs Dynamic Modules
Ditsmod supports importing modules in two forms:
- Static Module: A raw class reference (e.g.,
SomeModule). Use this when importing a module with its default, statically declared metadata and providers.imports: [SomeModule]; - Dynamic Module: An object configuration implementing the
DynamicModuleinterface (e.g.,{ module: SomeModule, path: 'prefix' }). Use this when configuration details, route prefixes, or custom provider overrides need to be supplied at the import site. Dynamic modules are often returned by static helper methods (e.g.,SomeModule.withConfig(opts)).imports: [{ module: SomeModule, providersPerReq: [MyOverrideService] }];
Import, Append, Export
imports — use when the current module needs exported providers or extensions from another module:
@restModule({
imports: [AuthModule],
})
export class UsersModule {}
imports with { path, module } (@ditsmod/rest) — additionally mounts the imported module's controllers under a route prefix. The path property activates controller mounting even when set to an empty string. Use absolutePath instead of path when the prefix must be absolute (the two are mutually exclusive). Add guards to protect all routes in the imported module:
@restModule({
imports: [
{ path: 'users', module: UsersModule },
{ path: 'admin', module: AdminModule, guards: [AuthGuard] },
{ absolutePath: 'health', module: HealthModule }, // absolute path, no prefix stacking
],
})
export class AppFeatureModule {}
appends — use when you only need to attach another module's controllers under the current module's route prefix without consuming its exported providers or extensions. Only append modules that have controllers:
@restRootModule({
appends: [{ module: AdminModule, path: 'admin' }],
})
export class AppModule {}
Export Providers By Token
Export provider tokens, not provider objects. providersPerApp providers are globally available and must not be listed in exports (doing so will throw a ForbiddenAppExport error at bootstrap).
Use getTokens() when the provider array contains object-form providers (e.g., { token, useClass, ... }), because their tokens cannot be statically extracted otherwise:
import { getTokens } from '@ditsmod/core';
import { restModule } from '@ditsmod/rest';
import { authProviders } from './auth.providers.js';
@restModule({
providersPerReq: [...authProviders],
exports: [...getTokens(authProviders)],
})
export class AuthModule {}
Export only what consumer modules inject directly. Internal implementation services do not need to be exported unless consumers inject them. Do not export controllers — exports apply only to providers and modules.
Exporting & Re-Exporting from the Root Module (AppModule)
The root module (AppModule) has a unique global propagation capability:
- Declared Providers: Any providers registered in
AppModule(providersPerMod,providersPerRou, orprovidersPerReq) that are also listed inAppModule'sexportsarray are automatically imported into all local feature modules. - Re-Exported Modules: Any modules listed in both
importsandexportsofAppModulehave their exported providers and extensions automatically imported into all local feature modules. - Nuance: This automatic propagation applies exclusively to local feature modules (it excludes third-party modules imported from
node_modules).
Re-Export Modules
To pass through another module's exports to importers of the current module, both import and export it:
@restModule({
imports: [AuthModule],
exports: [AuthModule],
})
export class SecurityModule {}
When re-exporting a DynamicModule object, export the same object reference that was imported:
const usersWithPath = { module: UsersModule, path: 'users' };
@restModule({
imports: [usersWithPath],
exports: [usersWithPath], // must be the identical object, not a new literal
})
export class ApiModule {}
Module Parameters
Use a static factory method when a module is commonly imported with configuration:
import { type DynamicModule } from '@ditsmod/core';
export class UsersModule {
static withPrefix(path: string): DynamicModule<UsersModule> {
return { module: this, path };
}
}
Pass extension-specific data via extensionsMeta inside DynamicModule or module decorators (@featureModule, @restModule, etc.). Keep each extension's data under a single dedicated key. During module initialization, extensionsMeta is normalized into normalizedModuleMeta.extensionsMeta, where extensions can access it (e.g. const oasOptions = normalizedModuleMeta.extensionsMeta.oasOptions as OasOptions).
Init Decorators
An init decorator is a custom class decorator created using Reflector.makeClassDecorator() whose options are processed and normalized by init hooks during the module initialization phase.
[!WARNING] If you can easily pass metadata to a module using a dynamic module, creating an init decorator is not recommended. Consider using a dynamic module first.
For details on the roles of init decorators (root module, feature module, and modifier decorators), usage rules, and parent init hook propagation mechanics, see references/REFERENCE.md.
Provider Visibility And Lifetime
Provider registration level determines scope and sharing:
| Level | Array | Scope | Shared across modules? |
|---|---|---|---|
| App | providersPerApp | Entire app | Yes — singleton, no export needed |
| Module | providersPerMod | Module + its importers | Only if exported |
| Route | providersPerRou | Single route subtree | Only if exported |
| Request | providersPerReq | Single HTTP request | Only if exported |
Imported provider classes are registered — not their instances. If the same provider class is declared at mod/rou/req level in two separate modules, each module gets its own instance. Use providersPerApp when a true singleton must be shared across all modules.
Collision Handling
A collision occurs when two imported modules export non-identical providers under the same token. Resolve it in the module that imports both:
@restRootModule({
imports: [DefaultAuthModule, CustomAuthModule],
resolvedCollisionsPerMod: [[AuthService, CustomAuthModule]],
})
export class AppModule {}
Match the resolvedCollisionsPer* array to the provider scope level named in the collision error:
-
Root module constraint:
resolvedCollisionsPerAppis only available and valid on the root module (rootModule/restRootModule/trpcRootModule). You cannot configure or resolve application-level provider collisions inside feature modules. -
Feature module resolution: Collisions at module, route, or request levels (
resolvedCollisionsPerMod,resolvedCollisionsPerRou,resolvedCollisionsPerReq) must be resolved in whichever importing module encounters the conflict. -
If the collision originates from modules re-exported by a third-party package's root module, remove the conflicting re-exported module from the package root and import it explicitly where needed.
-
Default providers exported by
@ditsmod/*packages (e.g.,Logger,ModuleInfo) are registered automatically. Do not export default providers from feature modules unless explicitly overriding them, to avoid token collisions.
Module Assembly Checklist & Common Mistakes
- Keep each feature module narrowly specialized.
- Use
importsfor provider/extension consumption; useappendsfor route attachment only. - Export tokens or modules — not provider objects or controller classes (exporting controllers or
providersPerAppcauses validation errors). - Use
getTokens()when exporting tokens from an array that contains object-form providers. - Reuse the identical
DynamicModuleobject reference when re-exporting. - Resolve provider collisions explicitly via
resolvedCollisionsPer*matching the provider scope level.
Part 2: Dependency Injection
[!IMPORTANT] In Ditsmod applications, providers are registered inside modules (such as in
providersPer*arrays) which shape the DI hierarchy. Before registering or wiring providers in application code, you must read the Modules section above to understand provider visibility, imports/exports, and collision resolution. Note that in unit tests, injectors can be instantiated directly (e.g., usingInjector.resolveAndCreate()) without modules.
Working Rules
-
@injectable()is mandatory for every class that has constructor parameters used as DI dependencies. Without it, TypeScript does not emit parameter metadata and DI silently fails with aNo provider for [Token]!error even when the provider is correctly registered:// ❌ WRONG — missing @injectable(), DI will fail silently class MyService { constructor(private dep: OtherService) {} } // ✅ CORRECT @injectable() class MyService { constructor(private dep: OtherService) {} } -
Treat TypeScript-only constructs (
interface,type,declare,enumused only as types, or type-only imports) as unavailable at JavaScript runtime; do not use them as DI tokens. -
Array Token Constraint: An array cannot be used simultaneously as a TypeScript type and as a DI token. Use an instance of
InjectionToken<T[]>or a string/symbol token for arrays, matching it with the proper TypeScript interface in the constructor. -
Remember that provider values are cached in the injector where their provider is registered (unless contextual data is passed via
@inject). -
When diagnosing hierarchy errors, use the error's
Resolution pathto identify the injector level where each token was found or missing.
Providers And Tokens
Recognize these provider forms:
import { InjectionToken, type Provider } from '@ditsmod/core';
class Logger {}
class ConsoleLogger extends Logger {}
interface LoggerConfig {
level: 'info' | 'debug';
}
export const LOGGER_CONFIG = new InjectionToken<LoggerConfig>('LOGGER_CONFIG');
const providers: Provider[] = [
Logger, // TypeProvider (shorthand)
{ token: LOGGER_CONFIG, useValue: { level: 'info' } },
{ token: Logger, useClass: ConsoleLogger },
{ token: 'alias-to-config', useToken: LOGGER_CONFIG },
];
The shorthand SomeService is exactly equivalent to { token: SomeService, useClass: SomeService }.
Duplicate regular providers with the same token resolve to the last provider in the same injector. Multi-providers are the exception.
TokenProvider Resolution Rule
A TokenProvider creates an alias to another token. It is not self-sufficient. The dependency chain formed by useToken properties must ultimately resolve to a non-TokenProvider (ValueProvider, ClassProvider, FactoryProvider, or TypeProvider). If the target token lacks a concrete provider in the hierarchy, DI throws a No provider for [TargetToken]! error.
Factory Providers
-
ClassFactoryProvider: Use when the factory method needs decorated parameters (@inject,@optional,@ctx,@input). The factory class must use@factoryMethod()on the target method:import { factoryMethod, optional, LoggerConfig, Logger } from '@ditsmod/core'; class PatchLogger { @factoryMethod() patchLogger(@optional() rawConfig?: LoggerConfig) { // ... return pino(config); } } const provider = { token: Logger, useFactory: [PatchLogger, PatchLogger.prototype.patchLogger], }; -
FunctionFactoryProvider: Use when plain token dependencies are sufficient.depsaccepts tokens only (not providers or parameter decorators):
const provider = {
token: Logger,
deps: [LoggerConfig],
useFactory: (rawConfig: LoggerConfig) => pino(config),
};
Parameter Validation & Transformation (Pipes)
Ditsmod implements parameter validation/transformation inside FactoryProvider logic using @input (for class factories) or deps: [Context, input] (for function factories). Passing input to @inject(Token, 'paramName') disables caching and executes the factory afresh for each injection site. See Parameter Validation & Transformation (Pipes) in references/REFERENCE.md for full implementation details.
Injector Hierarchy
Ditsmod application injectors are organized as a parent-child hierarchy (top = root/ancestor, bottom = deepest child):
providersPerApp ← root (ancestor)
└─ providersPerMod
└─ providersPerRou
└─ providersPerReq ← deepest child
Child injectors look up to parents for missing tokens; parent injectors never look down to children.
Apply this rule when placing providers:
If provider A depends on provider B, B must be registered in the same injector as A or in an ancestor injector. Do not put B in a child injector.
Important consequences:
- Child injectors can ask parent injectors for missing tokens.
- Parent injectors never ask child injectors for tokens.
- If a child delegates
get(Service)to a parent, the parent createsServiceusing dependencies visible from the parent level, not from the child level. - Register a service in the child injector when it must use child-level overrides.
- Provider Shadowing: Registering a provider at a higher level (e.g.,
providersPerApporprovidersPerMod) will not override or change the behavior of that token at a lower level (e.g.,providersPerReq) if the lower level already has its own provider registered for that token. The child-level provider shadows (overrides) the parent-level provider, not vice-versa. - Name injectors (e.g.,
Injector.resolveAndCreate(providers, 'injectorName')) in low-level tests or examples when hierarchy diagnostics matter.
get() Versus pull()
Use injector.get(Token) for normal resolution and caching.
Use child.pull(Token) only for the specific case where the child lacks a provider that exists in an ancestor, but the pulled provider should be created in the child context so it can use child-level dependencies.
- Pulled from ancestor:
pull()temporarily brings the ancestor provider into the child context, creates a new instance each time (no caching), using child-level dependencies. - Exists in child: If the provider already exists in the current child injector,
pull()acts identically toget()— creates and caches the instance on first call, returns cached value on subsequent calls.
Multi-Providers
Use multi: true only with object providers and only when all providers for the same token in the same injector are multi-providers:
const LOCALES = new InjectionToken<string[]>('LOCALES');
const providers = [
{ token: LOCALES, useValue: 'uk', multi: true },
{ token: LOCALES, useValue: 'en', multi: true },
];
Do not mix regular and multi-providers for the same token in one injector. If a child injector declares multi-providers for a token, it returns its own multi-provider array for that token rather than merging parent values.
To make one multi-provider entry substitutable, point the multi-provider at a class with useToken, then override that class with a normal class provider.
[!TIP] Authors of shared libraries or npm packages should prefer registering multi-providers via
useTokenpointing to a distinct class or token, rather than usinguseClassoruseValuedirectly. This enables consumers of the package to easily substitute individual multi-provider entries by overriding the referenced class/token in their application injectors.
Context
Use Context when data must be set after injector creation and read later at the same or lower injector level. Use createInjectionSymbol<T>() for typed context keys. In modular Ditsmod applications importing ContextModule (re-exported by @ditsmod/rest), context providers are included automatically.
ctx.get(key): Retrieves value from currentContextinstance.ctx.getInScope(key, injector): Traverses up the injector hierarchy to find value across nested context instances. See references/REFERENCE.md for detailed examples.
Special Token: ParentParams (Inheritance)
When an @injectable() class extends a parent class, DI automatically injects an array of the parent's constructor arguments into the ParentParams token.
Recommended approach (@ts-expect-error):
import { ParentParams, injectable } from '@ditsmod/core/di';
@injectable()
class Child extends Parent {
constructor(
parentParams: ParentParams,
public childParam1: ChildParam1,
) {
// @ts-expect-error auto-injected
super(...parentParams);
}
}
For alternative type-safe options without @ts-expect-error, see ParentParams Alternative Patterns in references/REFERENCE.md.
Current Injector & Lazy Loading
A service or controller can access the specific injector instance that instantiated it by requesting Injector directly in its constructor. This pattern is primarily used for lazy loading dependencies at runtime:
import { injectable, Injector } from '@ditsmod/core';
@injectable()
export class SecondService {
constructor(private injector: Injector) {}
someMethod() {
const firstService = this.injector.get(FirstService); // Lazily fetched
}
}
Parameter Decorators
- Use
@inject(token)when the runtime token differs from the TypeScript parameter type. - Contextual Input Decorator (
@inject+@input): You can pass contextual data during dependency instantiation by passing a second argument to@inject(Dependency, 'input-data'). The target dependency must accept it via the@inputdecorator (shorthand for@inject(input)). - Critical Nuance: When a second argument is passed to
@inject(), the injector does not create a cache for the specified dependency. - Use
@optional()when absence of a provider is valid. TypeScript optional syntax (?) alone is ignored by the JavaScript runtime DI mechanism. - Use
@fromSelf()to force lookup only in the injector creating the current value. - Use
@skipSelf()to start lookup from the parent injector.
Part 3: Metadata Reflector
Ditsmod's Reflector provides a unified, cached, and high-level abstraction over standard reflection metadata:
- Unified Decorators: It generates type-safe decorators that automatically register metadata in internal registries when evaluated.
- Inheritance Traversal: It merges class, property, and parameter metadata from parent classes down to children.
- Iterability:
collectMeta(Cls)returns aMergedClassMetaobject which is iterable and provides property-by-property details. - Cache-Optimized: Reflection metadata is resolved once and cached internally.
Prerequisites & Compilation
For reflection metadata to work, ensure these requirements are met:
- tsconfig.json Flags:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } - Runtime Polyfill:
reflect-metadata/litemust be imported before any decorators are evaluated. In typical Ditsmod applications, this is already imported automatically by@ditsmod/core, so you do not need to add it manually to the application entry points. - Test Setup:
In test configurations (like Jest or Vitest), if
@ditsmod/coreis not imported early enough (or at all), ensurereflect-metadata/liteis loaded viasetupFilesAfterEnvor an explicit top-level import.
For details on creating custom decorators, collecting metadata, inheritance chains, and programmatic metadata writing, see references/REFERENCE.md.
Part 4: Application Lifecycle & Graceful Shutdown
Ditsmod supports graceful shutdown, allowing applications to stop accepting new requests, wait for active requests to finish, and execute resource cleanup in singleton services before exiting cleanly.
Enabling Process Signal Interception
Graceful shutdown is activated on the application instance (typically in main.ts):
import { RestApplication } from '@ditsmod/rest';
import { AppModule } from './app/app.module.js';
const app = await RestApplication.create(AppModule);
app.enableShutdownHooks(); // Intercepts default signals: SIGTERM, SIGINT, SIGHUP, SIGUSR2, SIGQUIT
app.server.listen(3000, '0.0.0.0');
You can optionally supply a custom array of signals: app.enableShutdownHooks(['SIGTERM', 'SIGINT']).
Shutdown Execution Sequence & customShutdown
When app.close(signal) is called (via signal or manually), BaseApplication executes a 3-step sequence:
BeforeShutdownhooks: CallsbeforeShutdown(signal?: string)on instantiated singletons (providersPerApp/providersPerMod). Ideal for stopping background timers or queue workers.customShutdown(signal?: string): Extension point method inBaseApplication. Subclasses likeRestApplicationoverride this to perform package-level shutdown logic (e.g. callingserver.close(), destroying idle sockets, and waiting up toshutdownTimeoutfor active HTTP requests to drain). Custom application classes (e.g. for gRPC or WebSockets) can also overridecustomShutdown()to handle package-specific server stopping.OnShutdownhooks: CallsonShutdown(signal?: string)on instantiated singletons (providersPerApp/providersPerMod). Ideal for closing database connection pools, Redis clients, or file handles.
Shutdown Execution Mechanics & Nuances
- Instantiated Singletons Only: Shutdown hooks are invoked only on singleton instances (
providersPerAppandprovidersPerMod) that were actually instantiated during application runtime. Uninstantiated services or request-scoped providers (providersPerReq,providersPerRou) do not receive shutdown hooks. - Concurrent Execution:
runShutdownHooksexecutes hooks for all active instances concurrently usingPromise.allSettled(). Errors thrown in individual hooks are logged viaSystemLogMediatorwithout preventing other hooks from completing.
For implementation code examples (BeforeShutdown / OnShutdown), customShutdown subclassing patterns, and active instance discovery mechanics, see references/REFERENCE.md.
Part 5: Logging, LogMediator & Log Buffering
Ditsmod uses LogMediator as a strongly-typed abstraction over Logger. Modules create custom log mediators extending LogMediator (e.g., OpenapiLogMediator or AuthjsLogMediator) with typed methods calling this.setLog(level, msg).
During application bootstrap (bufferLogs: true), logs written via setLog() are held in a static buffer (LogMediator.buffer) instead of outputting immediately. Once bootstrap completes, systemLogMediator.flush() formats and outputs all buffered logs at the resolved log level.
For complete code examples, typed method design, log buffering mechanics, and overriding SystemLogMediator, see references/REFERENCE.md.
Core Debugging & Troubleshooting Checklists
Dependency Injection Debugging Checklist
- Identify the requested token and the injector where the request starts.
- Read the
Resolution pathin the error message (format:[Token in injectorA >> injectorB] -> [Dep in injectorB]). The>>chain shows which injectors were traversed to find the token; the->arrow shows the next dependency that failed. See the technical reference guide for detailed examples. - Find the provider level for that token.
- For every constructor or factory dependency, verify that its provider is at the same level or an ancestor of the provider that needs it.
- Check whether a child-level override is ineffective because the requested service is actually created in a parent injector.
- Check for invalid runtime tokens caused by interfaces, type aliases, enums used only as types, or
import type. - Check for missing
@injectable()on a class that has constructor dependencies. - Ensure that array types are not being passed directly as runtime tokens; check for
InjectionTokenusage. - Verify that any
TokenProvider(usinguseToken) eventually terminates in a non-token provider mapping. - Check for accidental mixing of regular and multi-providers.
Metadata Reflector Troubleshooting Checklist
Reflect.defineMetadata is not a functionerror:- Ensure
@ditsmod/coreis imported (which automatically loadsreflect-metadata/liteat startup), or explicitly importreflect-metadata/litein entry points/test configurations that do not load@ditsmod/coreearly enough (e.g.setupFilesAfterEnvin Jest).
- Ensure
- Missing metadata for constructor arguments or properties:
- Verify that the class is decorated (e.g. with
@injectable(),@controller(), etc.). If a class or property has no decorators at all, the TypeScript compiler will not emit any design metadata, even withemitDecoratorMetadataenabled. - Verify that
"emitDecoratorMetadata": trueand"experimentalDecorators": trueare enabled intsconfig.json.
- Verify that the class is decorated (e.g. with
- TypeScript compiler loses types after compiling imports:
- Check if parameter types in the constructor are imported using
import typeor interfaces. TypeScript does not emit runtime metadata for type-only constructs. Use classes, or use the@inject(MY_TOKEN)decorator on the parameter alongside the corresponding TypeScript interface/type.
- Check if parameter types in the constructor are imported using
- Decorator decoratorId type checks fail:
- When creating complex decorator factories, ensure you provide a valid
decoratorIdfunction/symbol and match it with a correspondingTypeGuard.
- When creating complex decorator factories, ensure you provide a valid
What ships with it: 1 file
43.9 KB alongside SKILL.md
references/
- REFERENCE.md43.9 KB