Парадигмы разработки Как выбирать и смешивать парадигмы в реальном проекте
0%

Как выбирать и смешивать парадигмы в реальном проекте

Как выбирать и смешивать парадигмы в реальном проекте

Девять статей трека мы разбирали парадигмы поодиночке: императивную, объектно-ориентированную, функциональную, декларативную и логическую, конкурентные, реактивную, обобщённую и мета, аспектную и событийную. Каждая была показана в своей лучшей форме — на задаче, под которую она создавалась.

Реальный проект так не устроен. В нём одновременно живут: парсинг входящего JSON, три бизнес-правила с исключениями, транзакция в Postgres, фоновая выгрузка в аналитику, вебсокет с котировками, ретраи к платёжному провайдеру и админка на CRUD. Ни одна парадигма не выигрывает на всех семи задачах. Вопрос «на какой парадигме мы пишем» — неправильно поставленный. Правильный звучит так: какое ограничение купит мне больше всего гарантий именно здесь, в этой подсистеме, и во что мне обойдётся граница с соседней подсистемой, где ограничение другое.

Эта статья — метод ответа на этот вопрос. Она про решения, а не про синтаксис.

Часть 1. Что вы на самом деле выбираете

Парадигма — это не про код, а про то, что нельзя сломать

Напомню рамку из обзорной статьи: парадигма отнимает свободу и взамен даёт гарантию. Значит, выбор парадигмы — это выбор гарантии, которую вы хотите получить бесплатно, то есть по построению, а не тестами и код-ревью.

Список гарантий, за которыми люди приходят на практике, довольно короткий:

Нужная гарантия Кто её даёт по построению Цена
«Функция при тех же входах вернёт то же самое» чистые функции, неизменяемость нет мутации на месте, аллокации, персистентные структуры
«Гонок данных нет» акторы, CSP, ownership в Rust копирование сообщений, отсутствие быстрого доступа к общему состоянию
«Инвариант объекта не нарушить снаружи» инкапсуляция ООП косвенность, сложнее сериализовать и сравнивать
«Невалидное состояние невыразимо» алгебраические типы, newtype больше кода на границах, больше типов
«Новый вид сущности добавляется без правки старого кода» полиморфизм, интерфейсы тяжело добавлять операции, динамическая диспетчеризация
«Новая операция добавляется без правки типов» закрытые суммы + сопоставление с образцом тяжело добавлять типы
«Решение переносимо и оптимизируется движком» SQL, Datalog, солверы, IaC вы теряете контроль над планом выполнения
«Изменение источника автоматически доходит до всех потребителей» реактивность, dataflow сложная отладка, глитчи, утечки подписок
«Сквозная логика не размазана по коду» аспекты, middleware, декораторы неявность, «магия» в стектрейсе

Обратите внимание: это ровно тот же список, что в статье про принципы проектирования, только сформулированный на уровне языка, а не на уровне договорённостей команды. В этом и разница между принципом и парадигмой: принцип можно нарушить в пятницу вечером, парадигму — нет, компилятор не даст.

Три вопроса, которые нужно задать подсистеме

Прежде чем выбирать, опишите подсистему по трём осям. Это занимает пять минут и отсекает 80 % вариантов.

  1. Как живёт состояние? Его нет (чистое преобразование)? Оно эфемерно (живёт в рамках запроса)? Оно долгоживущее и принадлежит одной сущности (сессия, заказ, устройство)? Оно общее для всех и должно быть согласованным (баланс, склад)?
  2. Что чаще меняется — множество типов или множество операций? Об этом отдельный разговор в части 3: эта ось определяет выбор между ООП и ФП жёстче, чем любые вкусовые предпочтения.
  3. Кто задаёт порядок? Программист (последовательный алгоритм)? Внешний мир (события, конкурентные запросы)? Данные (граф зависимостей, пайплайн)? Движок (оптимизатор, планировщик, резолюция)?

Диаграмма не догма, а быстрый навигатор: она говорит, откуда начинать думать, а не чем закончить.

Часть 2. Гранулярность: парадигма выбирается для модуля, а не для репозитория

Самая частая методологическая ошибка — выбирать парадигму на уровне проекта. «Мы делаем функциональный бэкенд» — это заявление примерно такой же полезности, как «мы делаем быстрый бэкенд». Быстрый — что именно? Функциональный — где именно, в слое HTTP-роутинга тоже?

Практическое правило: единица выбора парадигмы — модуль с собственной причиной изменения. То есть примерно то же зерно, на котором вы применяете принцип единственной ответственности. В типичном сервисе таких зёрен четыре-шесть, и в них естественно оказываются разные парадигмы.

Карта швов между парадигмами в одном сервисе

Разберём картинку по слоям, потому что каждая линия там — это решение, а не украшение.

