Edit this page on GitHub

Migrating from NestJS

This guide helps you migrate from NestJS to Setu-TS. Setu-TS provides a familiar developer experience with decorators and dependency injection while being runtime-independent and more flexible.

Key Differences

ConceptNestJSSetu-TS
RuntimeNode.js onlyDeno, Node.js, Bun, Cloudflare Workers
Reflectionreflect-metadata requiredExplicit injection tokens (no reflection)
Module System@Module decorators@Module (class style) or plugins
HTTP ServerExpress/FastifyHono (fetch API)
Dependency InjectionAutomatic via reflectionExplicit tokens (@Inject('token'))
DecoratorsBuilt-inOptional, via DecoratorPlugin

Scaffolding

The closest starting point to a NestJS app is the class-based style, which opts into decorators and dependency injection together. It is its own --style axis on the rest and microservice templates, and --template class-based is a byte-identical alias of --template rest --style class-based:

# REST service with decorators and DI
setu new my-app --template rest --style class-based

# Microservice (adds messaging, queue, resilience and telemetry) with decorators and DI
setu new my-service --template microservice --style class-based

# The alias: identical output to the first command
setu new my-app --template class-based

A class-based project registers DecoratorPlugin and DiPlugin together, emits decorated @Controller and @Injectable classes, and wires every generated artifact through the seam barrels in setu.config.ts. The --style choice persists: setu generate reads the project’s manifest, so a project holding @setu-ts/decorator-plugin keeps generating class-based output.

Basic Application

NestJS

import { NestFactory } from '@nestjs/core';
import { Controller, Get, Module } from '@nestjs/common';

@Controller()
class AppController {
  @Get()
  hello() {
    return { message: 'Hello' };
  }
}

@Module({ controllers: [AppController] })
class AppModule {}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
void bootstrap();

Setu-TS (Programmatic)

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication();

app.register(RuntimePlugin());

app.router.get('/', async (ctx) => {
  return ctx.response.json({ message: 'Hello' });
});

await app.start({ port: 3000 });

Setu-TS (With Decorators)

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DiPlugin } from '@setu-ts/di-plugin';
import { Controller, DecoratorPlugin, Get } from '@setu-ts/decorator-plugin';

@Controller('/')
class AppController {
  @Get()
  async hello() {
    return { message: 'Hello' };
  }
}

const app = createApplication({
  plugins: [RuntimePlugin(), DiPlugin(), DecoratorPlugin({ controllers: [AppController] })],
});

await app.start({ port: 3000 });

The container falls back to the kernel’s service registry, so a class can inject a framework capability such as CAPABILITIES.LOGGER, as a Nest provider can inject a global module’s export.

On 0.8.0 and earlier pass DiPlugin({ autoRegister: true }): the default there was false, and the same injection failed at startup with No provider registered for DI token 'logger'.

Controllers and Routes

NestJS

import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
import { UserService } from './user.service';

@Controller('users')
export class UsersController {
  constructor(private readonly userService: UserService) {}

  @Get()
  findAll() {
    return this.userService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.userService.findById(id);
  }

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
}

Setu-TS (Programmatic)

import { createCapabilityToken } from '@setu-ts/common';

const USER_SERVICE = createCapabilityToken('user-service');

app.router.get('/users', async (ctx) => {
  const userService = ctx.services.get<UserService>(USER_SERVICE);
  return ctx.response.json(await userService.findAll());
});

app.router.get('/users/:id', async (ctx) => {
  const userService = ctx.services.get<UserService>(USER_SERVICE);
  const id = ctx.params.id;
  return ctx.response.json(await userService.findById(id));
});

app.router.post('/users', async (ctx) => {
  const userService = ctx.services.get<UserService>(USER_SERVICE);
  const dto = await ctx.request.json();
  return ctx.response.status(201).json(await userService.create(dto));
});

Setu-TS (With Decorators)

import { Body, Controller, Get, Inject, Param, Params, Post } from '@setu-ts/decorator-plugin';

