# <u>MasterNote</u>: Building a battery-included BE service with Elysia + Bun + TS + Drizzle ORM (DEBT) <small>=))))))))))</small> ## Introduction This document is a centralized guide for building a battery-included BE service (compare to Spring Boot, Ruby on Rails) which primarily focus on these goals: :::warning :trophy: **GOALS OF THIS DOCUMENT** 1. **To standardize the processes / patterns** of implementing via provide best practices, formulas for DEBT stack **to avoid further decision fatigue** due to TS's flexible nature 2. To achieve enterprise-ready, battery-included features but with **minimalistic approach, best DX, and fastest Delivery speed** (*why do you need to write longer code if the short one can?*) 3. This document will be a **single source of truth** of the standard and process of improving this standard as well. As well as a log of decisions after spending time to take looks on different choices, comparisons, etc. ::: :::spoiler **Table of content** [toc] ::: --- ## Chapter 1: Construction of The standard based on Spring Boot and Ruby on Rails Experience Let's take a look into Spring Boot and Ruby on Rails ecosystem. They offer **an opinitioned way** to implement a backend service. However, we want to: - Embrace the **enterprise-ready features** of Spring Boot and **dark-magic, battery-included** of Ruby on Rails - Eliminate the boilerplate of Spring Boot and of course Java (the dead language) - Eliminate the loose typing, over-flexibity, weird ErGoNoMic of Ruby language ## Chapter 2: The Standards ### Folder structure Instead of seperate by layer as traditional Spring Boot application, we will combine them into **a single folder for each feature / module**. ``` /src /lib /features /base /auth /products - product.models.ts // contains Drizzle Model definition - product.repo.ts // contains DAO definition - product.handlers.ts // contains business logic (service layer) - product.routers.ts // contains declarative API defintition ``` 1. **Suffix should be plural**. Because usually we will have multiple routes, handlers in a single file. Not one function per file. (Except `repo` suffix) ~~`product.model.ts`~~ -> `product.models.ts` ### Router layer In router layer, we should ensure these goals: 1. The code must be as conside, declarative as possible **This is a typical router for a business object** ```ts export default new Elysia({ prefix: '/object-name' }) .use(middleware) .get('/', async () => handlers.get()) .post('/', async () => handlers.create()) .as('scoped') ``` ### Index.ts ### Error Handling 1. We should define a class that represent a Business exception, that accepts: `key`, `customMessage` ```ts class BusinessError extends Error { static const errorKeyMappings = { 'NOT_FOUND': { message: 'No existing entity with the given ID', statusCode: 404 } } as const; constructor(key: keyof typeof BusinessError.errorKeyMappings, customMessage?: string) { const errorDetails = BusinessError.errorKeyMappings[key]; if (customMessage) { errorDetails.message = customMessage } new Error(errorDetails); } } export function raise(key: string, customMessage?: string) { throw new BusinessError(key, customMessage); } ``` 2. We should have a function that translate `BusinessError | Error` to correspond `JsonResponse` (error handler for Elysia's `onError`) ```ts export function handleError(e: any) {} ``` ### RESTful Pattern | Action | HTTP Endpoint | Handler signature | Description | | :- | :- | :- | :- | | **Query** | `POST /query` | `query(ObjectQuery<T>): T[]` | `200`, `400` | | **Get one by ID** | `GET /:id` | `get(string \| number): T` | `200`, `404` | | **Create** | `POST /` | `create(CreateReq<T>): T` | `201`, `400` | | **Update** | `PUT /:id` | `update(id: string \| number, UpdateReq<T>): T` | `200`, `404`, `400` | | **Partial Edit** | `PATCH` <br> `/:id?field={{value}}` | `edit(id: string \| number, fieldName: string, newValue: any): T` | `200` | | **Partial Edit** (*Semantic*) | `PATCH` <br> `/:id/{{action-name}}` | `{{actionName}}(id: string \| number)` | `200` <br> Custom implementation in handler layer | | **Delete** | `DELETE /:id` | `delete(id: string \| number): T` | `200` <br> Also apply for soft delete | | **Delete Many** | `DELETE /` | `delete(id: (string \| number))[]: T[]` | `200` <br> Also apply for soft delete | **Additional information that you surely need** | Action | HTTP Endpoint | Handler signature | Description | | :- | :- | :- | :- | | **Statistic** | `GET /stats` | `stats(): Record<string, any>` | | | **Count all** | `GET /count` | `query(ObjectQuery<T>): T[]` | | | **Available Filters** | `GET /available-filters` | `string[]` | | **Notes:** 1. ***Why there is no `getAll` option?*** We will combine it to query to simplify the code and centralise the control ### Protected Routes There is 4 layer of protection: 1. **Authenticated user**: require user to authenthenticate before proceed 2. **Role guard**: require user to match a specific role to proceed 3. **Permission guard**: require user to have a set of permissions to proceed 4. **Custom guard**: *implements by developer based on busineed need* ```ts= export default new Elysia({ prefix: '/object-name' }) // ... .use(requireAuthenticated) // layer 1 middleware .use(allowRoles([ 'admin' ])) // layer 2 middleware .use(requirePermissions([ 'object-name:create' ])) // layer 3 middleware .post('/', async ({ body }) => await handlers.create(body)) ``` :::spoiler **Spring Boot version** ```java= @RestController @RequestMapping("/object-name") @RequiredArgsConstructor public class ProtectedController { private final ProtectedService service; //... @PostMapping("/") @PreAuthorize("hasAnyRole('ADMIN') and hasAuthority('object-name:create')") public ResponseEntity<Map<String, Object>> create( Authentication authentication, @RequestBody Object body ) { return service.create(body); } } ``` ::: ### Handlers layer In handlers layer, we implement most of the business logic. We take the arguments from the router layer to process. If we encounter any error from the repository layer, we can catch for handling it, or to translate it to `BusinessError` or even let it throw, the global `handleError` will take care for it ```ts class {{ObjectName}}Handlers { private {{otherObjectName}}Handlers = {{otherObjectName}}Handlers; private repo = {{otherObjectName}}Repo; async get(id: string | number) {} async create(body: CreateReq) {} async query(q: ObjectQuery) {} // ... } export default new {{ObjectName}}Handlers(); ``` ### Typical Handlers In case you don't want to implement again and again the same flow / logic across features, if they are simple enough. 1. You may need to intervene before / after the flow, you may need to pass these functions into the argument object. If you need a more complex business flow, please consider DIY. 2. You must need to provide a typical repository to work with this one. During the execution, repo's method will be invoked (with exact name with the handler) ```ts { repo?: RepositoryObject, before?: (args) => Promise<void>, after?: (result) => Promise<void>, } ``` **1. Typical Get**: - Take an `id` (which is `number | string`) as an argument - if you provide a repo object, call it's `get` method: if an object is found, return it. Otherwise, raise a business error with `NOT_FOUND` key **2. Typical Query**: - Take a ObjectQuery as an argument, then translate to drizzle query based on it - The query is paginated by default, if you want to a get all method, just DIY - If there is no result, return an empty list **3. Typical Create**: - Take a validated object (via Elysia's `t` schema) to create a new entry - In the typical flow, you may need to check preconditions via `before` (and raise corresponse BusinessError) or perform a side-effect after created via `after` (for example, attach the document into a parent document after created) - Return the created object **4. Typical Update**: - Take an `id` and a validated object (via Elysia's `t` schema) that match with your creation payload - This one will have 2 phases: - **Record retrieval**: will use `repo.get` to retrieve, if there is no matched record, raise `BusinessError('NOT_FOUND')`. You can intervene this process with `getIntervention.before`, `getIntervention.after` - **Update**: will use `repo.update` to update the whole record. You can intervene this process with `before`, `after` - Return the edited object. **5. Typical Partial Update**: similar to typical update, but only one single field. - Take an `id`, `fieldName`, and `newValue`. If `fieldName` is not exist in the table, raise `BusinessError('PARTIAL_UPDATE_NOT_FOUND_FIELD')`. - Check if `fieldName` is an editable field. If not, raise `BusinessError('PARTIAL_UPDATE_UNEDITABLE_FIELD')`. - By default, these fields are uneditable: `id`, `createdAt`, `createdBy` - Update the `newValue` into the record, if any issue related to data convertion, raise `BusinessError('PARTIAL_UPDATE_DATA_ERROR')` - Return the updated value after complete :::info Due to the complexity of this one, the implementation will be deprioritized. We strongly encourage you to introduce field-specific update endpoints ::: **6. Typical Delete Update**: - Take in `id`, and use `repo.delete` to delete one, if there is no matched record, raise `BusinessError('NOT_FOUND')` **7. Typical Query**: - Take a standardized query object and translate it to drizzle query, return a paginated result ```ts { } ``` **8. Typical Delete Many**: - Take a standardized query object and translate it to drizzle query ```ts export const handlers = { get: useTypicalGetHandler(repo), query: useTypicalQueryHandler(repo), create: useTypicalCreateHandler(repo), update: useTypicalUpdateHandler(repo), delete: useTypicalDeleteHandler(repo), query: useTypicalQueryHandler(repo), count: repo.count() // more faster =))) don't even need to introduce availableFilters: () => [ 'field1', 'field2' ], stats: () => {} } ``` A typical handlers may look like this. Super efficient :ok_hand: Or even, if your business flow is so straightforward and there is no customization needed, you can totally create a typical handlers object via `useTypicalHandlers(repo)`, it will create all 6 default handlers for you ### Typical Repo A Typical repo is a straightforward translation between this standard's verbs to drizzle's correspond queries / actions. Support following actions: - `count` - `get` - `query` - `create` - `update` - `delete` - `deleteMany` We will provide a base typical repo for you, we out-of-the-box support MongooseRepo, PostgresRepo ```ts class TypicalPostgresRepo { constructor(entity: any) { this._entity = entity; } async count(): Promise<number> { return _entity.count(); } // ... } ``` :::info This suggest that with some business object with straightforward business logic. You can focus completely on: - Define routers with permissions and role control - Define business object's entity schema - Even business object entity schema can be translated to `CreateReq` easily ::: ## Chapter 3: Automation