Разработка бэкенда сайта на Node.js (Express)
Express остаётся прагматичным выбором для бэкенда веб-сайта: минимум магии, предсказуемое поведение, огромная экосистема middleware. Не самый быстрый фреймворк (Fastify быстрее), не самый feature-rich (NestJS богаче), но сочетание простоты и гибкости делает его рабочим инструментом для большинства задач.
Структура проекта
Плоская структура «все файлы в routes/» работает до определённого масштаба. При росте проекта нужна явная многослойная архитектура:
src/
├── config/
│ ├── env.ts # typed env validation (zod)
│ └── database.ts
├── modules/
│ ├── users/
│ │ ├── users.router.ts
│ │ ├── users.service.ts
│ │ ├── users.repository.ts
│ │ ├── users.schema.ts # Zod схемы валидации
│ │ └── users.types.ts
│ ├── products/
│ └── orders/
├── middleware/
│ ├── auth.ts
│ ├── errorHandler.ts
│ ├── requestLogger.ts
│ └── rateLimit.ts
├── lib/
│ ├── database.ts # Prisma client
│ ├── redis.ts
│ ├── mailer.ts
│ └── queue.ts
└── app.ts
Настройка приложения
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { pinoHttp } from 'pino-http';
import { usersRouter } from './modules/users/users.router';
import { productsRouter } from './modules/products/products.router';
import { ordersRouter } from './modules/orders/orders.router';
import { errorHandler } from './middleware/errorHandler';
import { notFound } from './middleware/notFound';
import { env } from './config/env';
export function createApp() {
const app = express();
// Безопасность
app.use(helmet());
app.use(cors({
origin: env.ALLOWED_ORIGINS.split(','),
credentials: true,
}));
// Парсинг
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Логирование запросов
app.use(pinoHttp({
logger: logger,
customLogLevel: (_req, res) => {
if (res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
}));
// Healthcheck (без auth и rate limit)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Роуты
app.use('/api/users', usersRouter);
app.use('/api/products', productsRouter);
app.use('/api/orders', ordersRouter);
// Обработка ошибок — должна быть последней
app.use(notFound);
app.use(errorHandler);
return app;
}
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
ALLOWED_ORIGINS: z.string().default('http://localhost:5173'),
});
export const env = envSchema.parse(process.env);
Падение при старте с явным сообщением об ошибке лучше, чем непонятное поведение в рантайме при отсутствии переменной окружения.
Паттерн Router → Service → Repository
// modules/products/products.router.ts
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { validate } from '../../middleware/validate';
import { ProductsService } from './products.service';
import { createProductSchema, updateProductSchema, listProductsSchema } from './products.schema';
const service = new ProductsService();
export const productsRouter = Router();
productsRouter.get(
'/',
validate({ query: listProductsSchema }),
async (req, res, next) => {
try {
const result = await service.list(req.query as any);
res.json(result);
} catch (err) { next(err); }
}
);
productsRouter.get('/:id', async (req, res, next) => {
try {
const product = await service.getById(req.params.id);
if (!product) return res.status(404).json({ error: 'Товар не найден' });
res.json(product);
} catch (err) { next(err); }
});
productsRouter.post(
'/',
authenticate,
validate({ body: createProductSchema }),
async (req, res, next) => {
try {
const product = await service.create(req.body, req.user!.id);
res.status(201).json(product);
} catch (err) { next(err); }
}
);
// modules/products/products.service.ts
import { ProductsRepository } from './products.repository';
import type { CreateProductDto, ListProductsQuery } from './products.types';
import { redis } from '../../lib/redis';
export class ProductsService {
private repo = new ProductsRepository();
async list(query: ListProductsQuery) {
const cacheKey = `products:list:${JSON.stringify(query)}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const result = await this.repo.findMany(query);
await redis.set(cacheKey, JSON.stringify(result), 'EX', 300);
return result;
}
async getById(id: string) {
const cacheKey = `products:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await this.repo.findById(id);
if (product) await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;
}
async create(data: CreateProductDto, createdBy: string) {
const product = await this.repo.create({ ...data, createdBy });
// Инвалидация списочного кеша
const keys = await redis.keys('products:list:*');
if (keys.length > 0) await redis.del(keys);
return product;
}
}
Middleware: валидация, аутентификация, ошибки
// middleware/validate.ts — типизированная валидация через Zod
import type { Request, Response, NextFunction } from 'express';
import type { ZodSchema } from 'zod';
interface ValidateSchemas {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
export function validate(schemas: ValidateSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body);
}
if (schemas.query) {
req.query = schemas.query.parse(req.query) as any;
}
if (schemas.params) {
req.params = schemas.params.parse(req.params) as any;
}
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(422).json({
error: 'Validation failed',
details: err.flatten().fieldErrors,
});
}
next(err);
}
};
}
// middleware/errorHandler.ts
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
// Логируем с stack trace только в dev
logger.error({ err }, 'Unhandled error');
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
// Prisma: запись не найдена
if (err.constructor.name === 'NotFoundError') {
return res.status(404).json({ error: 'Not found' });
}
// Prisma: нарушение уникального ограничения
if ((err as any).code === 'P2002') {
return res.status(409).json({ error: 'Already exists' });
}
const status = process.env.NODE_ENV === 'production' ? 500 : 500;
const message = process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message;
res.status(status).json({ error: message });
}
Аутентификация через JWT
// middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
interface JwtPayload {
sub: string;
role: string;
iat: number;
exp: number;
}
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string };
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
export function authorize(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
Graceful shutdown
// src/server.ts
import { createApp } from './app';
import { prisma } from './lib/database';
import { redis } from './lib/redis';
import { env } from './config/env';
const app = createApp();
const server = app.listen(env.PORT, () => {
logger.info({ port: env.PORT }, 'Server started');
});
async function shutdown(signal: string) {
logger.info({ signal }, 'Shutdown signal received');
server.close(async () => {
await prisma.$disconnect();
await redis.quit();
logger.info('Server closed gracefully');
process.exit(0);
});
// Force kill после 10 секунд
setTimeout(() => {
logger.error('Forced shutdown');
process.exit(1);
}, 10_000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Тестирование
// modules/products/products.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProductsService } from './products.service';
import { ProductsRepository } from './products.repository';
vi.mock('./products.repository');
vi.mock('../../lib/redis', () => ({
redis: { get: vi.fn().mockResolvedValue(null), set: vi.fn(), del: vi.fn(), keys: vi.fn().mockResolvedValue([]) }
}));
describe('ProductsService', () => {
let service: ProductsService;
let repo: ProductsRepository;
beforeEach(() => {
repo = new ProductsRepository();
service = new ProductsService();
(service as any).repo = repo;
});
it('возвращает товар из кеша при повторном запросе', async () => {
const product = { id: '1', name: 'Test', price: 100 };
vi.mocked(repo.findById).mockResolvedValue(product as any);
await service.getById('1');
await service.getById('1');
expect(repo.findById).toHaveBeenCalledTimes(1);
});
});
Сроки
REST API для сайта среднего размера (10–15 модулей, аутентификация, файлы, уведомления) — четыре-шесть недель. Включает: настройку окружения и CI/CD, разработку модулей, написание тестов, интеграцию с внешними сервисами, документацию API (OpenAPI/Swagger).







