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
| Concept | NestJS | Setu-TS |
|---|---|---|
| Runtime | Node.js only | Deno, Node.js, Bun, Cloudflare Workers |
| Reflection | reflect-metadata required | Explicit injection tokens (no reflection) |
| Module System | @Module decorators | @Module (class style) or plugins |
| HTTP Server | Express/Fastify | Hono (fetch API) |
| Dependency Injection | Automatic via reflection | Explicit tokens (@Inject('token')) |
| Decorators | Built-in | Optional, 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.
| NestJS | Setu-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
@Modulewith@Modulefrom@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
ConfigModulewithConfigPlugin - Replace
CacheModulewithCachePlugin - Replace
@WebSocketGatewaywith@GatewayandWebSocketPlugin - Update tests to boot
createApp()throughcreateTestAppand drive it withinject - Update deployment configuration for target runtime
Next Steps
- Getting Started - Set up your first application
- Plugin Architecture - Deep dive into plugins
- Examples - See real-world applications