Documentation/titan/Authoring a module
In this section

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:

  1. Declares providers — services it contributes to the container.

  2. Declares exports — providers visible to importers.

  3. Optionally provides a forRoot (and forRootAsync) static factory that returns a DynamicModule, 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
    return { path };
  }

  // 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 IFooModuleOptions directly.

  • 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/titan is a peerDependency (not dependency)

  • Module class is named TitanFooModule (matches official naming)

  • forRoot(options) returns DynamicModule with explicit providers / 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:

  1. Add an entry under Community Modules with package URL + one-line purpose.

  2. Open a PR against omnitron-dev/omni referencing 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