Продакшн-архитектура на TypeScript: слои, DI, БД, устойчивость
Знание синтаксиса и типов позволяет писать функции. Продакшн-сервис требует большего: как организовать код, чтобы он оставался изменяемым через два года; как подменять зависимости для тестов; как читать конфигурацию; как переживать сбои базы и внешних сервисов. Разберём это на примере бэкенда на Node.js, но принципы универсальны.
Выбор фреймворка: Express против Fastify
Для HTTP-сервера на Node два основных выбора:
| Express | Fastify | |
|---|---|---|
| Зрелость | стандарт де-факто, огромная экосистема | молодой, активно растёт |
| Производительность | средняя | высокая (в разы быстрее на JSON) |
| Типизация | слабая из коробки, @types/express |
first-class TS, вывод типов из схем |
| Валидация | вручную | встроена (JSON Schema), сериализация тоже |
| Стиль | минималистичный, «сам собери» | «батарейки в комплекте», плагины, хуки |
Рекомендация для нового TS-сервиса — Fastify: он проектировался с оглядкой на типы и производительность, встроенная schema-валидация закрывает границы, а экосистема плагинов покрывает аутентификацию, CORS, rate limiting. Express берут ради экосистемы и когда команда его уже знает. Для крупных приложений с сильной структурой существует NestJS — фреймворк с встроенным DI и модульностью в стиле Angular (мощно, но с высоким «входным налогом»).
Слоистая и гексагональная архитектура
Главная цель архитектуры — изолировать бизнес-логику от деталей (фреймворка, БД, внешних API). Тогда логику можно тестировать без поднятия сервера и менять БД, не переписывая правила предметной области.
Гексагональная архитектура (она же «порты и адаптеры», Алистер Кокбёрн) формулирует это через правило зависимостей: зависимости направлены только внутрь, к домену. Домен ничего не знает о внешнем мире; внешний мир зависит от домена через интерфейсы («порты»).
На практике для большинства сервисов достаточно трёх слоёв:
- Domain — сущности и бизнес-правила. Чистый TypeScript, без импортов фреймворков и драйверов БД. Здесь живут инварианты (например, «email уникален»).
- Application (use cases) — сценарии, оркестрирующие домен и порты («зарегистрировать пользователя»: провалидировать, сохранить, отправить письмо).
- Infrastructure (адаптеры) — реализации портов: контроллеры, репозитории, клиенты. Здесь и только здесь — знание про Fastify, Postgres, SMTP.
Порт — это интерфейс, объявленный в домене; адаптер — его реализация в инфраструктуре:
// domain/ports.ts — домен объявляет, ЧТО ему нужно, не зная, КАК
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface Mailer {
sendWelcome(to: string): Promise<void>;
}
// application/register-user.ts — use case зависит только от портов
import type { UserRepository, Mailer } from "../domain/ports.js";
export class EmailAlreadyUsedError extends Error {}
export class RegisterUser {
// зависимости приходят снаружи (DI) — легко подменить в тесте
constructor(
private readonly users: UserRepository,
private readonly mailer: Mailer,
) {}
async execute(email: string): Promise<User> {
if (await this.users.findByEmail(email)) {
throw new EmailAlreadyUsedError(email);
}
const user: User = { id: crypto.randomUUID(), email, createdAt: new Date() };
await this.users.save(user);
await this.mailer.sendWelcome(email);
return user;
}
}
Обратите внимание: RegisterUser тестируется чистым юнит-тестом с фейковым
репозиторием — без БД, без сети. Это и есть выигрыш от разворота зависимостей.
Dependency Injection без «магии»
DI — это всего лишь «передавай зависимости снаружи, а не создавай их внутри». В TypeScript для этого чаще всего не нужны фреймворки: достаточно передавать их в конструктор/аргументы и собирать граф в одной точке («composition root»).
// main.ts — composition root: единственное место, где всё соединяется
const pool = new Pool({ connectionString: env.DATABASE_URL });
const users = new PostgresUserRepository(pool); // адаптер реализует порт
const mailer = new SmtpMailer(env.SMTP_URL);
const registerUser = new RegisterUser(users, mailer);
// контроллер получает готовый use case
app.post("/users", async (req, reply) => {
const { email } = RegisterBody.parse(req.body); // Zod на границе
const user = await registerUser.execute(email);
return reply.code(201).send(user);
});
Такой «ручной DI» прозрачен и типобезопасен: граф зависимостей виден целиком,
циклы всплывают сразу. DI-контейнеры (tsyringe, awilix, встроенный в NestJS)
нужны, когда граф становится очень большим; для многих сервисов ручная сборка
чище и понятнее. Ключевой принцип — программировать против интерфейсов (портов),
а не конкретных классов — работает в любом варианте.
Конфигурация
Правило 12-factor app: конфигурация — в переменных
окружения, не в коде. Но process.env — это Record<string, string | undefined>,
то есть источник undefined и строк, которые надо привести к числам. Валидируйте
конфиг при старте — пусть сервис падает мгновенно с внятной ошибкой, а не через
час в рантайме на undefined.
// config.ts
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
// парсим один раз, экспортируем типизованный, замороженный конфиг
export const config = Object.freeze(EnvSchema.parse(process.env));
export type Config = typeof config;
Секреты (пароли БД, ключи API) — только через окружение/секрет-менеджер (Vault, AWS Secrets Manager), никогда в git. Разные значения на dev/stage/prod задаются окружением, а не ветвлением в коде.
Работа с базой данных
Слой доступа к данным — обычно самый «текущий» источник багов из-за рассинхрона между типами в коде и схемой в БД. TypeScript-экосистема решает это генерацией типов из схемы:
- Prisma — самый популярный ORM. Вы описываете схему
в
schema.prisma, генератор создаёт полностью типизованный клиент. Отличный DX, но своя абстракция над SQL. - Drizzle — «типизованный SQL»: схема в TypeScript, запросы близки к SQL, минимум магии, лёгкий.
- Kysely — типобезопасный query builder, тонкий слой над SQL без ORM-абстракций.
Пример с Prisma — типы и автодополнение выводятся из схемы:
// schema.prisma
model User {
id String @id @default(uuid())
email String @unique
createdAt DateTime @default(now())
}
// адаптер репозитория: реализует доменный порт поверх Prisma
import { PrismaClient } from "@prisma/client";
import type { UserRepository } from "../domain/ports.js";
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findByEmail(email: string): Promise<User | null> {
// возвращаемый тип полностью выведен из схемы — опечатка в поле = ошибка компиляции
return this.prisma.user.findUnique({ where: { email } });
}
async save(user: User): Promise<void> {
await this.prisma.user.create({ data: user });
}
}
Миграции схемы — обязательны и версионируются в git (prisma migrate,
drizzle-kit, или отдельный инструмент вроде node-pg-migrate). Никогда не
меняйте прод-схему руками: каждое изменение — это миграция в репозитории, которую
применяет CI/CD.
Помните урок из файла про типы: клиент БД возвращает данные, чей тип обещан генератором на основе схемы, но реальные строки в БД могли попасть туда до миграции. Для критичных инвариантов рантайм-проверки уместны и здесь.
Паттерны устойчивости
Сеть ненадёжна, внешние сервисы падают. Продакшн-код проектируют «на отказ».
Таймауты. Никогда не ждите внешний вызов бесконечно — используйте AbortSignal
(см. файл про конкурентность). Зависший запрос без таймаута исчерпает пул
соединений и уронит сервис.
Ретраи с экспоненциальной задержкой и джиттером. Повторяйте только
идемпотентные и транзиентные сбои (сеть, 503), не повторяйте 400/404.
Добавляйте случайный джиттер, чтобы не создать «громовое стадо»:
async function withRetry<T>(
fn: (signal: AbortSignal) => Promise<T>,
opts: { retries: number; baseMs: number; signal?: AbortSignal },
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn(opts.signal ?? AbortSignal.timeout(5000));
} catch (err) {
if (attempt >= opts.retries || !isTransient(err)) throw err;
// экспонента + джиттер: 2^attempt * base ± случайность
const backoff = opts.baseMs * 2 ** attempt + Math.random() * opts.baseMs;
await new Promise((r) => setTimeout(r, backoff));
}
}
}
Circuit breaker (предохранитель). Если сервис стабильно падает, перестаньте его дёргать на время («открытый» контур), чтобы не тратить ресурсы и дать ему восстановиться. Готовые реализации: cockatiel, opossum.
Идемпотентность. При ретраях операция может выполниться дважды. Проектируйте критичные операции (списания, отправки) идемпотентными — через ключ идемпотентности или проверку «уже сделано».
Graceful shutdown. По сигналу SIGTERM (его шлёт Kubernetes/Docker при
остановке) прекратите принимать новые запросы, дождитесь завершения текущих,
закройте пул БД — только потом завершитесь:
process.on("SIGTERM", async () => {
logger.info("Получен SIGTERM, завершаемся аккуратно");
await app.close(); // перестать принимать соединения, дослать ответы
await pool.end(); // закрыть соединения с БД
process.exit(0);
});
Health checks. Отдавайте /health (liveness — «процесс жив») и /ready
(readiness — «готов принимать трафик», БД доступна). Оркестратор опрашивает их и
перезапускает/выводит из ротации нездоровые инстансы.
Типичные ошибки
- Бизнес-логика в контроллерах. Логика, привязанная к Fastify/Express, не тестируется без сервера и не переиспользуется. Выносите в use cases.
- Прямой
process.envпо всему коду. Централизуйте и валидируйте конфиг. - Отсутствие таймаутов на внешних вызовах — самая частая причина каскадных отказов.
- Ретраи неидемпотентных операций — двойные списания.
- Утечки соединений БД — используйте пул и закрывайте соединения (Prisma делает это сам, «сырой» драйвер — нет).
Источники
- Алистер Кокбёрн, Hexagonal Architecture.
- The Twelve-Factor App — конфигурация, процессы, логи.
- Fastify и Prisma — документация.
- Michael Nygard, «Release It!» — каноничная книга про устойчивость (circuit breaker, bulkhead, timeout).
Что дальше
Сервис спроектирован. Теперь соберём его в артефакт, упакуем в контейнер, задеплоим и настроим наблюдаемость.