Files
agent/.agent/skills/tech-stack/elysiajs/patterns/mvc.md

10 KiB

MVC pattern

This file contains a guideline for using Elysia with MVC or Model View Controller patterns

  • Controller:
    • Prefers Elysia as a controller for HTTP dependant
    • For non HTTP dependent, prefers service instead unless explicitly asked
    • Use onError to handle local custom errors
    • Register Model to Elysia instance via Elysia.models({ ...models }) and prefix model by namespace `Elysia.prefix('model', 'Namespace.')
    • Prefers Reference Model by name provided by Elysia instead of using an actual Model.name
  • Service:
    • Prefers class (or abstract class if possible)
    • Prefers interface/type derive from Model
    • Return status (import { status } from 'elysia') for error
    • Prefers return Error instead of throw Error
  • Models:
    • Always export validation model and type of validation model
    • Custom Error should be in contains in Model

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:

  1. Elysia type is complex and heavily depends on plugin and multiple level of chaining.
  2. Hard to type, Elysia type could change at anytime, especially with decorators, and store
  3. Loss of type integrity, and inconsistency between types and runtime code.

We recommended one of the following approach 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.

// 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 follows 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:

  1. Loss of type integrity
  2. Make it harder to test and reuse
  3. Lead to vendor lock-in

We recommended to keep 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

Service is a set of utility/helper functions decoupled as a 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 a service class/function 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 may use abstract class and static instead to avoid allocating 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)

                return {
                	session: cookie.session.value,
                }
            }
        }
    })

const UserController = new Elysia()
    .use(AuthService)
    .get('/profile', ({ Auth: { user } }) => user, {
    	isSignIn: true
    })

Do: Decorate only request dependent property

It's recommended to decorate only request-dependent properties, such as requestIP, requestTime, or session.

Overusing decorators may tie 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 }
	})

Don't: Pass entire Context to a service

Context is a highly dynamic type that can be inferred from Elysia instance.

Do not pass an entire Context to a service, instead use object destructuring to extract what you need and pass it to the service.

import type { Context } from 'elysia'

class AuthService {
	constructor() {}

	// Don't do this
	isSignIn({ status, cookie: { session } }: Context) {
		if (session.value)
			return status(401)
	}
}

As Elysia type is complex, and heavily depends on plugin and multiple level of chaining, it can be challenging to manually type as it's highly dynamic.

Model

Model or DTO (Data Transfer Object) is handle by Elysia.t (Validation).

Elysia has a validation system built-in which can infers type from your code and validate it at runtime.

Do: Use Elysia's validation system

Elysia strength is prioritizing a single source of truth for both type and runtime validation.

Instead of declaring an interface, reuse validation's model instead:

// Do
import { Elysia, t } from 'elysia'

const customBody = t.Object({
	username: t.String(),
	password: t.String()
})

// Optional if you want to get the type of the model
// Usually if we didn't use the type, as it's already inferred by Elysia
type CustomBody = typeof customBody.static

export { customBody }

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

Don't: Declare type separate from the model

Do not declare a type separate from the model, instead use typeof with .static property to get the type of the model.

// Don't
import { Elysia, t } from 'elysia'

const customBody = t.Object({
	username: t.String(),
	password: t.String()
})

type CustomBody = {
	username: string
	password: string
}

// Do
const customBody = t.Object({
	username: t.String(),
	password: t.String()
})

type CustomBody = typeof customBody.static

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

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 provide several benefits:

  1. Allow us to name a model and provide auto-completion.
  2. Modify schema for later usage, or perform a remap.
  3. Show up as "models" in OpenAPI compliance client, eg. OpenAPI.
  4. Improve TypeScript inference speed as model type will be cached during registration.