MeshiTrack/docs/instructions/fastify.md

8.1 KiB

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()):

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<ZodTypeProvider>();

    app.route({
      method: 'GET',
      url: '/api/v1/products',
      schema: {
        querystring: ListProductsQuerySchema,
        response: { 200: ProductListResponseSchema },
      },
      handler: async (request, reply) => {
        const service = request.diScope.resolve<ProductsService>('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:

export class ProductsService {
  private readonly productsRepository: ProductsRepository;

  constructor({ productsRepository }: { productsRepository: ProductsRepository }) {
    this.productsRepository = productsRepository;
  }
}

Registration

Register classes in the route plugin that owns them:

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:

handler: async (request) => {
  const service = request.diScope.resolve<ProductsService>('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:

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:

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():

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

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:

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

app.route({
  method: 'GET',
  url: '/api/v1/health',
  config: { public: true },
  // ...
});

Skipping household validation

app.route({
  method: 'GET',
  url: '/api/v1/users/me',
  config: { skipHousehold: true },
  // ...
});

Repository Pattern

Keep Mongoose queries in repositories

export class ProductsRepository {
  async findByHousehold(householdId: string, cursor?: string, limit = 20) {
    const query: Record<string, unknown> = { 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:

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

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 <test-jwt>',
        '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:

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' });
  });
});