MeshiTrack/docs/instructions/fastify.md

325 lines
8.1 KiB
Markdown
Raw Permalink Normal View History

2026-03-27 14:50:34 +09:00
# 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' });
});
});
```