Skip to content

Migrating from routing-controllers

routing-controllers (with routing-controllers-openapi and class-validator) is the closest decorator-for-decorator analog to covenix: both describe Express routes with class decorators and generate OpenAPI. If you're coming from it, the routing maps almost 1:1 — the real shift is the same one as the NestJS migration: class-validator DTOs → Zod schemas, with the spec derived from that one schema instead of a separate documentation layer.

  • routing-controllers is decorators + class-validator + a bolt-on spec. Controllers carry @JsonController/@Get/@Param/@Body; validation comes from class-validator decorators on DTO classes; the OpenAPI document is produced by routing-controllers-openapi (which runs class-validator-jsonschema to turn those classes into schemas), often supplemented by @OpenAPI()/@ResponseSchema().
  • covenix is decorators + Zod, one source. The Zod schema validates the request, serializes the response, and becomes the OpenAPI component — no class-validator, no class-validator-jsonschema, no separate @ResponseSchema/@OpenAPI declarations to keep in sync.

Both run on Express and Node, so there's no runtime caveat and the routing decorators line up closely.

Should you migrate? (the honest version)

These are close cousins; the decision is mostly about validation source-of-truth and how you wire dependencies.

Stay on routing-controllers if you're invested in class-validator/class-transformer across the codebase, or you lean on its tight IoC integration (useContainer with typedi/typeorm), the Koa adapter, or the action-based extras and don't want to rewrite DTOs.

covenix is the better fit if you want:

  • One schema instead of three declarations. In routing-controllers a field's truth is split across class-validator (@IsEmail), class-transformer (@Type), and the doc layer (@ResponseSchema, @OpenAPI, or class-validator-jsonschema inference). covenix collapses that to a single Zod schema. No drift.
  • Response validation on by default. routing-controllers-openapi documents responses via @ResponseSchema, but nothing validates that the returned object matches it. covenix parses every response through its @Returns schema (extra fields stripped; a mismatch throws 500).
  • No reflection-fragility around generics/optionals. class-validator-jsonschema inference can miss nested generics, Records, and unions; Zod models all of them explicitly and z.toJSONSchema is exact.
  • First-class files, range, SSE, and auth in the spec via dedicated decorators.

The fundamental shift: class-validator DTOs → Zod

A routing-controllers DTO is a class with class-validator decorators (runtime) and, for the spec, either class-validator-jsonschema inference or @JSONSchema:

typescript
// routing-controllers — CreateUser.ts
import { IsEmail, IsString, Length, IsEnum, IsOptional } from 'class-validator';

export class CreateUser {
  @IsString()
  @Length(3, 32)
  username: string;

  @IsEmail()
  email: string;

  @IsEnum(['admin', 'user'])
  @IsOptional()
  role?: 'admin' | 'user';
}

The same contract as a single Zod schema — validates the request, serializes the response, and becomes the CreateUser component:

typescript
// covenix
import { z } from 'zod';

export const CreateUserSchema = z
  .object({
    username: z.string().min(3).max(32),
    email: z.email(),
    role: z.enum(['admin', 'user']).optional(),
  })
  .meta({ id: 'CreateUser' });

export type CreateUser = z.infer<typeof CreateUserSchema>;

The class-validator → Zod translations are the same as the NestJS guide's cookbook@Length(3,32).min(3).max(32), @IsEmail()z.email(), @ValidateNested() + @Type(() => X) → a nested XSchema, and so on.

At a glance

