Как спроектировать приложение на годы вперед

от автора

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

Лет двенадцать назад создание большого монолита было обычной практикой. Семь лет назад многие подсели на микросервисную архитектуру. Причем микросервисами часто называли все подряд: и сервисно-ориентированный подход (SOA), и набор крупных сервисов, и распределенный монолит. Главное было быть в тренде.

Сейчас маятник снова качнулся. Микросервисы уже не выглядят универсальным ответом: слишком хорошо видна их цена в инфраструктуре, отладке, версионировании контрактов и сопровождении. Поэтому все чаще можно услышать про модульный монолит.

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


Ремарка

Я буду в основном опираться на личный опыт в .NET и TypeScript. Но сама идея не привязана к конкретному стеку.

Вот о чем идет рассказ

Вот о чем идет рассказ

Основная проблема

Возьмем за основу, что технологии и подходы меняются. Меняются версии .NET, TypeScript, React. Появляются и уходят моды на MobX, Redux, MediatR и другие инструменты.

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

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

Как следствие, появляются две важные потребности.

Первая — понятная модель поставки и запуска. Если приложение начинает состоять из нескольких backend-сервисов, frontend-модулей, фоновых процессов и инфраструктурных компонентов, их уже неудобно держать как набор вручную настроенных процессов на сервере. Часто становятся нужны контейнеры, единый способ конфигурации, health checks, управление секретами, rolling update и возможность независимо масштабировать разные части системы. Для оркестрации такой среды обычно используют Kubernetes.

Вторая потребность — единая точка входа в систему. Когда frontend и backend перестают быть одним приложением, клиент не должен знать всю внутреннюю топологию: где живет профиль пользователя, где отчеты, где уведомления, а где фоновые операции. Один из вариантов для такой задачи — паттерн API Gateway. Он прячет внутреннее устройство системы, маршрутизирует запросы, держит общие технические правила входа и становится местом, через которое внешнему миру показывается цельное приложение, а не набор разрозненных сервисов.

Backend и инфраструктура

Исходя из описанного, к остальным частям приложения можно применить сервисно-ориентированный подход в широком смысле, то есть Service-Oriented Architecture (SOA). Kubernetes дает площадку для запуска таких частей, а API Gateway скрывает от клиента то, что происходит «под капотом». При этом реализация gateway может быть полностью ручной или основываться на готовой библиотеке вроде YARP.ReverseProxy. Это не принципиально. Важно другое: изменение внутренней реализации gateway не должно требовать изменений во внутренних сервисах, пока сохраняется внешний контракт.

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

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

Frontend-модули

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

С архитектурной точки зрения здесь появляется подход Micro Frontends: frontend разбивается на относительно независимые части, которые могут разрабатываться и поставляться отдельно. Одним из заметных технических механизмов для этого стала Module Federation. Она позволяет не просто вынести общий код в библиотеку, а подключать независимые frontend-проекты к общему shell-приложению. В терминах Module Federation такой проект обычно выступает как remote-контейнер и может отдавать наружу несколько компонентов: страницу, виджет или другой UI-блок. В рамках этой статьи именно такой внешний компонент я и буду называть frontend-частью функционального модуля.

За несколько лет инструменты вокруг Module Federation стали заметно зрелее. Сейчас уже можно говорить не только о независимых проектах, но и о более сложных сценариях: общих зависимостях, динамической загрузке компонентов, SSR и отдельных сборках для разных частей системы. Да, на практике это часто привязывает нас к webpack или совместимым с ним решениям. Другие сборщики тоже развиваются, но для долгоживущей архитектуры важнее не гнаться за самым быстрым инструментом сборки, а выбрать решение, которое предсказуемо закрывает нужные сценарии. Скорость сборки можно частично компенсировать тем, что мы собираем не один большой frontend-монолит, а отдельные frontend-проекты или remote-контейнеры.