Край. Здесь правит внешний мир: соединения открываются и рвутся, сообщения приходят пачками, потребитель может быть медленнее продюсера. Событийная и реактивная парадигмы тут не выбор стиля, а необходимость: вам нужны подписки, отмена, таймауты и backpressure. Пытаться писать край в чисто функциональном стиле — значит имитировать мир, который принципиально грязный.

Оболочка приложения. Сценарий использования — это по сути процедура: сходи туда, прочитай это, вызови ядро, запиши результат, отправь событие, обработай ошибку. Императивный код здесь честнее любого другого. Попытка завернуть сценарий в трансформеры монад делает его короче в строках и длиннее в часах на онбординг.

Домен. Здесь вы решаете, а не действуете. Значит, здесь можно позволить себе самое дорогое и самое ценное ограничение — отсутствие эффектов. Функциональное ядро проверяется таблицей примеров, а не моками; его можно прогнать сто тысяч раз в property-based тесте; его можно перенести в другой рантайм.

Инфраструктура. Тут выигрывает декларативность: SQL вместо цикла по строкам, миграции вместо ручных ALTER, контроллер согласования вместо скрипта развёртывания.

Сквозное. Трассировка, метрики, аудит, сериализация — то, что не должно попадать в бизнес-код. Аспекты, middleware, декораторы и кодогенерация тут на своём месте.

Правило швов

Ключевая мысль всей статьи: вредно не количество парадигм, а количество неохраняемых переходов между ними.

Стоимость понимания системы растёт не как число парадигм k, а примерно как число пар соседних областей, между которыми происходит перевод. Если каждый модуль граничит с каждым, это O(k²) швов; если система выстроена слоями, это O(k). Отсюда два инженерных следствия:

  • парадигмы должны быть организованы иерархически (слоями), а не мозаично («тут у нас акторы, а вон в том хелпере — монада State»);
  • у каждого шва должен быть один владеющий тип и одно правило перевода. Шов, через который данные ходят в трёх разных форматах, — это уже не шов, а протечка.

Ровно эта последовательность объясняет, почему «функциональное ядро, императивная оболочка» так живуче: оно превращает потенциально O(k²) швов в цепочку из трёх, каждый со своим правилом.

Часть 3. Ось изменчивости: где ООП объективно лучше ФП и наоборот

Это единственный критерий выбора между ООП и ФП, который не сводится к вкусу. Он формализован как expression problem — термин ввёл Филип Уодлер в заметке 1998 года, а сама проблема обсуждалась ещё Джоном Рейнольдсом в 1975-м.

Представьте матрицу: строки — типы данных, столбцы — операции над ними. Программа обязана заполнить все клетки. Разница между парадигмами — в том, как код нарезан на файлы.

Матрица тип на операцию и оси расширения

  • ООП нарезает матрицу по строкам. Класс — это строка: один тип, все его операции. Добавить тип = добавить файл, ничего не трогая (принцип открытости-закрытости работает). Добавить операцию = зайти в каждый класс.
  • ФП с закрытыми суммами нарезает по столбцам. Функция с сопоставлением по образцу — это столбец: одна операция, все типы. Добавить операцию = добавить функцию. Добавить тип = поправить каждую функцию (и компилятор скажет, какие именно — если проверка полноты включена).

Смотрите на код. Одна и та же задача, Python, обе нарезки.

# ── Нарезка по строкам: ООП. Дёшево добавлять типы. ──────────────────────────
from abc import ABC, abstractmethod
from dataclasses import dataclass
import math


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    @abstractmethod
    def perimeter(self) -> float: ...


@dataclass(frozen=True)
class Circle(Shape):
    r: float

    def area(self) -> float:
        return math.pi * self.r ** 2

    def perimeter(self) -> float:
        return 2 * math.pi * self.r


@dataclass(frozen=True)
class Rect(Shape):
    w: float
    h: float

    def area(self) -> float:
        return self.w * self.h

    def perimeter(self) -> float:
        return 2 * (self.w + self.h)


# Новый тип — новый файл, старые не трогаем. Это и есть OCP.
@dataclass(frozen=True)
class Arc(Shape):
    r: float
    angle: float          # в радианах

    def area(self) -> float:
        return 0.5 * self.r ** 2 * self.angle

    def perimeter(self) -> float:
        return self.r * (self.angle + 2)
# ── Нарезка по столбцам: сумма типов + match. Дёшево добавлять операции. ─────
from dataclasses import dataclass
import math


@dataclass(frozen=True)
class Circle:
    r: float


@dataclass(frozen=True)
class Rect:
    w: float
    h: float


Shape = Circle | Rect        # закрытая сумма: список вариантов известен целиком


def area(s: Shape) -> float:
    match s:
        case Circle(r):
            return math.pi * r ** 2
        case Rect(w, h):
            return w * h
    raise AssertionError("неполный разбор")   # mypy при --strict поймает раньше