routing-controllers (+ openapi)covenixNotes
@JsonController('/users') / @Controller@Route('users')Nearly identical (drop the leading slash).
@Get('/:id'), @Post(), …@Get('{id}'), @Post(), …:id{id}.
@Param('id') id: string@Params(Schema) + @Param('id') idSchema validates on the method; param injects.
@QueryParam('q') q / @QueryParams() q@Query(Schema) + @QueryParam('q')Schema + injection split.
@Body() body: CreateUser@Body(Schema) + @BodyParam() bodySame split.
@HeaderParam('x-id')@Header('x-id')Injection.
@Req() req / @Res() res@Req() req / @Res() resEscape hatch.
class-validator on a DTO classz.object({…}).meta({ id }) + z.inferRuntime types move to Zod.
@ResponseSchema(User, { statusCode: 200 })@Returns(200, UserSchema)Stackable; and validated, not just documented.
@OpenAPI({ summary, description })@Summary / @Description / @OperationIdFirst-class decorators.
@HttpCode(201)@Returns(201, Schema) (first 2xx)Success status from @Returns.
@OnUndefined(204)@Returns(204) (omit schema)No-content response.
@Authorized('role') + authorizationChecker@Security('jwt', scopes) + bearer() handlerScheme + spec from one place.
@CurrentUser() user@Principal() userInjected principal.
@UseBefore(mw) / @UseAfter / @Middleware@Use(...) (class or method)Express middleware.
@UploadedFile() / @UploadedFiles()z.file() in @Body + @File/@FilesAuto-detected multipart; web-standard File.
return a stream / set headers for downloads@ReturnsFile(...) + FileResponse/RangeFileResponseDisposition + range negotiation handled.
(no built-in SSE)@Sse(schema?)Validated + documented text/event-stream.
useContainer(Container) (container owns construction)api.register(new C(deps)) — or register a resolved instanceContainer-agnostic; you hand covenix the instance.
routingControllersToSpec(storage, options)api.swagger() / generateSwagger([...])Native, Zod-derived; no class-validator-jsonschema.
useExpressServer(app, { controllers })api.mount(app)Wires routes + validation.
Express or Koa driverExpress onlycovenix targets Express 5.

A controller, side by side

routing-controllers — DTO classes + @ResponseSchema describe the contract; the container constructs the controller:

typescript
import { JsonController, Get, Post, Param, Body, NotFoundError } from 'routing-controllers';
import { ResponseSchema, OpenAPI } from 'routing-controllers-openapi';
import { Service } from 'typedi';

@Service()
@JsonController('/users')
export class UsersController {
  constructor(private service: UserService) {} // resolved by typedi

  @Get('/:id')
  @ResponseSchema(User)
  @OpenAPI({ summary: 'Get a user' })
  async getUser(@Param('id') id: string): Promise<User> {
    const user = await this.service.get(id);
    if (!user) throw new NotFoundError();
    return user;
  }

  @Post()
  @ResponseSchema(User, { statusCode: 201 })
  async createUser(@Body() body: CreateUser): Promise<User> {
    return this.service.create(body);
  }
}

covenix — Zod schemas + @Returns (which also validates); you hand register a constructed instance:

typescript
import { z } from 'zod';
import { Route, Tags, Get, Post, Params, Body, Returns, Summary, Param, BodyParam } from 'covenix';
import createError from 'http-errors';

@Route('users')
@Tags('Users')
export class UsersController {
  constructor(private readonly service: UserService) {}

  @Get('{id}')
  @Params(z.object({ id: z.uuid() }))
  @Returns(200, UserSchema)
  @Summary('Get a user')
  async getUser(@Param('id') id: string): Promise<User> {
    const user = await this.service.get(id);
    if (!user) throw new createError.NotFound();
    return user; // validated against UserSchema
  }

  @Post()
  @Body(CreateUserSchema)
  @Returns(201, UserSchema) // 201 is the success status
  async createUser(@BodyParam() body: CreateUser): Promise<User> {
    return this.service.create(body);
  }
}

Differences to internalize:

  1. @ResponseSchema(User)@Returns(200, UserSchema) — and it's not just documentation; the return value is parsed through it.
  2. @HttpCode/@OnUndefined aren't needed. The success status is the first declared 2xx @Returns; for errors you throw and let the error middleware map it (routing-controllers' NotFoundErrorcreateError.NotFound()).
  3. @Service()/useContainer aren't required. You pass register an instance — new it, or resolve it from your container (see below).

Dependency injection: keep your container, or drop it

routing-controllers owns the container: you call useContainer(Container) and it resolves every controller (and its dependencies) for you, which couples your app to that specific IoC setup.

covenix is container-agnostic. api.register() takes an already-constructed instance, so where that instance comes from is entirely up to you:

typescript
// Plain construction — no DI library at all:
api.register(new UsersController(new UserService(db)));

// Or keep using typedi (or tsyringe / awilix / inversify) — just resolve and register:
import { Container } from 'typedi';
api.register(Container.get(UsersController));