@Controller('/users')
@Inject('user-service')
export class UsersController {
  constructor(private readonly userService: UserService) {}

  @Get()
  async findAll() {
    return this.userService.findAll();
  }

  @Get('/:id')
  @Params(Param('id'))
  async findOne(id: string) {
    return this.userService.findById(id);
  }

  @Post()
  @Params(Body())
  async create(dto: CreateUserDto) {
    return this.userService.create(dto);
  }
}

Dependency Injection

NestJS

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) {}
}

Setu-TS

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

@Injectable({ token: 'user-service' })
@Inject('user-repository')
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}
}

// Register the service with the DecoratorPlugin, or programmatically:
ctx.services.register('user-service', new UserService(userRepository));

Modules vs Plugins

NestJS

import { Module } from '@nestjs/common';

@Module({
  controllers: [UsersController],
  providers: [UserService],
  exports: [UserService],
})
export class UsersModule {}

// In AppModule
@Module({
  imports: [UsersModule, DatabaseModule],
})
export class AppModule {}

Setu-TS

import { DecoratorPlugin, Module } from '@setu-ts/decorator-plugin';

class UsersController {}
class UserService {}

@Module({
  controllers: [UsersController],
  providers: [UserService],
})
export class UsersModule {}

@Module({ imports: [UsersModule] })
export class AppModule {}

app.register(DecoratorPlugin({ modules: [AppModule] }));

@Module groups domain classes inside one application. It has no exports member because Setu-TS does not create a module-scoped service registry or DI container: a provider registered by one module is already visible to the rest of that application.

Use a plugin factory instead for a self-contained capability that owns lifecycle work, publishes a capability, or should be reusable across applications:

import { CAPABILITIES, createCapabilityToken } from '@setu-ts/common';
import type { IPlugin } from '@setu-ts/common';
import { DatabasePlugin } from '@setu-ts/database-plugin';

const USER_SERVICE = createCapabilityToken('user-service');

export function UsersPlugin(): IPlugin {
  return {
    name: 'users',
    version: '1.0.0',
    dependencies: [CAPABILITIES.RUNTIME, CAPABILITIES.DATABASE],
    provides: [USER_SERVICE],
    async register(ctx) {
      // Register services
      ctx.services.register(USER_SERVICE, new UserService());

      // Register routes directly
      ctx.router.get('/users', async (requestCtx) => {
        const userService = requestCtx.services.get<UserService>(USER_SERVICE);
        return requestCtx.response.json(await userService.findAll());
      });
    },
  };
}

// In main.ts
app.register(UsersPlugin());
app.register(DatabasePlugin());

Guards

NestJS

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<{ headers: { authorization?: string } }>();
    const token = this.extractTokenFromHeader(request);
    return !!token;
  }

  private extractTokenFromHeader(request: { headers: { authorization?: string } }): string | null {
    const [kind, token] = request.headers.authorization?.split(' ') ?? [];
    return kind === 'Bearer' && token !== undefined ? token : null;
  }
}

Setu-TS

import { AuthPlugin, requireAuth, requireRole } from '@setu-ts/auth-plugin';

app.register(
  AuthPlugin({
    jwt: { secret: 'replace-with-a-secret-from-configuration' },
    rbac: { roles: { admin: { permissions: ['*'] } } },
  }),
);

// Guards attach per route, like @UseGuards. An unauthenticated request answers 401
// and a principal without the role answers 403, before the handler runs.
app.router.get('/profile', { middleware: [requireAuth()], handler });
app.router.delete('/users/:id', { middleware: [requireAuth(), requireRole('admin')], handler });

AuthPlugin verifies the bearer token on every request and puts the principal on ctx.request.user; it never refuses a request itself, so /health and other public routes keep answering. The guards are what refuse. In a decorated controller the same guards attach with @UseGuards(requireAuth()), and @Roles(...) and @Permissions(...) are enforced. See Decorators — Guards.

Interceptors