# Новая операция — новая функция. Ни один тип не тронут.
def to_svg(s: Shape) -> str:
    match s:
        case Circle(r):
            return f'<circle r="{r}"/>'
        case Rect(w, h):
            return f'<rect width="{w}" height="{h}"/>'
    raise AssertionError("неполный разбор")

Теперь практический вывод, ради которого всё это писалось. Посмотрите в свой git log за последний год и посчитайте, чего добавлялось больше: типов или операций.

  • Добавляются типы (новый платёжный провайдер, новый формат импорта, новый вид уведомления, новый драйвер) → интерфейс и полиморфизм. Это классический код расширения.
  • Добавляются операции (новый отчёт по тем же сущностям, новый проход компилятора, новая проверка правил, новая проекция) → закрытая сумма и сопоставление с образцом. Это классический код интерпретации.

Если растут обе оси одновременно, вы упёрлись в саму expression problem. Обходы существуют — тайпклассы в Haskell и Rust (trait + impl для чужого типа), протоколы в Clojure, паттерн «посетитель» с двойной диспетчеризацией, мультиметоды. Все они стоят сложности; включайте их сознательно, а не по умолчанию. Подробный разбор посетителя есть в треке по паттернам проектирования.

Кстати, о цене: динамическая диспетчеризация в ООП — это косвенный вызов через таблицу виртуальных методов, обычно один лишний indirect jump и потенциальный промах предсказателя ветвлений (десятки тактов при плохом раскладе); match по тегу компилируется в переход по таблице или цепочку сравнений и, как правило, дешевле и лучше инлайнится. На горячем пути в миллионы вызовов в секунду это измеримо; в бизнес-логике, где доминирует поход в БД, — шум.

Часть 4. Дерево решений

Соберём метод в один воспроизводимый алгоритм. Он не заменяет мышление, но не даёт забыть вопрос.

Три оговорки к дереву, без которых оно вредно:

  1. Проходите его для модуля, а не для сервиса целиком. Разные ветки для разных модулей — это норма, а не признак хаоса.
  2. Если ветка приводит к парадигме, которой команда не владеет, это ответ «нет». Незнакомая парадигма стоит примерно один-два квартала до продуктивности и оставляет за собой код, который никто не хочет трогать. Формальный критерий: если завтра автор уйдёт в отпуск на месяц, кто починит прод?
  3. Дерево не спрашивает про язык — а он ограничивает. В Go вы не сделаете нормальные АТД с проверкой полноты; в Java до недавнего времени тоже. Это не повод менять язык, это повод выбрать соседнее решение и не воевать с рантаймом. Как язык влияет на доступные ходы, хорошо видно в треках Go, Elixir, TypeScript и C#.

Часть 5. Как выглядит грамотное смешение: разбор одного сервиса

Возьмём конкретику: сервис оформления заказов маркетплейса. Покажу каждый шов кодом.

5.1. Край: парсинг превращает хаос в типы

Принцип называется «parse, don’t validate» (Александр Кинг, 2019). Валидация возвращает bool и оставляет вас с теми же сомнительными данными; парсинг возвращает другой тип, в котором проверка уже нельзя обойти.

// TypeScript. Шов 1: за этой функцией сырых строк больше не существует.
type Brand<T, B extends string> = T & { readonly __brand: B };
type Sku = Brand<string, "Sku">;
type PositiveInt = Brand<number, "PositiveInt">;

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parseSku(raw: unknown): Result<Sku, string> {
  if (typeof raw !== "string" || !/^[A-Z]{2}-\d{6}$/.test(raw)) {
    return { ok: false, error: `невалидный SKU: ${String(raw)}` };
  }
  return { ok: true, value: raw as Sku };
}

function parseQty(raw: unknown): Result<PositiveInt, string> {
  if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
    return { ok: false, error: `количество должно быть целым > 0: ${String(raw)}` };
  }
  return { ok: true, value: raw as PositiveInt };
}

interface OrderLine { sku: Sku; qty: PositiveInt }

// Домен принимает ТОЛЬКО OrderLine. Функция, которая берёт string,
// физически не может быть вызвана из домена — тип не тот.
function parseOrderLine(raw: Record<string, unknown>): Result<OrderLine, string> {
  const sku = parseSku(raw.sku);
  if (!sku.ok) return sku;
  const qty = parseQty(raw.qty);
  if (!qty.ok) return qty;
  return { ok: true, value: { sku: sku.value, qty: qty.value } };
}

Что мы купили: ниже по стеку исчезает целый класс проверок и целый класс багов «а вдруг тут пустая строка». Что заплатили: границу нужно писать руками (или генерировать из схемы — zod, protobuf, OpenAPI), и типы размножаются.

