Размышления об идеальной архитектуре для JavaScript

Kate

Administrator
Команда форума
В 2020 году, в конце марта, меня пригласили писать бэк на Node.JS для сервиса видеоконференций. Тогда, во времена начала очередного витка мирового спектакля, резко возрос спрос на инструменты, позволяющие вести работу дистанционно. На прототип сервиса, до того простоявший несколько лет практически без дела, из ниоткуда свалился ежедневный трафик в 2000 человек, что породило необходимость начинать в ускоренном темпе развивать продукт и делать деньги.

Спойлер: миллионерами мы так и не стали.

По мере роста объема и связности кода, становилось все труднее держать логику целиком в голове и, соответственно, гарантировать, что после очередных внесенных изменений ни один из вариантов использования не отвалится. И вот, в один прекрасный момент, было решено начать покрывать код тестами. Так началась история поиска идеальной архитектуры.

Спойлер: тестами код мы тоже так и не покрыли.

Давид Хейнемейер Ханссон, создатель фреймворка Ruby on Rails, в своей статье Test-induced design damage утверждает, что те архитектурные изменения, которые необходимо внести в проект, чтобы сделать возможным написание unit тестов для контроллеров, настолько сильно бьют по остальным характеристикам кода, что лучше отказаться от этой идеи в пользу интеграционных тестов.

Реально ли придумать такую архитектуру, которая не заставляла бы чем-то жертвовать? Это можно выяснить при помощи научного метода. Сначала необходимо проанализировать имеющиеся зоны боли, а затем попытаться наложить на код такие ограничения и правила, которые бы исправили ситуацию. На каждой последующей итерации ограничения либо добавляются, либо пересматриваются. Сложность состоит в том, что неправильные решения могут стать причиной появления еще большего числа зон боли. В этом болоте можно увязнуть надолго. Лучший способ проверить адекватность любой гипотезы — поставить эксперимент. И на чем же еще стоит экспериментировать, как не на рабочем проекте?

Спойлер: в итоге мы переписали проект 6 раз. Все ради науки.

Глубокая аналитика текущей ситуации​

Как известно, все возможные подходы к программированию были придуманы еще в 60е годы разработчиками на LISP. Все новые, по заверениям авторов, разработки, чаще всего, либо заново открывают давно забытое, либо комбинируют уже имеющееся. Время от времени, еще изобретаются надстройки, но они, как показывает практика, не получают особой популярности и долго не живут. Привет аспектно-ориентированному программированию.

Чтобы собрать архитектуру мечты, нужно понять, какими преимуществами и недостатками обладают уже существующие парадигмы программирования, и каких целей они стремятся достичь. Также рассмотрим, каких результатов ожидает бизнес.

Требования бизнеса​

Можно ли не заставлять бизнес выбирать два пункта из трех — "Быстро, качественно, недорого"? Чтобы ответить на этот вопрос, нужно разобраться, в чем причины нарушения каждого из пунктов.

Быстро​

Зачастую, на этапе разработки начальной версии продукта, фичи выкатываются очень стремительно. Затем, по мере увеличения объема кода, скорость начинает замедляться. Все чаще приходится признавать, что на предыдущих этапах были приняты неверные решения, и что имеющихся возможностей расширения системы, если они вообще закладывались, недостаточно. В такие моменты появляется выбор: воткнуть костыль, или переписать часть системы так, чтобы она гармонично подходила под новые требования? При принятии решения хорошо помогает вопрос: сколько из грядущих изменений будут опираться на этот участок логики? Если навалить поверх костыля еще кода, то все равно этот костыль, в скором времени, придется раскопать и переписать нормально вместе со всей надстройкой. Если, конечно, вы не ставите целью превращение проекта в тотальный хаос. И чем больше будет навалено сверху кода, тем бóльшая когнитивная нагрузка ляжет на разработчика, который займется переписыванием. Это может привести, в лучшем случае, к невнимательности и появлению новых багов, а в худшем — к выгоранию и дальнейшему падению скорости разработки.

