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 decoratorsPlugin composition
HTTP ServerExpress/FastifyHono (fetch API)
Dependency InjectionAutomatic via reflectionExplicit tokens (@Inject('token'))
DecoratorsBuilt-inOptional, via DecoratorPlugin

Basic Application

NestJS

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

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
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 });

Controllers and Routes

NestJS

@Controller('users')
export class UsersController {
  @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)

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

app.router.get('/users/:id', async (ctx) => {
  const userService = ctx.services.get<UserService>('userService');
  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>('userService');
  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('UserService')
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

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

Setu-TS

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

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

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

Modules vs Plugins

NestJS

@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:

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

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

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

Guards

NestJS

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

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    return !!token;
  }
}

Setu-TS

export const authMiddleware: MiddlewareFunction = async (ctx, next) => {
  const authHeader = ctx.request.headers.get('Authorization');
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

  if (!token) {
    return ctx.response.status(401).json({ error: 'Unauthorized' });
  }

  // Verify token and set user
  const user = await verifyToken(token);
  ctx.state.set('user', user);

  await next();
};

// Use middleware
app.middleware.add(authMiddleware);

Interceptors

NestJS

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

Setu-TS

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

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

Exception Filters

NestJS

@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

export const errorMiddleware: MiddlewareFunction = async (ctx, next) => {
  try {
    await next();
  } catch (error) {
    if (error instanceof HttpException) {
      return ctx.response.status(error.status).json(
        {
          statusCode: error.status,
          timestamp: new Date().toISOString(),
          path: new URL(ctx.request.url).pathname,
          message: error.message,
        },
      );
    }

    // Log error
    console.error('Unhandled error', { error });

    return ctx.response.status(500).json(
      {
        statusCode: 500,
        message: 'Internal server error',
      },
    );
  }
};

Pipelines (Validation)

NestJS

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

Setu-TS

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

app.router.post('/users', {
  middleware: [validateBody(CreateUserDto)],
  handler: async (ctx) => {
    const dto = await ctx.request.json();
    // dto is validated
    return ctx.response.json({ created: dto });
  },
});

Configuration

NestJS

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

// Usage
constructor(@InjectConfig() private readonly config: ConfigService) {}

Setu-TS

import { ConfigPlugin } from '@setu-ts/config-plugin';

app.register(ConfigPlugin({
  // Optional: load .env files before reading `runtime.env` (requires a
  // runtime with filesystem support). Validate with a structural schema
  // (e.g. Zod) via `validationSchema` — `ConfigPluginOptions` has no `validate`
  // field.
  envFilePath: '.env',
}));

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

Database (TypeORM → Prisma/Drizzle)

NestJS (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 { DatabasePlugin } 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

@Injectable()
@CacheTTL(300)
export class UserService {
  async findAll() {
    // Result cached for 300 seconds
  }
}

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 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

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

  @IsEmail()
  email: string;
}

Setu-TS

import { z } from '@std/zod';

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.errors });
  }
  const dto = result.data;
  return ctx.response.status(201).json({ created: dto });
});

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.

@Controller('pages')
export class PagesController {
  @Get('users')
  @Render('users/index')
  users() {
    return { users: 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

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

Setu-TS

import { WebSocketPlugin } from '@setu-ts/websocket-plugin';
import { CAPABILITIES, type IWebSocketService } from '@setu-ts/common';

// WebSocketPlugin options carry heartbeat/idle/limit knobs only — routes and
// rooms are application-level, registered on the WebSocketService after the
// plugin (no `rooms` plugin option exists).
app.register(WebSocketPlugin({ heartbeatMs: 30_000 }));

const ws = app.services.get<IWebSocketService>(CAPABILITIES.WEBSOCKET);
ws.route('/ws', {
  onOpen: (conn) => {
    console.log('Client connected');
    ws.room('events').add(conn);
  },
  onMessage: (conn, message) => {
    ws.room('events').broadcast(message, { except: conn });
  },
});

Testing

NestJS

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

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [UsersController],
    }).compile();

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

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

Setu-TS

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

describe('Users', () => {
  it('GET /users', async () => {
    const app = await createTestApp({
      plugins: [RuntimePlugin()],
    });

    app.router.get('/users', async (ctx) => {
      return ctx.response.json([{ id: 1, name: 'John' }]);
    });

    const response = await inject(app, {
      method: 'GET',
      url: '/users',
    });
    expect(response.statusCode).toBe(200);
    const body = response.json();
    expect(body).toEqual([{ id: 1, name: 'John' }]);
  });
});

Common Patterns

Request-Scoped Services

NestJS

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

Setu-TS

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

@Injectable({ scope: 'scoped' })
export class RequestScopedService {}

Middleware Order

NestJS

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

Setu-TS

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

Migration Checklist

  • Replace @nestjs/* imports with @setu-ts/*
  • 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 modules with plugin factories
  • Replace TypeORM with Prisma/Drizzle or other supported ORM
  • Replace ConfigModule with ConfigPlugin
  • Replace CacheModule with CachePlugin
  • Replace @WebSocketGateway with WebSocketPlugin
  • Update testing utilities to use createTestApp and inject
  • Update deployment configuration for target runtime

Next Steps