Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Fundamentals

Modules

Group providers and controllers into modules, share them through exports, and configure reusable modules with dynamic and global modules.

On this page 12 sections

A Knead application is a graph of modules. Each module is a class decorated with @Module() that declares the providers and controllers it owns, the modules it imports and the parts of itself it shares. Providers are private to their module unless the module exports them, so a module's public surface is explicit and a missing import fails at startup with an error that names the fix.

#Defining a module

@Module() takes one options object. Every field is optional.

OptionTypeDefaultDescription
importsModuleImport[][]Module classes or dynamic modules whose exports this module can inject.
providersProvider[][]Providers this module owns. See providers.
controllersType[][]Controller classes. They are also registered as providers of this module (not exported).
exportsModuleExport[][]Tokens and modules that importers of this module can see.
globalbooleanfalseMakes this module's exports visible in every module. See global modules.

A module class needs no body. The decorator stores the options as metadata on the class; Knead reads them when the application starts.

#Sharing providers with exports

A module can inject its own providers, the exports of the modules it imports and the exports of global modules, nothing else. Here DatabaseModule owns two providers and exports one of them:

database.module.ts
@Injectable()
export class Database {
  query(sql: string): string[] {
    return [`result of ${sql}`];
  }
}

// Only for DatabaseModule's own use: not exported.
@Injectable()
export class ConnectionPool {
  readonly size = 10;
}

@Module({
  providers: [Database, ConnectionPool],
  exports: [Database],
})
export class DatabaseModule {}

UsersModule imports DatabaseModule, so UsersService can inject Database. It cannot inject ConnectionPool, which DatabaseModule keeps to itself:

users.module.ts
@Injectable()
export class UsersService {
  readonly db = inject(Database);

  findAll(): string[] {
    return this.db.query('select * from users');
  }
}

