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

```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>('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<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:

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

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