DI and decorators example

Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example di-decorators
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.

This app combines DiPlugin with DecoratorPlugin, with each feature declared as a @Module. The decorated controller receives its GreetingService through the class-level @Inject(GREETING_SERVICE).

LifetimesController (GET /lifetimes) visibly calls container.createScope(): singleton services are shared across those scopes, while scoped services are retained inside one scope and differ between scopes. The framework does not create a DI scope per request; applications that need request scoping must create and carry that scope themselves.

pnpm --dir apps/di-decorators run start
pnpm --dir apps/di-decorators run smoke

Read the example deep dive for the concept and source walkthrough.

Read, in order

src/modules/greeting/greeting.controller.ts

import { Controller, Get, Inject } from '@setu-ts/decorator-plugin';
import { GREETING_SERVICE } from './greeting.service.ts';
import type { GreetingService } from './greeting.service.ts';

/** A decorated controller; `@Inject` lists its constructor dependencies by position. */
@Controller('/greetings')
@Inject(GREETING_SERVICE)
export class GreetingController {
  constructor(private readonly greetings: GreetingService) {}

  @Get('/')
  index(): { readonly greeting: string } {
    return { greeting: this.greetings.greet('decorators') };
  }
}

src/modules/greeting/greeting.module.ts

import { Module } from '@setu-ts/decorator-plugin';
import { GreetingController } from './greeting.controller.ts';
import { GreetingService } from './greeting.service.ts';

@Module({ controllers: [GreetingController], providers: [GreetingService] })
export class GreetingModule {}

src/modules/greeting/greeting.service.ts

import { Injectable } from '@setu-ts/decorator-plugin';

/** The DI token `GreetingService` is registered under. */
export const GREETING_SERVICE = 'greeting-service';

/** Builds greetings for the controller that injects it. */
@Injectable({ token: GREETING_SERVICE })
export class GreetingService {
  greet(name: string): string {
    return `Hello, ${name}!`;
  }
}

src/modules/lifetimes/lifetimes.controller.ts

import { CAPABILITIES } from '@setu-ts/common';
import type { IContainer, IRequestContext } from '@setu-ts/common';
import { Controller, Ctx, Get, Inject, Params, Service } from '@setu-ts/decorator-plugin';
import { ScopedReportService, SingletonReportService } from './lifetimes.services.ts';

/**
 * Per-request identity and lifetime comparisons.
 * @since 0.11.0
 */
export interface LifetimesReport {
  readonly singletonShared: boolean;
  readonly scopeRetainsInstance: boolean;
  readonly scopedId: string;
}

/**
 * Demonstrates lazy request scopes through Service and forRequest.
 * @since 0.11.0
 */
@Controller('/lifetimes')
@Inject(CAPABILITIES.DI_CONTAINER)
export class LifetimesController {
  /**
   * @param container - Application container
   * @since 0.11.0
   */
  constructor(private readonly container: IContainer) {}

  /**
   * Compares parameter-injected services with the same request scope.
   *
   * @param scoped - Request-owned service
   *
   * @param singleton - Application-owned service
   *
   * @param ctx - Context owning the scope and its disposal
   *
   * @returns Instance identity comparisons and this request service identifier
   *
   * @since 0.11.0
   */
  @Get('/')
  @Params(Service(ScopedReportService), Service(SingletonReportService), Ctx())
  report(
    scoped: ScopedReportService,
    singleton: SingletonReportService,
    ctx: IRequestContext,
  ): LifetimesReport {
    const scope = this.container.forRequest(ctx);
    return {
      singletonShared: singleton === scope.resolve(SingletonReportService),
      scopeRetainsInstance: scoped === scope.resolve(ScopedReportService),
      scopedId: scoped.id,
    };
  }
}

src/modules/lifetimes/lifetimes.module.ts

import { Module } from '@setu-ts/decorator-plugin';
import { LifetimesController } from './lifetimes.controller.ts';
import { ScopedReportService, SingletonReportService } from './lifetimes.services.ts';

/**
 * Demonstrates singleton and lazily created HTTP request lifetimes.
 * @since 0.11.0
 */
@Module({
  controllers: [LifetimesController],
  providers: [SingletonReportService, ScopedReportService],
})
export class LifetimesModule {}

src/modules/lifetimes/lifetimes.services.ts

import type { IRuntimeServices } from '@setu-ts/common';
import { CAPABILITIES } from '@setu-ts/common';
import { Inject, Injectable } from '@setu-ts/decorator-plugin';

export const SINGLETON_REPORT = 'singleton-report';
export const SCOPED_REPORT = 'scoped-report';

/** One instance for the whole application. */
@Injectable({ scope: 'singleton', token: SINGLETON_REPORT })
export class SingletonReportService {}

/**
 * One instance per lazy request scope.
 * @since 0.11.0
 */
@Injectable({ scope: 'scoped', token: SCOPED_REPORT })
@Inject(CAPABILITIES.RUNTIME)
export class ScopedReportService {
  /**
   * Request service identity.
   * @since 0.11.0
   */
  readonly id: string;
  /**
   * @param runtime - Portable identifier source
   * @since 0.11.0
   */
  constructor(runtime: IRuntimeServices) {
    this.id = runtime.uuid();
  }
}

smoke.ts

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { IKernelApplication } from '@setu-ts/kernel';
import type { LifetimesReport } from './src/modules/lifetimes/lifetimes.controller.ts';
import { createApp } from './setu.config.ts';

describe('di-decorators example', () => {
  let app: IKernelApplication;

  before(async () => {
    app = createApp();
    await app.start();
  });

  after(async () => {
    await app.stop();
  });

  it('answers through the service injected into the controller', async () => {
    const response = await app.inject({ method: 'GET', url: '/greetings/' });

    assert.equal(response.statusCode, 200);
    assert.deepEqual(response.json(), { greeting: 'Hello, decorators!' });
  });

  it('shares singletons and creates a distinct scoped service for each request', async () => {
    const first = await app.inject({ method: 'GET', url: '/lifetimes/' });
    const second = await app.inject({ method: 'GET', url: '/lifetimes/' });
    assert.equal(first.statusCode, 200);
    assert.equal(second.statusCode, 200);
    const a = first.json<LifetimesReport>();
    const b = second.json<LifetimesReport>();
    assert.equal(a.singletonShared, true);
    assert.equal(b.singletonShared, true);
    assert.equal(a.scopeRetainsInstance, true);
    assert.equal(b.scopeRetainsInstance, true);
    assert.notEqual(a.scopedId, b.scopedId);
  });
});