# Fastify Best Practices — MeshiTrack API > Instruction file for developing the Fastify backend (`packages/api`). ## Module Organization ### One route plugin per domain feature Each business domain gets its own folder under `src/modules/`: ``` src/modules/ ├── health/ │ ├── health.routes.ts │ └── health.routes.test.ts ├── users/ │ ├── users.routes.ts │ ├── users.service.ts │ ├── users.repository.ts │ └── users.routes.test.ts ├── households/ │ ├── households.routes.ts │ ├── households.service.ts │ ├── households.repository.ts │ └── households.routes.test.ts ├── products/ │ ├── products.routes.ts │ ├── products.service.ts │ ├── products.repository.ts │ └── products.routes.test.ts └── ... ``` ### Route plugins Each module exports a Fastify plugin using `fastify-plugin` (`fp()`): ```typescript import fp from 'fastify-plugin'; import { asClass, Lifetime } from 'awilix'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { ProductsRepository } from './products.repository.js'; import { ProductsService } from './products.service.js'; export default fp( async (fastify) => { // Register DI fastify.diContainer.register({ productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }), }); const app = fastify.withTypeProvider(); app.route({ method: 'GET', url: '/api/v1/products', schema: { querystring: ListProductsQuerySchema, response: { 200: ProductListResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('productsService'); const result = await service.list(request.householdId, request.query); return reply.send(result); }, }); }, { name: 'products-routes' }, ); ``` ## Dependency Injection with Awilix ### Constructor injection via destructuring Awilix injects dependencies by matching constructor parameter names: ```typescript export class ProductsService { private readonly productsRepository: ProductsRepository; constructor({ productsRepository }: { productsRepository: ProductsRepository }) { this.productsRepository = productsRepository; } } ``` ### Registration Register classes in the route plugin that owns them: ```typescript import { asClass, asValue, Lifetime } from 'awilix'; fastify.diContainer.register({ productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }), }); ``` ### Resolving per-request Use `request.diScope.resolve()` in handlers: ```typescript handler: async (request) => { const service = request.diScope.resolve('productsService'); return service.findById(request.params.id); }; ``` ### Lifetime rules - **SINGLETON** for stateless services and repositories (default choice) - **SCOPED** only when you need per-request state (e.g., transaction context) - Never use **TRANSIENT** unless you have a specific reason ## Request Validation with Zod ### Use `fastify-type-provider-zod` Set up the Zod type provider at app level: ```typescript import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); ``` ### Schema definitions Define schemas in `packages/shared` and import them in route definitions: ```typescript app.route({ method: 'POST', url: '/api/v1/products', schema: { body: CreateProductSchema, response: { 201: ProductResponseSchema }, }, handler: async (request, reply) => { // request.body is fully typed from CreateProductSchema const product = await service.create(request.householdId, request.body); return reply.status(201).send(product); }, }); ``` ## Plugin Architecture ### Use `fastify-plugin` for shared plugins Plugins that need to be visible to sibling routes must use `fp()`: ```typescript import fp from 'fastify-plugin'; export default fp( async (fastify) => { // decorations/hooks registered here are visible to all routes }, { name: 'my-plugin', dependencies: ['other-plugin'] }, ); ``` ### Plugin ordering matters Register plugins in this order in `main.ts`: 1. Security plugins (`@fastify/helmet`, `@fastify/cors`) 2. Compression (`@fastify/compress`) 3. Swagger (`@fastify/swagger`, `@fastify/swagger-ui`) 4. DI container (`@fastify/awilix`) 5. Database (`mongoose.plugin`) 6. Auth (`auth.plugin`) 7. Household guard (`household.plugin`) 8. Route modules (health, users, households, etc.) ## Error Handling ### Custom AppError hierarchy ```typescript export class AppError extends Error { constructor( message: string, public readonly statusCode: number, public readonly error: string, public readonly details?: unknown, ) { super(message); } } // Subclasses: NotFoundError, UnauthorizedError, ForbiddenError, ConflictError, BadRequestError ``` ### Throw from services, catch in global handler Services throw `AppError` subclasses. The global error handler in `main.ts` maps them to `ApiError` response shape: ```typescript app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ statusCode: error.statusCode, error: error.error, message: error.message, timestamp: new Date().toISOString(), path: request.url, }); } // ... handle Zod validation errors, unexpected errors }); ``` ## Route Configuration ### Marking routes as public ```typescript app.route({ method: 'GET', url: '/api/v1/health', config: { public: true }, // ... }); ``` ### Skipping household validation ```typescript app.route({ method: 'GET', url: '/api/v1/users/me', config: { skipHousehold: true }, // ... }); ``` ## Repository Pattern ### Keep Mongoose queries in repositories ```typescript export class ProductsRepository { async findByHousehold(householdId: string, cursor?: string, limit = 20) { const query: Record = { householdId }; if (cursor) query['_id'] = { $gt: cursor }; return ProductModel.find(query) .sort({ _id: 1 }) .limit(limit + 1) .lean() .exec(); } } ``` ### Always use `.lean().exec()` Every read query must use `.lean().exec()` for performance: ```typescript // Good const product = await ProductModel.findById(id).lean().exec(); // Bad — returns full Mongoose document with all overhead const product = await ProductModel.findById(id); ``` ## Testing with Vitest ### Use `app.inject()` for route tests Fastify's built-in `inject()` method tests routes without starting a real HTTP server: ```typescript import { describe, it, expect } from 'vitest'; describe('Products Routes', () => { it('GET /api/v1/products returns products for household', async () => { const app = await buildTestApp(); const response = await app.inject({ method: 'GET', url: '/api/v1/products', headers: { authorization: 'Bearer ', 'x-household-id': 'test-household-id', }, }); expect(response.statusCode).toBe(200); expect(response.json()).toHaveProperty('items'); }); }); ``` ### Service unit tests with manual DI No test module builder needed — just pass mock dependencies: ```typescript import { describe, it, expect, vi } from 'vitest'; describe('ProductsService', () => { const mockRepo = { findByHousehold: vi.fn(), create: vi.fn(), }; const service = new ProductsService({ productsRepository: mockRepo as any }); it('should create a product', async () => { mockRepo.create.mockResolvedValue({ id: '1', name: 'Chicken' }); const result = await service.create('hh1', { name: 'Chicken' }); expect(result).toEqual({ id: '1', name: 'Chicken' }); expect(mockRepo.create).toHaveBeenCalledWith('hh1', { name: 'Chicken' }); }); }); ```