---
url: https://masyaka.github.io/injecute/guide/custom-registration.md
description: >-
  Build your own way to register services (files in a directory, wiring
  generated from files, standard decorators) as a module applied with extend(),
  what the compiler still checks, and when a middleware fits instead.
---

# Custom registration

injecute registers services with explicit `add*` calls. When you want services to be found instead
(files in a directory, classes with decorators, a manifest, a list of plugins), build that on top of the
public API. There are two places to plug it in:

| You want to                                        | Use                              | Types                                                     |
| -------------------------------------------------- | -------------------------------- | --------------------------------------------------------- |
| register services you can find when the app starts | a module applied with `extend()` | declared once by the module, then inferred like any other |
| answer for keys nobody can list in advance         | a middleware added with `use()`  | not in the container's type: every `get()` needs a cast   |

**Use a module by default.** It finds what to register and calls `addSingleton()` or `addTransient()`.
From then on these are ordinary registrations: they are created lazily, cached, released by
`dispose()`, shared with forks and replaced in [isolated forks](./testing.md), and `has()`,
`preload()` and `buildServicesGraph()` see them.

The compiler can't see what is found at runtime, so the module declares what it adds: it returns a
`ServiceRegistry<Requires, Services>`. How much of that declaration the compiler can check depends on
the approach. The examples below show both ends, and how generating the wiring from files gets the
compiler's full checks back.

## Services from files

Each file in `services/` is a service: the file name is the key, the default export is the factory and
`dependencies` lists the keys the factory receives, in order.

::: code-group

```ts \[services/clock.ts]
// One service per file: the file name is the key, the default export the factory.
export default function createClock() {
  return { now: () => new Date(0) };
}

```

```ts \[services/greeter.ts]
import type createClock from './clock.ts';

// Keys of the services the factory receives, in order.
export const dependencies = ['config', 'clock'];

export default function createGreeter(
  config: { greeting: string },
  clock: ReturnType<typeof createClock>,
) {
  return {
    greet: (name: string) =>
      `${config.greeting}, ${name} (${clock.now().toISOString()})`,
  };
}

```

:::

A loader imports the files, and a module registers them:

```ts
/** A file in the services directory: the file name is the key, the default export the factory. */
interface ServiceFile {
  default: (...args: any[]) => unknown;
  dependencies?: string[];
}

type ServiceOf<F extends ServiceFile> = ReturnType<F['default']>;

/** Imports every service file in `directory`. */
async function loadServiceFiles(directory: URL) {
  const names = (await readdir(directory)).filter(
    (name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts'),
  );
  return Promise.all(
    names.map(async (name) => ({
      key: name.replace(/\.[jt]s$/, ''),
      file: (await import(new URL(name, directory).href)) as ServiceFile,
    })),
  );
}

/**
 * A module that registers the loaded files as singletons. The compiler can't see the directory, so
 * `Services` declares what the files provide.
 */
function addServiceFiles<Services extends object, Requires extends object = {}>(
  files: { key: string; file: ServiceFile }[],
) {
  return (
    registry: ServiceRegistry<Requires>,
  ): ServiceRegistry<Requires, Services> => {
    const untyped = registry as unknown as ServiceRegistry<any>;
    for (const { key, file } of files) {
      untyped.addSingleton(key, file.default, file.dependencies ?? []);
    }
    return registry as unknown as ServiceRegistry<Requires, Services>;
  };
}
```

The app declares the services from the files' own types and applies the module:

```ts
// Taken from the files' own types: a changed factory changes the service type here.
type Services = {
  clock: ServiceOf<typeof import('./services/clock.ts')>;
  greeter: ServiceOf<typeof import('./services/greeter.ts')>;
};

const files = await loadServiceFiles(new URL('./services/', import.meta.url));

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(addServiceFiles<Services, { config: { greeting: string } }>(files));

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"
```

* **Checked:** the type of each service (taken from its file, so a changed factory changes it), and
  that the container has what the files need (`Requires`) when you call `extend()`.
