«Почему мы снова говорим про архитектуру?»
- суббота, 8 августа 2026 г. в 00:00:09
Разработчик интерфейсов уверен, что выбранный им фреймворк автоматически решает за него вопросы структуры и организации кода. Он искренне верит, что если взять React, Angular, Vue, да тысячи их, то получает в руки серебряную пулю, которая закроет все проблемы разработки. Достаточно следовать документации фреймворка, и с проектом все будет хорошо в долгосрочной перспективе. И я сам долгое время рассуждал точно так же, я воспринимал только Angular, потому что там есть хоть какой-то структурный подход. На практике это оказывается большим заблуждением.
Фреймворк - это просто библиотека для рендеринга интерфейса и работы с DOM. На заре зарождения фреймворков так они и воспринимались сообществом. Но со временем инструменты вбирали в себя все больше и больше ответственности, и сложилась ситуация, при которой разработчики переложили на плечи фреймворков ответственность за то, за что они отвечать не должны: за бизнес-логику, маршрутизацию, хранение данных, валидацию данных и доменные правила.
Когда мы привязываем бизнес-логику к компонентам, мы теряем контроль над приложением. Логика размазывается по всем слоям приложения. Уверенность в том, что фреймворк сам укажет правильный путь, разбивается о реальность масштабирования. Кратко посмотрим на инструменты представленные на рынке глазами разработчика, который пытается выбрать правильный подход:
Здесь исторически сложился дикий запад. Так как изначально это была тонкая UI библиотека и уже потом сложилась целостная экосистема, то и строгих правил тут нет. Это привело к тому, что типичный проект начинается с раскладки по типам файлов (папки components, hooks, utils, api). Разработчик открывает проект, видит такую структуру и безошибочно определяет что проект написан на React, но совершенно не понимаете, какую бизнес-задачу он решает.
Со временем это неизбежно превращается в простыню из контекстов, эффектов, которые обновляют что-нибудь на другом конце приложения, и пропсов, которые прокидываются через десять слоев. Бизнес-логика живет прямо в кнопках и формах. React-разработчик точно знает, что логика приложения навсегда связана с React-рантаймом.
Здесь картина еще более коварная. В отличие от React, где логика явно расположена внутри хуков и компонентов, Vue и Svelte предлагают удобные реактивные примитивы (ref, computed, $state), которые можно выносить в отдельные файлы.
Разработчикам кажется, что они выделили бизнес-логику в независимый слой, но на самом деле они просто затащили движок UI-фреймворка в бизнес-слой приложения. Если вам нужно будет переиспользовать сложную логику в фоновом процессе, в микросервисе или просто сменить UI-библиотеку - придется выдирать эту логику с корнем, потому что она гвоздями прибита к реактивности конкретного фреймворка.
Строгий ООП, Dependency Injection из коробки, модульность, какая-никакая структура. Кажется, мы нашли если не идеальное решение, то хотя бы приемлемый компромисс! Но за этот комфорт приходится платить жесточайшей привязкой к экосистеме. Доменную логику почти невозможно оторвать от экосистемы Angular: ваши классы используют декораторы @Injectable(), а бизнес-логика начинает зависеть от RxJS и специфики самого фреймворка.
Фреймворк практически полностью завязан на RxJS, что приводит к использованию паттерна Observer повсеместно. В итоге простейшая синхронная проверка бизнес-правила (например, “может ли юзер купить товар”) превращается в цепочку из pipe(map(), filter()), которую сложно прочитать и покрыть простыми юнит-тестами.
Да, пока проект находится в стадии MVP и пока в команде пара человек, работает любой подход — можно вообще не заморачиваться. Но стоит подтвердить бизнес-ценность продукта и перейти в стадию интенсивного развития, и тут команда сталкивается с проблемой, вызванной принятыми на старте техническими решениями, точнее отсутствием этих решений. Что это за проблемы?
Сильная связность: Разные части приложения становятся неразрывно связаны друг с другом, хотя логически это совершенно разные модули. Чтобы изменить одну фичу, разработчику приходится править десятки файлов в UI, сторе и утилитах на разных концах проекта. А потом еще, скрестив пальцы, надеяться, что ничего не поломалось в местах, где вообще не было изменений.
Циклические зависимости: Циклические импорты становятся нормой. Из-за отсутствия строгих границ (Bounded Contexts) разработчики случайно импортируют UI-компоненты в файлы с бизнес-логикой, намертво связывая ядро с фреймворком.
Сложность рефакторинга: Эта проблема напрямую связана с предыдущей — сборщик вынужден переваривать огромный монолитный граф зависимостей. Любая попытка распутать этот узел оборачивается гигантскими конфликтами при мерже, а новые разработчики панически боятся трогать общий код.
Сложность тестирования: Попробуйте протестировать бизнес-логику React-приложения без рендера компонентов. Зачастую это у вас не получится, потому что логика лежит внутри кастомного хука или, что еще хуже, в самом компоненте. Проблема даже не в самом рендере - тесты работают и работают, нам то какая разница, но такие тесты работают в 10–20 раз медленнее, чем тесты чистой логики. Для CI/CD на большом проекте это критично, приходится усложнять пайплайны запуском параллельных пачек тестов.
Мы снова и снова наступаем на одни и те же грабли, пытаясь решить архитектурные проблемы инструментами UI-рендеринга. Предлагаю признаться самим себе: UI — это лишь деталь реализации, а не центр вашего приложения.
Осознав, что UI-фреймворк не должен диктовать архитектуру, сообщество разработчиков начало искать решение, не зависящее от конкретных библиотек. И на рынке уже есть сформировавшиеся подходы, которые пытаются навести порядок во фронтенде. Прежде чем переходить к моему решению, важно подсветить эти подходы и разобрать их сильные и слабые стороны.
Вы наверняка уже сталкивались с Feature-Sliced Design. Сегодня это стандарт де-факто для многих команд, пишущих на React и Vue. FSD предлагает четкую методологию организации файловой структуры на основе бизнес-сущностей и слоев.
Плюсы:
Стандартизация: Подход ввел четкую структуру папок и слоев приложения. Когда новый разработчик приходит на FSD-проект, он точно знает, где искать код: layers (app, pages, widgets, features, entities, shared), slices (бизнес-домены) и segments (ui, model, api). Это снижает время погружения в кодовую базу.
Однонаправленные зависимости: Вышележащие слои могут импортировать нижележащие, но не наоборот. features могут использовать entities, но entities ничего не знают о features. Вся эта структура подкрепляется правилами линтера (eslint-plugin-boundaries или плагин самого FSD), которые автоматически предотвращают кросс-зависимости.
Бизнес-ориентированность: Вместо группировки файлов по типам (все компоненты в одной папке, все сторы в другой), код группируется по бизнес-сущностям (slices). Папка entities/user содержит всё, что связано с пользователем. Это очень близко к концепции Bounded Context из DDD.
Минусы:
Мнимая независимость бизнес-логики: FSD все еще ставит UI во главу угла. FSD не заставляет вас изолировать ядро приложения от фреймворка рендеринга. Разработчики тащут Zustand/Vuex/RxJS прямо внутри слайсов, завязывая домен на экосистеме фреймворка.
Сложность кросс-доменного взаимодействия: По правилам FSD слайсы на одном слое не могут импортировать друг друга (например, features/auth не может импортировать features/cart). Если вам нужно связать два бизнес-процесса, вам приходится либо поднимать логику на слой выше (в widgets или pages), либо спускать ее в entities. Это часто приводит к раздуванию верхних слоев.
Чистая и Гексагональная архитектуры (порты и адаптеры) проповедуют идею того, что бизнес-ядро должно находиться в самом центре системы и ничего не знать о внешнем мире (ни о базе данных, ни об HTTP-клиенте, ни тем более о DOM или React-компонентах).
Плюсы:
Полная изоляция домена: Ваше доменное ядро использует исключительно чистый TypeScript, паттерны и структуры данных, не подозревая о существовании DOM, браузера или библиотеки рендеринга. Вы можете взять папку core и без изменений запустить ее в браузере (через Lit или React), в мобильном приложении (Expo / React Native), в Web Worker-е или даже в CLI-утилите на Node.js.
Максимальная гибкость: Браузерное API (localStorage, IndexedDB, window.history), сетевые запросы (Axios, Fetch) или локальные базы данных (SQLite + ORM), если речь идет о нативных приложениях — всё это выносится за пределы ядра. Ядро определяет интерфейсы (Порты), а инфраструктура их реализует (Адаптеры). Если завтра вы решите поменять REST на GraphQL, вы напишете новый адаптер и больше ничего менять не нужно.
Идеально для сложных систем: Если ваше приложение offline-first со сложной логикой синхронизации, запутанные права доступа или тяжелые конечные автоматы, Чистая Архитектура, за счет усложнения на начальном этапе, позволяет навести во всем этом порядок, не смешивая логику с жизненным циклом компонентов.
Минусы:
Высокий порог входа: Фронтенд-разработчики привыкли мыслить экранами, стейтом и хуками. Чистая архитектура требует умения мыслить абстракциями: интерфейсами, инверсией зависимостей (IoC), DI-контейнерами и границами доменов. Переучить команду писать логику отличную от экосистемы любимого фреймворка бывает очень тяжело.
Многословность и бойлерплейт: На этом этапе ломается подавляющее число разработчиков, которые и хотели бы попробовать данный подход, но сталкиваются с невероятным количеством дополнительных сущностей, которые нужно написать. Чтобы просто вывести список пользователей на экран, вам придется написать: DTO, интерфейс репозитория (Порт), реализацию репозитория (Адаптер), Use Case, фасад, и только потом UI-компонент. Для тривиальных приложений, где нужно просто переложить JSON с сервера в UI компонент — это катастрофический оверинжиниринг.
В итоге у нас две крайности: либо удобный, но местами протекающий FSD, либо академически идеальная, но перегруженная Чистая архитектура. Я попробовал объединить эти две концепции: строгую изоляцию бизнес-логики и четко предопределенную структуру папок и слоев приложения. Плюс прикрутить к этому всему современные инструменты автоматизации.
Если обобщить все архитектурные проблемы фронтенда, то станет понятно что главная проблема в том как изолировать модули приложения друг от друга. Когда весь код лежит в одной папке src, то нет никакой гарантии, что разработчик, делая очередной хотфикс, не сделает импорт одного модуля в соседний. Правила в README.md, как правило, не соблюдаются. Работают только ограничения на уровне линтеров.
Поэтому основой моего решения стала связка Tactical DDD (тактического Domain-Driven Design) и строгой изоляции через монорепозиторий на базе Nx.
Вместо того чтобы плодить вложенность директорий (как в FSD) или писать тонны интерфейсов для каждого чиха (как в классической Clean Architecture), мы смещаем фокус на уровень NPM-пакетов.
В монорепозитории каждый домен (например, авторизация, профиль пользователя, корзина) — это не просто папка, а набор физически изолированных библиотек, каждая из которых закрывает строго свою зону ответственности. Nx позволяет нам задать жесткие правила: какой пакет имеет право импортировать какой. Если кто-то попытается нарушить архитектуру, линтер сразу выдаст ошибку вида «A project cannot import a library from this tag» — и проект не сбилдится, пока нарушение не устранено.
Каждый бизнес-домен в нашей системе разбивается ровно на 4 библиотек (2 обязательные и 2 опциональные), каждая из которых имеет свою строгую зону ответственности:
contracts
Это самый нижний слой. Здесь живут интерфейсы и типы, которые экспортируются наружу из домена. Интерфейсы фасадов, типы DTO (Data Transfer Objects) и токены для DI — все, что может использоваться в других доменах и приложениях. Важное замечание: contracts не зависят ни от чего, это чистые типы TypeScript. Эта библиотека должна быть настолько легкой, насколько это возможно. Перед тем как положить в нее какой-то интерфейс или тип, нужно десять раз подумать, а нужен ли этот тип внешнему миру.
core
Чистая бизнес-логика. Пакет не знает о существовании React, Angular или браузерного API. Внутри он делится на классические слои из DDD:
domain — чистые бизнес-правила, Value Objects и Entities.
application — это слой приложения который отвечает за бизнес процессы - Use Cases (стейт-машины, например на XState, или просто классы-сервисы). Тут же лежат порты для инверсии зависимостей в инфраструктурном слое.
infrastructure — здесь сосредоточена работа со всем внешним миром: реализация работы с низкоуровневыми API, мапперы и репозитории. Сущности из этого слоя реализуют порты из application, тем самым инвертируя граф зависимостей домена.
ui
Здесь живут «тупые» компоненты. Они получают данные через пропсы и отдают действия через коллбэки. Они могут быть написаны на чем угодно — React, Vue или даже нативно на веб-компонентах (например, Lit) для максимальной переиспользуемости. Главное правило: ui не получает данные и не знает о бизнес-логике.
features
Самый верхний слой домена. Это компоненты-оркестраторы. Их задача — взять данные из core (через вызов use case или стейт-машины) и прокинуть их в тупые компоненты из слоя ui. Именно features экспортируются наружу для использования на конкретных страницах приложения.
Обязательные только contracts и core — домен может вообще не содержать интерфейсных компонентов, а сосредотачиваться только на бизнес-логике. Например, permission — домен, в котором определены правила доступа для пользователей, но нет самостоятельных интерфейсов компонентов.
В любом крупном приложении есть инфраструктурный код, который нужен всем, но сам не относится ни к какому конкретному бизнес-домену. Это локальное хранилище, логгер, обертки над HTTP-клиентом или дизайн-система. Для таких вещей мы выделяем особый технический домен — shared.
shared/ui — это атомарные компоненты дизайн-системы (кнопки, инпуты, модалки). Эта библиотека является корнем в графе зависимостей UI. Она никогда не знает о бизнес-логике.
shared/infrastructure — общие технические сервисы: реализация http-клиента (axios, fetch и т.д.), коннектор для локальной базы данных (SQLite, IndexedDB) или базовые классы API-запросов. Стоит отдельно отметить что классы API-запросов лежат именно тут и могут использоваться всеми доменами. Для предотвращения дублирующих запросов из разных доменов в этой же библиотеке лежит кэш(TanStack Query или аналоги)
shared/contracts — глобальные интерфейсы (например, интерфейс HttpClient), от которых зависят другие домены.
Самое главное правило: бизнес-домены (например, auth или user-profile) могут зависеть от shared, но shared никогда не может зависеть от бизнес-доменов — иначе возникает циклическая зависимость; если такое случилось, нужный код следует вынести в отдельный бизнес-домен или в contracts.
Как эти изолированные пакеты собираются вместе, если они ничего не знают о внешнем мире? Ответ — Composition Root в связке с InversifyJS (или любым другим DI-контейнером).
Само приложение выступает в роли единственного места, где разрешено использовать импорты из всех доменов и библиотек. Именно в точке входа приложения мы регистрируем все зависимости, чтобы остальной код оставался чистым.
Например, слой core домена auth требует HTTP-клиента для запросов. Он не создает axios внутри себя, а ожидает интерфейс HttpClient. При старте приложения Composition Root инжектит в этот домен реальный инстанс HTTP-клиента с уже настроенными интерсепторами, токенами и базовым URL (если зависимость не зарегистрирована, контейнер выбросит ошибку при резолве, что сразу укажет на проблему).
Такой подход позволяет отвязать бизнес-логику от инфраструктуры приложения и интерфейса.
Самая большая проблема любой архитектурной концепции — человеческий фактор. Вы можете написать подробный README.md, нарисовать красивые диаграммы потока данных и провести серию встреч с командой. Но в пятницу вечером вместе с очередным хотфиксом на прод улетит импорт пропсов компонента прямо в бизнес домен. Повезет если вместе с этой правкой будет создана задача на техдолг и рефакторинг. Поэтому фундаментом архитектуры должна быть автоматизация и контроль на уровне линтеров и пайплайнов.
Монорепозиторий nx предоставляет как раз то, что нам нужно — правило ESLint @nx/enforce-module-boundaries. С его помощью мы вешаем на наши изолированные библиотеки динамические теги и описываем матрицу разрешений.
Например, мы помечаем библиотеки тегами областей (domain:auth, domain:payments) и тегами слоев (type:contracts, type:core, type:ui, type:features).
В конфигурации Nx мы задаем строгие правила:
type:features может импортировать type:core и type:ui.
type:core может импортировать только type:contracts и библиотеки из слоя shared (например, логгер или http-клиент).
Никто, кроме приложения, не может импортировать домен целиком. Домены не могут зависеть друг от друга напрямую (только через публичные контракты).
Если разработчик попытается нарушить это правило в коде, линтер подсветит ошибку, а CI-пайплайн заблокирует Pull Request.
Nx защищает нас на уровне библиотек. Но внутри пакета core код разделен на слои (domain, application, infrastructure) и это обычные папки, @nx/enforce-module-boundaries тут не поможет.
Эту проблему можно решить стандартным правилом ESLint no-restricted-imports. Мы настраиваем его так, чтобы ограничить направление зависимостей внутри библиотеки. Например, мы запрещаем папке domain импортировать что-либо из infrastructure или application. Благодаря этому правилу инфраструктурные протечки пресекаются еще на этапе импортов.
Строгая архитектура превращает добавление нового функционала в рутину. Создать библиотеку для UI, настроить сборщик, добавить конфиги TypeScript, прописать правила линтера, обновить пути — это боль, которая заставляет разработчиков ненавидеть того кто это все придумал и внедрил.
Все это можно автоматизировать через написание собственных nx-генераторов . Тогда создание нового домена будет сводится к одной консольной команде: nx g @my-org/plugin:domain auth
Этот генератор:
Автоматически создает все нужные библиотеки (contracts, core, ui, features).
Расставляет правильные теги для @nx/enforce-module-boundaries.
Настраивает правила no-restricted-imports внутри core.
Генерирует базовые классы и мапперы, если нужно.
Правильно конфигурирует сборку (например, выставляет bundler: none для библиотек без UI).
Разработчику не нужно думать о том, как правильно настроить архитектуру. Он запускает генератор и сразу начинает писать бизнес-логику в готовых, правильно изолированных библиотеках, не задумываясь о том как разложить фичу по папкам и файлам.
Внедрение Tactical DDD в связке с Nx — это инвестиция в будущее проекта. На старте вам придется написать генераторы, договориться о границах доменов и привыкнуть к мапперам, репозиториям, кейсам и прочим усложняющим, на первый взгляд, работу сущностям. Но что реально получает команда, переходя на такую архитектуру?
Вам больше не нужно мучиться с моками React-контекстов или писать неповоротливые e2e-тесты просто для того, чтобы проверить калькуляцию скидки в корзине. Бизнес-логика в пакете core — это чистый TypeScript. Unit-тесты для доменных сущностей и юзкейсов пишутся за минуты и выполняются за миллисекунды.
Поскольку код разбит на изолированные пакеты, монорепозиторий Nx раскрывает свой потенциал на полную мощность. Благодаря кэшированию, если вы изменили UI-компонент в домене auth, сборщик пересоберет и прогонит тесты только для этого конкретного пакета и тех, кто от него зависит. Вам больше не нужно ждать долгие минуты, пока сборщик проверит весь монолит.
React, Vue и Angular — хорошие инструменты, но они стареют, меняют парадигмы и ломают обратную совместимость. В моей архитектуре фреймворк изолирован в слоях ui и features. Это позволяет относительно безболезненно менять презентационный слой (всякое бывает) или использовать разные UI библиотеки в рамках одного проекта (миграция на новый фреймворк, компетенции в команде), и это никак не затронет бизнес-логику.
В монолитном приложении разработчики постоянно наступают друг другу на ноги, решая конфликты при мерже. Строгие границы пакетов позволяют кросс-функциональным командам работать в соседних доменах абсолютно независимо. Команда А пилит фичи в payments, команда Б развивает user-profile. Точка их пересечения — исключительно согласованные контракты.
Архитектура фронтенда — это не про то, как красиво назвать папки. Это про управление сложностью. Подход с Tactical DDD и монорепозиторием делает сложность предсказуемой, а разработку — масштабируемой.
Эта статья призвана привлечь внимание к подходам к архитектуре в современных UI-приложениях и предложить альтернативный подход. Я планирую развивать это направление и написать цикл статей с подробным описанием концепций и примерами кода.
Если данная тема небезразлична еще кому-нибудь, то приглашаю вас совместно развивать проект и продвигать предложенный подход в массы.