MVC Pattern
Elysia is pattern agnostic framework, we the decision up to you and your team for coding patterns to use.
However, we found that several people are using the MVC pattern (Model-View-Controller) on Elysia, and found it's hard to decouple and handling with types.
This page is a guide to use Elysia with MVC pattern.
Controller
1 Elysia instance = 1 controller.
DO NOT create a separate controller, use Elysia itself as a controller instead.
import { Elysia, t } from 'elysia'
// ❌ don't:
new Elysia()
.get('/', Controller.hi)
// ✅ do:
new Elysia()
// Get what you need
.get('/', ({ query: { name } }) => {
Service.do1(name)
Service.do2(name)
}, {
query: t.Object({
name: t.String()
})
})
Elysia does a lot to ensure type integrity, and if you pass an entire Context type to a controller, these might be the problems:
- 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
- Type casting may cause lost of type integrity or unable to ensure type and runtime code.
- Harder for Sucrose (Elysia's "kind of" compiler) to statically analyze your code
We recommended using object destructuring to extract what you need and pass it to "Service" instead.
By passing an entire Controller.method
to Elysia is an equivalent of having 2 controllers passing data back and forth. It's against the design of framework and MVC pattern itself.
// ❌ don't:
import { Elysia, type Context } from 'elysia'
abstract class Controller {
static root(context: Context<any, any>) {
return Service.doStuff(context.stuff)
}
}
new Elysia()
.get('/', Controller.root)
Here's an example of what it looks like to do something similar in NestJS.
// ❌ don't:
abstract class InternalController {
static root(res: Response) {
return Service.doStuff(res.stuff)
}
}
@Controller()
export class AppController {
constructor(private appService: AppService) {}
@Get()
root(@Res() res: Response) {
return InternalController.root(res)
}
}
Instead treat an Elysia instance as a controller itself.
import { Elysia } from 'elysia'
import { HiService } from './service'
// ✅ do:
new Elysia()
.use(HiService)
.get('/', ({ Hi, stuff }) => {
Hi.doStuff(stuff)
})
If you would like to call or perform unit test on controller, use Elysia.handle.
import { Elysia } from 'elysia'
import { HiService } from './service'
const app = new Elysia()
.use(HiService)
.get('/', ({ Hi, stuff }) => {
Hi.doStuff(stuff)
})
app.handle(new Request('http://localhost/'))
.then(console.log)
Or even better, use Eden with end-to-end type safety.
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/eden'
import { HiService } from './service'
const AController = new Elysia()
.use(HiService)
.get('/', ({ Hi, stuff }) => Hi.doStuff(stuff))
const controller = treaty(AController)
const { data, error } = await controller.index.get()
Service
Service is a set of utility/helper functions for each module, in our case, Elysia instance.
Any logic that can be decoupled from controller may be live inside a Service.
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 may use abstract class
and static
instead to avoid allocating a class instance.
But if your service involves local mutation eg. caching, you may want to initiate an instance instead.
import { Elysia, t } from 'elysia'
class Service {
public cache = new Map<number, number>()
fibo(number: number): number {
if(number < 2)
return number
if(this.cache.has(number))
return this.cache.get(number)!
const a = this.fibo(number - 1)
const b = this.fibo(number - 2)
this.cache.set(number - 1, a)
this.cache.set(number - 2, b)
return a + b
}
}
new Elysia()
.decorate({
Service: new Service()
})
.get('/fibo', ({ Service, body }) => {
return Service.fibo(body)
}, {
body: t.Numeric()
})
You may use Elysia.decorate to embed a class instance into Elysia, or not, it depends on your usecase.
Using Elysia.decorate is an equivalent of using dependency injection in NestJS:
// Using dependency injection
@Controller()
export class AppController {
constructor(service: Service) {}
}
// Using separate instance from dependency
const service = new Service()
@Controller()
export class AppController {
constructor() {}
}
Request Dependent Service
If your service is going to be used in multiple instances or may require some properties from the request, we recommend creating a dedicated Elysia instance as a Service instead.
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.
import { Elysia } from 'elysia'
const AuthService = new Elysia({ name: 'Service.Auth' })
.derive({ as: 'scoped' }, ({ cookie: { session } }) => {
return {
Auth: {
user: session.value
}
}
})
.macro(({ onBeforeHandle }) => ({
isSignIn(value: boolean) {
onBeforeHandle(({ Auth, error }) => {
if (!Auth?.user || !Auth.user) return error(401)
})
}
}))
const UserController = new Elysia()
.use(AuthService)
.guard({
isSignIn: true
})
.get('/profile', ({ Auth: { user } }) => user)
Model
Model or DTO (Data Transfer Object) is handled by Elysia.t (Validation).
We recommended using Elysia reference model or creating an object or class of DTOs for each module.
- Using Elysia's model reference
import { Elysia, t } from 'elysia'
const AuthModel = new Elysia({ name: 'Model.Auth' })
.model({
'auth.sign': t.Object({
username: t.String(),
password: t.String({
minLength: 5
})
})
})
const UserController = new Elysia({ prefix: '/auth' })
.use(AuthModel)
.post('/sign-in', async ({ body, cookie: { session } }) => {
return {
success: true
}
}, {
body: 'auth.sign'
})
This allows approach provide several benefits.
- Allow us to name a model and provide auto-completion.
- Modify schema for later usage, or perform remapping.
- Show up as "models" in OpenAPI compliance client, eg. Swagger.
View
You may use Elysia HTML to do Template Rendering.
Elysia support JSX as template engine using Elysia HTML plugin
You may create a rendering service or embedding view directly is up to you, but according to MVC pattern, you are likely to create a seperate service for handling view instead.
- Embedding View directly, this may be useful if you have to render multiple view, eg. using HTMX:
import { Elysia } from 'elysia'
new Elysia()
.get('/', ({ query: { name } }) => {
return (
<h1>hello {name}</h1>
)
})
- Dedicated View as a service:
import { Elysia, t } from 'elysia'
abstract class Render {
static root(name?: string) {
return <h1>hello {name}</h1>
}
}
new Elysia()
.get('/', ({ query: { name } }) => {
return Render.root(name)
}, {
query: t.Object({
name: t.String()
})
})
As being said, Elysia is pattern agnostic framework, and we only a recommendation guide for handling Elysia with MVC.
You may choose to follows or not is up to your and your team preference and agreement.