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

Я занимаюсь разработкой различного программного обеспечения уже более 25 лет и сталкивался с очень разными представлениями об архитектуре программного обеспечения. Когда говорят об архитектуре ПО, то чаще всего имеют в виду одно из следующих понятий.

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

Архитектура программной системы — обычно это состав и структура, инструменты реализации функциональных и нефункциональных требований к комплексу прикладных программ (приложений), которые необходимо разработать для решения поставленных задач. Основой обычно является клиент‑серверная парадигма. Серверная часть именуется бэкендом, в качестве клиентов выступают мобильные приложения и фронтенд‑приложения, работающие в веб‑браузерах, приложения для десктопных операционных систем (Windows, MacOS, Linux), а также бэкенды других систем и приложений в рамках интеграций с ними.

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

Моя статья посвящена теме архитектуры приложений. Я хочу кратко, просто, но емко систематизировать самые основные сведения, идеи и практические шаблоны, с которыми мне приходилось работать. Я буду опираться на идеи авторитетных авторов и здравый смысл и не претендую на единственно верное мнение. Наверняка найдется множество людей, чей опыт будет противоречить моим тезисам.

Я буду рассматривать архитектуру приложения на примере бэкенда веб-приложений. Примеры будут для языка Typescript и платформы Node.js. При этом большую часть идей несложно переложить на другие языки программирования и другие типы приложений.

Почему архитектура приложения важна

Уважаемый автор концепции Clean Architecture Роберт Мартин рассуждает об архитектуре приложений примерно так.

ПО можно охарактеризовать поведением и архитектурой. Поведение — это какие задачи решает приложение и насколько хорошо, то есть его функциональность. Например, какие отчеты позволяет создавать приложение и как быстро они создаются. Архитектура же определяет сложность (и косвенно — стоимость) поддержки и развития приложения. Бизнес и менеджеры обычно в первую очередь ценят поведение, функции приложения. В то же время, именно архитектура со временем начинает ограничивать скорость внедрения нового функционала, повышать стоимость разработки и тогда к разработчикам возникают претензии. Нередко неудачная архитектура начинает негативно влиять и на функционал — снижать производительность и надежность приложения, создавать повышенную нагрузку на БД, потреблять много памяти, приводя к неожиданным отказам приложения. Таким образом, если разработчики будут пренебрегать архитектурой под давлением сроков и дедлайнов, то в итоге они будут объявлены виновными в проблемах биз��еса. Поэтому разработчикам недостаточно просто решать поставленные задачи, обеспечивать надежность и производительность приложения здесь и сейчас, разработчикам необходимо думать о сложности и трудоемкости наращивания функционала приложения в будущем с прогнозируемой скоростью. Именно за это отвечает архитектура, с точки зрения Роберта Мартина.

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

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

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

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

Последствия плохой архитектуры

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

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

Часто плохая архитектура дает о себе знать под нагрузкой. В начале все работает хорошо. Но потом приходят несколько тысяч пользователей и приложение начинает сбоить, выдавать ошибки или полностью перестает отвечать. Также бывает, что при тестировании приложения пользователи оперировали десятком записей в БД, а потом начинается реальная эксплуатация и программа не тянет обработку десятков тысяч записей в БД, не говоря уж о миллионах. Не все разработчики проводят нагрузочное тестирование своих программ, поэтому недостатки архитектуры проявляются только на этапе эксплуатации. Неспособность надежно работать под нагрузкой — одно из самых ярких и наглядных проявлений плохой архитектуры.

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

Признаки хорошей архитектуры приложения

К признакам хорошей архитектуры ПО обычно относят следующее.

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

Модульность — исходный код просто и логично структурирован, разбит на компоненты таким образом, что помогает реализовать другие критерии хорошей архитектуры.

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

Тестируемость — структура исходного кода не создает существенных трудностей для разработки модульных автотестов, разработка тестов не становится слишком трудоемкой задачей.

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

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

Низкая связанность (Low Coupling), независимость — модули исходного кода минимально зависят друг от друга, что позволяет модифицировать один модуль, не затрагивая остальные.

Опытные разработчики наверняка найдут что еще можно добавить в этот перечень.