В динамически развивающемся продукте не может существовать конечных решений. Раньше, по неопытности, я старался придерживаться принципа открытости-закрытости, создавая абстракции, в рамки которых, как мне казалось, должны уложиться все последующие изменения в системе. Опыт показал, что при первой же необходимости изменить или дополнить имеющееся поведение, эти абстракции приходится выкидывать, полностью или частично, заменяя их новыми. Тогда получается, что максимальной гибкости системы можно достичь только путем тотального отказа от подобного оверинжиниринга. Чрезмерное использование инверсии зависимостей, метаданных, преждевременные попытки сделать решение переиспользуемым. Все это, на самом деле, приносит больше вреда, чем пользы. Конечно, добиться абсолютной гибкости кода так же невозможно, как невозможно в одно мгновение изменить спецификацию, чтобы учесть все новые требования. Зато можно не плодить лишнего, чтобы потом не пришлось тратить время на избавление от всего этого.

Качественно​

Качество кода, в большинстве своем, зависит от опыта разработчиков и строгости используемого набора анализаторов. Хороший код должен быть понятным, гибким, безопасным и, по возможности, коротким. Желательно еще, чтобы запускался. Правильная архитектура при помощи правил структурирует мышление, помогая сеньорам поддерживать качество кода на высоком уровне. Джунов и миддлов, время от времени, все равно придется корректировать.

Однако, это все касается внутреннего устройства кодовой базы. Бизнес же интересует, чтобы все работало безошибочно и, по возможности, быстро. Большинство компаний пытаются снизить число ошибок при помощи добавления тестирования. Оно, безусловно, важно, ведь позволяет, при внесении в код изменений, быть уверенными, что старая логика не поломается. К сожалению, тестирование не может помочь отыскать ошибочные состояния, в которые, при определенной последовательности вызовов, может попасть программа, и которые не были учтены разработчиками. Найти их можно только при помощи моделирования и автоматизированной проверки полученной модели на непротиворечивость. Сейчас у всех на слуху язык спецификаций TLA+, который нацелен на моделирование параллельных систем. Можно ли как-то адаптировать его для описания распределенных систем на JavaScript, или же придется использовать что-то другое? Вопрос требует изучения.

Недорого​

Наши знакомые недавно пожаловались, что двухкратное увеличение штата разработчиков совсем не увеличило скорость разработки их продукта. Секрет кроется в функции роста сложности процессов. Во сколько раз больше людей и, соответственно, денег потребуется, чтобы поддерживать вдвое более сложную систему на плаву? В 2 раза больше? В 4? Или, может, в 20 раз? Все зависит от объема технического долга и архитектуры. Допустим, команда регулярно уделяла внимание качеству кода, и технический долг стремится к нулю. Тогда остается сравнивать лишь архитектурные подходы. Хороший подход сохраняет максимальную простоту и предсказуемость системы, что позволяет дольше удерживать в голове полную картину происходящего и, соответственно, управлять процессами силами меньшего числа разработчиков. А это значит, что пропадает необходимость нанимать кучу макак на поддержку. Все высвободившиеся деньги можно и нужно будет потратить на оплату услуг высококвалифицированных хранителей тайн совершенной архитектуры — нас с вами. Недорого разработать продукт не получится 😎.

Уровни разработки​

Наконец, переходим к технической части.

Разработка любого продукта всегда осуществляется на двух параллельных уровнях: уровне прецедентов и уровне реализации. Оба они описывают поведение программы, разница лишь в степени абстракции.

Когда происходит составление спецификации системы, ее поведение в деталях выражается при помощи натурального языка. Представим, что мы решили написать консольное приложение для управления списком задач. Тогда часть спецификации может выглядеть примерно так: "Программа выполняется в бесконечном цикле. На каждой итерации цикла необходимо очистить консоль, вывести текущий список задач и запрос на ввод команды, а затем считать введенную пользователем строку. Если команда не может быть распознана, необходимо вывести сообщение об ошибке и запросить подтверждение пользователя. Команда '.' завершает работу программы. За добавление новой задачи отвечает команда '+'. Оставшаяся часть введенной строки — текст задачи. Когда пользователь пытается добавить новую задачу, необходимо очистить текст задачи от пробельных символов слева и справа. Если полученная строка является пустой, оповестить пользователя об ошибке. Иначе добавить новую задачу в список.". Это есть уровень прецедентов.

