Skip to content
Our Sponsors
Open in Anthropic

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.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:

typescript
// 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
} } )
typescript
// 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)
		}
	}
}
typescript
// 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:

  1. Elysia types are complex and heavily depend on plugins and multiple levels of chaining.
  2. Hard to type; Elysia types could change at any time, especially with decorators and store.
  3. Loss of type integrity and inconsistency between types and runtime code.

We recommend one of the following approaches to implement a controller in Elysia.

  1. Use Elysia instance as a controller itself
  2. 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.

typescript
// ✅ 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.

typescript
// ❌ 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.

typescript
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:

  1. Loss of type integrity
  2. Making it harder to test and reuse
  3. 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.

typescript
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)

typescript
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:

  1. Non-request dependent service
  2. 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.

typescript
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:

typescript
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.

typescript
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:

typescript
// ✅ 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.

typescript
// ✅ 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:

typescript
// ❌ 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.

typescript
import { Elysia, t } from 'elysia'

export const AuthModel = {
	sign: t.Object({
		username: t.String(),
		password: t.String()
	})
}

const models = AuthModel.models

Model 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

typescript
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:

  1. Allows you to name a model and provide auto-completion.
  2. Modifies schemas for later usage, or performs a remap.
  3. Shows up as "models" in OpenAPI-compliant clients, eg. OpenAPI.
  4. Improves TypeScript inference speed as model types will be cached during registration.