Что помогает создавать хорошие архитектуры

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

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

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

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

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

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

Примеры применения этих принципов я продемонстрирую далее.

Слоистая архитектура

Основным распространённым и широко применяемым подходом для реализации хорошей архитектуры является принцип слоистой архитектуры. Суть в том, чтобы разделить приложение на изолированные слои или уровни, выполняющие строго свои функции. Это могут быть такие слои: интерфейс пользователя или сетевого клиента, слой бизнес‑логики, слой доступа к источникам данных, слой низкоуровневого доступа к оборудованию (например, управление механизмами), слой сетевого взаимодействия по какому‑либо протоколу и так далее. При взаимодействии слоев рекомендуется использовать абстрактные интерфейсы и внедрение зависимостей вместо прямого обращения к объектам другого слоя.

Далее буду объяснять на примере бэкенда веб‑приложений, но этот принцип несложно применить и к другим видам приложений.

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

Приложение бэкенда реализует функционал веб‑сервера (сервера протокола HTTP) с помощью различных фреймворков или библиотек. Для платформы Node.js в простейшем случае можно воспользоваться функционалом встроенной библиотеки поддержки протокола http, но обычно используют один из популярных фреймворков. Исторически одним из первых широко распространенных таких фреймворков стал Express.js, затем появились Koa.js, Fastify, которые сохранили основные принципы express.js, но улучшили производительность и некоторые другие характеристики. Одним из самых новых express‑подобных фреймворков сейчас является Hono — легковесный и производительный http‑фреймворк с полной поддержкой Typescript. В дальнейшем я буду приводить примеры на основе Hono.

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

Контроллеры маршрутов (путей, эндпоинтов) — это точки входа в бэкенд‑приложение для клиентов. Этот слой зависит от выбранного фреймворка HTTP‑сервера и тесно с ним взаимодействует. На этом слое мы реализуем получение параметров из HTTP‑запроса и структурную валидацию этих параметров (их наличие, типизацию, допустимые значения), возвращаем клиенту ошибки валидации параметров. Если параметры в порядке, то затем вызывается основная бизнес‑логика приложения, реализованная в слое приложения. Ответ или ошибки, которые вернет слой приложения затем могут быть переупакованы перед отправкой клиенту в слое контроллеров.

Слой приложения — тут реализуется бизнес‑логика приложения с абстрагированием от источников данных (БД и внешних систем). Источники данных для слоя приложения должны быть просто функциями или классами, которые делают определенную работу. Детали работы с источниками данных должны быть изолированы от слоя приложения в слое провайдеров/репозиториев данных. Если вы видите текст SQL‑запроса или fetch‑запрос к веб‑ресурсу в бизнес‑логике (в слое приложения) или в обработчике HTTP‑запроса (в контроллере) — то перед вами пример плохой архитектуры.

Слой репозиториев и провайдеров данных — тут реализуются операции доступа к данным — получение данных, изменение данных с помощью ORM и библиотек доступа к БД или внешним системам.

Тут кто‑то может задаться вопросом — можем ли мы использовать ORM в качестве слоя репозитория, зачем создавать еще один слой? Ответ такой — желательно всегда создавать отдельный слой репозитория, по многим причинам. Работа с ORM вместо репозитория вынудит вас использовать имена таблиц и сущностей на уровне приложения, что уже в процессе разработки бизнес‑логики жестко привяжет вас к структуре БД и в случае изменения этой структуры заставит вас каскадно внести множество изменений на в слое приложения. В случае использования репозитория вы можете более гибко управлять отображением абстракций приложения на структуру БД. Использование отдельного слоя репозиториев упрощает написание автоматических тестов для слоя приложения (проще реализовывать и переиспользовать моки репозиториев, чем мокать ORM). Далее я поясню это подробнее.

В чем удобство разделения кода приложения на такие слои?