5.2. Домен: чистое ядро, которое решает и не действует

"""Чистое ядро: никакого времени, сети и БД. Только (состояние, запрос) -> решение."""
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Sequence


@dataclass(frozen=True)
class Line:
    sku: str
    qty: int
    unit_price: Decimal


@dataclass(frozen=True)
class Customer:
    id: str
    tier: str                 # "basic" | "silver" | "gold"
    is_blocked: bool


@dataclass(frozen=True)
class Facts:
    """Всё, что оболочка вычитала из внешнего мира ДО вызова ядра."""
    customer: Customer
    stock: dict[str, int]     # sku -> доступный остаток
    now: datetime             # время передаётся как ЗНАЧЕНИЕ, не берётся из datetime.now()


# --- события: что произошло; эффекты: что оболочке надо сделать ---
@dataclass(frozen=True)
class OrderPlaced:
    order_id: str
    total: Decimal


@dataclass(frozen=True)
class Rejected:
    reason: str


@dataclass(frozen=True)
class ReserveStock:
    sku: str
    qty: int


@dataclass(frozen=True)
class SendEmail:
    to: str
    template: str


@dataclass(frozen=True)
class Decision:
    events: tuple[object, ...]
    effects: tuple[object, ...]


DISCOUNT = {"basic": Decimal("0"), "silver": Decimal("0.05"), "gold": Decimal("0.10")}


def place_order(order_id: str, lines: Sequence[Line], facts: Facts) -> Decision:
    """Тотальная функция: любой вход даёт осмысленный Decision, исключений нет."""
    if facts.customer.is_blocked:
        return Decision(events=(Rejected("клиент заблокирован"),), effects=())
    if not lines:
        return Decision(events=(Rejected("пустой заказ"),), effects=())

    missing = [l.sku for l in lines if facts.stock.get(l.sku, 0) < l.qty]
    if missing:
        return Decision(events=(Rejected(f"нет на складе: {', '.join(missing)}"),), effects=())

    subtotal = sum((l.unit_price * l.qty for l in lines), Decimal("0"))
    total = (subtotal * (1 - DISCOUNT[facts.customer.tier])).quantize(Decimal("0.01"))

    return Decision(
        events=(OrderPlaced(order_id, total),),
        effects=tuple(ReserveStock(l.sku, l.qty) for l in lines)
              + (SendEmail(facts.customer.id, "order_placed"),),
    )

Сложность: place_orderO(n) по времени при n позициях в заказе (два прохода по списку) и O(n) по памяти на кортеж эффектов; поиск остатка — O(1) амортизированно по словарю. Никаких сюрпризов, потому что нет ни одного скрытого похода наружу — это, кстати, ещё один аргумент за чистое ядро: его сложность видна прямо в коде, а не прячется в вызове репозитория.

Тест такого ядра выглядит как таблица, и это лучший индикатор, что шов проведён верно:

def test_gold_gets_ten_percent():
    facts = Facts(
        customer=Customer("c1", "gold", is_blocked=False),
        stock={"AB-000001": 10},
        now=datetime(2026, 1, 1),
    )
    d = place_order("o1", [Line("AB-000001", 2, Decimal("100"))], facts)
    assert d.events == (OrderPlaced("o1", Decimal("180.00")),)
    assert ReserveStock("AB-000001", 2) in d.effects
    # ни одного мока: ни БД, ни часов, ни почты

5.3. Оболочка: императивно, скучно, честно

def handle_place_order(req, db, mailer, clock, bus) -> dict:
    """Императивная оболочка: собрать факты, вызвать ядро, исполнить эффекты."""
    with db.transaction():                                   # шов 3 начинается тут
        customer = db.load_customer(req.customer_id)
        skus = [l.sku for l in req.lines]
        stock = db.load_stock_for_update(skus)               # SELECT ... FOR UPDATE

        facts = Facts(customer=customer, stock=stock, now=clock.now())
        decision = place_order(req.order_id, req.lines, facts)

        for event in decision.events:
            db.append_event(req.order_id, event)             # источник истины — события

        for effect in decision.effects:                      # интерпретатор эффектов
            match effect:
                case ReserveStock(sku, qty):
                    db.decrement_stock(sku, qty)
                case SendEmail(to, template):
                    bus.enqueue_after_commit(to, template)   # НЕ отправляем внутри транзакции
    return {"status": "ok", "order_id": req.order_id}

Обратите внимание на enqueue_after_commit. Это типичный баг смешения: эффект, исполненный внутри транзакции, при откате уже улетел наружу. Письмо о заказе, которого нет, — классика. Правильный ход — паттерн transactional outbox: эффект записывается в ту же транзакцию как строка в таблице, а отправляет его отдельный воркер. Разбор есть у Криса Ричардсона.