* **Not checked:** that a file exists for every declared key, and that `dependencies` match the
  factory's parameters. A missing file fails with `INJECUTE_NOT_REGISTERED` when something resolves
  it. [Check the wiring](#checking-the-wiring) in a test to catch it before deploying, or
  [generate the wiring](#generating-the-wiring-from-files) and the compiler checks both.
* `extend()` is synchronous: load the files first (`await`), then apply the module.
* Bundled apps and browsers can't read a directory. With Vite,
  `import.meta.glob('./services/*.ts', { eager: true })` returns the same modules at build time.

## Generating the wiring from files

Instead of loading the files when the app starts, a script can write the wiring from them: plain
`addSingleton()` calls, as you would write them by hand. The compiler then checks the generated file like
any other wiring.

**For files in your project, generate the wiring.** Load files at runtime only when they aren't known
before the app starts, such as plugins installed later.

The script reads the same files as the loader above: the file name is the key, the default export the
factory, and `dependencies` the keys it receives. It orders the registrations so each service comes after
its dependencies, and keys that no file provides become the module's requirements:

```ts
/** A file in the services directory: the file name is the key, the default export the factory. */
interface ServiceFile {
  default: (...args: any[]) => unknown;
  dependencies?: readonly string[];
}

/**
 * Writes a wiring file with one `addSingleton()` per file in `directory`, dependencies first. Keys
 * that no file provides become the module's requirements, typed from the factory parameters that
 * receive them.
 */
export async function generateWiring(
  directory: URL,
  output: URL,
): Promise<string> {
  const names = (await readdir(directory))
    .filter((name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts'))
    .sort();
  const services = new Map<string, { path: string; dependencies: string[] }>();
  for (const name of names) {
    const url = new URL(name, directory);
    const file = (await import(url.href)) as ServiceFile;
    let path = relative(dirname(fileURLToPath(output)), fileURLToPath(url));
    if (!path.startsWith('.')) path = `./${path}`;
    const key = name.replace(/\.[jt]s$/, '');
    services.set(key, { path, dependencies: [...(file.dependencies ?? [])] });
  }

  // A service is registered after the services it depends on, so each line type-checks.
  const order: string[] = [];
  const visiting = new Set<string>();
  const visit = (key: string, path: string[]) => {
    if (order.includes(key) || !services.has(key)) return;
    if (visiting.has(key))
      throw new Error(`Circular dependency: ${[...path, key].join(' -> ')}`);
    visiting.add(key);
    for (const dependency of services.get(key)!.dependencies)
      visit(dependency, [...path, key]);
    order.push(key);
  };
  for (const key of services.keys()) visit(key, []);

  // Keys no file provides: the container must have them, typed as the factory expects.
  const requires = new Map<string, string>();
  for (const key of order)
    services.get(key)!.dependencies.forEach((dependency, index) => {
      if (!services.has(dependency) && !requires.has(dependency))
        requires.set(dependency, `Parameters<typeof ${key}.default>[${index}]`);
    });

  const source = [
    `// Generated from ${relative(dirname(fileURLToPath(output)), fileURLToPath(directory))}/ by generate-wiring.ts. Don't edit: run the generator.`,
    `import type { ServiceRegistry } from 'injecute';`,
    ...order.map(
      (key) => `import * as ${key} from '${services.get(key)!.path}';`,
    ),
    ``,
    `export interface Requires {`,
    ...[...requires].map(([key, type]) => `  ${key}: ${type};`),
    `}`,
    ``,
    `export const addServices = (registry: ServiceRegistry<Requires>) =>`,
    `  registry`,
    ...order.map((key) => {
      const dependencies = services.get(key)!.dependencies;
      const list = dependencies.map((d) => `'${d}'`).join(', ');
      return `    .addSingleton('${key}', ${key}.default${list ? `, [${list}]` : ''})`;
    }),
  ];
  return `${source.join('\n')};\n`;
}
```

For the two files above, it writes:

```ts
// Generated from ../services/ by generate-wiring.ts. Don't edit: run the generator.
import type { ServiceRegistry } from 'injecute';
import * as clock from '../services/clock.ts';
import * as greeter from '../services/greeter.ts';

export interface Requires {
  config: Parameters<typeof greeter.default>[0];
}

export const addServices = (registry: ServiceRegistry<Requires>) =>
  registry
    .addSingleton('clock', clock.default)
    .addSingleton('greeter', greeter.default, ['config', 'clock']);

```

The app applies it like any module, without declaring `Services`:

```ts
import { DIContainer } from 'injecute';
import { addServices } from './services.generated.ts';

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(addServices);

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"
```

* **Checked:** everything the compiler checks in hand-written wiring. `dependencies` that don't match
  the factory's parameters are an error at that service's `addSingleton()`. A dependency that no file
  and no registration provides is an error at `extend()`, and so is `get()` with a key that has no file.

* **Not checked:** that the wiring file is up to date with the files. Run the script before type
  checking, or compare its output with the committed file in a test:

  ```ts
  it('the wiring file is up to date', async () => {
    const output = new URL('./services.generated.ts', import.meta.url);
    const source = await generateWiring(
      new URL('./services/', import.meta.url),
      output,
    );
    expect(source).toBe(await readFile(output, 'utf8'));
  });
  ```

* The script imports the files to read `dependencies`: run it with a runtime that runs TypeScript, such
  as Node.js 22.18 or later, Deno or Bun. File names must be valid identifiers.

* The wiring is ordinary imports, so bundlers and browsers need nothing extra.

Mistakes in the files are compile errors. Dependencies in the wrong order:

```ts
export const dependencies = ['clock', 'config']; // the factory takes (config, clock)
```

```text
services.generated.ts: Argument of type '(config: { greeting: string; }, clock: { now: () => Date; }) => …'
  is not assignable to parameter of type 'Factory<["clock", "config"], …>'.
    Types of parameters 'config' and 'args_0' are incompatible.