Слои независимы друг от друга и неплохо изолированы. Вы можете создать API вашего приложения для клиентов на слое контроллеров, сформировать API и интерфейсы слоя приложения и далее распараллелить работу — кто‑то из разработчиков будет заниматься контроллерами, а кому‑то достанется реализация кусков бизнес‑логики. При этом вопросы выбора БД и разработки слоя репозиториев могут быть отложены на более позднее время. Разрабатывая бизнес‑логику, вы можете определить API и интерфейсы репозиториев, замокать их для разработки тестов и сосредоточиться на бизнес‑логике, а репозитории и провайдеры могут разрабатываться и тестироваться отдельно от нее.

Изоляция слоя приложения от слоя контроллеров и слоя инфраструктуры данных (репозиториев и провайдеров) позволяет при необходимости заменить HTTP‑фреймворк или базу данных или другие источники данных без необходимости менять что‑либо в слое бизнес‑логики.

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

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

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

На уровне контроллеров мы соединяем бизнес‑логику и репозитории, внедряем репозитории в модули бизнес‑логики. Хотя можно пойти и дальше — скомпоновать слои на более высоком уровне сборки и запуска приложения. Тогда контроллер будет знать о бизнес‑логике лишь ее абстрактный интерфейс.

Пример приложения бэкенда

Вот фрагмент кода, который создает контроллеры, обрабатывающие http‑запросы о неких продуктах. Файл экспортирует функцию создания обработчиков маршрутов. Параметры этой функции — объект для работы с БД и интерфейс ILogger, описывающий нужный нам функционал логгера — методы info и error. При вызове этой функции мы сможем подставить нужную нам реализацию логгера, например логгер, который выводит сообщения на экран или логгер, записывающий сообщения в файл или отправляющий сообщения в таблицу БД и так далее. Этот выбор мы можем сделать позже или даже реализовать несколько логгеров и подставлять нужный в зависимости от настроек приложения. Экземпляр объекта репозитория мы создаем тут же, это делается один раз при настройке маршрутов, затем этот объект‑репозиторий используется в контроллере каждый раз при обработке клиентского запроса. Функция‑обработчик маршрута (контроллер) обращается к объекту репозитория, созданному в области видимости родительской функции — этот прием называется замыкание и часто используется в js/ts коде.

import { Hono } from 'hono'
import { PGlite } from '@electric-sql/pglite'
import { ILogger } from '@common/ILogger'
import { ProductRepo } from '@repo/ProductRepo'
import { createProduct } from '@application/product'
import { HTTPException } from 'hono/http-exception'

export function createProductApiRouter(
  {
    pglite,
    logger,
  }: {
    pglite: PGlite,
    logger: ILogger,
  }
) {
  const productApiRouter = new Hono()

  logger.info('createProductApiRouter, ' + `pglite.ready: ${pglite.ready.toString()}`)

  // создадим экземпляр репозитория
  const productRepo = new ProductRepo(pglite)

  productApiRouter.get('/', async (context) => {
    // пример плохой архитектуры
    const response = (await pglite.query(
      'SELECT * FROM product;',
    )).rows

    return context.json(response)
  });

  productApiRouter.post('/', async (context) => {
    let values: any = {}
    try {
      values = await context.req.json()
      // тут может быть расширенная валидация параметров 
    } catch(err) {
      logger.error(err)
      throw new HTTPException(400, { message: (err as Error).message })
    }

    const response = await createProduct(productRepo, values)
    return context.json(response);
  });

  return productApiRouter
}

Первый контроллер демонстрирует пример плохой архитектуры, так как прямо в контроллере выполняется SQL‑запрос. Если у нас что‑то изменится в БД — поменяется БД, имя таблицы, появятся скрытые поля или условия отображения записей (например, при мягком удалении — нужно обрабатывать признак удаленных записей через специальное поле) и так далее, то нам придется искать и исправлять по всему коду места, где мы обращаемся к этой таблице. Эти проблемы минимизируются, если мы для доступа к данным используем репозиторий, тогда все изменения будут выполняться только в нем.

Второй контроллер берет атрибуты создаваемого продукта из тела POST‑запроса и создает исключение, если не удается прочитать тело запроса как JSON‑объект. Если атрибуты продукта получены, то затем вызывается функция слоя приложения createProduct, в которую передаются экземпляры репозитория, логгера и атрибуты продукта.

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

import { ICreateProductParams, IProductAttributes } from '../types'
import { randomUUID } from 'crypto'