На втором уровне, уровне реализации, в случае, например, объектно-ориентированного программирования с использованием разрекламированного подхода CQRS, часть, занимающаяся обработкой пользовательского ввода будет выглядеть следующим образом: "Вызвана команда AskUserForInput, обработкой которой занимается AskUserForInputHandler. Он ожидает ввода пользователя и выбрасывает событие NewUserInput, на которое подписан слушатель, вызывающий команду HandleUserInput, обрабатываемую классом HandleUserInputHandler. Он проверяет, равна ли строка символу '.', и, если да, выбрасывает событие ProgramEnd. Иначе проверяет, начинается ли строка со знака '+', и если нет, выбрасывает событие UnknownCommandInput, на которое подписан слушатель, вызывающий команду NotifyUserAboutUnknownInput, обработкой которой занимается NotifyUserAboutUnknownInputHandler, выводящий сообщение об ошибке на экран. Если же команда начинается со знака '+', то выбрасывается событие AddCommandInvoked, на которую подписан слушатель, вызывающий команду HandleAddCommand, обрабатываемую классом HandleAddCommandHandler, который отрезает крайний левый '+', а затем очищает пробельные символы слева и справа...". Кто, не дочитав, посмотрел в конец?

Как можно было убедиться, описание логики на уровне реализации оказалось невероятно раздуто и замусорено лексикой, которая отсутствует в спецификации. Так как на проектирование системы на обоих уровнях необходимо потратить время, то будет выгодно, если уровень реализации будет максимально тонок и близок к уровню прецедентов. В идеале, код должен читаться практически как английский текст спецификации.

Также хочется отметить еще одну распространенную проблему — разорванность логики. Причиной этого является желание чрезмерно разделять код. Шины команд и событий, рушащие связь между вызывающим и вызываемым кодом, инверсия зависимостей там, где это не надо, попытки писать функции длиной не более 10 строк. Не спорю, короткие функции, выполняющие одно действие, это хорошо. Но что если одно действие выполняет большая функция? Стоит ли ее разбивать на маленькие функции, задача которых размыта? Должно ли это зависеть от того, возможно ли эту маленькую функцию переиспользовать? А что насчет принципа единственности ответственности? Где гарантия, что вынесенный блок кода не потребуется изменить только для одного из потребителей? На самом деле, высосанные из пальца функции выявить очень легко — им сложно придумать простое название. Обычно получается что-то в духе addRoomWatchdogIfOnlyBotClientsLeftInRoom или canSendMessageToSocketOrCanStoreItInQueue.

Если подытожить все сказанное выше, то промежуточный вариант идеального кода, реализующего спецификацию, должен выглядеть как-то так:

async function runProgram () {
while (true) {
clearConsole()

writeTodoItems()

const input = await readLine('Write the next command: ')

if (input === '.') { return }

if (input.startsWith('+')) {
const text = input.substring(1).trim()

if (text.length === 0) {
await notify('Can not add an empty item')

continue
}

addTodoItem(text)

continue
}

await notify('Wrong command')
}
}
Потом, когда команд станет больше, обработку каждой из них можно будет вынести в отдельную функцию.

Типизация​

Как некоторые уже заметили, в представленном выше коде не хватает типов. Я сторонник статической типизации, однако, мне не нравится "Горизонтальная" природа TypeScript. Так как информация о типах размещается на той же строке, что и исполняемый код, то, чтобы уложиться в ширину экрана, во многих местах приходится одну строку разбивать на несколько. Особенно часто и сильно от этого страдают объявления функций — некоторые строки получаются длиннее других до 20-30 раз, что не добавляет читаемости.

Я уже высказывался в одной из дискуссий на Github, где сразу набежали хейтеры и меня заминусовали, что дальнейшее развитие TypeScript, как языка программирования, бессмысленно, ведь их компилятор научился проверять в .js файлах типы, описанные через JsDoc. Теперь ничего не надо компилировать, да еще и декларации типов расположены на отдельной строке. Все, о чем можно было мечтать. Если вы уже пишете проект на TypeScript, то тратить время на переписывание не стоит, а вот новые проекты определенно следует попробовать типизировать через JsDoc.

Давайте добавим декларацию типов к нашей функции.