Но здесь есть важный нюанс — настройка локальной dev-среды. Модульная архитектура может быть хороша на схемах и в продакшене, но если разработчику больно запускать ее локально, команда быстро начнет воспринимать эту архитектуру как наказание. Формально каждый frontend-проект можно запустить как отдельное приложение. Но если для работы над одной задачей frontend-разработчику нужно поднять все remote-контейнеры сразу, он будет тратить силы не на задачу, а на борьбу с окружением.

Поэтому для такой архитектуры нужна отдельная локальная среда разработки. Например, docker-compose-проект, который поднимает shell и подтягивает remote-контейнеры с dev-площадки. А тот frontend-проект, в котором лежит frontend-часть текущего функционального модуля, подключается с локальной машины. Такая настройка требует дополнительной работы, которую не всегда видно в финальном продукте, но она сильно влияет на то, будет ли команда вообще хотеть пользоваться модульной архитектурой.

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

Промежуточный итог

Мы структурно разделяем приложение на отдельные части и модули. Благодаря этому модуль может иметь свою frontend-часть, свою backend-часть и свой темп развития. Но для пользователя система при этом остается единым приложением.

Два слоя: модули и домены

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

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

То есть мы не строим «модуль сотрудников» или «модуль проектов» в классическом смысле модульного монолита. Мы строим, например, модуль карточки сотрудника, модуль согласования или виджет оценок — и не даем им прорастать друг в друга так же, как в модульном монолите не дают смешиваться доменным областям.

В такой схеме сразу появляются два слоя.

Первый — слой функциональных модулей. Функциональный модуль — это часть интерфейса вместе с backend-слоем, который ее обслуживает. Например: форма согласования, блок работы с оценками или страница карточки сотрудника.

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

Дальше я сначала опишу функциональный модуль как единицу интерфейса и backend-логики, а потом отдельно вернусь к доменному слою.

Функциональный модуль

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

По сути, здесь используется идея Backend for Frontend. Backend функционального модуля не должен становиться владельцем данных домена. Его задача — принять запрос от пользователя, проверить доступ к этой части интерфейса, обратиться к нужным доменным сервисам и собрать ответ в удобном для frontend виде.

В этом смысле функциональный модуль работает и как Service Facade: закрывает от клиента внутреннюю схему сервисов и возвращает готовую модель отображения.

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

Например, он может вернуть не просто список записей, а список записей вместе с доступными действиями для каждой из них: edit, delete, approve, sendToReview. Или вернуть признак, что пользователю доступно создание новой записи.

В результате frontend не обязан знать, какие роли, права или политики стоят за этим решением. Он получает уже готовое состояние интерфейса и отображает его. Правила можно менять на backend-стороне, а frontend при этом часто не придется пересобирать и публиковать заново.

Но здесь есть важная граница. Такие флаги и списки доступных действий нужны для отображения интерфейса, а не для защиты данных. Если backend сказал frontend не показывать кнопку удаления, это не заменяет проверку прав на endpoint удаления. Любая команда, которая меняет состояние системы, все равно должна повторно проверять права в backend-части функционального модуля, еще до вызова доменного сервиса. Иначе мы получим красивый интерфейс, но слабую безопасность.

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

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

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

С frontend-частью картина более-менее понятна: есть shell, есть Module Federation, есть remote-контейнеры, которые отдают наружу компоненты. При этом remote-контейнер не равен функциональному модулю. Один remote может экспонировать несколько страниц или виджетов, а модулем в нашей терминологии остается конкретная пара: frontend-часть страницы, виджета или другой интерфейсной области и backend-часть, которая ее обслуживает.

С backend возникает похожее различие. Backend-часть функционального модуля — это логическая часть архитектуры. А solution, проект, сервис или deployable-компонент — это уже способ упаковки одной или нескольких backend-частей. Их не стоит смешивать. В одном solution вполне может жить backend нескольких модулей, если они связаны общей предметной областью, общим этапом разработки или общей командой.