5.4. Правила, которые меняет бизнес, — декларативно

Таблица DISCOUNT в коде выше — упрощение. Как только скидок становится десяток и они зависят от категории, региона, промокода и дня недели, императивный if превращается в болото. Здесь окупается декларативность: правила становятся данными, а код — интерпретатором.

-- Правила как строки в таблице: бизнес меняет их без релиза.
CREATE TABLE discount_rule (
    id           bigserial PRIMARY KEY,
    priority     int         NOT NULL,     -- меньше = раньше
    tier         text        NULL,         -- NULL = «любой»
    category     text        NULL,
    min_subtotal numeric     NULL,
    valid_from   timestamptz NOT NULL,
    valid_to     timestamptz NOT NULL,
    percent      numeric     NOT NULL CHECK (percent BETWEEN 0 AND 90)
);

-- Выбор применимого правила — декларативный запрос, а не цикл в приложении.
SELECT percent
  FROM discount_rule
 WHERE (tier     IS NULL OR tier = $1)
   AND (category IS NULL OR category = $2)
   AND (min_subtotal IS NULL OR $3 >= min_subtotal)
   AND $4 BETWEEN valid_from AND valid_to
 ORDER BY priority
 LIMIT 1;

Trade-off честный и его надо озвучивать вслух: вы получили изменяемость без релиза и потеряли типобезопасность и покрытие тестами. Правила из БД обязаны иметь: версионирование, аудит изменений, предпросмотр эффекта («сколько заказов вчера попало бы под это правило») и тесты на самом движке правил. Иначе через год у вас 400 строк с пересекающимися условиями и никто не знает, какая сработает. Подробнее про эту парадигму — в статье про декларативное программирование.

5.5. Конкурентность: акторы там, где состояние принадлежит сущности

Резервирование позиций на складе можно защитить строчной блокировкой в БД — и для большинства нагрузок это правильный, скучный, работающий ответ. Но когда речь идёт о долгоживущем состоянии одной сущности с высокой частотой обновлений — торговая сессия, партия аукциона, состояние устройства — акторы дают то, чего БД не даёт: сериализация доступа без похода на диск.

# Elixir: один процесс на аукционный лот. Состояние внутри, гонок нет по построению.
defmodule Auction.Lot do
  use GenServer

  # ── Клиентский API ────────────────────────────────────────────────────────
  def start_link(lot_id), do: GenServer.start_link(__MODULE__, lot_id, name: via(lot_id))
  def bid(lot_id, bidder, amount), do: GenServer.call(via(lot_id), {:bid, bidder, amount})

  defp via(lot_id), do: {:via, Registry, {Auction.Registry, lot_id}}

  # ── Сервер ────────────────────────────────────────────────────────────────
  @impl true
  def init(lot_id), do: {:ok, %{lot_id: lot_id, best: nil, amount: 0}}

  @impl true
  def handle_call({:bid, bidder, amount}, _from, state) do
    # Вся логика — вызов ЧИСТОЙ функции. GenServer только хранит и сериализует.
    case Auction.Rules.apply_bid(state, bidder, amount) do
      {:ok, new_state, event} ->
        :ok = Auction.Bus.publish(event)         # эффект — после решения
        {:reply, {:ok, event}, new_state}

      {:error, reason} ->
        {:reply, {:error, reason}, state}        # состояние не изменилось
    end
  end
end

Ключевая строчка тут — Auction.Rules.apply_bid. Даже внутри актора логика вынесена в чистую функцию: актор отвечает за владение состоянием и порядок, а не за правила. Это тот же шов «ядро/оболочка», просто оболочкой служит процесс. Такое сочетание — акторы снаружи, ФП внутри — и есть штатный стиль всей экосистемы BEAM; подробности в треке Elixir и в статье про конкурентные парадигмы.

Аналогичный приём в Go выглядит как CSP-оболочка вокруг чистой функции:

// Go: горутина владеет состоянием, канал сериализует доступ.
type cmd struct {
    bid   Bid
    reply chan Result // ответ конкретному отправителю
}

func RunLot(ctx context.Context, initial State, cmds <-chan cmd) {
    state := initial
    for {
        select {
        case <-ctx.Done():
            return
        case c := <-cmds:
            // ApplyBid — чистая функция: (State, Bid) -> (State, Result).
            newState, res := ApplyBid(state, c.bid)
            state = newState
            c.reply <- res
        }
    }
}

Правило безопасности для обеих реализаций: сообщения должны быть неизменяемыми значениями. В Elixir это гарантирует рантайм. В Go — только дисциплина: передали слайс или указатель на общую структуру — и вернули себе те самые гонки, ради избавления от которых всё затевалось. Отсюда практическое требование к шву: через канал ходят либо значения, либо копии, а go test -race в CI обязателен.

5.6. Сквозное: аспекты и метапрограммирование на своём месте