/** @type {() => Promise<void>} */
async function runProgram () {
...
}
Также понадобится определить, в каком формате в системе будут храниться задачи.

/**
* @typedef {{
* text: string
* id: number
* }} Item
*/

Возможность тестирования​

Основным условием для обеспечения возможности unit-тестирования полученного кода является добавление механизма инверсии зависимостей. Он позволит подменять реализации функций для тестового и рабочего окружений. Чтобы использовать этот механизм правильно, следует разобраться, какие именно участки кода должны быть заменяемыми, и как правильно все организовать.

В объектно-ориентированных системах друг в друга пробрасываются сервисы. Это требует затрат времени на проектирование иерархии зависимостей и, в целом, вообще является оверинжинирингом. На самом деле, в подмене нуждаются только те функции, которые производят побочные эффекты. Наборы таких функций, будем называть их алгебрами эффектов, можно разделить на два вида — фасады и репозитории. Технически, работа с ними ничем не отличается. Разные названия нужны лишь для того, чтобы показать разное назначение — репозитории предоставляют доступ к состоянию, а фасады являются прослойкой между вычислениями и внешней средой.

С точки зрения JavaScript, алгебры на множестве всех типов это просто объекты, поля которых являются функциями. Также стоит отметить, что набор операций алгебры образует встраиваемый предметно-ориентированный язык, конкретная реализация которого называется интерпретатором. Договоримся, что, когда будем говорить об уровне типов, будем использовать слово "Алгебра", а когда о runtime объектах, удовлетворяющих типу алгебры — "Интерпретатор".

Отдельно стоит обратить внимание на работу с состоянием программы, хранящимся в памяти процесса. В процедурной парадигме, функции, когда им требуется доступ к состоянию, обращаются напрямую к глобальным переменным. Намного удобнее спрятать эти переменные за репозиториями, что позволит работать с ними так же, как и с другими побочными эффектами.

В случае нашего консольного приложения, будет необходим фасад для взаимодействия с консолью и два репозитория — первый даст возможность хранить и получать объект задачи по ее идентификатору, а второй будет представлять собой счетчик, хранящий уникальный идентификатор следующей добавляемой задачи — аналог автоинкрементируемого id в базах данных. Все остальные вычисления можно описать статически, на основе представленных выше трех алгебр. Под статическим описанием вычислений следует понимать не статические методы классов, а обычные функции, к которым другие, такие же вычисления, могут обращаться напрямую, без инверсии зависимостей.

Итак, достаточно выделить набор функций, порождающих все возможные побочные эффекты программы, а затем, на его основе, статически описать требуемую логику. В этом деле важно правильно выбирать уровень абстракции, на котором будут находиться эффекты. Например, если стоит задача вывода логов в консоль, с отображением времени их появления, то разумно будет выделить два фасада — первый, отвечающий за работу с консолью, и второй, возвращающий текущую дату, что также является побочным эффектом. Само вычисление, которое будет использовать эти фасады, может быть объявлено статическим. Второй пример — работа с базой данных. Здесь можно рассматривать эффекты на уровне выполнения произвольных sql запросов, или же подняться на уровень выше и обозначить, в качестве эффектов, множество необходимых вызовов, для которых уже существуют заранее подготовленные запросы. Второй способ выгоднее, так как в тестовой реализации интерпретатора не придется заниматься парсингом sql.

Следующий вопрос — как именно производить инъекцию зависимостей. В объектно-ориентированном подходе все зависимости передаются в сервисы через конструктор и сохраняются в полях. Это не удобно, так как вынуждает использовать устаревшую модель программирования, основанную на прототипах. Особенно доставляет боль тот факт, что методы классов теряют контекст, если попробовать их передать в функцию высшего порядка. Чтобы это обойти, приходится писать конструкции вида

const result = array.map(this.myFacade.doSomething.bind(this.myFacade))
Это прекрасно лечится заменой классов на замыкания. Однако, есть еще одна проблема: зависимости по прежнему передаются списком, и доступ к каждой зависимости происходит через ее собственный параметр функции. Если для выполнения действий требуются 5-10 зависимостей, то код, отвечающий за инстанцирование, будет по объему примерно равен самой логике программы. В Tagless Final решили эту проблему, предложив просто объединить все фасады и репозитории в один объект при помощи spread оператора. Тогда этот объект всех объектов будет удовлетворять любому требуемому объединению алгебр эффектов, о котором известно в программе.

