AponiaJSDocs
Migrate

From NestJS

Every NestJS construct next to its AponiaJS equivalent, side by side, including the ones that have no equivalent yet.

AponiaJS borrows structural vocabulary from NestJS, so most of a port is mechanical. This page puts the two versions of each construct next to each other. If you are still choosing rather than porting, read AponiaJS vs NestJS first.

What ports and what does not

Nest conceptAponiaJS 0.6.0-alpha.18
ModulesImplemented with imports, controllers, providers, and exports.
Singleton providersImplemented.
Class/value/factory/alias providersImplemented synchronously.
Decorated controllersImplemented for all seven HTTP methods.
Request parameter decoratorsImplemented, including named selection.
ValidationImplemented as route schemas and @Validation() model classes, not pipes.
GatewaysImplemented over native Elysia WebSockets, without Socket.IO semantics.
Exception filtersNot implemented; throw httpErrors.* for Problem Details responses.
Platform adapterElysia only.
Guards/interceptors/filters/middleware runtimeNot implemented.
Provider scopes and lifecycle hooksNot implemented.
Testing module and overridesNot implemented.
OpenAPI/auth/microservicesNot implemented.

Packages

The decorator names survive the port. The imports do not.

NestJS

Core package plus a platform adapter.

import { Body, Controller, Get, Injectable, Module, Param, Post } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";

AponiaJS

Decorators from common, the factory from the platform.

import { Body, Controller, Get, Injectable, Module, Param, Post } from "@aponiajs/common";
import { AponiaFactory } from "@aponiajs/platform-elysia";
bun add @aponiajs/common@alpha @aponiajs/platform-elysia@alpha elysia@^1.4.29

Bootstrap

NestJS

The factory chooses an HTTP adapter.

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

bootstrap();

AponiaJS

Top-level await; the platform is always Elysia.

const application = await AponiaFactory.create(AppModule);
await application.listen(3000);

Modules

Identical, minus the features that have no runtime behind them yet.

NestJS

@Module({
  imports: [DatabaseModule],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

AponiaJS

Same four keys, validated at startup.

@Module({
  imports: [DatabaseModule],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

A provider a module never exported is a bootstrap error with a diagnostic rather than a runtime failure. See visibility.

Controllers

NestJS

@Controller("users")
export class UserController {
  constructor(private readonly userService: UserService) {}

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

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

AponiaJS

Same decorators; the body type is annotated, not inferred from a DTO class.

@Controller("users")
export class UserController {
  constructor(private readonly userService: UserService) {}

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

  @Post()
  create(@Body() body: CreateUser) {
    return this.userService.create(body);
  }
}

Two differences that bite during a port:

  • there is no @Injectable()-driven metadata for method decorators. @Injectable() exists only so TypeScript emits constructor parameter types;
  • handler parameter types come from your annotations, not from schema inference, so keep the schema in a const and derive with z.infer or Static<typeof …>.

Providers

NestJS

Scope is a decorator option.

@Injectable({ scope: Scope.DEFAULT })
export class UserService {
  findOne(id: string) {
    return { id };
  }
}

AponiaJS

No scopes exist. Every provider is a startup singleton.

@Injectable()
export class UserService {
  findOne(id: string) {
    return { id };
  }
}

Non-class dependencies use an explicit token on both sides.

NestJS

@Module({
  providers: [{ provide: "CONFIG", useValue: { url: "…" } }],
})
export class ConfigModule {}

constructor(@Inject("CONFIG") private readonly config: Config) {}

AponiaJS

Same shape; see tokens and injection.

@Module({
  providers: [{ provide: "CONFIG", useValue: { url: "…" } }],
})
export class ConfigModule {}

constructor(@Inject("CONFIG") private readonly config: Config) {}

Async factory providers do not port — useFactory is synchronous. See providers and tokens.

Validation replaces pipes

This is the largest rewrite in a typical port: class-validator DTO classes become schemas attached to the route.

NestJS

A DTO class, decorators, and a global ValidationPipe.

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;
}

app.useGlobalPipes(new ValidationPipe());

@Post()
create(@Body() body: CreateUserDto) {
  return body;
}

AponiaJS

A schema on the decorator, rejected before the handler runs.

const createUser = {
  body: z.object({ name: z.string().min(2) }),
};

@Post("/", createUser)
create(@Body() body: z.infer<(typeof createUser)["body"]>) {
  return body;
}

Any Standard Schema validator works — Zod, ArkType, Valibot — as do TypeBox and Elysia's t. A rejected request answers 422. If you prefer the DTO-shaped arrangement, @Validation() model classes keep the schema in a named class; see validation.

Errors replace exception filters

NestJS

HttpException, optionally reshaped by a filter.

throw new NotFoundException("User not found");

@Catch(HttpException)
export class HttpFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    // reshape the response
  }
}

AponiaJS

RFC 9457 Problem Details, no filter layer.

throw httpErrors.notFound("User not found");

// {
//   "type": "about:blank",
//   "title": "Not Found",
//   "status": 404,
//   "detail": "User not found"
// }

There is no exception filter runtime. The response shape is the framework's, documented in errors.

Gateways

NestJS

Socket.IO semantics by default.

@WebSocketGateway()
export class EventsGateway {
  @SubscribeMessage("events")
  handle(@MessageBody() data: string) {
    return { event: "events", data };
  }
}

AponiaJS

Native Elysia and Bun sockets, same decorator names.

@WebSocketGateway()
export class EventsGateway {
  @SubscribeMessage("events")
  handle(@MessageBody() data: string) {
    return { event: "events", data };
  }
}

The decorators match; the transport does not. There are no rooms, namespaces, acknowledgements, or Socket.IO clients — messages are a plain { event, data } envelope over a native WebSocket. See websockets.

Guards, interceptors, and middleware

These have no equivalent. Nothing ports; the behaviour is rewritten as an Elysia hook.

NestJS

Four extension points around the handler.

@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
@Get()
findAll() {
  return this.service.findAll();
}

AponiaJS

An Elysia hook installed through native access.

const application = await AponiaFactory.create(AppModule, {
  configureNative: (elysia) =>
    elysia.onBeforeHandle(({ request, status }) => {
      if (!request.headers.get("authorization")) return status(401);
    }),
});

The hook is not part of the module graph and cannot be applied per controller method. Details in native access and plugin modules.

Project layout

NestJS

src/
  app.module.ts
  main.ts
  users/
    users.controller.ts
    users.service.ts
    users.module.ts
    dto/create-user.dto.ts

AponiaJS

Same idea; DTO classes become model files.

src/
  app.module.ts
  main.ts
  users/
    users.controller.ts
    users.service.ts
    users.module.ts
    users.model.ts

Do the gap analysis before scheduling

Current limitations lists every runtime feature that is missing. Guards, interceptors, filters, middleware, provider scopes, lifecycle hooks, and the testing module are all on it.

On this page