# Декоратор как аспект: трассировка и метрики не попадают в доменный код.
import functools
import time


def traced(span_name: str):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            started = time.perf_counter()
            try:
                return fn(*args, **kwargs)
            finally:
                elapsed_ms = (time.perf_counter() - started) * 1000
                METRICS.observe(span_name, elapsed_ms)
        return wrapper
    return decorator


@traced("place_order")
def handle_place_order(req, db, mailer, clock, bus):
    ...

Граница дозволенного для аспектов простая: аспект не должен менять результат. Логирование, метрики, трассировка, кэш с прозрачной семантикой — можно. Аспект, который перехватывает исключение и подменяет ответ, или транзакция, «магически» открывающаяся по аннотации в неожиданном месте, — это невидимая логика, и в стектрейсе на проде вы её проклянёте. Разбор рисков — в статье про аспектную парадигму.

Метапрограммирование в проде оправдано ровно тогда, когда оно генерирует скучный код на границах: клиенты API из OpenAPI/protobuf, сериализаторы, маппинг БД. Как только кодогенерация начинает порождать бизнес-логику, вы получили второй язык без отладчика и IDE. Об этом — статья про обобщённое программирование и мета.

Часть 6. Цена смешения: чем платим

Смешение не бесплатно. Считайте эти статьи расходов явно, иначе они всплывут через год.

Когнитивная нагрузка. Разработчик должен держать в голове не только каждую парадигму, но и правило перехода на каждом шве. Грубая модель: усилие ≈ Σ(сложность парадигмы) + Σ(сложность шва), причём второе слагаемое обычно больше. Практический предел для команды из 5–8 человек — три-четыре парадигмы с явными швами.

Отладка. Стектрейс в чистом коде читается сверху вниз. В реактивном — это цепочка операторов без исходных кадров. В акторной системе трассировка разрывается на границе сообщения. Смешивая, вы обязаны компенсировать: сквозной correlation id, контекст трассировки, передаваемый через сообщения, и структурные логи. Это не опция, а часть цены.

Производительность на швах. Каждый перевод между парадигмами — это работа. Копирование сообщения актору — O(n) по размеру. Конвертация ORM-сущности в неизменяемый доменный объект и обратно — две аллокации на объект. Персистентная структура вместо мутабельного массива — O(log₃₂ n) вместо O(1) на обновление, что практически означает константу в 2–6 раз хуже плюс давление на GC. По отдельности мелочь; на горячем пути в 100k rps — статья бюджета. Правило: швы допустимы там, где они не в самом внутреннем цикле. Внутри горячего цикла парадигма должна быть одна, и обычно императивная — об этом статья про императивную парадигму.

Найм и онбординг. Каждая экзотическая парадигма сокращает пул людей, способных поддерживать код. Это не аргумент «писать всё на процедурах» — это аргумент за то, чтобы экзотика была локализована в модуле с чёткой границей, а не размазана.

Инструменты. Профилировщик, отладчик, линтер и IDE понимают императивный код лучше всего. Реактивные цепочки, макросы и DSL нередко остаются вне их зоны. Проверьте до внедрения, а не после.

Часть 7. Типичные ошибки смешения

Каждая из них встречается в проде регулярно.

  1. «Функциональный» код поверх мутабельного ORM. Доменные объекты выглядят неизменяемыми, но это прокси-объекты сессии Hibernate/SQLAlchemy: они лениво ходят в БД при обращении к полю и мутируются при коммите. Все гарантии чистоты фиктивны. Лечится явным маппингом: сущность ORM → доменное значение → сущность ORM.
  2. Акторы поверх общей БД без границ. Каждый актор владеет своим состоянием, но все пишут в одну таблицу без агрегатных границ — гонки вернулись, просто теперь их не видит -race, зато видит пользователь.
  3. Реактивщина в CRUD. Форма с пятью полями, обёрнутая в пять потоков и три оператора комбинирования. Реактивность окупается при множественных асинхронных источниках и требовании отмены; на запросе «загрузить и показать» она чистый убыток.
  4. DSL, который понимает один человек. Внутренний язык правил экономит строки и создаёт узкое место в лице автора. Критерий допустимости: у DSL есть документация, тесты, сообщения об ошибках с указанием строки и как минимум два человека, способных его развивать.
  5. Наследование как способ переиспользовать код. Классическая ошибка на стыке ООП и процедурности: базовый класс становится свалкой утилит, а иерархия — жёсткой. Композиция и явные зависимости почти всегда лучше; см. статью про ООП и принцип подстановки Лисков.
  6. Эффекты внутри чистого ядра «только один разочек». Один datetime.now(), один поход в кэш — и тесты начинают мигать, а решение становится невоспроизводимым. Пропускать время и случайность внутрь как значения — не педантизм, а условие детерминизма.
  7. Смена парадигмы без удаления старой. Начали переезд на события, старый синхронный путь оставили «на всякий случай». Через полгода два источника истины расходятся. Миграция считается завершённой только после удаления старого пути.
  8. Парадигма выбрана под резюме, а не под задачу. Самый дорогой вариант: код никто не поддерживает, а переписать «уже вложились».