```

A dependency that no file provides and the app doesn't register:

```ts
export const dependencies = ['config', 'clock', 'mailer']; // there is no services/mailer.ts
```

```text
app.ts: Argument of type '(registry: ServiceRegistry<Requires>) => …' is not assignable to parameter
  of type '{ 'injecute: extension requires services that are not registered': "mailer"; }'.
```

## Services from decorators

Standard decorators (TC39, TypeScript 5 and later without `experimentalDecorators`) don't emit the
types of constructor parameters, so a decorator can't infer what a class needs. Each decorator names
its key and its dependency keys, as `addSingleton()` does, and the compiler checks them against the
class.

`createDecorators()` is about 50 lines. Copy it into your project and adapt it:

```ts
type Key<S> = Extract<keyof S, string>;
type Resolved<S, D extends unknown[]> = { [I in keyof D]: S[D[I] & keyof S] };
type Lifetime = 'addSingleton' | 'addTransient';
// The keys are checked by the decorators; registering them needs no types.
type UntypedRegistry = Record<
  Lifetime,
  (key: string, target: unknown, dependencies: string[]) => unknown
>;

/**
 * Standard (TC39) class decorators that register classes in a list; the returned function is a
 * module that adds them to a container with `extend()`.
 *
 * `Services` is what the decorated classes provide, `Requires` what they need from the container.
 */
export function createDecorators<
  Services extends object,
  Requires extends object = {},
>() {
  type All = Services & Requires;
  const registrations: ((registry: UntypedRegistry) => void)[] = [];

  const decorator =
    (lifetime: Lifetime) =>
    <K extends Key<Services>, const D extends Key<All>[] = []>(
      key: K,
      dependencies?: D,
    ) =>
    // The class must produce `Services[K]` from the services listed in `dependencies`.
    (
      target: new (...args: Resolved<All, D>) => Services[K],
      _context: ClassDecoratorContext,
    ) => {
      registrations.push((registry) =>
        registry[lifetime](key, target, dependencies ?? []),
      );
    };

  const addDecorated = (
    registry: ServiceRegistry<Requires>,
  ): ServiceRegistry<Requires, Services> => {
    for (const register of registrations)
      register(registry as unknown as UntypedRegistry);
    return registry as unknown as ServiceRegistry<Requires, Services>;
  };

  return Object.assign(addDecorated, {
    singleton: decorator('addSingleton'),
    transient: decorator('addTransient'),
  });
}
```

Declare the services once, decorate the classes and apply the decorators as a module:

```ts
// The services the decorated classes provide, and what they need from the container.
type Services = { clock: Clock; greeter: Greeter };
type Requires = { config: Config };

const services = createDecorators<Services, Requires>();

@services.singleton('clock')
class Clock {
  now() {
    return new Date(0);
  }
}

@services.singleton('greeter', ['config', 'clock'])
class Greeter {
  constructor(
    private config: Config,
    private clock: Clock,
  ) {}

  greet(name: string) {
    return `${this.config.greeting}, ${name} (${this.clock.now().toISOString()})`;
  }
}

const app = new DIContainer()
  .addInstance('config', { greeting: 'Hello' })
  .extend(services); // DIContainer<{ config: Config } & Services>

app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)"
```

[Open in the playground](../playground?example=custom-registration/decorators): the example is
one file, with `createDecorators()` at the top.

* **Checked:** the key is one of `Services`. Each dependency key is in `Services` or `Requires`. The
  constructor accepts those services in that order, and its instances are the declared service. The
  container has `Requires` when you call `extend()`, and has `Services` afterwards.
* **Not checked:** that every key of `Services` has a decorated class. A decorator runs when its file
  is evaluated, so a class whose file nobody imports is never registered, and resolving it fails with
  `INJECUTE_NOT_REGISTERED`. Import every decorated file from the wiring file that calls `extend()`,
  and [check the wiring](#checking-the-wiring) in a test.
* Two classes under the same key fail with `INJECUTE_ALREADY_REGISTERED` at `extend()`.
* The decorators only collect registrations. Instances live in the container that applies them, so
  every container, fork and test gets its own.

Mistakes are compile errors at the decorator:

```ts
@services.singleton('mailer') // not a key of Services
class Mailer {}

@services.singleton('greeter', ['clock']) // the constructor needs (Config, Clock)
class Greeter {
  constructor(config: Config, clock: Clock) {}
}
```

```text
Argument of type '"mailer"' is not assignable to parameter of type 'keyof Services'.