Описание алгебр эффектов​

Ладно, давайте посмотрим на алгебру репозитория, хранящего задачи

/** @typedef {{ hasTodoItem(id: number): boolean }} HasTodoItem */

/** @typedef {{ removeTodoItem(id: number): void }} RemoveTodoItem */

/** @typedef {{ getTodoItems(): ReadonlyArray<Item> }} GetTodoItems */

/** @typedef {{ addTodoItem(id: number, item: Item): void }} AddTodoItem */

/**
* @typedef {(
* & HasTodoItem
* & AddTodoItem
* & GetTodoItems
* & RemoveTodoItem
* )} TodoItemsRepository
*/
Здесь мы можем наблюдать список из 4 алгебр операций. Каждая такая алгебра содержит в себе всего одно поле-функцию. Затем, при помощи операции объединения типов, из этих 4 алгебр получается одна алгебра репозитория, содержащая в себе 4 операции. 4(1) -> 1(4).

Аналогичным образом объявим алгебры для репозитория-счетчика идентификаторов:

/** @typedef {{ getNextTodoId(): number }} GetNextTodoId */

/** @typedef {{ incrementNextTodoId(): void }} IncrementNextTodoId */

/**
* @typedef {(
* & GetNextTodoId
* & IncrementNextTodoId
* )} TodoIdsRepository
*/
И фасада логирования:

/** @typedef {{ clearConsole(): void }} ClearConsole */

/** @typedef {{ write(message: string): void }} Write */

/** @typedef {{ readLine(question: string): Promise }} ReadLine */

/**
* @typedef {(
* & Write
* & ReadLine
* & ClearConsole
* )} ConsoleFacade
*/
Далее, когда все алгебры фасадов и репозиториев готовы, наступает время объединить их в алгебры сервисов. Принцип прост — если несколько алгебр не могут использоваться друг без друга, то они должны быть объединены. В нашем случае, репозиторий, хранящий задачи, не рационально использовать без репозитория, хранящего идентификатор следующей задачи.

/**
* @typedef {(
* & TodoIdsRepository
* & TodoItemsRepository
* )} TodoItemsAlgebra
*/
Фасад консоли может быть использован сам по себе, так что в состав сервиса консоли входит только он один.

/**
* @typedef {(
* & ConsoleFacade
* )} ConsoleAlgebra
*/
И последний этап — объединить все алгебры сервисов в единую алгебру приложения.

/**
* @typedef {(
* & ConsoleAlgebra
* & TodoItemsAlgebra
* )} Program
*/

Реализация интерпретаторов​

Теперь, когда есть алгебры, можно реализовать интерпретаторы, которые будут использоваться в рабочем запуске приложения.

Интерпретатор репозитория задач

/** @type {Map<number, Item>} */
const items = new Map()

/** @type {TodoItemsRepository} */
const todoItemsRepository = {
getTodoItems: () => Array.from(items.values()),
addTodoItem: (id, item) => item.set(id, item),
removeTodoItem: id => items.delete(id),
hasTodoItem: id => items.has(id),
}
Интерпретатор, хранящий идентификатор для следующей задачи

/** @type {number} */
let nextId = startTodoId

/** @type {TodoIdsRepository['getNextTodoId']} */
function getNextTodoId () { return nextId }

/** @type {TodoIdsRepository['incrementNextTodoId']} */
function incrementNextTodoId () { nextId = nextId + 1 }

/** @type {TodoIdsRepository} */
const todoIdsRepository = {
incrementNextTodoId,
getNextTodoId,
}
Интерпретатор для фасада консоли

const c = readline.createInterface({
input: process.stdin,
output: process.stdout
})

/** @type {ConsoleFacade['write']} */
function write (message) {
c.write(message)
}

/** @type {ConsoleFacade['readLine']} */
function readLine (question) {
return new Promise(resolve => {
c.question(question, resolve)
})
}

/** @type {ConsoleFacade['clearConsole']} */
function clearConsole () {
console.clear()
}

/** @type {ConsoleFacade} */
const consoleFacade = { readLine, write, clearConsole }
Теперь, по аналогии с алгебрами, создадим интерпретаторы для сервисов