Часть 8. Как мигрировать между парадигмами, не останавливая продукт

Смена парадигмы в живой системе — это не рефакторинг на неделю, а процесс с фазами. Полезно относиться к нему как к жизненному циклу с явными состояниями и явным правом на откат.

Практические приёмы, доказавшие себя:

  • Strangler fig (термин Мартина Фаулера): новый код обрастает вокруг старого через фасад, старый отмирает по кусочкам. Для смены парадигмы фасадом служит именно шов.
  • Начинайте с самого «решающего» модуля. Первым в чистое ядро выносят расчёт цены, скоринг, правила доступа — то, что уже сейчас тяжело тестировать из-за моков. Выигрыш заметен сразу, риск минимален.
  • Характеризационные тесты до рефакторинга. Прежде чем менять парадигму модуля, зафиксируйте его текущее поведение тестами на реальных входах (Майкл Фезерс, «Working Effectively with Legacy Code»). Иначе вы не рефакторите, а переписываете вслепую.
  • Правило «одна парадигма — один PR». Смешивать смену стиля с исправлением бага — гарантированный способ не понять, что именно сломалось.

Как проект дрейфует, если этим не управлять:

Смысл диаграммы не в том, что дрейф — зло. Дрейф неизбежен: система усложняется, и парадигмы приходят вслед за реальными требованиями. Плохо, когда они приходят без швов, и на 24-м месяце оказывается, что в системе четыре стиля и ни одной границы.

Часть 9. Комбинации, которые работают в проде

Не гипотетические, а те, что стоят под нагрузкой у известных компаний.

Комбинация Где применяется Что даёт
Функциональное ядро + императивная оболочка Ruby/Python/Java-бэкенды, доклад Гэри Бернхардта тесты без моков, детерминизм логики
Акторы + чистые функции внутри Erlang/Elixir, WhatsApp, Discord, Ericsson AXD301 изоляция отказов, миллионы процессов
CSP + неизменяемые сообщения Go-сервисы: Kubernetes, Docker, Prometheus простая конкурентность, -race в CI
Реактивность на границе + императив внутри Netflix (RxJava), Project Reactor, WebFlux backpressure и отмена без блокировки потоков
Декларативное состояние + контроллеры согласования Kubernetes, Terraform, ArgoCD идемпотентность, воспроизводимость
ФП + однонаправленный поток данных React/Redux, Elm Architecture воспроизводимый UI, time-travel debugging
Event sourcing + CQRS + проекции банковские и биржевые системы, LMAX Disruptor аудит, реплей, разделение записи и чтения
ООП-агрегаты + декларативный SQL классический DDD-бэкенд инварианты в коде, выборки в БД
Датафрейм-декларативность + императивные UDF Spark, Pandas, Polars, см. трек Data Engineering оптимизатор запросов + гибкость там, где надо
Обобщённое программирование + владение Rust: сервисы, embedded, компиляторы ноль стоимости абстракций, отсутствие гонок

Две поучительные истории для калибровки ожиданий.

Elm Architecture и Redux. Идея (state, action) -> state вместе с неизменяемостью пришла в JavaScript-мир из ФП и стала индустриальным стандартом на годы. Но затем маятник качнулся: сообщество осознало, что для серверного состояния (кэш, инвалидация, повторные запросы) редьюсеры — лишний слой, и появились специализированные решения. Мораль: парадигма, выигравшая на одной задаче, не обязана выигрывать на соседней, даже в том же приложении. Клиентское состояние и серверный кэш — разные подсистемы, у них разные ответы.

Twitter и Scala. Переход на JVM и функциональный стиль с Future дал масштабируемость, но принёс собственные издержки: сложность типов и порог входа. Компания вложилась в внутренние библиотеки (Finagle) и обучение — то есть заплатила ту самую цену онбординга явно и осознанно. Урок не «Scala плохая», а «цена парадигмы существует и должна быть заложена в бюджет».

Часть 10. Чек-лист перед решением