So this isn't "you must give up DI" — it's the opposite of useContainer: covenix doesn't take over construction, it accepts whatever you hand it. The practical effects:

  • You're decoupled from the container. covenix never reaches into typedi's global state; it has no useContainer to register. Swap DI libraries, or use none, without touching covenix.
  • Construction is explicit at the registration site. Whether that's new C(...) or Container.get(C), it's plain, type-checked code — no boot-time token-resolution surprises.
  • Testing is just construction. new UsersController(fakeService) (or a test container) is the fixture; there's no framework testing module to spin up.

If you have a large dependency graph and already use a container, keep it — point its resolved instances at api.register(). If you don't, you don't need to adopt one.

Authentication

routing-controllers splits auth across @Authorized(roles) + a global authorizationChecker, with @CurrentUser() + a currentUserChecker:

typescript
// routing-controllers
useExpressServer(app, {
  authorizationChecker: async (action, roles) => {
    const user = verifyJwt(action.request.headers.authorization);
    return !!user && roles.every((r) => user.roles.includes(r));
  },
  currentUserChecker: (action) => getUser(action.request),
});

@Authorized(['users:read'])
@Get('/me')
me(@CurrentUser() user: User) { return user; }

covenix registers each named scheme once — definition and handler together — and injects the principal with @Principal():

typescript
import { Covenix, Security, Principal, bearer, SecurityError } from 'covenix';

const api = new Covenix({
  info,
  security: {
    jwt: bearer((req, scopes) => {
      const user = verifyJwt(req.headers.authorization);
      if (!user) return null; // → 401
      if (!scopes.every((s) => user.scopes.includes(s))) throw new SecurityError(403);
      return user; // → @Principal()
    }),
  },
});

@Get('me')
@Security('jwt', ['users:read'])
me(@Principal() user: User): User {
  return user;
}

The big win over authorizationChecker: the scheme lands in components.securitySchemes and the per-operation requirement on the spec automatically — no separate documentation of the security scheme. See Authentication.

Files, downloads, and SSE

routing-controllers injects multer files with @UploadedFile()/@UploadedFiles() and leaves downloads to raw stream returns. covenix models the form as a @Body schema and has dedicated response types:

  • Upload: z.file() in a @Body schema → auto-detected multipart/form-data, injected as a web-standard File via @File/@Files. See File uploads.
  • Download / range: @ReturnsFile(...) + FileResponse / RangeFileResponse (automatic Content-Disposition, 206/416 negotiation). See File downloads.
  • SSE: @Sse(schema?) — routing-controllers has no built-in SSE; covenix validates and documents it.

OpenAPI & the typed client

routing-controllers builds the spec with routingControllersToSpec(...), feeding it schemas from class-validator-jsonschema. covenix derives the document directly from Zod — no second conversion step:

typescript
api.swagger(); // OpenAPI 3.1 from Zod 4 (api.swagger({ specVersion: '3.0' }) to down-convert)
generateSwagger([UsersController]); // instance-free, for CI / codegen
api.serveDocs(app); // browsable UI
await writeFile('api.gen.ts', generateTypeScriptClient(api.contract())); // standalone client

routing-controllers has no first-party typed client; covenix generates one from the same contract. See Typed Client.

Bootstrapping

typescript
// routing-controllers
import 'reflect-metadata';
import { useExpressServer } from 'routing-controllers';
const app = express();
useExpressServer(app, { controllers: [UsersController] });

// covenix
import 'reflect-metadata';
import express from 'express';
import { Covenix, covenixErrorHandler } from 'covenix';

const app = express();
app.use(express.json());
const api = new Covenix({ info: { title: 'My API', version: '1.0.0' } });
api.register(new UsersController(service)); // new it, or Container.get(UsersController)
api.mount(app);
api.serveDocs(app);
app.use(covenixErrorHandler());
app.listen(3000);

Gaps: what routing-controllers does that covenix doesn't

  • Built-in container ownership (useContainer auto-resolving controllers). covenix is container-agnostic — you can still use any DI library, you just resolve and register the instance yourself rather than handing covenix the container (see above).
  • Koa driver. routing-controllers runs on Express or Koa; covenix is Express 5 only (see #2).
  • Action-based middleware extras@Middleware, global interceptors (@Interceptor), @UseBefore/@UseAfter ordering nuances. covenix uses plain Express middleware via @Use.
  • class-validator/class-transformer ecosystem — covenix is Zod-only; DTOs must be rewritten as Zod schemas (the NestJS cookbook applies directly).

If you hit a routing-controllers feature without an obvious covenix equivalent, please open an issue.