export interface ICreateProductRepo {
  create: (values: IProductAttributes) => Promise<IProductAttributes | null>
}

export async function createProduct(
  productRepo: ICreateProductRepo,
  values: ICreateProductParams,
): Promise<IProductAttributes | null> {
  const result = await productRepo.create({
    id: randomUUID(),
    title: values.title,
    price: values.price ?? 0,
  })

  return result 
}

Для реализации логики createProduct нам нужен будет лишь минимальный набор функциональности репозитория — метод создания продукта. Вот это еще один пример внедрения зависимостей. Мы просим репозиторий, который умеет создавать продукты и нам для нашей бизнес‑логики неважно, как и где будет записан продукт — будет это оперативная память, файл или какая‑то БД. В качестве уникального идентификатора продукта тут используется uuid — уникальный случайный строковый идентификатор, созданный по правилам стандарта RFC 9562.

Использование интерфейсов, а не конкретных классов дает нам возможность написать модульный тест, который протестирует функциональность бизнес‑логики (функции createProduct слоя приложения) без использования БД. В тесте мы передадим «мок» репозитория, то есть специальный объект, созданный для целей тестирования. Основная задача мока — реализовать интерфейс и вернуть ожидаемый результат.

Перед тем как рассмотрим модульный тест, давайте рассмотрим ситуацию, когда со временем требования к бизнес‑логике изменились.

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

Для обработки ошибок при разработке программ используются один из двух подходов или их сочетание:

  • Ошибка как результат

  • Исключения

Политику применения этих практик в одном приложении я бы сформулировал так: cтоит придерживаться одного варианта обработки ошибок на каждом слое.

Например, на слое контроллеров мы зависим от выбранного фреймворка и, если там принято использовать исключения, мы используем исключения. Конкретно в случае Hono принято выбрасывать исключения типа HTTPException, которые затем обрабатывает перехватчик исключений фреймворка и генерирует соответствующие им HTTP‑ответы с ошибками.

На слое бизнес‑логики лично я считаю уместным использовать технику «ошибка как результат», так как это улучшает контроль за потоком выполнения кода и тестируемость.

Есть еще несколько моментов, которые можно улучшить.

По сути решаемых задач элементы бизнес‑логики (запрашиваемые операции приложения) можно разделить на команды и запросы. Запросы — это операции чтения, выборки данных по каким‑либо критериям, которые не приводят к изменению данных приложения. Команды — это операции изменения состояния данных приложения. В нашем случае создание нового продукта — это команда.

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

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

import { EProductAppError, ICreateProductParams, IProductAttributes } from '../types'
import { FunctionResult, FunctionResultFactory } from '../../common/FunctionResult'
import { randomUUID } from 'crypto'

export interface ICreateProductRepo {
  create: (values: IProductAttributes) => Promise<IProductAttributes | null>
  findByTitleEqual: (title: string) => Promise<IProductAttributes[]>
}

export class CreateProductCommand {
  constructor (
    private readonly productRepo: ICreateProductRepo,
  ) {}

  public async execute(
    values: ICreateProductParams
  ): Promise<FunctionResult<IProductAttributes | null, string>> {
    const findResult = await this.productRepo.findByTitleEqual(values.title)
    if (findResult.length) {
      return FunctionResultFactory.fail(EProductAppError.TitleIsNotUnique)
    }
  
    const update_date_time = new Date().toISOString()
  
    const result = await this.productRepo.create({
      id: randomUUID(),
      title: values.title,
      price: values.price ?? 0,
    })
  
    return FunctionResultFactory.success(result)
  }
}

Что тут нового?

Во‑первых, теперь команда представлена классом, конструктор которого принимает репозиторий. Язык Typescript позволяет определить свойство вместе с параметром конструктора, сокращая исходный код. Единственный метод команды execute возвращает экземпляр специального типа результата FunctionResult, который может хранить успешный результат или ошибку. Объект результата типа FunctionResult создается одним из статических методов фабрики FunctionResultFactory — success или fail. Это не часть стандартной библиотеки или Hono, это вспомогательные объекты данного проекта.