/** @type {TodoItemsAlgebra} */
const todoItemsAlgebra = {
...todoItemsRepository,
...todoIdsRepository,
}

/** @type {ConsoleAlgebra} */
const consoleAlgebra = {
...consoleFacade
}
И интерпретатор приложения

/** @type {Program} */
const program = {
...todoItemsAlgebra,
...consoleAlgebra,
}

Философия UNIX​

Часто приходится слышать мнение, что программы стоит проектировать в соответствии с философией UNIX. Когда начинаешь узнавать, в чем, по мнению говорящего, она заключается — в ответ слышишь: "Нужно писать маленькие программы, которые делают одно дело, но делают его лучше остальных". Подобная трактовка этой философии уже успела завести человечество в ад микросервисов, из которого, пока, мало кто хочет выбираться.

Что же упущено здесь из виду? Давайте посмотрим на две программы, которые являются эталонными представителями философии UNIX — grep и sed. Какими еще особенностями они обладают, помимо того, что выполняют единственную задачу? Во-первых, они ничего не знают друг про друга. Чтобы использовать несколько независимых программ вместе, их достаточно просто объединить при помощи слоя высшего порядка, которым выступает Shell, в случае UNIX. Получается красивая двуслойная архитектура — множество независимых приложений, на нижнем уровне, объединяются в одно на верхнем. Сравните это с графом асинхронно взаимодействующих друг с другом микросервисов. Или хотя-бы с иерархией объектно-ориентированных сервисов, взаимодействующих точно так же, но синхронно.

Так как все в нашем мире фрактально, можно опустить эту же двуслойную методологию на уровень монолитного приложения. Вместо приложений будут независимые сервисы, а заменой Shell станет обычный код на JavaScript. Если посмотреть еще глубже, то можно заметить, что алгебры внутри сервиса тоже независимы. И даже отдельные операции ничего не знают друг о друге.

Вычисления​

Теперь, когда интерпретаторы готовы, можно на их основе реализовать вычисления.

Вычисления делятся на два вида: сервисные и прецедентные. Сервисные вычисления строятся на основе алгебры сервиса, что позволяет легко переиспользовать их в других проектах. Так как сервисы полностью независимы друг от друга, то для этого достаточно просто скопировать папку с сервисом в нужный проект и подключить алгебру сервиса к алгебре приложения, а интерпретатор сервиса к интерпретатору приложения. Вычисления прецедентов являются, с точки зрения описанного в предыдущем разделе, вторым, верхним, слоем архитектуры, который объединяет все сервисы воедино. Они зависят от алгебры приложения.

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

/** @type {(P: TodoItemsAlgebra) => (text: string) => void} */
const addTodoItem = P => text => {
const id = P.getNextTodoId()

/** @type {Item} */
const item = { id, text }

P.addTodoItem(id, item)

P.incrementNextTodoId()
}
Вычисление принимает, в качестве единственного параметра, интерпретатор, удовлетворяющий алгебре сервиса, а затем возвращает функцию, принимающую текст задачи и выполняющую действия по ее добавлению. Сначала вызывается операция getNextTodoId, являющаяся частью интерпретатора, и возвращающая идентификатор для новой задачи. Затем строится объект задачи и добавляется к списку при помощи операции addTodoItem. Чтобы в следующий раз задача создавалась уже с новым идентификатором, необходимо его инкрементировать, вызвав incrementNextTodoId.

Важно отметить, что на данном этапе можно отбросить знание о том, что операции принадлежат к разным репозиториям. Поэтому я намеренно опустил, что addTodoItem, например, принадлежит к TodoItemsRepository.

Чтобы использовать данное вычисление, достаточно передать в него любой интерпретатор, удовлетворяющий алгебре TodoItemsAlgebra. Это может быть сервисный интерпретатор, либо же интерпретатор приложения, так как множество операций сервисной алгебры является подмножеством операций алгебры приложения.

addTodoItem(todoItemsAlgebra)('task1')
// Is equal to
addTodoItem(program)('task1')
Обычно, сервисные интерпретаторы напрямую используются только при тестировании сервисных вычислений, чтобы не создавать за зря интерпретатор приложения.

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

