Authoring a module
This is the canonical recipe for building a Titan module. Every
official titan-* package is built from this template, and any
community module worth adopting follows the same shape.
The goal is a module that looks and behaves like an official one:
typed forRoot options, lifecycle-aware services, exported tokens,
documented in the same shape, ready to drop into an existing
application without surprise.
What "a module" means in Titan
A module is a class with a @Module decorator that:
- Declares providers — services it contributes to the container.
- Declares exports — providers visible to importers.
- Optionally provides a
forRoot(andforRootAsync) static factory that returns aDynamicModule, configuring its providers from caller-supplied options.
The pattern lets each Titan application compose its set of modules with the configuration it needs — same code, different bindings.
Package skeleton
Start from this layout:
my-titan-foo/
├── src/
│ ├── foo.module.ts ← the @Module class + forRoot
│ ├── foo.service.ts ← main service exposed to consumers
│ ├── foo.types.ts ← public types (options, interfaces)
│ ├── foo.tokens.ts ← DI tokens for every export
│ ├── foo.decorators.ts ← optional: decorators if needed
│ └── index.ts ← public barrel
├── package.json
├── tsconfig.json
└── README.md
package.json essentials
{
"name": "@your-scope/titan-foo",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"peerDependencies": {
"@omnitron-dev/titan": "^0.1.0"
},
"scripts": {
"build": "tsc -p tsconfig.json"
}
}
Always declare @omnitron-dev/titan as a peerDependency —
never as a regular dependency. Otherwise the consumer ends up with
two copies of the framework and DI breaks silently.
Step 1 — define the options interface
Every module's forRoot accepts a single options bag. Put the
interface in *.types.ts:
// src/foo.types.ts
export interface IFooModuleOptions {
/** Required: API key for the upstream service */
apiKey: string;
/** Optional: request timeout in milliseconds */
timeoutMs?: number;
/** Optional: enable verbose logging */
debug?: boolean;
/** Optional: make module global */
isGlobal?: boolean;
}
export interface IFooModuleAsyncOptions {
imports?: any[];
useFactory?: (...args: any[]) => Promise<IFooModuleOptions> | IFooModuleOptions;
inject?: any[];
isGlobal?: boolean;
}
Conventional naming:
- Interface name =
I+ module name +ModuleOptions - Async variant = same +
Async - Every field documented with JSDoc
- Optionals end in
?; required fields don't
Step 2 — define the DI tokens
// src/foo.tokens.ts
import { createToken } from '@omnitron-dev/titan/nexus';
import type { FooService } from './foo.service.js';
import type { IFooModuleOptions } from './foo.types.js';
export const FOO_SERVICE_TOKEN = createToken<FooService>('FooService');
export const FOO_OPTIONS_TOKEN = createToken<IFooModuleOptions>('FooOptions');
Tokens go in their own file so consumers can import them without pulling in the implementation.
Step 3 — write the service
// src/foo.service.ts
import { Injectable, Inject } from '@omnitron-dev/titan';
import { LOGGER_SERVICE_TOKEN, type ILoggerModule }
from '@omnitron-dev/titan/module/logger';
import { FOO_OPTIONS_TOKEN } from './foo.tokens.js';
import type { IFooModuleOptions } from './foo.types.js';
@Injectable()
export class FooService {
private readonly logger;
constructor(
@Inject(FOO_OPTIONS_TOKEN) private readonly options: IFooModuleOptions,
@Inject(LOGGER_SERVICE_TOKEN) loggerModule: ILoggerModule,
) {
this.logger = loggerModule.getLogger().child({ context: 'FooService' });
}
async fetch(path: string): Promise<unknown> {
if (this.options.debug) {
this.logger.debug('fetch', { path });
}
// …actual work using this.options.apiKey, this.options.timeoutMs
}
// Lifecycle hooks — implement if the service holds external state
async onInit(): Promise<void> {
// E.g. validate credentials before serving traffic
}
async onDestroy(): Promise<void> {
// E.g. close connections / drain queues
}
}
Key conventions:
@Injectable()so the container can resolve it.- Inject the options bundle via the dedicated token; never
inject
IFooModuleOptionsdirectly. - Inject the logger so consumers can see what your module is doing.
- Implement lifecycle interfaces (
OnInit,OnStart,OnStop,OnDestroy) if your service holds resources.
Step 4 — write the module class
// src/foo.module.ts
import { Module, type DynamicModule } from '@omnitron-dev/titan';
import { FOO_SERVICE_TOKEN, FOO_OPTIONS_TOKEN } from './foo.tokens.js';
import { FooService } from './foo.service.js';
import type { IFooModuleOptions, IFooModuleAsyncOptions } from './foo.types.js';
@Module({})
export class TitanFooModule {
/**
* Configure FooModule with static options.
*/
static forRoot(options: IFooModuleOptions): DynamicModule {
return {
module: TitanFooModule,
global: options.isGlobal,
providers: [
{ provide: FOO_OPTIONS_TOKEN, useValue: options },
{ provide: FOO_SERVICE_TOKEN, useClass: FooService },
],
exports: [FOO_SERVICE_TOKEN],
};
}
/**
* Configure FooModule with async options — resolves options via
* a factory that can inject other providers.
*/
static forRootAsync(options: IFooModuleAsyncOptions): DynamicModule {
return {
module: TitanFooModule,
global: options.isGlobal,
imports: options.imports,
providers: [
{
provide: FOO_OPTIONS_TOKEN,
useFactory: options.useFactory!,
inject: options.inject ?? [],
},
{ provide: FOO_SERVICE_TOKEN, useClass: FooService },
],
exports: [FOO_SERVICE_TOKEN],
};
}
}
@Module({}) is intentionally empty on the class — the
forRoot/forRootAsync methods produce a DynamicModule at call
time that contains the actual provider list.
Step 5 — write the public barrel
// src/index.ts
export { TitanFooModule } from './foo.module.js';
export { FooService } from './foo.service.js';
export type {
IFooModuleOptions,
IFooModuleAsyncOptions,
} from './foo.types.js';
export {
FOO_SERVICE_TOKEN,
FOO_OPTIONS_TOKEN,
} from './foo.tokens.js';
// Decorators, if any:
// export { Foo } from './foo.decorators.js';
Only export what's truly public. Internal helpers stay un-exported.
Step 6 — write the consumer-facing README
The README is the public contract. Use this skeleton (the same shape every official module uses):
# @your-scope/titan-foo
> One-sentence description.
## Install
\`\`\`bash
pnpm add @your-scope/titan-foo @omnitron-dev/titan
\`\`\`
## Quickstart
\`\`\`typescript
import { TitanFooModule } from '@your-scope/titan-foo';
@Module({
imports: [TitanFooModule.forRoot({ apiKey: env.FOO_API_KEY })],
})
class AppModule {}
\`\`\`
## Options
| Option | Type | Default | Description |
| ------------ | -------- | ------- | ----------- |
| `apiKey` | string | — | … |
| `timeoutMs` | number | 5000 | … |
## Services
| Class | Token | Purpose |
| ------------- | ---------------------- | ------- |
| `FooService` | `FOO_SERVICE_TOKEN` | … |
## Lifecycle
…
## Anti-patterns
…
Optional — async-options patterns
For modules whose options need to be loaded from ConfigModule or
another async source, forRootAsync supports several factories:
useFactory (most common)
TitanFooModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
apiKey: config.get('foo.apiKey'),
}),
inject: [ConfigService],
})
useClass — for factories with their own dependencies
@Injectable()
export class FooOptionsFactory implements IFooOptionsFactory {
constructor(private readonly config: ConfigService) {}
createOptions(): IFooModuleOptions {
return { apiKey: this.config.get('foo.apiKey') };
}
}
TitanFooModule.forRootAsync({
imports: [ConfigModule],
useClass: FooOptionsFactory,
})
useExisting — when the factory class is already provided
TitanFooModule.forRootAsync({
useExisting: SomeExistingFactory,
})
Optional — decorators
If your module benefits from a decorator (e.g. @FooCache,
@FooLog), put it in src/foo.decorators.ts:
// src/foo.decorators.ts
import { Inject } from '@omnitron-dev/titan';
import { FOO_SERVICE_TOKEN } from './foo.tokens.js';
export const InjectFoo = () => Inject(FOO_SERVICE_TOKEN);
Or for method-level decorators, follow the createMethodInterceptor
pattern from @omnitron-dev/titan/decorators.
Optional — health indicator
If your module talks to an external dependency, ship a health
indicator so consumers can register it with
titan-health:
// src/foo.health.ts
import { Injectable } from '@omnitron-dev/titan';
import type { IHealthIndicator, HealthIndicatorResult }
from '@omnitron-dev/titan-health';
@Injectable()
export class FooHealthIndicator implements IHealthIndicator {
name = 'foo';
constructor(private readonly foo: FooService) {}
async check(): Promise<HealthIndicatorResult> {
try {
await this.foo.ping();
return { status: 'healthy' };
} catch (e) {
return { status: 'unhealthy', message: String(e) };
}
}
}
Export it as FooHealthIndicator + FOO_HEALTH_INDICATOR_TOKEN.
Module conventions checklist
When your module is ready to share, verify:
-
@omnitron-dev/titanis apeerDependency(notdependency) - Module class is named
TitanFooModule(matches official naming) -
forRoot(options)returnsDynamicModulewith explicitproviders/exports -
forRootAsync(options)exists if options can come from another module - Every public export is in
index.ts; nothing leaks via deep imports - Every DI token is typed (
createToken<T>(name)) - The main service is
@Injectable() - Lifecycle hooks (
onInit/onStart/onStop/onDestroy) implemented where state requires - Options interface is fully documented with JSDoc
- README follows the official-module shape
- Health indicator exported if module owns an external dependency
- Test suite covers happy path + lifecycle + error cases
Publishing
For npm publish:
pnpm build
npm publish --access public
For inclusion in the Titan documentation:
- Add an entry under Community Modules with package URL + one-line purpose.
- Open a PR against
omnitron-dev/omnireferencing your repo, the maintenance commitment, and links to docs / tests.
Worked example — complete titan-foo
Putting it all together:
// src/index.ts (final state)
export { TitanFooModule } from './foo.module.js';
export { FooService } from './foo.service.js';
export { FooHealthIndicator } from './foo.health.js';
export {
FOO_SERVICE_TOKEN,
FOO_OPTIONS_TOKEN,
FOO_HEALTH_INDICATOR_TOKEN,
} from './foo.tokens.js';
export type {
IFooModuleOptions,
IFooModuleAsyncOptions,
} from './foo.types.js';
export { InjectFoo } from './foo.decorators.js';
Consumer:
import { TitanFooModule, InjectFoo, type FooService } from '@your-scope/titan-foo';
@Module({
imports: [TitanFooModule.forRoot({ apiKey: env.FOO_API_KEY })],
providers: [MyService],
})
class AppModule {}
@Injectable()
class MyService {
constructor(@InjectFoo() private readonly foo: FooService) {}
async fetchSomething() {
return this.foo.fetch('/data');
}
}
A consumer using your module shouldn't be able to tell whether it's official or community. That's the bar.
See also
- Modules system / Defining modules — module fundamentals
- Modules system / Dynamic modules — forRoot / forRootAsync patterns
- Community modules — how to register your module in the catalogue
- Testing / Modules — write tests for your module that consumers can rely on