324 lines
8.1 KiB
Markdown
324 lines
8.1 KiB
Markdown
# 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' });
|
|
});
|
|
});
|
|
```
|