Best Practice
Elysia is a pattern‑agnostic framework, leaving the decision of which coding patterns to use up to you and your team.
However, there are several concerns when trying to adapt an MVC pattern https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller with Elysia, and we found it hard to decouple and handle types.
This page is a guide on how to follow Elysia structure best practices combined with the MVC pattern, but it can be adapted to any coding pattern you prefer.
Folder Structure
Elysia is unopinionated about folder structure, leaving you to decide how to organize your code yourself.
If you don’t have a specific structure in mind, we recommend a feature‑based folder structure where each feature has its own folder containing controllers, services, and models.
| src
| modules
| auth
| index.ts (Elysia controller)
| service.ts (service)
| model.ts (model)
| user
| index.ts (Elysia controller)
| service.ts (service)
| model.ts (model)
| utils
| a
| index.ts
| b
| index.ts
This structure allows you to easily find and manage your code and keep related code together.
Here’s an example code of how to distribute your code into a feature‑based folder structure:
auth/index.ts
// Controller handle HTTP related eg. routing, request validation
import { Elysia } from 'elysia'
import { Auth } from './service'
import { AuthModel } from './model'
export const auth = new Elysia({ prefix: '/auth' })
.get(
'/sign-in',
async ({ body, cookie: { session } }) => {
const response = await Auth.signIn(body)
// Set session cookie
session.value = response.token
return response
},
{
body: AuthModel.signInBody,
response: {
200: AuthModel.signInResponse,
400: AuthModel.signInInvalid
}
}
)
auth/service.ts
// Service handle business logic, decoupled from Elysia controller
import { status } from 'elysia'
import type { AuthModel } from './model'
// If the class doesn’t need to store a property,
// you may use `abstract class` to avoid class allocation
export abstract class Auth {
static async signIn({ username, password }: AuthModel.signInBody) {
const user = await sql`
SELECT password
FROM users
WHERE username = ${username}
LIMIT 1
`
if (await Bun.password.verify(password, user.password))
// You can throw an HTTP error directly
throw status(
400,
'Invalid username or password' satisfies AuthModel.signInInvalid
)
return {
username,
token: await generateAndSaveTokenToDB(user.id)
}
}
}
auth/model.ts
// Model define the data structure and validation for the request and response
import { t } from 'elysia'
export namespace AuthModel {
// Define a DTO for Elysia validation
export const signInBody = t.Object({
username: t.String(),
password: t.String(),
})
// Define it as TypeScript type
export type signInBody = typeof signInBody.static
// Repeat for other models
export const signInResponse = t.Object({
username: t.String(),
token: t.String(),
})
export type signInResponse = typeof signInResponse.static
export const signInInvalid = t.Literal(
'Invalid username or password'
)
export type signInInvalid = typeof signInInvalid.static
}
Each file has its own responsibility as follows:
- Controller: Handle HTTP routing, request validation, and cookie.
- Service: Handle business logic, decoupled from Elysia controller if possible.
- Model: Define the data structure and validation for the request and response.
Feel free to adapt this structure to your needs and use any coding pattern you prefer.
Controller
Due to type soundness of Elysia, it’s not recommended to use a traditional controller class that is tightly coupled with Elysia’s Context because:
- Elysia type is complex and heavily depends on plugin and multiple level of chaining.
- Hard to type, Elysia type could change at anytime, especially with decorators, and store
- Loss of type integrity and inconsistency between types and runtime code.
We recommend one of the following approaches to implement a controller in Elysia.
- Use Elysia instance as a controller itself
- Create a controller that is not tied with HTTP request or Elysia.
1. Elysia instance as a controller
1 Elysia instance = 1 controller
Treat an Elysia instance as a controller, and define your routes directly on the Elysia instance.
// ✅ Do
import { Elysia } from 'elysia'
import { Service } from './service'
new Elysia()
.get(
'/',
({ stuff }) => {
Service.doStuff(stuff)
}
)
This approach allows Elysia to infer the Context type automatically, ensuring type integrity and consistency between types and runtime code.
// ❌ Don’t
import { Elysia, t, type Context } from 'elysia'
abstract class Controller {
static root(context: Context) {
return Service.doStuff(context.stuff)
}
}
new Elysia()
.get('/', Controller.hi)
This approach makes it hard to type Context properly, and may lead to loss of type integrity.
2. Controller without HTTP request
If you want to create a controller class, we recommend creating a class that is not tied to HTTP request or Elysia at all. This approach allows you to decouple the controller from Elysia, making it easier to test, reuse, and even swap a framework while still following the MVC pattern.
import { Elysia } from 'elysia'
abstract class Controller {
static doStuff(stuff: string) {
return Service.doStuff(stuff)
}
}
new Elysia()
.get(
'/',
({ stuff }) => Controller.doStuff(stuff)
)
Tying the controller to Elysia Context may lead to:
- Loss of type integrity
- Make it harder to test and reuse
- Lead to vendor lock‑in
We recommend keeping the controller decoupled from Elysia as much as possible.