Как выбирать и смешивать парадигмы в реальном проекте
Девять статей трека мы разбирали парадигмы поодиночке: императивную, объектно-ориентированную, функциональную, декларативную и логическую, конкурентные, реактивную, обобщённую и мета, аспектную и событийную. Каждая была показана в своей лучшей форме — на задаче, под которую она создавалась.
Реальный проект так не устроен. В нём одновременно живут: парсинг входящего JSON, три бизнес-правила с исключениями, транзакция в Postgres, фоновая выгрузка в аналитику, вебсокет с котировками, ретраи к платёжному провайдеру и админка на CRUD. Ни одна парадигма не выигрывает на всех семи задачах. Вопрос «на какой парадигме мы пишем» — неправильно поставленный. Правильный звучит так: какое ограничение купит мне больше всего гарантий именно здесь, в этой подсистеме, и во что мне обойдётся граница с соседней подсистемой, где ограничение другое.
Эта статья — метод ответа на этот вопрос. Она про решения, а не про синтаксис.
Часть 1. Что вы на самом деле выбираете
Парадигма — это не про код, а про то, что нельзя сломать
Напомню рамку из обзорной статьи: парадигма отнимает свободу и взамен даёт гарантию. Значит, выбор парадигмы — это выбор гарантии, которую вы хотите получить бесплатно, то есть по построению, а не тестами и код-ревью.
Список гарантий, за которыми люди приходят на практике, довольно короткий:
| Нужная гарантия | Кто её даёт по построению | Цена |
|---|---|---|
| «Функция при тех же входах вернёт то же самое» | чистые функции, неизменяемость | нет мутации на месте, аллокации, персистентные структуры |
| «Гонок данных нет» | акторы, CSP, ownership в Rust | копирование сообщений, отсутствие быстрого доступа к общему состоянию |
| «Инвариант объекта не нарушить снаружи» | инкапсуляция ООП | косвенность, сложнее сериализовать и сравнивать |
| «Невалидное состояние невыразимо» | алгебраические типы, newtype | больше кода на границах, больше типов |
| «Новый вид сущности добавляется без правки старого кода» | полиморфизм, интерфейсы | тяжело добавлять операции, динамическая диспетчеризация |
| «Новая операция добавляется без правки типов» | закрытые суммы + сопоставление с образцом | тяжело добавлять типы |
| «Решение переносимо и оптимизируется движком» | SQL, Datalog, солверы, IaC | вы теряете контроль над планом выполнения |
| «Изменение источника автоматически доходит до всех потребителей» | реактивность, dataflow | сложная отладка, глитчи, утечки подписок |
| «Сквозная логика не размазана по коду» | аспекты, middleware, декораторы | неявность, «магия» в стектрейсе |
Обратите внимание: это ровно тот же список, что в статье про принципы проектирования, только сформулированный на уровне языка, а не на уровне договорённостей команды. В этом и разница между принципом и парадигмой: принцип можно нарушить в пятницу вечером, парадигму — нет, компилятор не даст.
Три вопроса, которые нужно задать подсистеме
Прежде чем выбирать, опишите подсистему по трём осям. Это занимает пять минут и отсекает 80 % вариантов.
- Как живёт состояние? Его нет (чистое преобразование)? Оно эфемерно (живёт в рамках запроса)? Оно долгоживущее и принадлежит одной сущности (сессия, заказ, устройство)? Оно общее для всех и должно быть согласованным (баланс, склад)?
- Что чаще меняется — множество типов или множество операций? Об этом отдельный разговор в части 3: эта ось определяет выбор между ООП и ФП жёстче, чем любые вкусовые предпочтения.
- Кто задаёт порядок? Программист (последовательный алгоритм)? Внешний мир (события, конкурентные запросы)? Данные (граф зависимостей, пайплайн)? Движок (оптимизатор, планировщик, резолюция)?
Диаграмма не догма, а быстрый навигатор: она говорит, откуда начинать думать, а не чем закончить.
Часть 2. Гранулярность: парадигма выбирается для модуля, а не для репозитория
Самая частая методологическая ошибка — выбирать парадигму на уровне проекта. «Мы делаем функциональный бэкенд» — это заявление примерно такой же полезности, как «мы делаем быстрый бэкенд». Быстрый — что именно? Функциональный — где именно, в слое HTTP-роутинга тоже?
Практическое правило: единица выбора парадигмы — модуль с собственной причиной изменения. То есть примерно то же зерно, на котором вы применяете принцип единственной ответственности. В типичном сервисе таких зёрен четыре-шесть, и в них естественно оказываются разные парадигмы.
Разберём картинку по слоям, потому что каждая линия там — это решение, а не украшение.
Край. Здесь правит внешний мир: соединения открываются и рвутся, сообщения приходят пачками, потребитель может быть медленнее продюсера. Событийная и реактивная парадигмы тут не выбор стиля, а необходимость: вам нужны подписки, отмена, таймауты и backpressure. Пытаться писать край в чисто функциональном стиле — значит имитировать мир, который принципиально грязный.
Оболочка приложения. Сценарий использования — это по сути процедура: сходи туда, прочитай это, вызови ядро, запиши результат, отправь событие, обработай ошибку. Императивный код здесь честнее любого другого. Попытка завернуть сценарий в трансформеры монад делает его короче в строках и длиннее в часах на онбординг.
Домен. Здесь вы решаете, а не действуете. Значит, здесь можно позволить себе самое дорогое и самое ценное ограничение — отсутствие эффектов. Функциональное ядро проверяется таблицей примеров, а не моками; его можно прогнать сто тысяч раз в property-based тесте; его можно перенести в другой рантайм.
Инфраструктура. Тут выигрывает декларативность: SQL вместо цикла по строкам, миграции вместо ручных ALTER, контроллер согласования вместо скрипта развёртывания.
Сквозное. Трассировка, метрики, аудит, сериализация — то, что не должно попадать в бизнес-код. Аспекты, middleware, декораторы и кодогенерация тут на своём месте.
Правило швов
Ключевая мысль всей статьи: вредно не количество парадигм, а количество неохраняемых переходов между ними.
Стоимость понимания системы растёт не как число парадигм k, а примерно как число пар соседних областей, между которыми происходит перевод. Если каждый модуль граничит с каждым, это O(k²) швов; если система выстроена слоями, это O(k). Отсюда два инженерных следствия:
- парадигмы должны быть организованы иерархически (слоями), а не мозаично («тут у нас акторы, а вон в том хелпере — монада State»);
- у каждого шва должен быть один владеющий тип и одно правило перевода. Шов, через который данные ходят в трёх разных форматах, — это уже не шов, а протечка.
дальше идёт только OrderRequest E->>S: OrderRequest (валидный тип) S->>DB: SELECT ... FOR UPDATE (собрать факты) DB-->>S: Customer, Inventory, PriceList Note over S,D: ШОВ 2 — факты внутрь, команды наружу S->>D: decide(state, request) D-->>S: Decision(events=[...], effects=[...]) Note over S,D: ядро не знает про время, сеть и БД S->>DB: INSERT события, UPDATE остатков (одна транзакция) S->>E: OrderAccepted E-->>C: 201 Created S--)E: publish(OrderPlaced) — асинхронно, после коммита
Ровно эта последовательность объясняет, почему «функциональное ядро, императивная оболочка» так живуче: оно превращает потенциально 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. Дерево решений
Соберём метод в один воспроизводимый алгоритм. Он не заменяет мышление, но не даёт забыть вопрос.
собственное состояние?} B -- нет, чистое преобразование --> C[Чистые функции и пайплайн
ФП, dataflow] B -- да --> D{Кто владеет состоянием?} D -- одна сущность,
долгоживущая --> E{Нужна ли
конкурентность?} D -- общее для всех,
нужна согласованность --> F[Транзакции в БД
ООП-агрегат + декларативный SQL] E -- да, много
независимых сущностей --> G[Акторы: состояние внутри процесса
сообщения снаружи] E -- нет --> H[ООП-объект с инкапсуляцией
инварианта] C --> I{Что меняется чаще?} H --> I G --> I F --> I I -- добавляются типы --> J[Интерфейсы и полиморфизм] I -- добавляются операции --> K[Закрытая сумма и match] I -- меняются правила,
а не код --> L[Декларативные правила:
таблица, DSL, Datalog, солвер] J --> M{Логика сквозная
для многих модулей?} K --> M L --> M M -- да, и она не про домен --> N[Аспекты, middleware,
декораторы, кодогенерация] M -- нет --> O[Оставить в модуле] N --> P{Данные должны
сами доходить
до потребителей?} O --> P P -- да, push и много
подписчиков --> Q[Реактивность и события
с явным backpressure] P -- нет, pull по запросу --> R[Обычные вызовы]
Три оговорки к дереву, без которых оно вредно:
- Проходите его для модуля, а не для сервиса целиком. Разные ветки для разных модулей — это норма, а не признак хаоса.
- Если ветка приводит к парадигме, которой команда не владеет, это ответ «нет». Незнакомая парадигма стоит примерно один-два квартала до продуктивности и оставляет за собой код, который никто не хочет трогать. Формальный критерий: если завтра автор уйдёт в отпуск на месяц, кто починит прод?
- Дерево не спрашивает про язык — а он ограничивает. В 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_order — O(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. Типичные ошибки смешения
Каждая из них встречается в проде регулярно.
- «Функциональный» код поверх мутабельного ORM. Доменные объекты выглядят неизменяемыми, но это прокси-объекты сессии Hibernate/SQLAlchemy: они лениво ходят в БД при обращении к полю и мутируются при коммите. Все гарантии чистоты фиктивны. Лечится явным маппингом: сущность ORM → доменное значение → сущность ORM.
- Акторы поверх общей БД без границ. Каждый актор владеет своим состоянием, но все пишут в одну таблицу без агрегатных границ — гонки вернулись, просто теперь их не видит
-race, зато видит пользователь. - Реактивщина в CRUD. Форма с пятью полями, обёрнутая в пять потоков и три оператора комбинирования. Реактивность окупается при множественных асинхронных источниках и требовании отмены; на запросе «загрузить и показать» она чистый убыток.
- DSL, который понимает один человек. Внутренний язык правил экономит строки и создаёт узкое место в лице автора. Критерий допустимости: у DSL есть документация, тесты, сообщения об ошибках с указанием строки и как минимум два человека, способных его развивать.
- Наследование как способ переиспользовать код. Классическая ошибка на стыке ООП и процедурности: базовый класс становится свалкой утилит, а иерархия — жёсткой. Композиция и явные зависимости почти всегда лучше; см. статью про ООП и принцип подстановки Лисков.
- Эффекты внутри чистого ядра «только один разочек». Один
datetime.now(), один поход в кэш — и тесты начинают мигать, а решение становится невоспроизводимым. Пропускать время и случайность внутрь как значения — не педантизм, а условие детерминизма. - Смена парадигмы без удаления старой. Начали переезд на события, старый синхронный путь оставили «на всякий случай». Через полгода два источника истины расходятся. Миграция считается завершённой только после удаления старого пути.
- Парадигма выбрана под резюме, а не под задачу. Самый дорогой вариант: код никто не поддерживает, а переписать «уже вложились».
Часть 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. Чек-лист перед решением
Распечатайте и применяйте на дизайн-ревью. Каждый пункт — вопрос, у которого должен быть ответ вслух.
- Какую гарантию я покупаю этим ограничением? Если ответ «так модно» — стоп.
- Какую боль это лечит? Есть ли она измеренная (время на баг, доля флаки-тестов, число инцидентов) или предполагаемая?
- Какой модуль затрагивается? Если ответ «весь проект» — дробите.
- Что чаще меняется — типы или операции? Ответ определяет ООП или суммы с match.
- Где пройдёт шов и какой тип им владеет? Если шов не называется одним типом — его нет.
- Кто владеет состоянием по ту сторону шва и как он сериализует доступ?
- Сколько парадигм окажется в модуле после изменения? Больше двух — обоснуйте.
- Как это будет отлаживаться в 3 часа ночи? Есть ли стектрейс, correlation id, логи?
- Кто ещё в команде сможет это поддерживать? Назовите двух людей поимённо.
- Как это откатить, если через месяц окажется, что не окупилось?
- Что будет удалено после успешной миграции и когда?
- Записан ли выбор в 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# — мультипарадигменность в промышленном исполнении.
- Нужен маршрут целиком. Общая карта портала и порядок изучения — в дорожной карте.
Финальный совет, если оставить только один: не ищите правильную парадигму — ищите правильный шов. Парадигму почти всегда можно заменить позже, если граница проведена честно. Плохо проведённую границу приходится переделывать вместе со всей системой.