@Module({
  imports: [DatabaseModule],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

The root module is the one you pass to KneadFactory.create() (from @knead-js/http) or to ApplicationContext.create():

app.module.ts
@Module({ imports: [UsersModule] })
export class AppModule {}

Visibility is not transitive. AppModule imports UsersModule, which imports DatabaseModule, but a provider of AppModule cannot inject Database: UsersModule does not export it. To use Database in AppModule, import DatabaseModule there too, or have UsersModule re-export it.

#What a module can export

An entry in exports is one of:

  • a token the module provides itself (a class or an InjectionToken);
  • a token that one of its imported modules exports, which re-exports that single token;
  • an imported module (its class, or the dynamic module object), which re-exports everything that module exports.

When two re-exported modules export the same ordinary token, the first in import order wins, as it does for the module's own lookups. For a multi provider token, exports add up instead: a module exports its own contributions (when it lists the token) and those of every module it re-exports the token from, own contributions first, then imports in import order. Listing a multi token re-exports it from every import that exports it.

Anything else is a ModuleDefinitionError at startup, for example UsersModule exports Database, which it neither provides nor imports.

#Re-exporting modules

A module that lists an imported module in its exports passes that module's exports on to its own importers. This is useful for a shared module that bundles several others:

shared.module.ts
@Injectable()
export class Clock {
  now(): Date {
    return new Date();
  }
}

// Importers of SharedModule see Clock and everything DatabaseModule exports.
@Module({
  imports: [DatabaseModule],
  providers: [Clock],
  exports: [Clock, DatabaseModule],
})
export class SharedModule {}

@Injectable()
export class ReportsService {
  readonly db = inject(Database);
  readonly clock = inject(Clock);
}

@Module({ imports: [SharedModule], providers: [ReportsService] })
export class ReportsModule {}

ReportsService can inject Database because SharedModule re-exports DatabaseModule.

#One instance per module

A module class appears in the graph once, however many modules import it, so its singleton providers exist once and every importer gets the same instances.

Dynamic modules are identified by object identity instead. Each call to a forRoot()-style method returns a new object, and each object is a separate module instance with its own providers. Import a dynamic module once (usually in the root module), or store the object in a constant and import that constant everywhere it is needed. Importing a module class plainly and as a dynamic module also creates two instances.

#Global modules

A module with global: true makes its exports visible to every module in the application, without an import. Use it for application-wide infrastructure such as configuration, logging and feature flags:

feature-flags.module.ts
export interface FeatureFlags {
  isEnabled(flag: string): boolean;
}

export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('FEATURE_FLAGS');

@Module({
  global: true,
  providers: [
    {
      provide: FEATURE_FLAGS,
      useValue: { isEnabled: (flag: string) => flag === 'new-checkout' },
    },
  ],
  exports: [FEATURE_FLAGS],
})
export class FeatureFlagsModule {}

// No import of FeatureFlagsModule needed here.
@Injectable()
export class CheckoutService {
  readonly flags = inject(FEATURE_FLAGS);
}

@Module({ providers: [CheckoutService], exports: [CheckoutService] })
export class CheckoutModule {}

// A global module is still imported once, usually by the root module.
@Module({ imports: [FeatureFlagsModule, CheckoutModule] })
export class AppModule {}

Keep these rules in mind:

  • A global module must still be part of the graph: import it once, usually in the root module. A global module that nothing imports is not loaded at all.
  • Only its exports become global. Its other providers stay private, like any module's.
  • Global visibility has the lowest precedence. A module's own providers and the exports of the modules it imports win over a global module that provides the same token (see how a provider is found).
  • If a module lists a global module in its own imports, that module treats it as an ordinary import, at its position in the list.

The modules that the @knead-js/* packages return from forRoot(), such as ConfigModule, LoggingModule and CorsModule, are global.

#Dynamic modules

A dynamic module is a plain object that names a module class and adds options to it. Use one when a module needs configuration from the importing application:

FieldTypeDescription
moduleTypeThe module class. Required. It does not need its own @Module() decorator.
imports, providers, controllers, exportsas in @Module()Appended after the lists in the class's @Module() options.
globalbooleanOverrides the class's global option when set. Otherwise the class's value applies, and false if it has none.

The convention, which the @knead-js/* packages follow, is a static forRoot(options) method that returns a DynamicModule and provides the options under an InjectionToken:

mail.options.ts
export interface MailOptions {
  from: string;
  transport: 'smtp' | 'log';
}

export interface MailModuleAsyncOptions {
  /** Modules whose exports the factory may inject. */
  imports?: ModuleImport[];
  /** Runs in the injection context: call inject() before the first await. */
  useFactory: () => MailOptions | Promise<MailOptions>;
}

export const MAIL_OPTIONS = new InjectionToken<MailOptions>('MAIL_OPTIONS');
mail.service.ts
@Injectable()
export class MailService {
  readonly options = inject(MAIL_OPTIONS);

  send(to: string, subject: string): string {
    return `${this.options.transport}: ${this.options.from} -> ${to}: ${subject}`;
  }
}
mail.module.ts
function validate(options: MailOptions): MailOptions {
  if (!options.from.includes('@')) {
    throw new Error(`MailModule: "from" must be an email address, got "${options.from}".`);
  }
  return options;
}

@Module({ providers: [MailService], exports: [MailService] })
export class MailModule {
  static forRoot(options: MailOptions): DynamicModule {
    validate(options);
    return {
      module: MailModule,
      providers: [{ provide: MAIL_OPTIONS, useValue: options }],
    };
  }

  static forRootAsync(options: MailModuleAsyncOptions): DynamicModule {
    return {
      module: MailModule,
      imports: options.imports ?? [],
      providers: [
        {
          provide: MAIL_OPTIONS,
          useFactory: async () => validate(await options.useFactory()),
        },
      ],
    };
  }
}

forRoot() validates its options before returning, so invalid options fail when the module graph is defined, not on the first email. @Module() on MailModule holds the parts that never change (MailService and its export); the dynamic module adds MAIL_OPTIONS. Because the options token is not exported, only the module's own providers can read it.

Because Knead creates every singleton at startup, importing MailModule without forRoot() fails at startup: MailService (MailModule) requested MAIL_OPTIONS, but no provider is visible. To make that mistake clearer, a module can check for its options in a factory provider of its own and throw a dedicated error, as LoggingModule does.

#Options from other providers

When the options depend on other providers (configuration loaded at startup, a secrets client), add a forRootAsync() that takes imports and a useFactory. Its factory becomes a factory provider of the dynamic module, so it runs in the injection context and can inject the exports of the modules listed in imports:

app.module.ts
@Injectable()
export class Settings {
  readonly mailFrom = 'noreply@example.com';
}

@Module({ providers: [Settings], exports: [Settings] })
export class SettingsModule {}

@Module({
  imports: [
    MailModule.forRootAsync({
      imports: [SettingsModule],
      useFactory: () => ({ from: inject(Settings).mailFrom, transport: 'log' }),
    }),
  ],
})
export class AppModule {}

The factory may be async: Knead awaits it during startup, before it creates any class that injects MAIL_OPTIONS. As with every factory, call inject() before the first await. ConfigModule.forRootAsync(), GraphQLModule.forRootAsync() and PrismaModule.forRootAsync() accept the same imports and useFactory options.

#Exporting a dynamic module

To re-export a dynamic module, list it in exports the same way you list a module class:

  • Exporting the module class (exports: [MailModule]) re-exports every imported instance of that class, whether it was imported plainly or through any number of forRoot() calls.
  • Exporting the dynamic module object itself re-exports only that instance. Store the object in a constant and use the constant in both imports and exports. If the object in exports is not one of the imported objects (for example, a second forRoot() call), Knead falls back to re-exporting every imported instance of its class.

#How a provider is found

When a provider in module M calls inject(token), Knead looks for token in this order:

  1. The providers of M itself (including its controllers).
  2. The exports of the modules M imports, in the order of M's imports list.
  3. The exports of the global modules, in the order they appear in the module graph.
  4. The application's built-in providers: MODULES, plus defaults for LOGGER and DEVELOPMENT (see the core reference).

For an ordinary token the first match wins. The built-ins come last, so a global module that provides LOGGER (such as LoggingModule.forRoot()) replaces the default logger everywhere. A multi provider token is different: the result collects the contributions of every step, in this order.

If a module registers the same token twice in its own providers, the later registration replaces the earlier one.

The ApplicationContext.get() method used by scripts and tests is less strict than inject(): it looks in the root module first, then in every module whether or not the token is exported. See standalone applications.

#Inspecting the module graph

The MODULES token resolves to every module of the application, as a frozen array of ModuleRef objects. Any provider can inject it without an import:

module-report.ts
@Injectable()
export class ModuleReport {
  readonly #modules = inject(MODULES);

  describe(): string[] {
    return this.#modules.map(
      (ref) => `${ref.name}${ref.global ? ' (global)' : ''}: ${ref.controllers.length} controllers`,
    );
  }
}

