AponiaJSDocs
Migrate

From Elysia

The same Elysia application rewritten as modules and providers, one construct at a time, with the native instance still reachable.

This migration is additive. AponiaJS compiles to an Elysia application, so nothing has to move at once — chained routes and decorated modules run in the same process.

Add the platform packages:

bun add @aponiajs/common@alpha @aponiajs/platform-elysia@alpha elysia@^1.4.29

Bootstrap

Elysia

The application is the composition root.

import { Elysia } from "elysia";

new Elysia().use(users).use(orders).listen(3000);

AponiaJS

A root module is the composition root; the factory builds the application from it.

import { Module } from "@aponiajs/common";
import { AponiaFactory } from "@aponiajs/platform-elysia";

@Module({ imports: [UserModule, OrderModule] })
class AppModule {}

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

Use createNative() instead of create() when you want the native Elysia instance as the calling surface and statically visible route types for Eden Treaty. See bootstrap.

Routes become controllers

Elysia

Path, method, and handler in one chain.

const users = new Elysia({ prefix: "/users" })
  .get("/", () => repository.list())
  .get("/:id", ({ params }) => repository.find(params.id))
  .post("/", ({ body }) => repository.create(body));

AponiaJS

Prefix on the class, path on the method.

@Controller("users")
class UserController {
  constructor(private readonly repository: UserRepository) {}

  @Get()
  list() {
    return this.repository.list();
  }

  @Get(":id")
  find(@Param("id") id: string) {
    return this.repository.find(id);
  }

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

Handler arguments are selected by parameter decorators rather than destructured from one context object.

decorate becomes a provider

The dependency stops travelling on the request context and starts being declared.

Elysia

Visible to everything downstream of the call.

const users = new Elysia()
  .decorate("repository", new UserRepository())
  .get("/users/:id", ({ params, repository }) =>
    repository.find(params.id),
  );

AponiaJS

Constructed once at startup, injected where declared.

@Injectable()
class UserRepository {
  find(id: string) {
    return { id };
  }
}

@Module({
  controllers: [UserController],
  providers: [UserRepository],
  exports: [UserRepository],
})
class UserModule {}

state and derive have no direct equivalent. Per-request derivation stays in Elysia, because providers are singletons and there are no request-scoped providers yet.

Validation ports unchanged

Route schemas are the one construct that transfers verbatim.

Elysia

Schema in the route options object.

.post("/users", ({ body }) => body, {
  body: t.Object({ name: t.String({ minLength: 2 }) }),
})

AponiaJS

Same validators, passed to the decorator.

const createUser = {
  body: t.Object({ name: t.String({ minLength: 2 }) }),
};

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

Elysia's t, TypeBox, and any Standard Schema validator all work across the body, query, params, headers, cookie, and response slots. See validation.

Plugins become plugin modules

Elysia

Registered in composition order.

new Elysia().use(cors()).use(users).listen(3000);

AponiaJS

Imported by the module graph, still a real Elysia plugin.

@Module({
  imports: [ElysiaPluginModule.forPlugin(cors()), UserModule],
})
class AppModule {}

Plugins that need injected configuration use the DI-configured variant. Neither form hides the plugin from Elysia; see plugin modules and typed plugin context.

What stays in native Elysia

Do not try to port these. They have no decorator equivalent and are meant to stay where they are:

  • hooks, macros, error handlers, and lifecycle events;
  • application-wide state declared outside any module;
  • routes that need Elysia's complete handler signature;
  • plugins that depend on Elysia's accumulated types.

Before

A native route among the chained ones.

new Elysia().get("/native-health", () => ({ ok: true }));

After

The same route, kept native, mounted alongside the modules.

const app = await AponiaFactory.createNative(AppModule, {
  configureNative: (elysia) =>
    elysia.get("/native-health", () => ({ ok: true })),
});

createNative() preserves statically visible route types for Eden Treaty; the managed create() wrapper does not infer decorated routes. The exact inference boundary is in Eden Treaty, and low-level descriptors covers registering routes directly when a decorator cannot express them.

A suggested order

  1. add the packages and wrap the existing application with an empty root module using createNative();
  2. move one feature's dependencies into providers and a module;
  3. convert that feature's routes to a controller;
  4. repeat per feature, leaving hooks and macros native throughout.

Deciding rather than porting

AponiaJS vs Elysia covers what the module graph costs as well as what it adds.

On this page