Архитектура на ФП: функциональное ядро и императивная оболочка, ресурсы
Пятнадцать статей мы разбирали инструменты по одному: чистые функции, неизменяемость, композиция, ADT, функторы, монады, эффекты, конкурентность. Каждый инструмент по отдельности выглядит убедительно. Но между «я умею писать map и Result» и «у меня работает сервис на 200 тысяч строк, который поддерживают восемь человек» лежит пропасть, и заполняется она не абстракциями, а решением о том, где в системе проходит граница между чистым и грязным.
Эта статья — про эту границу. Она отвечает на вопрос, который задаёт каждый, кто дочитал курс до конца: «Хорошо, чистые функции прекрасны. Но моя программа состоит из походов в базу, HTTP-вызовов и записи в Kafka. Куда девать всё это?»
Ответ известен и до неприличия прост, но его последствия тянутся через весь дизайн системы, через тесты, через найм и через производительность. Разберём и ответ, и последствия — включая те, о которых в докладах на конференциях говорить не любят.
Обзорный взгляд на парадигму есть в статье https://courses.digitable.life/post/paradigms/03-functional/; здесь мы уже не сравниваем ФП с ООП, а собираем работающее приложение.
Сначала боль: почему «просто пишите чистые функции» не работает
Возьмём типичный обработчик — продление подписки. Так его пишут по умолчанию:
def renew_subscription(sub_id: int) -> None:
sub = db.get_subscription(sub_id) # эффект
if sub.status == "cancelled": # решение
return
plan = db.get_plan(sub.plan_id) # эффект
now = datetime.now(timezone.utc) # эффект
if sub.period_end > now: # решение
return
discount = 0
if sub.months_active >= 12: # решение
discount = 10
if plan.promo_until and plan.promo_until > now: # решение
discount = max(discount, plan.promo_discount)
amount = plan.price * (100 - discount) // 100 # решение
if sub.balance >= amount: # решение
db.update_balance(sub_id, sub.balance - amount) # эффект
db.extend_period(sub_id, plan.period_days) # эффект
queue.publish("subscription.renewed", ...) # эффект
else:
charge = payments.charge(sub.card_id, amount) # эффект
if charge.ok: # решение
db.extend_period(sub_id, plan.period_days) # эффект
queue.publish("subscription.renewed", ...) # эффект
else:
db.mark_past_due(sub_id) # эффект
mailer.send(sub.email, "payment_failed") # эффект
Функция короткая и вроде бы понятная. Проблема в том, что решения и эффекты перемешаны построчно. Отсюда всё остальное:
- Чтобы проверить формулу скидки, нужно поднять базу. Или замокать четыре зависимости. Тест на арифметику превращается в тест на инфраструктуру.
- Тесты проверяют не то. Вместо «при 12 месяцах и промо берётся большая скидка» тест говорит «был вызван
db.update_balanceс аргументом 900». Поменяли реализацию, не меняя поведения, — тест упал. - Ветвлений нельзя перечислить. Сколько здесь путей? Считать приходится глазами, а комбинации
status × promo × balance × charge.okникто не покроет. - Логику нельзя переиспользовать. Понадобилось показать пользователю «сколько с вас спишут 1-го числа» — либо дублируем формулу, либо зовём функцию, которая полезет в базу и спишет деньги.
- Ничего нельзя пересчитать задним числом. «А сколько бы мы взяли по прошлым правилам?» — вопрос, на который система не может ответить, потому что расчёт неотделим от записи.
Заметьте: ни один из этих пунктов не про «мутабельность плохо». Все — про то, что дорогая часть (решения) заперта внутри дешёвой части (перекладывания байтов).
Инструмент: функциональное ядро, императивная оболочка
Термин ввёл Гэри Бернхардт в докладе Boundaries (2012) — до сих пор лучшие 40 минут на эту тему. Идея в одном предложении:
Вся логика живёт в чистых функциях без зависимостей. Весь ввод-вывод живёт в тонком слое, который не принимает решений.
Марк Симанн называет ту же структуру impureim-сэндвичем: грязное чтение → чистое вычисление → грязная запись. Мы разбирали её механику в https://courses.digitable.life/post/functional-programming/13-effects-and-io/; здесь смотрим на неё как на архитектуру целого приложения, а не одной функции.
Ключевой признак правильно нарезанной системы: в ядре нет ни одного await, ни одного import драйвера, ни одного обращения к часам и генератору случайных чисел. Если это выполнено, ядро можно вызвать миллион раз в цикле в тесте, и ничего не произойдёт.
Переписываем пример
Сначала описываем домен типами. Каноническая запись — на Haskell, она короче всего показывает суть:
data Decision
= Skip Reason
| PayFromBalance Money Period
| ChargeCard CardId Money Period
deriving (Eq, Show)
decideRenewal :: Subscription -> Plan -> UTCTime -> Decision
Сигнатура уже рассказывает всё: на входе три значения, на выходе — описание того, что надо сделать, а не сделанное действие. Тип Decision — сумма-тип (https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/), поэтому компилятор заставит оболочку разобрать все случаи.
Теперь то же самое на TypeScript, идиоматично:
// ---------- ЯДРО: ни одного импорта инфраструктуры ----------
export type Decision =
| { kind: "skip"; reason: "cancelled" | "not_due" }
| { kind: "pay_from_balance"; amount: number; newPeriodEnd: Date }
| { kind: "charge_card"; cardId: string; amount: number; newPeriodEnd: Date };
export function discountPercent(sub: Subscription, plan: Plan, now: Date): number {
const loyalty = sub.monthsActive >= 12 ? 10 : 0;
const promo = plan.promoUntil && plan.promoUntil > now ? plan.promoDiscount : 0;
return Math.max(loyalty, promo); // отдельная функция — её и тестируем отдельно
}
export function decideRenewal(sub: Subscription, plan: Plan, now: Date): Decision {
if (sub.status === "cancelled") return { kind: "skip", reason: "cancelled" };
if (sub.periodEnd > now) return { kind: "skip", reason: "not_due" };
const amount = Math.round((plan.price * (100 - discountPercent(sub, plan, now))) / 100);
const newPeriodEnd = addDays(sub.periodEnd, plan.periodDays); // чистая, now не трогает
return sub.balance >= amount
? { kind: "pay_from_balance", amount, newPeriodEnd }
: { kind: "charge_card", cardId: sub.cardId, amount, newPeriodEnd };
}
// ---------- ОБОЛОЧКА: ни одного бизнес-if ----------
export async function renewSubscription(deps: Deps, subId: string): Promise<void> {
// 1. читаем мир
const sub = await deps.db.getSubscription(subId);
const plan = await deps.db.getPlan(sub.planId);
const now = deps.clock.now();
// 2. решаем — чисто
const decision = decideRenewal(sub, plan, now);
// 3. исполняем — тупо
switch (decision.kind) {
case "skip":
deps.log.info({ subId, reason: decision.reason }, "renewal skipped");
return;
case "pay_from_balance":
await deps.db.applyRenewal(subId, decision.amount, decision.newPeriodEnd);
await deps.queue.publish("subscription.renewed", { subId });
return;
case "charge_card": {
const charge = await deps.payments.charge(decision.cardId, decision.amount);
await handleChargeResult(deps, subId, decision, charge); // второй сэндвич, см. ниже
return;
}
}
}
На Elixir то же ядро выглядит естественнее всего — сопоставление с образцом (https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/) буквально написано под такую задачу:
defmodule Billing.Renewal do
# ЯДРО. Чистые функции, только структуры на входе и выходе.
def decide(%Subscription{status: :cancelled}, _plan, _now),
do: {:skip, :cancelled}
def decide(%Subscription{period_end: pe}, _plan, now) when pe > now,
do: {:skip, :not_due}
def decide(%Subscription{} = sub, %Plan{} = plan, now) do
amount = round(plan.price * (100 - discount(sub, plan, now)) / 100)
new_end = Date.add(sub.period_end, plan.period_days)
if sub.balance >= amount do
{:pay_from_balance, amount, new_end}
else
{:charge_card, sub.card_id, amount, new_end}
end
end
defp discount(sub, plan, now) do
loyalty = if sub.months_active >= 12, do: 10, else: 0
promo = if plan.promo_until && Date.compare(plan.promo_until, now) == :gt,
do: plan.promo_discount, else: 0
max(loyalty, promo)
end
end
defmodule Billing.RenewalUseCase do
# ОБОЛОЧКА. Здесь есть эффекты и нет бизнес-условий.
alias Billing.Renewal
def run(sub_id, deps) do
with {:ok, sub} <- deps.repo.get_subscription(sub_id),
{:ok, plan} <- deps.repo.get_plan(sub.plan_id) do
sub
|> Renewal.decide(plan, deps.clock.today())
|> execute(sub_id, deps)
end
end
defp execute({:skip, reason}, sub_id, deps),
do: deps.log.info("skip #{sub_id}: #{reason}")
defp execute({:pay_from_balance, amount, new_end}, sub_id, deps) do
:ok = deps.repo.apply_renewal(sub_id, amount, new_end)
:ok = deps.bus.publish({:subscription_renewed, sub_id})
end
defp execute({:charge_card, card, amount, new_end}, sub_id, deps) do
deps.payments.charge(card, amount) |> handle_charge(sub_id, amount, new_end, deps)
end
end
Python-версия для тех, кто живёт в Django/FastAPI, — те же три шага, но с dataclass и структурным match:
from dataclasses import dataclass
from datetime import date
# ---------- ЯДРО ----------
@dataclass(frozen=True)
class Skip: reason: str
@dataclass(frozen=True)
class PayFromBalance: amount: int; new_period_end: date
@dataclass(frozen=True)
class ChargeCard: card_id: str; amount: int; new_period_end: date
Decision = Skip | PayFromBalance | ChargeCard
def decide_renewal(sub: Subscription, plan: Plan, today: date) -> Decision:
if sub.status == "cancelled":
return Skip("cancelled")
if sub.period_end > today:
return Skip("not_due")
amount = plan.price * (100 - discount_percent(sub, plan, today)) // 100
new_end = sub.period_end + timedelta(days=plan.period_days)
if sub.balance >= amount:
return PayFromBalance(amount, new_end)
return ChargeCard(sub.card_id, amount, new_end)
# ---------- ОБОЛОЧКА ----------
def renew_subscription(deps: Deps, sub_id: int) -> None:
sub, plan, today = deps.db.get_sub(sub_id), None, deps.clock.today()
plan = deps.db.get_plan(sub.plan_id)
match decide_renewal(sub, plan, today):
case Skip(reason):
deps.log.info("skip %s: %s", sub_id, reason)
case PayFromBalance(amount, new_end):
deps.db.apply_renewal(sub_id, amount, new_end)
deps.queue.publish("subscription.renewed", sub_id=sub_id)
case ChargeCard(card_id, amount, new_end):
deps.handle_charge(sub_id, deps.payments.charge(card_id, amount), amount, new_end)
Что мы на самом деле получили
Тест на всю бизнес-логику теперь выглядит так — и это не упрощённый пример для статьи, а буквально то, как это пишется в проде:
test.each([
// months promo price balance → ожидаем
[ 3, null, 1000, 5000, { kind: "pay_from_balance", amount: 1000 } ],
[ 12, null, 1000, 5000, { kind: "pay_from_balance", amount: 900 } ],
[ 12, 25, 1000, 100, { kind: "charge_card", amount: 750 } ],
])("скидка и способ оплаты", (months, promo, price, balance, expected) => {
const d = decideRenewal(sub({ monthsActive: months, balance }), plan({ price, promo }), T0);
expect(d).toMatchObject(expected);
});
Ни одного мока. Ни одного beforeEach. Тысяча таких случаев прогоняется за десятки миллисекунд, а поверх легко ложится property-based тестирование (https://courses.digitable.life/post/functional-programming/01-pure-functions/): «скидка никогда не больше 100 %», «сумма к списанию никогда не отрицательна», «newPeriodEnd всегда строго позже periodEnd».
Это и есть главный практический дивиденд ФП — не «код красивее», а сдвиг стоимости проверки корректности на два порядка вниз. Всё остальное (монады, каррирование, ленивость) — техника, обслуживающая этот сдвиг.
Решение как данные: следующий уровень
В примере выше ядро возвращает одно решение. Логичное развитие — возвращать список команд, которые оболочка исполняет одну за другой:
type Command =
| { t: "db.apply_renewal"; subId: string; amount: number; until: Date }
| { t: "queue.publish"; topic: string; payload: unknown }
| { t: "mail.send"; to: string; template: string }
| { t: "metric.inc"; name: string };
function decide(state: State, input: Input): { state: State; commands: Command[] };
Такая сигнатура — (состояние, событие) → (новое состояние, команды) — встречается независимо в очень разных местах: это update в The Elm Architecture, это редьюсер в Redux, это handle_call в GenServer (https://courses.digitable.life/post/functional-programming/14-concurrency/), это агрегат в event sourcing (https://courses.digitable.life/post/ddd/00-overview/). Не совпадение: все они решают одну задачу — отделить «что должно произойти» от «как это исполнить».
ноль ожидания, ноль сети C-->>W: newState + [Command] loop по каждой команде W->>DB: применить запись W->>Q: опубликовать событие end Note over W,Q: любой сбой здесь — это retry,
а не «недосчитали скидку»
Три практических следствия:
- Аудит бесплатен. Список команд можно залогировать целиком — вы видите, что система решила сделать, отдельно от того, что удалось.
- Dry-run бесплатен. Тот же вызов ядра без исполнения команд — готовый режим «покажи, что произойдёт».
- Ретраи и идемпотентность локализованы. Они живут в исполнителе команд, а не размазаны по бизнес-логике.
Цена тоже есть: появляется свой мини-язык команд, который надо поддерживать, и лишний уровень косвенности. Для трёх команд это оверинжиниринг — оставьте прямые вызовы. Для домена с двадцатью правилами и требованием аудита — окупается за месяц.
Когда сэндвич ломается
Честная часть. Схема «прочитал всё → решил → записал всё» работает не всегда. Классический случай: чтобы понять, что читать дальше, нужно сначала что-то решить. Проверка платежа зависит от ответа шлюза, обход графа зависимостей — от того, куда привела предыдущая вершина.
Есть четыре выхода, и выбирать надо осознанно.
дозагрузить заранее?"} B -->|да| C["Пре-фетч / батч:
грузим всё нужное одним запросом,
ядро остаётся одним вызовом"] B -->|нет| D{"Шагов мало
и они разные?"} D -->|да| E["Несколько сэндвичей:
decide → execute → decide → execute
ядро = набор шаговых функций"] D -->|нет| F{"Шагов много,
структура однородная?"} F -->|да| G["Цикл-интерпретатор:
ядро возвращает Continue/Done,
оболочка крутит while"] F -->|нет| H["Система эффектов:
программа как значение,
интерпретатор снаружи"] C --> Z["Проверка: в ядре
по-прежнему нет await"] E --> Z G --> Z H --> Z
1. Пре-фетч. Самый недооценённый приём. Вместо «на каждой итерации сходим в базу» — «соберём все id, сходим один раз, передадим в ядро словарь». Побочный эффект: исчезает проблема N+1, которая в императивном коде живёт годами. Работает, пока множество нужных данных вычислимо заранее.
2. Несколько сэндвичей. Именно это делает handleChargeResult из примера выше: ядро решило «списать с карты», оболочка сходила в шлюз, ядро приняло второе решение по результату.
# ЯДРО: второй шаг — тоже чистая функция от результата первого
def decide_after_charge({:ok, _txn}, sub_id, new_end),
do: [{:extend_period, sub_id, new_end}, {:publish, {:subscription_renewed, sub_id}}]
def decide_after_charge({:error, :insufficient_funds}, sub_id, _new_end),
do: [{:mark_past_due, sub_id}, {:send_mail, sub_id, :payment_failed}]
def decide_after_charge({:error, :gateway_down}, sub_id, _new_end),
do: [{:schedule_retry, sub_id, minutes: 30}]
Ядро распадается на набор функций «состояние × событие → решение», а оболочка становится диспетчером между ними. Это ровно конечный автомат:
Автомат в ядре — это то, что делает систему объяснимой. Нарисованная диаграмма и код перестают расходиться, потому что диаграмма буквально и есть перечень веток decide.
3. Цикл-интерпретатор. Ядро возвращает {:continue, next_request, state} либо {:done, result}, а оболочка крутит цикл, выполняя запросы. Так устроены многие парсеры протоколов и краулеры: вся логика «что запросить следующим» чистая, весь сокет — снаружи.
4. Система эффектов. Программа описывается как значение и интерпретируется снаружи — free monad, tagless final, Effect-TS, ZIO, Cats Effect. Мы разбирали механику в https://courses.digitable.life/post/functional-programming/13-effects-and-io/. Это самый мощный и самый дорогой вариант: он даёт тестируемость без моков даже для произвольно переплетённого I/O, но платите вы понятностью стек-трейсов, скоростью компиляции и тем, что новый человек в команде месяц не может читать код. Не начинайте с него. Приходите к нему, только когда упёрлись в пункты 1–3.
Как это масштабируется на всё приложение
Один use case — это одна функция и одна оболочка. Приложение — это десятки. Правила, которые удерживают структуру:
Ядро режется по домену, а не по слоям. Не models/, services/, utils/, а billing/, subscriptions/, pricing/ — и внутри каждого модуля свои типы и чистые функции. Слоистость возникает внутри модуля, а не поперёк системы.
Направление зависимостей — только внутрь. Ядро не знает про оболочку. Проверяется механически: линтер запрещает импорты инфраструктуры из пакета ядра (eslint-plugin-boundaries, import-linter в Python, Boundary в Elixir, ArchUnit в Java). Это правило стоит поставить в CI на первой же неделе — иначе граница размывается за квартал.
Типы домена не равны типам БД. Строка таблицы, DTO из HTTP и доменное значение — три разных типа, и перевод между ними живёт в оболочке. Это самая частая жертва «ради скорости», и самая дорогая: как только ORM-сущность просочилась в ядро, ядро перестало быть чистым — ленивая подгрузка связей делает I/O там, где вы его не видите.
Парсим на границе, а не проверяем внутри. Алексис Кинг сформулировала это как «parse, don’t validate»: оболочка превращает сырой ввод в тип, существование значения которого само по себе гарантирует корректность (Email, а не string). Тогда ядро не проверяет ничего — проверять уже нечего. Подробнее — в https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/ про «сделать недопустимые состояния непредставимыми».
Родство с известными архитектурами. Функциональное ядро — это не альтернатива гексагональной архитектуре, а её версия, где центр состоит из функций и значений, а не из объектов с состоянием:
| Центр | Границы | Что подменяем в тестах | |
|---|---|---|---|
| Ports & Adapters | доменные объекты | порты-интерфейсы | адаптеры |
| Clean / Onion | сущности + use cases | интерфейсы репозиториев | реализации репозиториев |
| Functional core | чистые функции и ADT | сигнатуры функций | ничего — ядро вызывается напрямую |
Разница практическая: в первых двух подходах тест ядра всё равно требует подсунуть фейковый репозиторий, потому что домен умеет звать наружу. В третьем подменять нечего — ядро физически не способно ничего вызвать. Подробнее про сами стили — в треке https://courses.digitable.life/post/architecture-patterns/00-overview/, а про доменное моделирование — в https://courses.digitable.life/post/ddd/00-overview/.
Честная цена
Курс был бы нечестным без этой главы. ФП стоит денег, и платят их не абстрактные «противники прогресса», а ваша команда.
Производительность
Персистентные структуры (https://courses.digitable.life/post/functional-programming/12-persistent-data-structures/) не бесплатны. Порядки, о которых стоит помнить:
- Доступ по индексу. Массив — одно обращение по адресу. Persistent vector (HAMT/vector trie) — 1–7 переходов по указателям с промахами кэша. На последовательном обходе больших массивов разница легко достигает 5–20× — не из-за асимптотики (она формально O(log₃₂ n) ≈ константа), а из-за локальности.
- Обновление в цикле. Каждое «изменение» аллоцирует узлы. GC-нагрузка растёт, паузы растут. Решение — batch-режимы (
transientв Clojure,Object.freezeвокруг мутабельного билдера в TS,Enum.reduceс аккумулятором вместо цепочкиmap |> filter |> mapв Elixir). - Копирование на границах. В BEAM сообщение между процессами копируется целиком. Передавать мегабайтные структуры между GenServer’ами — верный способ уронить latency; для этого есть ETS и binary-refc-объекты.
- Ленивость даёт утечки. В Haskell классическая беда — накопление thunk’ов в аккумуляторе
foldl. Лечится строгостью (foldl', bang patterns), но лечится после того, как вы это обнаружили в проде (https://courses.digitable.life/post/functional-programming/11-laziness-and-streams/).
Правильная реакция: не «значит, ФП непригодно», а «горячие 3 % кода пишем императивно и изолируем». Мутабельность внутри функции, которая снаружи чистая, — совершенно легальный приём. Чистота — свойство интерфейса, а не реализации.
Кривая обучения
Стоимость входа распределена неравномерно, и это важно для планирования:
| Инструмент | Время до «пишу сам» | Реальная польза |
|---|---|---|
| Чистые функции, ядро/оболочка | часы | огромная, сразу |
| Неизменяемость, ФВП, композиция | дни | большая |
| ADT + сопоставление с образцом | дни | большая, если язык умеет |
Option/Result, railway |
1–2 недели | большая |
| Функторы/аппликативы как понятие | недели | средняя |
Монады, traverse, трансформеры |
месяцы | средняя, растёт с размером кодовой базы |
| Tagless final, оптики, свободные монады | полгода+ | узкая; часто отрицательная в командном коде |
Практический вывод, который стоит вешать на стену: первые две строки таблицы дают 80 % выгоды и стоят 5 % усилий. Команда, которая внедрила только «функциональное ядро + иммутабельные данные + Result», получает почти весь эффект. Команда, которая начала с монадных трансформеров, обычно получает застрявший рефакторинг и двух уволившихся разработчиков.
Где ФП мешает
Конкретные ситуации, где ФП честно проигрывает:
- Тонкий домен. Если приложение — это
SELECT+ сериализация в JSON, ядро будет пустым, а вы получите три лишних слоя радиDecision = { kind: "ok" }. Пишите прямо. - Жёсткий realtime и числодробилки. Игровой цикл, DSP, физика, ядра линейной алгебры: там правит расположение данных в памяти, SIMD и отсутствие аллокаций. ECS-архитектура в геймдеве, кстати, — интересный гибрид: данные отделены от поведения и обрабатываются пачками, но мутируются на месте.
- Языки без поддержки. ФП в Java до 17 или в старом C# — это ручное конструирование ADT через посетителей и запечатанные иерархии. Возможно, но налог на синтаксис съедает выгоду (https://courses.digitable.life/post/functional-programming/15-fp-in-mainstream-languages/).
- Фреймворки, требующие мутации. Многие ORM, UI-фреймворки и DI-контейнеры построены на изменяемых объектах и жизненных циклах. Бороться с фреймворком дороже, чем принять его правила в оболочке — а ядро держать отдельно.
- Отладка. Композиция в point-free стиле, ленивость и цепочки комбинаторов ломают привычный стек-трейс: вместо «упало в строке 42» вы получаете «упало где-то внутри
traverse». Профилирование ленивых языков — отдельный навык. - Найм. Разработчиков, готовых читать
ReaderT AppEnv (ExceptT AppError IO), на рынке кратно меньше. Elixir, Kotlin, TypeScript с лёгким ФП — куда более безопасная ставка, чем «полный Haskell-стек».
Есть и специфическая болезнь: абстракционизм ради абстракционизма. Когда в проекте появляется собственная иерархия тайпклассов до того, как появился второй реальный сценарий использования, — это не архитектура, а хобби за счёт компании. Полезное правило: абстракция вводится на третьем повторении, а не на первом.
Как внедрять в существующий код
Революция («перепишем на ФП») проваливается почти всегда. Работает эволюция:
болезненный модуль"] --> S2["2. Вытащить расчёты
в чистые функции"] S2 --> S3["3. Покрыть их
табличными тестами"] S3 --> S4["4. Ввести Result
вместо исключений
внутри модуля"] S4 --> S5["5. Заморозить данные
домена как immutable"] S5 --> S6["6. Поставить линтер
на границу в CI"] S6 --> S7["7. Повторить
на следующем модуле"] S7 -.обратная связь.-> S1
Что помогает по опыту:
- Начинать с расчётов, а не с эффектов. Скидки, лимиты, права доступа, тарификация — вытащить формулу в чистую функцию можно за час, и выгода видна сразу.
- Метрика вместо лозунга. «Доля кода в пакете
core», «время прогона юнит-тестов», «число моков в тесте» — измеримые вещи, которые видно на графике. - Не мигрировать тесты, а писать новые. Старые тесты с моками пусть живут, пока не мешают; новые пишутся против ядра.
- Договориться о словаре. Если половина команды говорит «монада», а половина не понимает — договоритесь называть вещи по-русски: «цепочка, которая обрывается на первой ошибке». Терминология не должна быть барьером входа.
Типичные анти-паттерны внедрения: чистое ядро, в которое протащили логгер («это же почти безобидно»); Result, который в оболочке разворачивается через .unwrap() и превращается обратно в исключение; «функциональный» код, где map вызывается ради побочного эффекта; иммутабельность, реализованная через глубокое копирование на каждом шаге.
Чек-лист для code review
Короткий список вопросов, который ловит 90 % проблем:
- Есть ли в файлах ядра импорты инфраструктуры,
await, обращения к часам,random, логгеру? - Можно ли вызвать эту функцию дважды с теми же аргументами и получить тот же результат?
- Тест проверяет возвращённое значение или факт вызова мока?
- Все ли ветки сумма-типа разобраны, и падает ли сборка при добавлении нового варианта?
- Может ли структура с недопустимым состоянием вообще быть сконструирована?
- Где в этом коде принимается решение, и почему оно принимается именно здесь?
- Эта абстракция появилась из третьего повторения или из желания её применить?
Карта курса и что читать дальше
освоено)) Основа чистота и эффекты неизменяемость ФВП и замыкания рекурсия и хвостовые вызовы Структурирование композиция и каррирование ADT и сопоставление с образцом функторы и аппликативы монады Практика обработка ошибок ленивость и потоки персистентные структуры эффекты и IO конкурентность Применение ФП в мейнстрим-языках ядро и оболочка цена и границы применимости
Книги
- Eric Normand. Grokking Simplicity — лучший вход для практика: разделение действий, вычислений и данных, без единого упоминания теории категорий.
- Scott Wlaschin. Domain Modeling Made Functional — как ФП и DDD складываются в один подход. Плюс его сайт F# for Fun and Profit.
- Mark Seemann. Code That Fits in Your Head (Addison-Wesley, 2021) и его блог — про сэндвич, границы и дисциплину.
- Saša Jurić. Elixir in Action — как всё это выглядит в языке, где ФП и отказоустойчивость встроены.
- Graham Hutton. Programming in Haskell (2nd ed., Cambridge, 2016) — самый аккуратный академический учебник; для бесплатного старта Learn You a Haskell.
- Abelson, Sussman. Structure and Interpretation of Computer Programs — книга не про ФП как таковое, а про то, как вообще думать о вычислении.
- Felleisen et al. How to Design Programs — методичный подход «от типа данных к структуре функции», бесплатно онлайн.
- Bartosz Milewski. Category Theory for Programmers — если после https://courses.digitable.life/post/functional-programming/09-functor-map/ захотелось теории; сопрягается с треком https://courses.digitable.life/post/mathematics/00-overview/.
Статьи и доклады
- John Hughes. Why Functional Programming Matters (1990) — классика про композицию и ленивость как средства модульности.
- Moseley, Marks. Out of the Tar Pit (2006) — про случайную сложность и состояние как её источник.
- Gary Bernhardt. Boundaries — исходный доклад про ядро и оболочку.
- Rich Hickey. Simple Made Easy и The Value of Values — почему значения лучше объектов.
- Simon Peyton Jones. Tackling the Awkward Squad — как чистый язык честно уживается с I/O.
- Alexis King. Parse, don’t validate.
Инструменты по языкам
- TypeScript: Effect, fp-ts, neverthrow, immer, fast-check для property-based тестов. См. https://courses.digitable.life/post/typescript/00-overview/.
- Elixir: стандартная библиотека, StreamData, Ecto для границы с БД. См. https://courses.digitable.life/post/elixir/00-overview/.
- Python: returns, pyrsistent, Hypothesis.
- C#/.NET: language-ext, records и pattern matching из коробки. См. https://courses.digitable.life/post/csharp/00-overview/.
- JVM: Arrow для Kotlin, Vavr для Java.
- Практика: Exercism с треками по Haskell, Elixir, F#, OCaml — разбор решений живыми людьми.
Итог
Функциональное программирование в проде — это не список абстракций, а одно архитектурное решение и его последствия: логика собрана в чистых функциях, ввод-вывод вытеснен на края. Из этого решения автоматически следуют дешёвые тесты, воспроизводимые баги, возможность считать «а что было бы» и безопасная конкурентность.
Всё остальное — инструменты разной степени продвинутости, которые вводят по мере надобности. Монады не цель. Result не цель. Цель — чтобы завтра можно было изменить бизнес-правило, узнать за пять секунд, что сломалось, и не бояться пятницы.
И последнее, что стоит унести из курса: ФП — это не религия, а набор компромиссов с известной ценой. Вы теперь знаете и выгоду, и цену. Выбирать осознанно — и есть профессионализм.
Что дальше
Трек по функциональному программированию закончен. Дальше логично двигаться в одну из сторон:
- К теории — https://courses.digitable.life/post/lambda-calculus/00-overview/ за формальным фундаментом и https://courses.digitable.life/post/mathematics/00-overview/ за теорией категорий, которая стоит за функторами и монадами.
- К архитектуре — https://courses.digitable.life/post/architecture-patterns/00-overview/ и https://courses.digitable.life/post/ddd/00-overview/: ядро и оболочка отлично ложатся на DDD и event sourcing.
- К дисциплине — https://courses.digitable.life/post/principles/00-overview/, https://courses.digitable.life/post/design-patterns/00-overview/ и https://courses.digitable.life/post/testing/00-overview/: property-based тестирование раскрывается именно на чистом ядре.
- К языкам — https://courses.digitable.life/post/elixir/00-overview/, https://courses.digitable.life/post/typescript/00-overview/, https://courses.digitable.life/post/csharp/00-overview/, https://courses.digitable.life/post/golang/00-overview/.
- К общей картине — сравнение подходов в https://courses.digitable.life/post/paradigms/00-overview/.
Если не уверены, что брать следующим, посмотрите общий план обучения портала: Дорожная карта.