The array is in initialization order: modules passed as globalModules to ApplicationContext.create() (the HTTP layer passes its own core module this way) come first, then the root module's graph in post-order, so every module appears after the modules it imports and the root module is last.

ModuleRef fieldTypeDescription
namestringThe module class's name.
metatypeTypeThe module class.
injectorModuleInjectorThe module's injector.
controllersreadonly Type[]The module's controller classes, from @Module() and the dynamic module combined.
globalbooleanWhether the module is global.

Packages use MODULES to discover things across the application. LoggingModule, for example, counts how many instances of itself are in the graph and fails startup if forRoot() was imported more than once.

#Module definition errors

Problems with the module graph itself are reported as ModuleDefinitionError when the application starts, before any provider is created:

MessageCause
AppModule imports NotAModule, which is not a module. Did you forget @Module()?A class in imports has no @Module() decorator.
AppModule imports 42 at index 0, which is neither a module class nor a dynamic module.An imports entry is neither a class nor an object with a module class.
AppModule has undefined in imports at index 0. This usually means a circular file import; check the import order of your module files.An imports entry is undefined or null.
The root module is undefined. This usually means a circular file import; check the import order of your module files.The root module passed to create() is undefined or null.
Circular module import: A → B → AModules import each other. Move what both need into a third module.
UsersModule exports Database, which it neither provides nor imports.An exports entry is neither provided by the module nor exported by one of its imports.

An undefined import nearly always comes from circular file imports: when a.module.ts and b.module.ts import each other, one of the two classes is still undefined while the other module's decorator runs.

A provider that is not visible is a NoProviderError, raised while Knead creates the singletons at startup. Its message names the requester, the module and the likely fix:

Output
UsersService (UsersModule) requested Database, but no provider is visible. Database is provided by DatabaseModule but not exported.

See missing providers for the three hints it can give.