Интерфейс репозитория, нужный для работы CreateProductCommand, расширился и теперь также включает метод поиска по совпадению title, который возвращает массив найденных продуктов. Хотя, строго говоря, с учетом новой функциональности у нас будет максимум только одна запись в БД с уникальным title. Но вдруг ранее уже было создано много записей, так что пусть вернет все. Если количество записей ненулевое (приводится к логическому TRUE), то вернем код ошибки. Иначе запишем и вернем успешный результат. Возможные ошибки приложения определены в перечислении (enum) EProductAppError.

Контроллер тоже можно улучшить и переписать в виде класса, например, так.

import { Context, Hono } from 'hono'
import { PGlite } from '@electric-sql/pglite'
import { ILogger } from '@common/ILogger'
import { getProductsAll } from '@application/product/getProductsAll'
import { ProductRepo } from '@repo/ProductRepo'
import { HTTPException } from 'hono/http-exception'
import { CreateProductCommand } from '@application/product/CreateProductCommand'

// TODO: добавить валидацию входных параметров 
class ProductApiController {
  constructor (
    private readonly logger: ILogger,
    private readonly productRepo: ProductRepo,
  ) {
    this.commandCreate = this.commandCreate.bind(this)
  }

  public async commandCreate(context: Context) {
    let values: any = {}
    try {
      values = await context.req.json()
    } catch(err) {
      this.logger.error(err)
      throw new HTTPException(400, { message: (err as Error).message })
    }

    const createProductCommand = new CreateProductCommand(this.productRepo)
    const response = await createProductCommand.execute(values)
    if (response.isSuccess) {
      return context.json(response.value)
    } else {
      throw new HTTPException(400, { message: response.error! })
    }
  }

  public createHonoRouter() {
    this.logger.info('ProductApiController.createHonoRouter')
    const productApiRouter = new Hono()

    productApiRouter.get('/', async (context) => {
      // и так тоже нехорошо
      const response = await getProductsAll(this.productRepo)
      return context.json(response)
    });
    productApiRouter.post('/', this.commandCreate)
 
    return productApiRouter
  }
}

export function createProductApiRouter(
  {
    pglite,
    logger,
  }: {
    pglite: PGlite,
    logger: ILogger,
  }
) {
  logger.info('createProductApiRouterNew' + `pglite.ready: ${pglite.ready.toString()}`)

  const productRepo = new ProductRepo(pglite)
  const productApiController = new ProductApiController(logger, productRepo)

  return productApiController.createHonoRouter()
}

Что тут нового?

Функционал контроллера представлен классом ProductApiController, а модуль, как и ранее, экспортирует функцию createProductApiRouter, которая теперь создает экземпляр контроллера и возвращает результат вызова его публичного метода createHonoRouter.

Метод ProductApiController.createHonoRouter создает экземпляр Hono и настраивает маршруты. На POST / в качестве обработчика назначается метод контроллера commandCreate, который использует команду CreateProductCommand слоя приложения.

В конструкторе ProductApiController выполняется важная операция привязки контекста, необходимость привязать контекст метода commandCreate возникает из‑за того, что метод используется как колбэк в методе другого объекта. Это особенность языка JavaScript.

В методе commandCreate контроллера в зависимости от response.isSuccess клиенту возвращается результат, либо выбрасывается ошибка с кодом 400 (дублирующиеся данные — это тоже некорректный запрос).

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

Рекомендации по разработке слоя приложения

Как уже говорилось выше, операции слоя приложения можно условно разделить на два вида: запросы (только получение данных без изменения) и команды (изменение данных).

Обобщенная схема бизнес‑логики запросов состоит из таких типичных этапов:

  • Проверка бизнес‑правил (с возвратом ошибок) и формирование фильтров на выборку данных

  • Получение данных от слоя провайдеров/репозиториев

  • Проверка бизнес‑правил (с возвратом ошибок) для полученных данных

  • Формирование результата — маппинг (отображение в другую структуру, переупаковка)

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

  • Проверка бизнес‑правил (с возвратом ошибок) и формирование фильтров на выборку данных

  • Получение данных от слоя провайдеров/репозиториев

  • Проверка бизнес‑правил (с возвратом ошибок) для полученных данных

  • Формирование заданий на изменение данных

  • Собственно изменение данных (обычно атомарно, в транзакции)

  • Если необходимо, выпуск событий об изменении данных и/или протоколирование

  • Формирование ��езультата — маппинг (отображение в другую структуру, переупаковка)

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

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