Если попытаться организовать backend в той же парадигме, в которой работает Module Federation, мы быстро упремся в зависимости. Во frontend remote-контейнеры имеют отдельные сборки и в некоторых сценариях могут жить с разными версиями зависимостей. В одном backend-процессе на .NET добиться такой же независимости намного сложнее. Если backend-части модулей начнут напрямую ссылаться друг на друга, со временем мы получим запутанный граф зависимостей, где любое изменение тянет за собой половину системы.

Можно ли держать backend-части всех модулей в одном большом проекте? Формально да. Но тогда есть риск превратить gateway или общий backend-хост в большой ком кода, который снова начнет знать слишком много. Мы как будто уйдем от монолита, но потом соберем его обратно в другом месте.

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

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

Слой доменов

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

На уровне доменов перестает использоваться пользовательская авторизация в привычном UI-смысле. Доменный сервис не должен знать, показывать ли пользователю кнопку approve или пункт меню delete. Этим занимается функциональный модуль. Доменный сервис может проверять доверенность вызывающего модуля через service credentials, но эти credentials не завязаны на конкретного пользователя.

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

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

Здесь мы уже идем по более классической схеме сервисов. Сервисы делятся по зонам ответственности, то есть по доменам: сотрудник, проект, премия и так далее. Взаимодействие таких сервисов можно строить через шину событий, то есть с использованием элементов event-driven architecture.

Идея простая: если доменный сервис изменил данные, он публикует событие. Например, сотрудник был создан, проект изменил статус, премия была пересчитана. Это событие не является командой другому сервису «сделай вот это». Скорее это факт, который уже произошел в домене. Остальные части системы могут на него отреагировать, если им это нужно.

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

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

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

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

Такие копии могут обновляться асинхронно. Значит, система должна спокойно относиться к eventual consistency: данные в разных местах могут сходиться не мгновенно. Для некоторых экранов это нормально, для других придется делать прямой запрос к доменному сервису или явно показывать пользователю состояние обработки.

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

Общая схема

Общий поток запроса

Если собрать все вместе, получается такая схема.

Клиентское приложение обращается не к доменным сервисам напрямую, а к API Gateway. Gateway проверяет, что пользователь в целом известен системе, и проксирует запрос в backend-часть нужного функционального модуля.

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

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

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

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

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

Взаимодействие на frontend

На frontend тоже может возникнуть потребность во взаимодействии между разными частями приложения. Например, один виджет изменил состояние, а другой должен обновиться. Или общий shell получил событие, которое нужно передать нескольким frontend-модулям.

Здесь можно использовать тот же общий принцип: не давать одному модулю напрямую лезть во внутреннее состояние другого. Вместо этого можно ввести клиентскую шину событий. В терминах паттернов это Event Bus и Publish/Subscribe внутри frontend-приложения. Например, такую схему можно реализовать через библиотеку js-event-bus.

Такой event bus не должен превращаться в глобальную помойку для всего подряд. Лучше считать его механизмом для межмодульных событий: один модуль сообщает факт, а другие модули, если им это нужно, реагируют. При этом событие должно быть достаточно нейтральным: не «поменяй мне вот этот store», а «изменился выбранный проект», «обновился список уведомлений», «пользователь сменил контекст».

Особенно полезно это становится при работе с сокетами. Если каждому функциональному модулю открыть свой SignalR connection, приложение быстро начнет плодить лишние подключения, усложнит авторизацию, переподключение и диагностику. Поэтому можно сделать отдельный frontend-модуль событий. У него может вообще не быть интерфейса. Его задача — держать одно или несколько socket-подключений, принимать клиентские события с backend и публиковать их уже во внутреннюю frontend-шину.

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

В итоге получается похожая схема, только уже внутри браузера: backend прислал событие по SignalR, frontend-модуль событий его принял, опубликовал во внутренний event bus, а заинтересованные виджеты или страницы обновили свое состояние.

Заключение

В заключение хочется сказать, что в этой схеме нет какой-то магии или нового серебряного паттерна. Почти все элементы давно известны: API Gateway, Backend for Frontend, Micro Frontends, SOA, event-driven architecture. Вопрос не в том, чтобы назвать их все, а в том, чтобы правильно провести границы между частями системы.

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1048828/