Ditsmod extensions
Practical guidance for creating, registering, ordering, exporting, grouping, and overriding Ditsmod extensions. Use when writing custom extensions with stage1/stage2/stage3 hooks, injecting ExtensionManager to retrieve same-module or app-wide data (handling delay), dynamically registering providers, configuring module extensions metadata, or resolving cyclic extension dependencies.From its SKILL.md
npx -y skills add ditsmod/agent-skills --skill ditsmod-extensionsAssembled 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
20.2 KB, ~4.1k tokens by cl100k_base, as published. Nobody here has run it
Ditsmod Extensions
Mental Model
Extensions run after Ditsmod collects static metadata from decorators but before request handlers are created. Think of them as "infrastructure setup" hooks that operate on the fully assembled module graph.
- Extensions operate strictly during the application initialization stage to set up infrastructure and do not participate directly in request processing.
- Extensions prepare infrastructure: dynamically add interceptors/providers, build route metadata, set up OpenAPI docs, initialize DB connections.
- Extensions can be async and depend on each other through
ExtensionManager. - Module Scope: An extension is instantiated and executed only in modules where it is explicitly registered in the
extensionsarray (or imported viaexport: true/exportOnly: true). It does not automatically execute in modules that do not register or import it.
Implementing An Extension
An extension is a class decorated with @injectable() that implements the Extension<T> interface, where T specifies the payload return type of stage1() (and no other stage method). All three stage methods are optional — implement only the stages you need.
[!IMPORTANT] The stages
stage1,stage2, andstage3are framework lifecycle hooks that are always executed sequentially in that order during the application bootstrap process. They are never skipped or executed out of order. Therefore, you can rely on state populated instage2(like a module injector instance) to always be available instage3.
import { injectable, inject, PROVIDERS_PER_APP } from '@ditsmod/core';
import type { Extension, ExtensionManager, Injector, Provider } from '@ditsmod/core';
@injectable()
export class MyExtension implements Extension<MyPayload | void> {
constructor(
private extensionManager: ExtensionManager,
@inject(PROVIDERS_PER_APP) protected providersPerApp: Provider[],
) {}
async stage1(isLastModule: boolean): Promise<MyPayload | void> {
// Stage 1: called once per module while providers are being dynamically collected.
// isLastModule === true when this is the last module that imports this extension.
// Return a payload (e.g., collected metadata) that other extensions can consume
// via ExtensionManager.stage1(). Return void if no payload is produced.
}
async stage2(injectorPerMod: Injector): Promise<void> {
// Stage 2: called after stage1 finishes for ALL modules.
// Receives a ready module-level injector.
}
async stage3(): Promise<void> {
// Stage 3: called after stage2 finishes for ALL modules.
// No strict role — use for final post-processing.
}
}
Constructor Injection Constraints
- The injector for the extension constructor is created before any extension runs.
- You can only inject providers statically registered (module-level or app-level) before extensions execute, or default extension providers exported via
defaultExtensionProvidersfrom@ditsmod/core. - You cannot inject providers that are dynamically added by other extensions.
- The extension and application injectors belong to separate hierarchy trees. The extension injector is short-lived and destroyed once the extensions finish initializing. Because any providers instantiated within the extension's constructor belong to this temporary injector, they are not shared with the main application.
isLastModule Parameter
stage1(isLastModule: boolean) receives true only for the final module that imports this extension. Use this flag to defer aggregation work (e.g., finalizing a data structure) until all modules that import this extension have contributed.
Accessing Extension Metadata (extensionsMeta)
Extension-specific configurations passed via @featureModule({ extensionsMeta: { ... } }) or dynamic module options (DynamicModule.withParams({ extensionsMeta: { ... } })) are normalized by ModuleNormalizer into normalizedModuleMeta.extensionsMeta.
An extension retrieves extensionsMeta from normalizedModuleMeta depending on how it receives module metadata:
-
Via
ResolvedModuleMetain constructor:const myOptions = this.resolvedModuleMeta.normalizedModuleMeta.extensionsMeta[MY_EXTENSION]; -
Via
RouteExtensionMetafromExtensionManager:const myOptions = routeExtensionMeta.normalizedModuleMeta.extensionsMeta[MY_EXTENSION];
In all cases, extensionsMeta is accessed directly as a property of normalizedModuleMeta (normalizedModuleMeta.extensionsMeta). Keep each extension's metadata stored under its own dedicated key. For full runnable class implementations and patterns for passing and consuming extensionsMeta, see references/REFERENCE.md.
Registering An Extension
Add extensions to the extensions array of any module decorator (@featureModule, @restModule, etc.).
[!IMPORTANT] Extensions are strictly module-scoped. Registering an extension in a module (including the root module) executes it only within that specific module instance, unless it is exported to importing modules using
export: trueorexportOnly: true.
Direct Class Registration
Simplest form — runs only in the current module with no ordering constraints:
@restModule({
extensions: [MyExtension],
})
export class AppModule {}
Config Object Registration
Use the config object form for ordering, exporting, grouping, or overriding:
@restModule({
extensions: [
{
extension: MyExtension,
beforeExtensions: [ConsumerExtension], // MyExtension runs before ConsumerExtension
afterExtensions: [ProducerExtension], // MyExtension runs after ProducerExtension
groups: [RestRouteExtension], // MyExtension joins the RestRouteExtension group
export: true, // also runs in every module that imports this module
},
],
})
export class FeatureModule {}
export and exportOnly are mutually exclusive. beforeExtensions, afterExtensions, and groups are not available when using overrideExtension (see below).
Configuration Properties Reference
Ordering
| Property | Meaning |
|---|---|
beforeExtensions | This extension runs before each listed class |
afterExtensions | This extension runs after each listed class |
Both accept an array of ExtensionClass values.
Export Behaviour
| Property | Runs in host module? | Runs in importing modules? |
|---|---|---|
| (default) | Yes | No |
export: true | Yes | Yes |
exportOnly: true | No | Yes |
[!TIP] When registering extensions that are only intended to run in importing modules (such as in wrapper, library, or shared modules), always prefer
exportOnly: trueoverexport: true. This prevents the extension from executing unnecessarily in the host module itself, which can lead to redundant execution or runtime errors due to missing configuration/routes in the host.
Overriding
Replace an imported extension with a custom one. The override form only accepts extension and overrideExtension — beforeExtensions, afterExtensions, groups, export, and exportOnly are not allowed in this form:
extensions: [{ extension: MyExtension, overrideExtension: ExternalExtension }];
MyExtension is registered under the token ExternalExtension, so all existing consumers transparently receive MyExtension.
Extension Groups
An extension group allows multiple extensions to contribute data under the class token of a lead extension. Analogous to HTTP interceptor groups, an extension group represents a specific type of work performed over application metadata.
extensions: [
{
extension: OpenApiRouteExtension,
groups: [RestRouteExtension], // joins the RestRouteExtension group
export: true,
},
];
[!NOTE] An extension group does not require a special token class or dedicated group entity. Any ordinary extension class (such as
RestRouteExtension) acts as the group token simply when other extensions list it in theirgroupsarray.
Key Rules & Requirements for Extension Groups
1. Shared Base Interface Contract (Critical Requirement)
All extensions belonging to a specific group must return data from stage1() that adheres to a shared base interface.
- Contract Guarantee: Consumers that call
extensionManager.stage1(LeadExtension)rely on the base interface shape to safely iterate and process elements ingroupData. - Extensibility: Group members can extend the base interface with additional properties (e.g.,
OpenApiRouteExtensionreturning extended route metadata with OpenAPI schemas), but must never narrow or break the base interface contract.
2. Automatic Execution Ordering via Group Membership
When an extension joins a group via groups: [LeadExtension], it automatically inherits the group's positioning relative to other extensions in the execution graph:
- If
LeadExtensionwas declared withbeforeExtensions: [TargetExtension], any extension joininggroups: [LeadExtension]is automatically placed in the execution queue afterLeadExtensionand beforeTargetExtension(for example, sinceRestRouteExtensionruns beforeDispatcherExtension, any extension joininggroups: [RestRouteExtension]will automatically run afterRestRouteExtensionand beforeDispatcherExtension). - You do not need to explicitly repeat
beforeExtensions: [TargetExtension]on member extensions. This allows third-party modules to seamlessly integrate into existing extension pipelines without manual ordering configuration.
3. Group Lookup Behavior & Asymmetry in ExtensionManager
Calling extensionManager.stage1() with an extension class yields different results depending on whether it is queried as a lead extension or as a specific member:
- Lead Extension (Group) Lookup:
await extensionManager.stage1(LeadExtension)returns aggregatedgroupDatacontaining the payload fromLeadExtensionand all member extensions registered in its group (groups: [LeadExtension]). - Direct Member Lookup:
await extensionManager.stage1(SpecificMemberExtension)returnsgroupDatacontaining only the payload fromSpecificMemberExtension. - Index Alignment: Elements in
groupDatamap 1-to-1 by array index to debug entries ingroupDebugMeta:groupData[i] === groupDebugMeta[i]?.payload;
4. Group Configuration Rules
- Multiple Groups: An extension can belong to multiple groups by passing multiple class tokens:
groups: [ExtensionA, ExtensionB]. - Override Constraint: If an extension is registered using
overrideExtension, thegroupsproperty (along withbeforeExtensions,afterExtensions,export, andexportOnly) is not allowed.
For a detailed step-by-step example demonstrating group formation, lead extension inclusion, and expansion, see Extension Group Formation & Lead Extension Membership Example.
Using ExtensionManager
ExtensionManager manages extension dependencies, caches stage1 results, and detects cyclic dependencies.
[!WARNING] An extension cannot request its own
stage1data viaExtensionManager.stage1()(e.g.,await this.extensionManager.stage1(CurrentExtension)orawait this.extensionManager.stage1(CurrentExtension, this)). Requesting itself creates a self-referential cyclic dependency and will throw a cyclic dependency error.
Inject it in the constructor:
constructor(private extensionManager: ExtensionManager) {}
Same-Module Data
Retrieves the stage1 output of TargetExtension (or its group) within the current module only:
const meta = await this.extensionManager.stage1(TargetExtension);
// meta.groupData — array of T values returned by each extension in the group/class
// meta.delay — always false for same-module calls
[!NOTE] If
TargetExtensionis not imported into the current module, this expression will not return data.
App-Wide Data (Cross-Module)
Pass this as the second argument to receive aggregated data from modules where TargetExtension is registered:
const meta = await this.extensionManager.stage1(TargetExtension, this);
A separate instance of your extension is created for each module where it is imported. Passing this instructs the framework to accumulate stage1 data across modules where TargetExtension is imported (e.g., if an application has 10 modules and TargetExtension is imported in only 2 of them, data is accumulated from those 2 modules).
Handling delay
When requesting app-wide data you must check meta.delay. If true, not all modules that import TargetExtension have completed stage1 yet — return early; the framework will call your extension again later:
const meta = await this.extensionManager.stage1(TargetExtension, this);
if (meta.delay) {
return; // not ready yet — framework will re-invoke this extension
}
// meta.groupDataPerApp: AppExtensionGroupMeta<T>[]
// Each entry = result from one module that registered TargetExtension
meta.groupDataPerApp.forEach((perModMeta) => {
// perModMeta.groupData: T[] — payloads from that module
perModMeta.groupData.forEach((payload) => {
// process payload
});
});
For the full ExtensionGroupMeta type shape, see references/REFERENCE.md.
Dynamic Provider Registration
Extensions can dynamically push into provider arrays that are still being assembled. Providers can be pushed to:
- Application-level metadata (
providersPerAppinjected via@inject(PROVIDERS_PER_APP)): Affects the entire application across all modules. - Module-level metadata (
normalizedModuleMeta.providersPerMod,providersPerRou,providersPerReq): Affects the entire current module. - Controller/Route-level metadata (
controllersMeta[i].providersPerRouorcontrollersMeta[i].providersPerReq): Affects only the specific controller or route (e.g. selectively adding interceptors as inBodyParserExtension).
| Stage | Allowed provider levels |
|---|---|
stage1 | Any: app, module, route, request |
stage2 | Only below module: route, request |
Typical Pattern: Reading Config, Pushing Interceptors
[!IMPORTANT] If you need to read statically declared module-level configuration in
stage1, you must build a temporary injector. This requires injecting thePROVIDERS_PER_APParray in the constructor via@inject(PROVIDERS_PER_APP)so you can resolve it.
async stage1(): Promise<void> {
const meta = await this.extensionManager.stage1(RestRouteExtension);
meta.groupData.forEach((routeExtensionMeta) => {
const { providersPerMod } = routeExtensionMeta.normalizedModuleMeta;
// Pushing to controller/route-level metadata specifically:
routeExtensionMeta.controllersMeta.forEach(({ providersPerRou, providersPerReq }) => {
// Build a temporary injector hierarchy to inspect config
const injectorPerApp = Injector.resolveAndCreate(this.providersPerApp, 'App');
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
const config = injectorPerMod.get(MyConfig, null);
if (config?.enableFeature) {
// Pushing to individual controller/route providersPerReq array (controller-scoped)
providersPerReq.push({
token: HTTP_INTERCEPTORS,
useClass: MyInterceptor,
multi: true,
});
}
});
});
}
Always ensure your extension is ordered after the extension that produces the metadata (e.g., after RestRouteExtension) and before the extension that consumes it (e.g., before DispatcherExtension).
Transferring State & Resources to the Application (ValueProvider)
Since the extension's internal injector is temporary and destroyed post-bootstrap, any state or runtime resource initialized by an extension (e.g., database connection pools, SDK clients, external service configurations, compiled caches) must be transferred to the application's long-lived DI hierarchy.
To pass a ready object or resource instance to the application:
- Initialize or connect to the resource asynchronously inside
stage1()orstage2(). - Push a
ValueProvider({ token: 'some-token', useValue: initializedInstance }) into the appropriate provider hierarchy array (providersPerApp,providersPerMod,providersPerRou, orprovidersPerReq).
@injectable()
export class DbExtension implements Extension<void> {
constructor(@inject(PROVIDERS_PER_APP) protected providersPerApp: Provider[]) {}
async stage1(): Promise<void> {
// 1. Asynchronously create/initialize the resource during bootstrap
const dbClient = await createDbConnection({/* config */});
// 2. Register the existing instance into application DI via ValueProvider
this.providersPerApp.push({ token: DbClient, useValue: dbClient });
}
}
Any application service or controller can then inject DbClient via standard DI.
Registration Decision Guide
| Goal | Configuration |
|---|---|
| Run only in this module | extensions: [MyExtension] |
| Control execution order | Object form with beforeExtensions / afterExtensions |
| Run in this module AND importers | export: true |
| Run in importers only (not host) | exportOnly: true |
| Contribute to an existing data pipeline | groups: [TargetExtension] |
| Replace an imported extension | { extension: MyExt, overrideExtension: OriginalExt } |
Troubleshooting
| Symptom | Fix |
|---|---|
| Extension does not run in importing module | Add export: true or exportOnly: true in host module |
| Extension runs too early | Add afterExtensions: [ProducerExtension] |
| Extension runs too late | Add beforeExtensions: [ConsumerExtension] |
| Group data is empty or missing | Verify groups: [TargetExtension] uses the correct class token |
groupDataPerApp is incomplete | Ensure this is passed as second arg to extensionManager.stage1() and delay is handled |
| Circular dependency error | Check the error log — ExtensionManager prints the full loop path; use afterExtensions to break the cycle |
UndeclaredExtensionDependency error | Extension A called extensionManager.stage1(B) but B was not listed in A's afterExtensions |
Further Reading
For full type definitions (Extension<T>, ExtensionGroupMeta<T>, AppExtensionGroupMeta<T>, ExtensionConfig union), see references/REFERENCE.md.
What ships with it: 1 file
10.2 KB alongside SKILL.md
references/
- REFERENCE.md10.2 KB