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.