Раздел 9 · Backend

Functional Core, Imperative Shell: где решение, где действие

middle-senior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Functional Core, Imperative Shell: где решение, где действие

В прошлом уроке про кеш ты заметил неприятное: чтобы проверить, правильно ли выбран ключ или TTL, приходилось поднимать Redis, базу, Express и гонять реальные запросы. Логика решения была вшита в код, который ходит по сети. Это типичная боль бэкенда: бизнес-правило спрятано внутри функции, которая заодно читает из базы, пишет в очередь и шлёт письмо. Чтобы протестировать одно маленькое правило, поднимаешь полмира.

Этот урок про границу, которая лечит сразу несколько болезней: тестируемость, читаемость и предсказуемость. Идея старая и называется Functional Core, Imperative Shell: вся логика собрана в чистом ядре, а всё общение с миром вынесено в тонкую оболочку вокруг него.

Через весь урок проходит один вопрос, и его стоит задавать каждой функции, которую пишешь:

Эта функция принимает решение или совершает действие?

Решение это чистый расчёт: из входных данных получить ответ. Действие это I/O: сходить в базу, записать файл, отправить письмо. Беда начинается, когда одна функция делает и то, и другое. Лекарство: развести их по разные стороны границы.

Идея: ядро думает, оболочка действует

Раздели программу на два слоя.

  • Функциональное ядро это все решения. Чистые функции: на вход данные, на выход данные. Никакого I/O, никаких await fetch, никаких db.save. Только расчёт. Здесь много ветвлений и мало строк.
  • Императивная оболочка это все действия. Тонкий слой, который читает данные из мира, передаёт их в ядро, получает решение и исполняет его. Здесь много I/O и почти нет ветвлений.

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

                 ┌──────────────────────────────┐
   мир  ──read──>│  imperative shell  (I/O)      │
                 │   ┌──────────────────────┐    │
                 │   │  functional core     │    │
                 │   │  decide(state, cmd)  │    │   решение
                 │   └──────────────────────┘    │
   мир  <─write──│  исполняем то, что решено     │
                 └──────────────────────────────┘

Это та же мысль, что в уроке про эффекты как значения из ФП-серии: ядро не выполняет эффект, оно описывает, что нужно сделать, а интерпретатор (оболочка) исполняет. Только здесь без библиотеки, голыми функциями.

Симптом: handler, который делает всё сразу

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

// перемешано: решения и действия в одной функции
async function placeOrder(req, res) {
  const cart = await db.carts.load(req.user.id); // действие
  if (cart.items.length === 0) {
    return res.status(400).json({ error: 'empty cart' }); // решение
  }

  let total = 0;
  for (const item of cart.items) {
    const product = await db.products.find(item.id); // действие в цикле решений
    if (product.stock < item.qty) {
      return res.status(409).json({ error: 'out of stock' }); // решение
    }
    total += product.price * item.qty; // решение
  }

  const discount = total > 10_000 ? total * 0.1 : 0; // решение
  const order = await db.orders.create({ ...cart, total: total - discount }); // действие
  await mailer.send(req.user.email, 'order placed', order.id); // действие
  res.json(order); // действие
}

Чтобы проверить одно правило “скидка 10 процентов на заказ дороже 10 000”, придётся поднять базу, замокать db.products.find, замокать mailer.send и собрать фейковый req. Тест будет длиннее логики, которую он проверяет, и сломается от любого касания I/O. А сама формула скидки прячется в третьей строке снизу, между двумя await.

Где решение, где действие Клик по строке: Р решение · Д действие

Разрез: сначала собрать данные, потом решить, потом исполнить

Тот же handler по схеме core/shell распадается на три фазы, и они идут строго по порядку: собрать вход, посчитать решение, исполнить эффекты.

Сначала чистое ядро. Это функция от данных к решению, никакого I/O:

// functional core: только данные, только расчёт, ноль I/O
type Cart = { userId: string; items: { id: string; qty: number }[]; email: string };
type Stock = Record<string, { price: number; stock: number }>;

type Decision =
  | { kind: 'reject'; status: number; reason: string }
  | { kind: 'place'; total: number; effects: Effect[] };

type Effect =
  | { type: 'createOrder'; userId: string; total: number }
  | { type: 'sendMail'; to: string; subject: string };

function decideOrder(cart: Cart, stock: Stock): Decision {
  if (cart.items.length === 0) {
    return { kind: 'reject', status: 400, reason: 'empty cart' };
  }

  let total = 0;
  for (const item of cart.items) {
    const product = stock[item.id]; // данные уже на руках, не идём в базу
    if (!product || product.stock < item.qty) {
      return { kind: 'reject', status: 409, reason: 'out of stock' };
    }
    total += product.price * item.qty;
  }

  const discount = total > 10_000 ? total * 0.1 : 0;
  const final = total - discount;

  return {
    kind: 'place',
    total: final,
    effects: [
      { type: 'createOrder', userId: cart.userId, total: final },
      { type: 'sendMail', to: cart.email, subject: 'order placed' },
    ],
  };
}

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

Теперь оболочка. Она тонкая и скучная, и это комплимент:

// imperative shell: читаем мир, зовём ядро, исполняем решённое
async function placeOrder(req, res) {
  const cart = await db.carts.load(req.user.id); // 1. собрать вход
  const stock = await db.products.findMany(cart.items.map((i) => i.id));

  const decision = decideOrder(cart, stock); // 2. посчитать решение (чисто)

  if (decision.kind === 'reject') {
    return res.status(decision.status).json({ error: decision.reason });
  }

  for (const effect of decision.effects) {
    // 3. исполнить эффекты
    if (effect.type === 'createOrder') await db.orders.create(effect);
    if (effect.type === 'sendMail') await mailer.send(effect.to, effect.subject);
  }
  res.json({ total: decision.total });
}

Вся логика заказа (валидация, расчёт, скидка) теперь в decideOrder, и она чистая. Оболочка только подносит данные и исполняет приговор. Если завтра скидку поменяют, ты трогаешь одну чистую функцию и один тест, не приближаясь к базе.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.