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 (Model-View-Controller) 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.
However, 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.tsThis 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:
// Controller (HTTP adapter) eg. routing, request validation
// You can define another Controller that is not tied with Elysia
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
// (Elysia cookie is proxy, it can never be null/undefined)
session!.value = response.token
return response
}, {
body: AuthModel.signInBody,
// response is optional, use to enforce return type
response: {
200: AuthModel.signInResponse,
400: AuthModel.signInInvalid
}
}
)// Service handles business logic, decoupled from Elysia controller
import { status } from 'elysia'
import type { AuthModel } from './model'
// If a class doesn't need to store a property,
// you can use an `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)
}
}
}// Model define the data structure and validation for the request and response
import { t, type UnwrapSchema } from 'elysia'
const AuthModel = {
signInBody: t.Object({
username: t.String(),
password: t.String(),
}),
signInResponse: t.Object({
username: t.String(),
token: t.String(),
}),
signInInvalid: t.Literal('Invalid username or password')
} as const
// Optional, cast all model to TypeScript type
export type AuthModel = {
[k in keyof typeof AuthModel]: UnwrapSchema<typeof AuthModel[k]>
}Each file has its own responsibility:
- Controller: Handles HTTP routing, request validation, and cookies.
- Service: Handles business logic, decoupled from the Elysia controller if possible.
- Model: Defines 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.
::: note You may get a warning when using cookie.name as it might be undefined depending on your TypeScript configuration.
Elysia cookie can never be undefined because it's a Proxy object. cookie is always defined, only its value (via cookie.value) can be undefined.
This can be fixed by using a [cookie schema] or disable strictNullChecks in tsconfig.json :::
Controller
Due to the type soundness of Elysia, it's not recommended to use a traditional controller class that is tightly coupled with Elysia's Context because:
- Elysia types are complex and heavily depend on plugins and multiple levels of chaining.
- Hard to type; Elysia types could change at any time, 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.root)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 the Elysia Context may lead to:
- Loss of type integrity
- Making it harder to test and reuse
- Vendor lock-in
We recommend keeping the controller decoupled from Elysia as much as possible.
❌ Don't: Pass entire Context to a controller
Context is a highly dynamic type that can be inferred from Elysia instance.
Do not pass an entire Context to a controller, instead use object destructuring to extract what you need and pass it to the controller.
import type { Context } from 'elysia'
abstract class Controller {
constructor() {}
// ❌ Don't do this
static root(context: Context) {
return Service.doStuff(context.stuff)
}
}This approach makes it hard to type Context properly, and may lead to loss of type integrity.
Testing
If you're using Elysia as a controller, you can test your controller using handle to directly call a function (and it's lifecycle)
import { Elysia } from 'elysia'
import { Service } from './service'
import { describe, it, expect } from 'bun:test'
const app = new Elysia()
.get('/', ({ stuff }) => {
Service.doStuff(stuff)
return 'ok'
})
describe('Controller', () => {
it('should work', async () => {
const response = await app
.handle(new Request('http://localhost/'))
.then((x) => x.text())
expect(response).toBe('ok')
})
})You may find more information about testing in Unit Test.
Service
A service is a set of utility/helper functions decoupled as business logic to use in a module/controller, in our case, an Elysia instance.
Any technical logic that can be decoupled from controller may live inside a Service.
There are 2 types of service in Elysia:
- Non-request dependent service
- Request dependent service
1. Abstract away Non-request dependent service
We recommend abstracting service classes/functions away from Elysia.
If the service or function isn't tied to an HTTP request or doesn't access a Context, it's recommended to implement it as a static class or function.
import { Elysia, t } from 'elysia'
abstract class Service {
static fibo(number: number): number {
if(number < 2)
return number
return Service.fibo(number - 1) + Service.fibo(number - 2)
}
}
new Elysia()
.get('/fibo', ({ body }) => {
return Service.fibo(body)
}, {
body: t.Numeric()
})If your service doesn't need to store a property, you can use an abstract class and static methods to avoid allocating a class instance.
2. Request dependent service as Elysia instance
If the service is a request-dependent service or needs to process HTTP requests, we recommend abstracting it as an Elysia instance to ensure type integrity and inference:
import { Elysia } from 'elysia'
// ✅ Do
const AuthService = new Elysia({ name: 'Auth.Service' })
.macro({
isSignIn: {
resolve({ cookie, status }) {
if (!cookie.session.value)
return status(401, 'Unauthorized')
return {
session: cookie.session.value,
}
}
}
})
const UserController = new Elysia()
.use(AuthService)
.get('/profile', ({ Auth: { user } }) => user, {
isSignIn: true
})TIP
Elysia handles plugin deduplication by default, so you don't have to worry about performance, as it will be a singleton if you specify a "name" property
✅ Do: Decorate only request dependent property
It's recommended to decorate only for request-dependent properties, such as requestIP, requestTime, or session.
Overusing decorators ties your code to Elysia, making it harder to test and reuse.
import { Elysia } from 'elysia'
new Elysia()
.decorate('requestIP', ({ request }) => request.headers.get('x-forwarded-for') || request.ip)
.decorate('requestTime', () => Date.now())
.decorate('session', ({ cookie }) => cookie.session.value)
.get('/', ({ requestIP, requestTime, session }) => {
return { requestIP, requestTime, session }
})Model
Models or DTOs (Data Transfer Objects) are handled by Elysia.t (Validation).
Elysia has a built-in validation system that can infer types from your code and validate them at runtime.
✅ Do: Use Elysia's validation system
Elysia's strength is prioritizing a single source of truth for both types and runtime validation.
Instead of declaring an interface, reuse validation's model instead:
// ✅ Do
import { Elysia, t, type UnwrapSchema } from 'elysia'
export const models = {
customBody: t.Object({
username: t.String(),
password: t.String()
})
}
// Optional if you want to extract the type from the model
type CustomBody = UnwrapSchema<typeof models.customBody>
// Or make the entire object as type
type Models = {
[k in keyof typeof models]: UnwrapSchema<typeof models[k]>
}
// ❌ Don't: declare model and type separately
interface ICustomBody {
username: string
password: string
}We can get type of model by using typeof with .static property from the model.
Then you can use the CustomBody type to infer the type of the request body.
// ✅ Do
new Elysia()
.post('/login', ({ body }) => {
return body
}, {
body: models.customBody
})❌ Don't: Declare a class instance as a model
Do not declare a class instance as a model:
// ❌ Don't
class CustomBody {
username: string
password: string
constructor(username: string, password: string) {
this.username = username
this.password = password
}
}
// ❌ Don't
interface ICustomBody {
username: string
password: string
}Group
You can group multiple models into a single object to make it more organized.
import { Elysia, t } from 'elysia'
export const AuthModel = {
sign: t.Object({
username: t.String(),
password: t.String()
})
}
const models = AuthModel.modelsModel Injection
Though this is optional, if you are strictly following MVC pattern, you may want to inject like a service into a controller. We recommended using Elysia reference model
Using Elysia's model reference
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
const AuthModel = new Elysia()
.model({
sign: customBody
})
const models = AuthModel.models
const UserController = new Elysia({ prefix: '/auth' })
.use(AuthModel)
.prefix('model', 'auth.')
.post('/sign-in', async ({ body, cookie: { session } }) => {
return true
}, {
body: 'auth.Sign'
})This approach provides several benefits:
- Allows you to name a model and provide auto-completion.
- Modifies schemas for later usage, or performs a remap.
- Shows up as "models" in OpenAPI-compliant clients, eg. OpenAPI.
- Improves TypeScript inference speed as model types will be cached during registration.