Оборачивание единичной операции в цикл приводит к деградации производительности. Если единичная операция требует Х времени, то массовая операция для N объектов потребует X * N времени. Если клиент ждет ответа, то может не дождаться. Тогда разработчики начинают строить схему с асинхронной обработкой — вначале клиент запускает операцию, а затем опрашивает готовность результата.

Оборачивание единичной операции в множество параллельных запусков, чтобы работало быстрее цикла, приводит к другой проблеме — множеству параллельных транзакций и нагрузке на БД.

Оба варианта плохи. А хороший вариант заключается в том, чтобы заранее предположить возможность массовой операции и разработать массовую операцию (или переписать по необходимости единичную на действительно массовую), а единичную операцию запускать как массовую для одного объекта.

Рассмотрим пример.

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

Предположим, на слое приложения есть примерно такой код.

public async updateProductPriceById(id: string, factor: number) {
    // stage 1
    const product = await this.productRepo.findById(id)
    if (product) {
        // stage 2
        const price = product.price * factor

        // stage 3
        await this.productRepo.updateById(id, { price })

        return true
    } else {
        throw new NotFoundError(`Product by id ${id} not found`) 
    }
}

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

public async updateProductsPriceByIdsLoop(ids: string[], factor: number) {
    for (const id of ids) {
        await this.updateProductPriceById(id, factor)
    }

    return true
}

Хитрый ленивый разработчик может обернуть в Promise.all примерно так.

public async updateProductsPriceByIdsPromise(ids: string[], factor: number) {
    const operations = ids.map(id => this.updateProductPriceById(id, factor))

    await Promise.all(operations)

    return true
}

Что мы получаем в итоге? Добавлены новые методы бизнес‑логики, функционируют правильно, тесты написаны и успешно выполняются. Отправляем в релиз, выкатываем в продакшен. Но начинают приходить жалобы от пользователей. И дело во�� в чем.

Допустим, один товар обновляется за 0,1 секунды, но если пользователь поставит фильтры так, что нужно обновить 100 или 1000 или 10 000 товаров, то время выполнения операции в первом случае (цикл) легко может достигнуть — 10 или 100 или 1000 секунд, что перевалит за типичный таймаут ожидания ответа сервера. Во втором случае (Promise.all) при 100 или 1000 или 10 000 параллельных запросов к БД — зависает сервер БД, время выполнения каждого запроса удлиняется, и результат трудно предсказуем. 100 одновременных запросов сервер БД скорее всего вывезет, а вот 1000 и больше — зависит его настроек и другой параллельной нагрузки.

Правильное решение будет выглядеть примерно так.

public async updateProductsPriceByIds(ids: string[], factor: number) {
    const idsChunks = chunk(ids, 100)

    for (const idsChunk of idsChunks) {
        const products = await this.productRepo.findByIds(idsChunk)

        const tasks = products.map(p => ({
            id: p.id,
            price: product.price * factor
        }))

        await this.productRepo.bulkUpdate(tasks)
    }

    return true
}

То есть нужно переписать алгоритм, не прибегая к использованию единичной операции. Входной массив идентификаторов разбивается на подмассивы, которые обрабатываются последовательно. В репозитории потребуется реализовать два новых метода (если их нет): массового чтения данных по id — findByIds и массовое обновление — bulkUpdate. Если используется БД на основе SQL, то внимательный читатель может возразить, что SQL не поддерживает массовое раздельное обновление данных. Однако есть возможность реализовать такое обновление через SQL команду INSERT и ON CONFLICT блок. Такой алгоритм будет работать значительно быстрее цикла единичных операций и будет минимально использовать соединения с БД. Вот пример использования SQL INSERT и ON CONFLICT.

INSERT INTO product (id, price)
VALUES 
    (1, 250),
    (2, 350),
    (3, 600)
ON CONFLICT (id) 
DO UPDATE SET 
    price = EXCLUDED.price

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

