Функциональное программирование Архитектура на ФП: функциональное ядро и императивная оболочка, ресурсы
0%

Архитектура на ФП: функциональное ядро и императивная оболочка, ресурсы

Архитектура на ФП: функциональное ядро и императивная оболочка, ресурсы

Пятнадцать статей мы разбирали инструменты по одному: чистые функции, неизменяемость, композиция, 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/). Не совпадение: все они решают одну задачу — отделить «что должно произойти» от «как это исполнить».

Три практических следствия:

  1. Аудит бесплатен. Список команд можно залогировать целиком — вы видите, что система решила сделать, отдельно от того, что удалось.
  2. Dry-run бесплатен. Тот же вызов ядра без исполнения команд — готовый режим «покажи, что произойдёт».
  3. Ретраи и идемпотентность локализованы. Они живут в исполнителе команд, а не размазаны по бизнес-логике.

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

Когда сэндвич ломается

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

Есть четыре выхода, и выбирать надо осознанно.

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-стек».

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

Как внедрять в существующий код

Революция («перепишем на ФП») проваливается почти всегда. Работает эволюция:

Что помогает по опыту:

  • Начинать с расчётов, а не с эффектов. Скидки, лимиты, права доступа, тарификация — вытащить формулу в чистую функцию можно за час, и выгода видна сразу.
  • Метрика вместо лозунга. «Доля кода в пакете core», «время прогона юнит-тестов», «число моков в тесте» — измеримые вещи, которые видно на графике.
  • Не мигрировать тесты, а писать новые. Старые тесты с моками пусть живут, пока не мешают; новые пишутся против ядра.
  • Договориться о словаре. Если половина команды говорит «монада», а половина не понимает — договоритесь называть вещи по-русски: «цепочка, которая обрывается на первой ошибке». Терминология не должна быть барьером входа.

Типичные анти-паттерны внедрения: чистое ядро, в которое протащили логгер («это же почти безобидно»); Result, который в оболочке разворачивается через .unwrap() и превращается обратно в исключение; «функциональный» код, где map вызывается ради побочного эффекта; иммутабельность, реализованная через глубокое копирование на каждом шаге.

Чек-лист для code review

Короткий список вопросов, который ловит 90 % проблем:

  1. Есть ли в файлах ядра импорты инфраструктуры, await, обращения к часам, random, логгеру?
  2. Можно ли вызвать эту функцию дважды с теми же аргументами и получить тот же результат?
  3. Тест проверяет возвращённое значение или факт вызова мока?
  4. Все ли ветки сумма-типа разобраны, и падает ли сборка при добавлении нового варианта?
  5. Может ли структура с недопустимым состоянием вообще быть сконструирована?
  6. Где в этом коде принимается решение, и почему оно принимается именно здесь?
  7. Эта абстракция появилась из третьего повторения или из желания её применить?

Карта курса и что читать дальше

Книги

  • 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/.

Статьи и доклады

Инструменты по языкам

  • 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/.

Если не уверены, что брать следующим, посмотрите общий план обучения портала: Дорожная карта.

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

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

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

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