Распечатайте и применяйте на дизайн-ревью. Каждый пункт — вопрос, у которого должен быть ответ вслух.

  1. Какую гарантию я покупаю этим ограничением? Если ответ «так модно» — стоп.
  2. Какую боль это лечит? Есть ли она измеренная (время на баг, доля флаки-тестов, число инцидентов) или предполагаемая?
  3. Какой модуль затрагивается? Если ответ «весь проект» — дробите.
  4. Что чаще меняется — типы или операции? Ответ определяет ООП или суммы с match.
  5. Где пройдёт шов и какой тип им владеет? Если шов не называется одним типом — его нет.
  6. Кто владеет состоянием по ту сторону шва и как он сериализует доступ?
  7. Сколько парадигм окажется в модуле после изменения? Больше двух — обоснуйте.
  8. Как это будет отлаживаться в 3 часа ночи? Есть ли стектрейс, correlation id, логи?
  9. Кто ещё в команде сможет это поддерживать? Назовите двух людей поимённо.
  10. Как это откатить, если через месяц окажется, что не окупилось?
  11. Что будет удалено после успешной миграции и когда?
  12. Записан ли выбор в ADR — с контекстом, вариантами и последствиями? Формат см. у Майкла Найгарда.

Мини-итог

  • Парадигма выбирается для модуля, а не для проекта; в здоровой системе их несколько.
  • Выбор — это выбор гарантии, которую вы получаете по построению, и цены, которую за неё платите.
  • Между ООП и ФП решает ось изменчивости: типы растут — полиморфизм; операции растут — суммы и сопоставление с образцом. Это expression problem, и она формальна.
  • Вредно не число парадигм, а число неохраняемых швов. Организуйте парадигмы слоями, у каждого шва — один владеющий тип и одно правило перевода.
  • Базовая, почти всегда выигрышная комбинация: реактивный край, императивная оболочка сценариев, чистое ядро домена, декларативная инфраструктура, сквозное — аспектами.
  • Эффекты не исполняются внутри транзакции и внутри чистого ядра; время и случайность передаются как значения.
  • Швы стоят производительности — не ставьте их внутри горячего цикла.
  • Миграция парадигмы — это фазы с правом на откат и обязательным удалением старого пути. Незавершённая миграция хуже, чем её отсутствие.

Источники

  • Robert W. Floyd. The Paradigms of Programming (Turing Award Lecture, CACM 1979) — первоисточник самого понятия.
  • Philip Wadler. The Expression Problem (1998) — формулировка оси изменчивости.
  • Robert C. Martin. «Clean Architecture» (2017), гл. 3–6 — парадигмы как система запретов.
  • Gary Bernhardt. Boundaries — доклад, из которого пошло «functional core, imperative shell».
  • Alexis King. Parse, don’t validate (2019) — как типы охраняют шов.
  • Michael Feathers. «Working Effectively with Legacy Code» (2004) — характеризационные тесты и понятие шва (seam) в исходном смысле.
  • Martin Fowler. Strangler Fig Application и The LMAX Architecture.
  • Eric Evans. «Domain-Driven Design» (2003) — агрегаты как единицы согласованности; см. также трек DDD.
  • Chris Richardson. Transactional Outbox — как не отправить письмо о заказе, который откатился.
  • Michael Nygard. Documenting Architecture Decisions — формат ADR.
  • Peter Van Roy, Seif Haridi. Concepts, Techniques, and Models of Computer Programming — самая систематическая книга о совместном использовании моделей вычислений; плакат с картой парадигм.
  • Rich Hickey. Simple Made Easy — про то, почему «простое» и «привычное» — разные вещи, и как это влияет на выбор.
  • Joe Armstrong. Making reliable distributed systems in the presence of software errors — диссертация о том, как ограничение (никакой общей памяти) даёт отказоустойчивость.
  • Fred Brooks. No Silver Bullet (1986) — почему ни одна парадигма не даст порядкового улучшения, и что с этим делать.

Что дальше

На этом трек «Парадигмы разработки» закончен. Вы прошли путь от карты парадигм до метода их осознанного смешения — и главный вывод здесь не в списке технологий, а в оптике: любое инженерное решение можно читать как выбор ограничения и цены, которую вы за него платите.

Куда идти дальше, в зависимости от того, чего не хватает:

  • Не хватает фундамента под кодом. Парадигмы говорят, как организовать программу; алгоритмы и структуры данных — что именно она делает и за какую цену. Начните с трека Алгоритмы и Структуры данных — там же разбираются персистентные структуры, без которых неизменяемость остаётся лозунгом.
  • Не хватает правил хорошего кода на уровне модуля. Трек Принципы разработки — SOLID, связность, зацепление, и почему они следуют из того же обмена «свобода за гарантию».
  • Не хватает готовых решений типовых задач. Паттерны проектирования и Архитектурные паттерны, а для моделирования сложного домена — DDD.
  • Хочется закрепить парадигмы практикой в конкретном языке. Go — CSP и минимализм; Elixir — акторы и ФП; TypeScript — типы как инструмент проектирования; C# — мультипарадигменность в промышленном исполнении.
  • Нужен маршрут целиком. Общая карта портала и порядок изучения — в дорожной карте.

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

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

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

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

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