NestJS

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, type Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    void context;
    return next.handle().pipe(
      map((data) => ({
        success: true,
        data,
      })),
    );
  }
}

Setu-TS

import type { MiddlewareFunction } from '@setu-ts/common';

export const transformMiddleware: MiddlewareFunction = async (ctx, next) => {
  await next();

  // Transform response
  const snapshot = ctx.response.snapshot();
  if (!snapshot.streaming && typeof snapshot.body === 'string') {
    const data = JSON.parse(snapshot.body);
    const transformed = { success: true, data };
    return ctx.response.json(transformed);
  }
};

Exception Filters

NestJS

import { ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status = exception.getStatus();
    const message = exception.message;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}

Setu-TS

import { errorHandler } from '@setu-ts/exceptions';

app.middleware.add(
  errorHandler({
    respond(error, ctx) {
      return ctx.response.status(error.statusCode).json({
        statusCode: error.statusCode,
        path: new URL(ctx.request.url).pathname,
        message: error.message,
      });
    },
  }),
  { priority: 0, name: 'error-handler' },
);

errorHandler logs the error and normalizes it to an HttpError before respond sees it. An unexpected error therefore arrives as a 500 with a generic message, while the original goes to the log. Return undefined from respond to keep the configured format ('rfc9457' Problem Details in a scaffolded project). Priority 0 makes it the outermost middleware, so it catches what every other stage throws.

Pipelines (Validation)

NestJS

import { Body, Post, UsePipes, ValidationPipe } from '@nestjs/common';

@Post()
@UsePipes(new ValidationPipe())
async create(@Body() createDto: CreateCatDto) {
  // dto is validated
}

Setu-TS

import { validateBody } from '@setu-ts/validation-plugin';

const createBody = validateBody(CreateUserDto);

app.router.post('/users', {
  middleware: [createBody],
  handler: async (ctx) => {
    const dto = createBody.value(ctx);
    // dto is the value validated and parsed by the middleware, typed by the schema
    return ctx.response.json({ created: dto });
  },
});

In a decorated controller, @ValidateBody(CreateUserDto) on the handler does the same, and @Params(Body()) then receives the parsed value. With ValidationPlugin registered the schema is enforced, not only documented. See Decorators — Validation.

Configuration

NestJS

import { Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
})
export class AppModule {}

// Usage
@Injectable()
export class AppService {
  constructor(private readonly config: ConfigService) {}
}

Setu-TS

import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';

const database = defineConfigSection({
  prefix: 'DATABASE_',
  keys: ['URL'],
  schema: z.object({ URL: z.string().url() }),
});

app.register(
  ConfigPlugin({
    // Load.env into the config store; skip a missing file, but an existing
    // unreadable file still throws. Requires filesystem support.
    envFilePath: '.env',
    envFileOptional: true,
    sections: [database],
  }),
);

// Usage
const config = ctx.services.get<IConfig>(CAPABILITIES.CONFIG);
const port = config.get('PORT');
const databaseSettings = getConfigSection(config, database);

Database (TypeORM → Prisma/Drizzle)

NestJS (TypeORM)

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Column, Entity, PrimaryGeneratedColumn, Repository } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
}

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly repo: Repository<User>) {}

  async findAll() {
    return this.repo.find();
  }
}

Setu-TS (Database Plugin)

import { CAPABILITIES } from '@setu-ts/common';
import { DatabasePlugin, type IDatabaseService } from '@setu-ts/database-plugin';

// The built-in arm selects the ORM via `type`; adapter-specific config lives
// under `options` (a `DatabaseAdapterOptions`), not a top-level `prisma`
// field. For Prisma v7, generate and construct the client in application code,
// then inject it through `options.prismaClient`; its generated output path is
// application-owned and cannot be located by this package.
//
// In a real application this import points at your own `prisma generate`
// output, e.g. `import { PrismaClient } from './generated/prisma/client.ts';`
declare const myPrismaClient: unknown;