const texts = ['task1', 'task2']

texts.forEach(text => addTodoItem(program)(text))
Как можно заметить, применение анонимной функции здесь излишне, так как результатом addTodoItem(program) уже является функция, принимающая текст и добавляющая задачу. Поэтому вызов можно упростить:

texts.forEach(addTodoItem(program))
Если бы интерпретатор не передавался отдельно, то, в этом случае, анонимную функцию создать все-же пришлось бы.

Также на уровне сервисных вычислений, но теперь уже на основе сервиса консоли, возможно реализовать функцию notify, оповещающую пользователя о чем-либо.

/** @type {(P: ConsoleAlgebra) => (text: string) => Promise<void>} */
const notify = P => async text => {
P.write('\n' + text + '\n')

await P.readLine('Press enter to continue')
}
Она выводит сообщение на экран, обрамляя его символами новой строки, а затем ожидает подтверждение от пользователя.

Чтобы вывести задачи на экран, необходимо обратиться сразу к двум сервисам: сервису задач и сервису консоли. Это значит, что такое вычисление будет прецедентным, зависящим от алгебры приложения.

/** @type {(P: Program) => (item: Item) => void} */
const writeTodoItem = P => item => P.write(`${item.id}) ${item.text}\n`)

/** @type {(P: Program) => () => void} */
const writeTodoItems = P => () => {
const items = P.getTodoItems()

if (items.length === 0) {
P.write('TODO list is empty\n')
} else {
P.write('TODO items list:\n')

items.forEach(writeTodoItem(P))
}
}
При написании кода было учтено, что вывод каждой отдельной задачи можно также представить в виде самостоятельного вычисления. Технически, оно зависит только от сервиса консоли, но его логика настолько специфична, что вряд-ли оно может понадобиться кому-то, кто будет переиспользовать этот сервис в другом приложении. Поэтому вычисление было решено объявить прецедентным.

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

В данном конкретном случае, вычисление writeTodoItems принимает интерпретатор через параметр P, а затем передает его во writeTodoItem:

items.forEach(writeTodoItem(P))
Осталось переписать самую главную функцию, чтобы она соответствовала новым правилам.

/** @type {(P: Program) => () => Promise<void>} */
async function runProgram = P => () => {
while (true) {
P.clearConsole()

writeTodoItems(P)()

const input = await P.readLine('Write the next command: ')

if (input === '.') { return }

if (input.startsWith('+')) {
const text = input.substring(1).trim()

if (text.length === 0) {
await notify(P)('Can not add an empty item')

continue
}

addTodoItem(P)(text)

continue
}

await notify(P)('Wrong command')
}
}
Все побочные эффекты, которые ранее могли быть выполнены напрямую, теперь проксируются через интерпретатор. Там, где требуется более сложная логика, интерпретатор передается в вычисления, которые берут часть работы на себя. А вот внешне код выглядит почти так же, как и выглядел ранее.

Последнее, что остается — на инфраструктурном уровне запустить алгоритм.

runProgram(program)()
Вот, как действительно должна выглядеть разработка программ по философии UNIX.

Итоги​

Подведем итоги, выделив три правила:

  1. Побочные эффекты должны быть абстрагированы встраиваемым предметно-ориентированным языком, выраженным алгеброй на множестве всех типов.
  2. Отдельные алгебры, которые не могут использоваться в отрыве друг от друга, должны быть объединены в сервисы. Алгебры сервисов должны быть объединены в алгебру приложения.
  3. Все вычисления, основанные на полученном предметно-ориентированном языке, должны делиться на два вида: сервисные вычисления, которые зависят от алгебры сервиса, и вычисления прецедентов, зависящие от алгебры приложения.
Для желающих глубже разобраться я создал репозиторий с кодом рассмотренной программы и даже чуть больше: awerlogus/todo-app-example.

Забыл сказать — на данный момент подход называется "Восьмая архитектура". После пятой архитектуры нам стало лень придумывать имена, и мы стали просто обозначать все новые архитектуры по порядковому номеру. Если есть идеи, как можно назвать получше — предлагайте в комментариях.

В следующей статье поговорим про написание тестов для вычислений.

Благодарю за внимание.

 
Сверху