Skip to Content

Services

Declaring services with or without a class, lifetimes, tokens, validation and disposal.

A service is anything your routes and other services depend on: a repository, a mailer, a clock. Suku resolves them from a container modelled on .NET's, where every service has an explicit lifetime. Like routes, a service can be written with a class or without one.

Dependencies arrive through the constructor.

import { Service } from '@sukujs/core';

@Service()
export class UsersService {
  constructor(private readonly db: Database) {}

  find(id: number) {
    return this.db.users.get(id);
  }
}

Both register the same way and can depend on each other.

feature('users', { services: [UsersService] });

When @Service() is needed

The decorator has no behaviour of its own. It makes TypeScript record the constructor's parameter types, which is how the container knows what to pass.

ClassNeeds @Service()?
No constructor parametersNo
Constructor parametersYes, or a static inject array
Already decorated with @Routes()No
Wants a default lifetime@Service({ lifetime: 'scoped' }), or register with scoped(...)

Forgetting it on a class that needs it fails at startup with a message naming the class.

Services without a class

service(name, options) returns a value that is both the registration and the token.

export const Clock = service('clock', () => ({ now: () => new Date() }));

export const Invoices = service('invoices', {
  lifetime: 'scoped',
  services: { clock: Clock, db: Database },
  create: ({ clock, db }) => ({
    due: () => db.invoices.dueBefore(clock.now()),
  }),
});

Use it by name in a route, or in another service.

route.get('/invoices/due', {
  services: { invoices: Invoices },
  handle: ({ invoices }) => invoices.due(),      // fully typed
});

In a class constructor, inject it with its token and take the type with ServiceOf.

@Service()
class Reports {
  constructor(@Inject(Invoices) private readonly invoices: ServiceOf<typeof Invoices>) {}
}

Declared dependencies are checked at startup exactly like constructor parameters: a missing one, or a singleton depending on a scoped service, stops the app before it listens. An object with a dispose() method is disposed with its scope.

Using the container directly

@sukujs/di also works on its own, without HTTP.

import { ServiceCollection } from '@sukujs/di';

const provider = new ServiceCollection()
  .addSingleton(Database)
  .addScoped(UsersService)
  .build({ validateOnBuild: true });

const scope = provider.createScope();
scope.get(UsersService);
await scope.dispose();

Lifetimes

LifetimeInstancesTypical use
singletonOne for the application.Configuration, connection pools, stateless services.
scopedOne per scope. Suku opens a scope for every request.Unit of work, current user, request-bound state.
transientOne per resolve.Cheap, stateful helpers.

Resolving a scoped service from the root provider throws. Create a scope first.

Tokens

A token identifies a registration. Classes are their own tokens.

Interfaces do not exist at runtime, so use an abstract class where .NET would use an interface.

export abstract class Cache {
  abstract get(key: string): Promise<string | undefined>;
}

@Service()
export class RedisCache extends Cache { /* ... */ }

services: [singleton(Cache, RedisCache)];

class Uses {
  constructor(private readonly cache: Cache) {}   // no decorator needed
}

For values with no class, use an InjectionToken and @Inject.

export const CONFIG = new InjectionToken<{ port: number }>('CONFIG');

services: [value(CONFIG, { port: 3000 })];

class Server {
  constructor(@Inject(CONFIG) private readonly config: { port: number }) {}
}

@Optional() yields undefined instead of throwing when nothing is registered.

Without decorators

To keep a class but avoid decorators, list its dependencies in a static inject array.

class UsersRepository {
  static inject = [Database, { token: CONFIG, optional: true }];

  constructor(
    private readonly db: Database,
    private readonly config?: Config,
  ) {}
}

Keyed services

Register several implementations of one token under keys.

services: [
  singleton(Storage, S3Storage, { key: 's3' }),
  singleton(Storage, DiskStorage, { key: 'disk' }),
];

class Uploader {
  constructor(@Inject(Storage, 's3') private readonly storage: Storage) {}
}

Several registrations of one token

get() returns the last registration. getAll() returns every one, in order, which suits plugin lists.

provider.getAll(HealthCheck).map((check) => check.run());

Startup validation

Suku.create builds the container with validateOnBuild, so mistakes surface before the server listens.

ProblemError
A constructor, service or route needs a service that is not registered.MissingServiceError
Services depend on each other in a loop.CircularDependencyError
A singleton depends on a scoped service.CaptiveDependencyError
A parameter's type is an interface or primitive with no @Inject.UnresolvableParameterError

A captive dependency is a long-lived service holding a short-lived one: a singleton that captures a scoped unit of work would keep the first request's instance forever. The messages include the resolution chain.

Captive dependency: OrdersRoutes (singleton) depends on UnitOfWork (scoped).

Disposal

When a scope or the root provider is disposed, the instances it created are disposed newest first. A service takes part by having any of dispose(), onDispose(), [Symbol.dispose]() or [Symbol.asyncDispose]().

@Service()
class UnitOfWork {
  async dispose() {
    await this.connection.release();
  }
}

Values registered with value() are never disposed, because the container did not create them. Providers support await using.

The provider is injectable

A class may ask for the ServiceProvider and receives the scope it is being resolved in.

class JobRunner {
  constructor(private readonly provider: ServiceProvider) {}

  run(job: Constructor<Job>) {
    return this.provider.get(job).execute();
  }
}

On this page