app.register(
  DatabasePlugin({
    type: 'prisma',
    options: {
      prismaClient: myPrismaClient,
    },
  }),
);

// Usage — IDatabaseService.getRepository() returns IRepository, not raw CRUD.
const db = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
const usersRepo = db.getRepository<{ id: string; name: string }>('users');
const users = await usersRepo.findAll();

Caching

NestJS

import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';

@Controller('users')
@UseInterceptors(CacheInterceptor)
@CacheTTL(300_000)
export class UsersController {
  @Get()
  findAll() {
    // GET responses are cached for five minutes.
    return [];
  }
}

Setu-TS

import { CachePlugin } from '@setu-ts/cache-plugin';

// `store` selects the backend; store-specific config lives under `options`
// (a `CacheStoreOptions`), not a top-level `redis` field. For Redis, pass the
// connection URL (or inject an ioredis-compatible `client`).
app.register(
  CachePlugin({
    store: 'redis',
    options: { url: 'redis://localhost:6379' },
  }),
);

// Usage — ICacheStore uses the token 'cache' (CAPABILITIES.CACHE), stores value
// with numeric TTL seconds (not an options bag), and deletes with delete().
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
const cache = ctx.services.get<ICacheStore>(CAPABILITIES.CACHE);
const users: unknown[] = [];
await cache.set('users:all', users, 300);
const cachedUsers = await cache.get<unknown[]>('users:all');
await cache.delete('users:all');

Validation

NestJS

import { IsEmail, IsString, MinLength } from 'class-validator';

class CreateUserDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsEmail()
  email: string;
}

Setu-TS

import { z } from 'zod';
import { ValidationPlugin } from '@setu-ts/validation-plugin';

const CreateUserDto = z.object({
  name: z.string().min(3),
  email: z.string().email(),
});

// Usage with validation plugin
app.register(
  ValidationPlugin({
    errorFormat: 'default',
  }),
);

// Or manual validation
app.router.post('/users', async (ctx) => {
  const result = CreateUserDto.safeParse(await ctx.request.json());
  if (!result.success) {
    return ctx.response.status(400).json({ errors: result.error.issues });
  }
  const dto = result.data;
  return ctx.response.status(201).json({ created: dto });
});

Where the DTOs live follows the same rule the setu CLI uses: a dto/ folder of one class per file becomes one <name>.schemas.ts per module — a Zod value per shape named by purpose (Create<P>Body, Update<P>Body, List<P>Query, <P>Params, <P>Response, <P>ListResponse), each paired with its z.infer type, and PartialType / PickType map to Zod’s .partial() / .pick() / .extend(). Feature-scoped placement is the counterpart of nest g service users/pricing: setu generate service pricing --module users writes the service into src/modules/users/ and regenerates the module’s index.ts to register it (in a class-based project it instead prints the @Module line to add, without editing the declaration).

Views

NestJS

@Render('users/index') names a template by path; the view engine resolves the file, and the string is checked against nothing — a typo in the template name or a missing props field surfaces at runtime.

import { Controller, Get, Render } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('pages')
export class PagesController {
  constructor(private readonly usersService: UserService) {}

  @Get('users')
  @Render('users/index')
  async users() {
    return { users: await this.usersService.findAll() };
  }
}

Setu-TS

@Render(Component) names the view BY REFERENCE — a function the application already has — so there is no view resolver and no filesystem lookup. The decorator type-checks the handler’s return against the component’s props, so the mistake NestJS catches at runtime is a compile error here. The engine comes from @setu-ts/view-plugin (registers under CAPABILITIES.VIEW); a rendered route with no provider fails at register(), never serving JSON where the author asked for HTML. A status code or header alongside a rendered body goes through the positional context source: the return value IS the props bag, so @Render carries no status argument.

import { Controller, Ctx, Get, Params, Render } from '@setu-ts/decorator-plugin';
import type { IRequestContext } from '@setu-ts/common';

// JSX, so every interpolation is escaped by the rendering runtime. A plain
// template literal is a `string`, returned unchanged with nothing escaped.
const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>
    {props.users.map((user) => (
      <li>{user}</li>
    ))}
  </ul>
);

@Controller('/pages')
export class PagesController {
  @Render(UserList)
  @Get('/users')
  users(): { readonly users: readonly string[] } {
    return { users: ['ada', 'grace'] }; // the props bag — the framework answers HTML
  }

  @Render(UserList)
  @Params(Ctx())
  @Get('/created')
  created(ctx: IRequestContext): { readonly users: readonly string[] } {
    ctx.response.status(201); // through the context, not through @Render
    return { users: ['new'] };
  }
}

WebSocket

NestJS

import { Server, Socket } from 'socket.io';
import { SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';

@WebSocketGateway()
export class EventsGateway {
  @WebSocketServer()
  private readonly server: Server;

  @SubscribeMessage('message')
  handleMessage(client: Socket, payload: string): void {
    this.server.emit('response', payload);
  }
}

Setu-TS

import type { IWebSocketConnection } from '@setu-ts/common';
import { DecoratorPlugin, Gateway, OnMessage } from '@setu-ts/decorator-plugin';
import { WebSocketPlugin } from '@setu-ts/websocket-plugin';

@Gateway('/ws')
class EventsGateway {
  @OnMessage
  message(connection: IWebSocketConnection, data: string | Uint8Array): void {
    connection.send(data);
  }
}

app.register(WebSocketPlugin({ heartbeatMs: 30_000 }));
app.register(DecoratorPlugin({ ingress: [EventsGateway] }));

A @Gateway class is registered through DecoratorPlugin’s ingress list, not controllers. To broadcast, resolve CAPABILITIES.WEBSOCKET and use room(name).broadcast(data, { except }). Without decorators, pass the same handlers as WebSocketPlugin({ routes: [...] }).

Testing

NestJS

import { Test } from '@nestjs/testing';

describe('UsersController', () => {
  let controller: UsersController;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UserService,
          useValue: {
            findAll: () => [],
            findById: (_id: string) => null,
            create: (_dto: CreateUserDto) => ({}),
          },
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

Setu-TS

import { CAPABILITIES } from '@setu-ts/common';
import { createTestApp, inject, overrideCapability } from '@setu-ts/testing';

describe('Users', () => {
  it('GET /users', async () => {
    // The application's own composition from setu.config.ts, with one plugin-provided
    // capability replaced by a test double.
    const app = await createTestApp({
      app: createApp(),
      overrides: [overrideCapability(CAPABILITIES.MAIL, fakeMailer)],
    });

    const response = await inject(app, { method: 'GET', url: '/users' });
    expect(response.statusCode).toBe(200);
    await app.stop();
  });
});

createTestApp({ app }) boots what main.ts boots, so a test cannot miss middleware or a plugin the way a hand-assembled test module can. overrides replaces a capability a plugin provides, and without: ['name'] keeps a plugin from running at all.

A decorated @Injectable class is different: DecoratorPlugin registers it in the DI container, which overrideCapability does not reach. overrideProvider replaces it there, the counterpart of Nest’s .overrideProvider(UsersService).useValue(fake): pass overrides: [overrideProvider('users-service', { useValue: fakeUsers })], using the class’s @Injectable({ token }). Every class that injects it, a controller included, is built with the fake, and a mistyped token fails start() instead of testing the real class.

Common Patterns

Request-Scoped Services

Every request gets a scope, but a controller is not rebuilt for it. The difference from Nest is in how a scoped service reaches a decorated handler, so it is stated plainly rather than mapped.

NestJS

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}

Nest instantiates this class once per request, automatically, and makes everything that depends on it request-scoped too — the controller included, rebuilt on every request.

Setu-TS

ServiceScope’s 'scoped' means one instance per scope, and each request has one: container.forRequest(ctx) creates it on first use and disposes it when the request finishes. A request that never asks pays nothing for it.

import { CAPABILITIES, type IContainer } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';

interface ReportCollector {
  readonly rows: readonly string[];
}

const app = createApplication();

app.router.get('/reports', (ctx) => {
  // Resolve through the request's scope, never through the root container.
  const scope = ctx.services.get<IContainer>(CAPABILITIES.DI_CONTAINER).forRequest(ctx);
  const reports = scope.resolve<ReportCollector>('per-scope');
  return ctx.response.json({ rows: reports.rows.length });
});

Scope does not spread up the dependency chain the way it does in Nest. A @Controller class is built once, during route registration, so it is a singleton; a constructor that reaches a 'scoped' service fails the boot with an error naming the fix, instead of quietly sharing one instance across requests. A decorated handler takes the service as a parameter, @Params(Service(token)), resolved from the request’s scope on every call — see Scoped Injection. apps/di-decorators serves a /lifetimes route that makes the three lifetimes visible across two requests.

Note that the kernel does give each request a child service registry (ctx.services), which is a different thing: it scopes capability registrations, not DI container lifetimes.

Middleware Order

NestJS

app.use(loggerMiddleware);
app.use(cors());

Setu-TS

// Middleware runs in priority order (lower first)
app.middleware.add(loggerMiddleware); // Default priority: 500
app.middleware.add(myMiddleware, { priority: 25 }); // Runs before default

Microservices

The microservice template adds messaging, queues, resilience, telemetry, service discovery, CQRS and events to the REST set. In a class-based microservice, every non-HTTP ingress artifact is registered through DecoratorPlugin’s ingress list, which is the one place each family has a registration site — a @CommandHandler, @QueryHandler, @OnEvent, @Subscribe or @Processor class reaches its bus, broker or queue from there. @OnEvent is the in-process events bus; @Subscribe is a broker topic, so it is the match for a NestJS @EventPattern that crosses services.

NestJSSetu-TS
@MessagePattern('user.lookup') (RPC)broker.respond('user.lookup', handler) on the IMessageBroker capability
@EventPattern('user.created') (broker pub/sub)@Subscribe('user.created') on a class in the ingress list (a broker topic)
@nestjs/event-emitter @OnEvent (in-process)@OnEvent('user.created') on a class in the ingress list
@nestjs/cqrs @CommandHandler / @QueryHandler@CommandHandler(TYPE) / @QueryHandler(TYPE) on ingress classes
@nestjs/bull @Processor@Processor('job-name') on an ingress class
@nestjs/schedule @Cron / @Interval@Cron('0 0 * * *') / @Every(60_000) on an ingress class
setu new my-service --template microservice --style class-based
cd my-service
setu g command-handler place-order
setu g query-handler get-order
setu g event-handler order-placed
setu g job send-receipt
npm run start

The generated ingress classes land in src/ingress/ and are listed in the managed src/ingress/index.ts barrel, which setu.config.ts passes to DecoratorPlugin({ ingress }). Broker request-reply is imperative — broker.respond(...) — because a responder answers a specific topic and returns a value, which the decorator ingress list does not model.

Migration Checklist

  • Replace @nestjs/* imports with @setu-ts/*
  • Scaffold with --style class-based (or --template class-based) so decorators and DI are present
  • Replace @Injectable() with @Injectable() from @setu-ts/decorator-plugin
  • Replace @Controller() with programmatic routes or @Controller() + DecoratorPlugin
  • Replace constructor injection with @Inject('token') from @setu-ts/decorator-plugin
  • Replace @Module with @Module from @setu-ts/decorator-plugin, or with a plugin factory for a capability that owns lifecycle work
  • Replace TypeORM with Prisma/Drizzle or other supported ORM
  • Replace ConfigModule with ConfigPlugin
  • Replace CacheModule with CachePlugin
  • Replace @WebSocketGateway with @Gateway and WebSocketPlugin
  • Update tests to boot createApp() through createTestApp and drive it with inject
  • Update deployment configuration for target runtime

Next Steps