TypeScript Продакшн-архитектура на TypeScript: слои, DI, БД, устойчивость
0%

Продакшн-архитектура на TypeScript: слои, DI, БД, устойчивость

Продакшн-архитектура на 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).

Что дальше

Сервис спроектирован. Теперь соберём его в артефакт, упакуем в контейнер, задеплоим и настроим наблюдаемость.

Деплой и наблюдаемость

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов