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.29Bootstrap
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
- add the packages and wrap the existing application with an empty root
module using
createNative(); - move one feature's dependencies into providers and a module;
- convert that feature's routes to a controller;
- 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.