Детерминированное ядро, недетерминированная оболочка: как обобщить идею Functional Core/Imperative Shell

Четырнадцать лет назад Гэри Бернхардт сформулировал архитектурный принцип «Functional Core, Imperative Shell» («Функциональное ядро, императивная оболочка»): бизнес-логику держат в чисто функциональном ядре без побочных эффектов и ввода-вывода, а всё взаимодействие с внешним миром, базы данных, сеть, файлы, интерфейс, выносят в оболочку, которая вызывает ядро с готовыми значениями и передаёт результат наружу. Автор разбираемого поста утверждает: то, что на самом деле делает ядро удобным для тестирования, не чистота функций сама по себе, а детерминированность, то есть свойство при одинаковой последовательности входов всегда давать одинаковый результат. Это позволяет расширить принцип и на языки и стили, где чистые функции неудобны (сам автор признаётся, что не стал бы пробовать чистый функциональный стиль на C). Чтобы показать разницу, автор сравнивает чистую функцию сложения массива чисел с императивным классом-«машиной состояний», который держит внутреннее состояние и обновляет его вызовами: обе реализации детерминированы, при одной и той же последовательности вызовов обе всегда возвращают один и тот же результат, хотя вторая явно не функциональна. Отсюда, переформулировка принципа: «Детерминированное ядро, недетерминированная оболочка». Автор перечисляет типичные источники недетерминированности, которые нужно выносить в оболочку: незасеянные генераторы случайных чисел, асинхронные и многопоточные операции, сетевое взаимодействие, обмен с другими процессами, чтение и запись на диск, работа с базой данных, запрос текущей даты или времени у операционной системы. Для существующих, запутанных кодовых баз («в шахтах устаревшего и написанного на скорую руку кода», как их называет автор) предлагается практика «дефрагментации детерминированности» по аналогии с дефрагментацией диска в старых версиях Windows: нужно находить фрагменты детерминированного кода, в файлах, классах, отдельных функциях, и постепенно группировать их вместе, либо разделяя функции вокруг недетерминированных операций, либо поднимая эти операции на уровень выше и передавая их результат внутрь как параметр. Цель, не обязательно свести всю систему к единому детерминированному ядру (на большой кодовой базе это вряд ли достижимо), а сократить площадь недетерминированного, трудно тестируемого кода, пусть даже с тысяч фрагментов до сотен. В качестве примера системы, изначально спроектированной с чётким разделением детерминированного и недетерминированного с первого дня, автор называет FoundationDB, отмечая, что та пошла ещё дальше, но подробности откладывает до отдельного поста.

Ключевые факты

  • Автор переформулирует принцип Гэри Бернхардта «Functional Core, Imperative Shell» («Функциональное ядро, императивная оболочка», сформулирован 14 лет назад) в «Deterministic Core, Non-Deterministic Shell» («Детерминированное ядро, недетерминированная оболочка»)
  • Ключевое свойство для тестируемости, не чистота функций, а детерминированность: пример с императивным классом-«машиной состояний», который детерминирован, хотя и не является чистой функцией
  • Перечислены типичные источники недетерминированности, которые нужно выносить в оболочку: незасеянные генераторы случайных чисел, асинхронность и многопоточность, сеть, межпроцессное взаимодействие, диск, база данных, системные дата и время
  • Для унаследованного (legacy) кода предложена практика «дефрагментации детерминированности», постепенно находить и группировать детерминированные фрагменты, разделяя функции вокруг недетерминированных операций или поднимая их на уровень выше
  • FoundationDB приведена как пример системы, разделившей детерминированное и недетерминированное с самого начала и пошедшей в этом ещё дальше, без подробностей, они обещаны в отдельном посте

Почему это важно

Идея сужает практическую пропасть между «правильной» функциональной архитектурой и реальным кодом на языках, где чистые функции неудобны или недоступны. Замена требования «чистота» требованием «детерминированность» формально слабее, но сохраняет главный практический эффект принципа Бернхардта, лёгкую тестируемость ядра без моков и заглушек, и при этом применима к императивному коду и состояниям, включая языки вроде C, где сам автор не стал бы пробовать строгую функциональную чистоту.

Кому это важно

Разработчикам и архитекторам, которые пишут тесты для систем с бизнес-логикой, и в первую очередь тем, кто поддерживает старый или написанный второпях код, где детерминированная логика и обращения к внешнему миру перемешаны. Идея адресована именно тем, кто не может позволить себе спроектировать систему «с нуля» по всем правилам, а работает с тем, что уже есть.

Как это применить

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

Можно ли доверять

Это авторская концептуальная заметка блога, а не эмпирическое исследование: аргумент строится на переформулировке известного принципа и на двух коротких иллюстративных примерах кода (чистая функция и класс-«машина состояний»), а не на разборе реального проекта с метриками «до и после». Ссылка на FoundationDB как на систему, разделившую детерминированное и недетерминированное с первого дня, не раскрыта: сам автор откладывает детали её подхода до отдельного поста. Дискуссия на Hacker News на момент составления пересказа была минимальной (2 комментария).

Риски и подводные камни

Практика описана только в общих терминах: конкретного пошагового кейса на реальной легаси-системе, с измеримым результатом, в посте нет, есть только принцип и метафора дефрагментации. Граница, где именно «поднимать» недетерминированную операцию на уровень выше, в реальном коде часто требует архитектурного решения, а не механического правила, и без такой дисциплины идея рискует остаться лозунгом, а не рабочей практикой рефакторинга.