Unable to resolve signature of class decorator when called as an expression.
  Argument of type 'typeof Greeter' is not assignable to parameter of type 'new (args_0: Clock) => Greeter'.
```

What this costs:

* The wiring moves from one file into the classes, and the classes import the decorators. You lose
  "readable in one place" ([principles 1 and 2](../concepts/principles.md)).
  The classes themselves stay plain: a test still creates them with `new Greeter(config, clock)`.
* Standard decorators must be compiled. `tsc` compiles them. Node.js (including its type stripping)
  doesn't run them yet, and Vite 8 and Vitest 5 compile only legacy decorators. In Vite or Vitest,
  compile files that use decorators with TypeScript first:

```ts
import ts from 'typescript';
import { defineConfig, type Plugin } from 'vitest/config';

// Vite's transform (Oxc) only lowers legacy decorators, and Node.js doesn't run standard ones yet:
// files with standard decorators go through TypeScript first.
const standardDecorators: Plugin = {
  name: 'standard-decorators',
  enforce: 'pre',
  transform(code, id) {
    if (!id.endsWith('.ts') || !/^\s*@\w/m.test(code)) return;
    const { outputText, sourceMapText } = ts.transpileModule(code, {
      fileName: id,
      compilerOptions: {
        target: ts.ScriptTarget.ES2022,
        module: ts.ModuleKind.ESNext,
        sourceMap: true,
      },
    });
    return { code: outputText, map: sourceMapText };
  },
};
```

## Testing

Services registered by a module are ordinary registrations, however the module found them. Tests work
as described in [Testing](./testing.md): replace a dependency in an isolated fork, and everything
resolved through the fork uses the replacement.

```ts
// The decorated classes are ordinary registrations: an isolated fork replaces the clock for the test.
await using testContainer = app
  .fork({ isolated: true })
  .addInstance(
    'clock',
    { now: () => new Date('2030-01-01') },
    { replace: true },
  );

const greeting = testContainer.get('greeter').greet('Ada'); // "Hello, Ada (2030-01-01T00:00:00.000Z)"
app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)": the app is untouched
```

The same works for services registered from files.

### Checking the wiring

The compiler checks what you declare in `Services`, but not that every declared service is actually
registered. List the keys in an object that `satisfies Record<keyof Services, true>`: the compiler then
checks the list against `Services`, so a declared service can't be left out. Pass the list to
[`checkWiring()`](./testing.md#check-the-wiring) in a test:

```ts
// The compiler can't see the directory, but it checks this list against `Services`: a key missing
// here, or one that `Services` doesn't have, is a compile error.
const declared = { clock: true, greeter: true } satisfies Record<
  keyof Services,
  true
>;

/** Reports every declared service without a file, and every dependency key that nothing registers. */
async function checkServiceFiles(container: ServiceProvider<Services>) {
  await checkWiring(container, {
    keys: Object.keys(declared) as (keyof Services)[],
  });
}

await checkServiceFiles(app);
```

* **A declared service without a file or decorated class** is reported as not registered.
* **A dependency key that nothing registers** (a typo in `dependencies`) is reported with every service
  that needs it. Neither check runs a factory.
* **Dependencies in the wrong order** are caught only if the factory fails with them: pass
  `resolve: true` to run every factory (on an [isolated fork](./testing.md) with fakes). With files,
  nothing compares `dependencies` with the factory's parameters; with the decorators, the compiler does.
* The same check works for decorated classes: list the keys of the `Services` you passed to
  `createDecorators()`.

## Keys nobody can list: a middleware

When the keys come from outside and can't be listed at startup, such as environment variables or
remote settings looked up by name, a middleware can answer for them:

```ts
// Resolve `env.*` keys from the environment when nothing is registered under them.
const env: Record<string, string> = { PORT: '8080' };

const app = new DIContainer().use((key, next) => {
  const value = next();
  if (
    value === undefined &&
    typeof key === 'string' &&
    key.startsWith('env.')
  ) {
    return env[key.slice('env.'.length)];
  }
  return value;
});

// Keys a middleware provides are not in the container's type; declare them where you use them:
const port = app.get('env.PORT' as never) as string; // "8080"
```

A middleware is the right tool only when the keys really are unknown up front:

* The keys aren't in the container's type, so every `get()` needs a cast. Wrap the lookup in a typed
  function instead of casting at every call site.
* It runs on every `get()` and caches nothing. Cache values yourself if they are expensive.
* The values aren't registrations: `has()`, `preload()` and `buildServicesGraph()` don't know them, and
  `dispose()` doesn't release them.

If you can list the keys when the app starts, even by computing them, register them in a module
instead. For example, read the environment once and call `addInstance()` for every variable. See
[Middlewares and events](./middleware-events.md) for what else middlewares do.