Модульные тесты и контроль качества

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

Во‑первых, модульные тесты позволяют убедиться, что отдельные части приложения — модули (функции, классы) работают правильно, в соответствии с ожидаемым поведением. Аналогично можно протестировать совместную работу модулей вплоть до полноценной системы автоматических тестов приложения. Для бэкенда это нормальная практика. С фронтендом немного сложнее, так как нужно имитировать действия пользователей.

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

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

Если же модульные тесты не используются, то для проверки функционала разработчику придется в рамках одной задачи разработать логику всех слоев — от контроллера, до репозитория. Если нет жесткой дисциплины, то это часто приводит к разрушению архитектуры и смешиванию слоев — для несложных задач и ради скорости разработки доступ к данным может быть реализован прямо в контроллере, без реализации слоев приложения и даже репозитория, как в примере выше — SQL SELECT прямо в контроллере. Как только один разработчик сделал так, другие думают, что им тоже так можно и очень быстро проект лишается архитектуры, и все делают, что хотят и где хотят. Договоренности об обязательной разработке модульных тестов как минимум слоев приложения и репозиториев помогают поддерживать хорошую архитектуру.

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

Для модульного тестирования применяются специальные библиотеки и фреймворки тестирования, которые помогают организовывать тесты, создавать и использовать моки, проверять различные сложные условия (например, сравнения массивов и объектов). Для Javascript/Typescript проектов очень популярным фреймворком тестирования является Jest, но я использую во многом похожий на него Vitest, который работает быстрее.

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

Пример теста CreateProductCommand.

import { EProductAppError, ICreateProductParams, IProductAttributes } from '../types'
import { CreateProductCommand, ICreateProductRepo } from './CreateProductCommand'

class FakeCreateProductRepo implements ICreateProductRepo {
  private created: IProductAttributes | null = null
  public createCallParams: IProductAttributes[] = []
  public findCallParams: string[] = []

  clearCallParams() {
    this.createCallParams = []
    this.findCallParams = []
  }

  async create(values: IProductAttributes) {
    this.created = values
    this.createCallParams.push(values)
    return values
  }

  async findByTitleEqual(title: string) {
    this.findCallParams.push(title)
    return this.created?.title === title
      ? [this.created]
      : []
  }
}

describe('Тест CreateProductCommand без БД на FakeCreateProductRepo', async () => {
  it('CreateProductCommand возвращает ожидаемый результат, вызывает методы репозитория', async () => {
    const fakeCreateProductRepo = new FakeCreateProductRepo()

    const item: ICreateProductParams = {
      title: "Cup_with_logo",
      category: "cup",
      price: 300
    }

    const createProductCommand = new CreateProductCommand(productRepo)
    
    const createResult = await createProductCommand.execute(item)
    expect(createResult.value).not.toBe(null)
    
    expect(fakeCreateProductRepo.findCallParams.length).toBe(1)
    expect(fakeCreateProductRepo.findCallParams[0]).toBe(item.title)
    // дубликата нет, создаем запись
    expect(fakeCreateProductRepo.createCallParams.length).toBe(1)
    expect(fakeCreateProductRepo.createCallParams[0]).toMatchObject(item)

    fakeCreateProductRepo.clearCallParams()

    const createDuplicateResult = await createProductCommand.execute(item)
    expect(createDuplicateResult.value).toBe(null)
    expect(createDuplicateResult.error).toBe(EProductAppError.TitleIsNotUnique)

    expect(fakeCreateProductRepo.findCallParams.length).toBe(1)
    expect(fakeCreateProductRepo.findCallParams[0]).toBe(item.title)
    // найден дубликат, не создаем запись
    expect(fakeCreateProductRepo.createCallParams.length).toBe(0)
  })
})

Чтобы протестировать функционал слоя приложения без БД нам понадобится мок репозитория, он представлен классом FakeCreateProductRepo, который реализует требуемый интерфейс ICreateProductRepo. Также он содержит все необходимое, чтобы проверить правильность работы бизнес‑логики.

