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.
| Class | Needs @Service()? |
|---|---|
| No constructor parameters | No |
| Constructor parameters | Yes, 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
| Lifetime | Instances | Typical use |
|---|---|---|
singleton | One for the application. | Configuration, connection pools, stateless services. |
scoped | One per scope. Suku opens a scope for every request. | Unit of work, current user, request-bound state. |
transient | One 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.
| Problem | Error |
|---|---|
| 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();
}
}