Vitest внедрен в глобальную область видимости в настройках проекта, поэтому его функции мы не импортируем. Функция describe() создает группу тестов, а it() создает отдельный тест. В ходе теста мы создаем экземпляр FakeCreateProductRepo и затем делаем два запуска createProductCommand.execute с одним и тем же объектом данных. После каждого запуска проверяем условия. После первого запуска мы ожидаем результат не null, а ошибка null. Вызывались оба метода репозитория. Запись произведена успешно. После второго запуска с теми же параметрами мы ожидаем, что результат null, а ошибка — TitleIsNotUnique. Вызывался метод репозитория findByTitleEqual, но метод create не вызывался.

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

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

Про именование переменных, классов, сущностей в БД

Есть несколько простых принципов, которые помогут всем участникам проекта лучше его понимать, а разработчикам помогут в создании более читаемого кода.

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

Например, в интернет‑магазинах в подавляющем большинстве случаев принято использовать такие термины в отношении продаваемых товаров:

  • product — основная информация о товаре (для каталога товаров, витрины);

  • sku — конкретная вариация (разновидность) товара, артикул (с учетом цвета, размера, комплектации);

  • item — элемент корзины/заказа, товар в корзине/заказе.

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

Что касается кода, в большинстве языков программирования разрешено использовать для именования единиц программы (переменных, констант, функций, классов и так далее) только латинские буквы, цифры и символ «подчеркивания».

Есть два основных подхода к комбинированию нескольких слов в название переменной, функции, сущности и так далее

Такой вариант — some_string_variable — называют snake case.

А такой — SomeClass или someClassInstance — называют camel case.

Есть несколько базовых лайфхаков и общепринятых договоренностей, которые позволяют выбрать удобную форму для вашего проекта.

Когда удобен snake_case?

В программах довольно часто используются базы данных, а они сейчас бывают двух основных видов: основанные на языке запросов SQL и все остальные. Для задач основного хранилища данных в большинстве проектов сейчас используются SQL‑совместимые БД.

Предположим, что у нас есть таблица данных товаров с именем product и в ней нам нужно создать поля «вес без упаковки» и «вес с упаковкой» — net weight и gross weight. Если мы назовем поля в camelCase для многих БД (например, PostgreSQL) нам придется каждый раз в запросах брать имя поля в кавычки — product.«netWeight» и это добавляет сложности, требует следить за кавычками, что может стать дополнительной нагрузкой в сложных и больших запросах. В случае snake_case кавычки не понадобятся — product_net_weight. Поэтому в SQL БД большинство разработчиков применяет snake_case, соответственно, в программе в структурах, отражающих сущности БД, также удобно использовать snake_case. Эти имена сразу несут дополнительную смысловую нагрузку — мы имеем дело с сырыми данными из БД или для сохранения в БД.

Когда используют camelCase?

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

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

I — для интерфейсов — IProduct

C — для классов — CProduct, хотя чаще классы именуют без префиксов — просто Product

T — TProduct — для комбинированных типов.

В программах на современных языках почти повсеместно используют camelCase, например, в Golang первая заглавная буква помечает сущности, экспортируемые из модуля, то есть без заглавных букв сколько‑нибудь сложную программу не написать. Однако все еще нередко можно встретить программы, где используется только snake_case, если язык программирования позволяет это.

Когда можно пренебречь архитектурой ради скорости разработки?

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

  • нет требований к тестированию кода

  • предполагаются нечастые однократные запуски алгоритма (нет ожидания команд, повторов, алгоритм отработал и программа завершилась), например, в случае задач начальной настройки, миграции данных (создание, простой перенос, импорт) и так далее

  • нет межпрограммного взаимодействия (запросы, ожидания ответов, обработка ошибок и так далее)

  • ограниченное кол‑во строк кода в решении (скажем, до 1000)

  • не предполагается дальнейшее развитие с заметным усложнением алгоритма

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

Думаю, что начала более осмысленной работы со структурой проекта этой базовой информации будет достаточно. За рамками обсуждения осталось множество вопросов, например, подходы к декомпозиции огромных приложений (сервисная/микросервисная архитектура) и многое другое.

Код демо-проекта можно найти тут

Спасибо, что дочитали до конца. Буду рад вашим вопросам и комментариям.