Вступление

Привет, меня зовут Иван Некипелов, я технический руководитель команды фронтенд-инфраструктуры в RWB.

Эта статья для frontend-разработчиков, которые хотят сделать растущее приложение проще в поддержке. На примере маркетплейса покажем, как разделить код на модули, связать сервисы и проверять бизнес-логику отдельно от интерфейса. Примеры написаны на React и TypeScript, но подход можно использовать с другим UI-фреймворком.

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

Мы используем собственное разбиение на feature-модули: каталог, корзину, доставку и другие части приложения. Это не реализация Feature-Sliced Design, хотя отдельные принципы могут совпадать. Разберём структуру директорий, допустимые зависимости, сборку сервисов через DI, хранение состояния и доступ к платформенным API.

Код примера доступен в репозитории. В нём есть работающий интерфейс, простой DI-контейнер, архитектурные ESLint-правила, ADR и инструкции для агентов.

Физическая организация модулей

Один из вопросов, который возникает при проектировании архитектуры: как физически организовать код в файловой системе. Один из распространённых вариантов — группировать файлы по их техническому назначению:

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

Для маркетплейса мы выбрали организацию вокруг набора бизнес-сценариев (фич) приложения:

Каждый feature-модуль владеет определённой бизнес-логикой. Например, всё, что относится к корзине, находится внутри basket, логика каталога — внутри catalog, а работа с избранным — внутри favorites. Благодаря этому структура проекта отражает устройство продукта, а код, изменяющийся по одной бизнес-причине, оказывается собран в одном месте.

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

Слой app отвечает за процессы всего приложения. Здесь находятся точки входа, глобальная конфигурация, провайдеры и composition root — место, в котором создаются общие зависимости и feature-модули связываются между собой.

В pages мы собираем страницы из фич и связываем их с маршрутизацией. В shared оставляем переиспользуемые компоненты, чистые функции и небольшие stateless-адаптеры платформенных API без привязки к бизнес-сценариям.

Внутреннее устройство feature-модуля

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

 

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

Роль

Ответственность

model

Типы, схемы валидации, мапперы и доменные константы

services

Бизнес-операции, работа с backend и внешними API, обработка результатов

hooks

Координация пользовательских действий, состояния и сервисов в React

store

Локальное и UI-состояние фичи

ui

Компоненты интерфейса, принадлежащие фиче

utils

Чистые вспомогательные функции без обращений к внешнему состоянию

di

Токены и функции регистрации зависимостей

Каждая роль предоставляет свой публичный вход через index.ts: в нём экспортируем только то, что понадобится остальному приложению. Общий barrel-файл в корне фичи не создаём по импорту должно быть видно, какую часть фичи мы используем.

Как модули взаимодействуют между собой?

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

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

Дополнительно запрещаем runtime-импорты между разными фичами и импорты между разными страницами.

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

Но бизнес-сценарии не существуют изолированно. Корзине нужна информация о товарах, а оформлению заказа расчёт стоимости доставки. Если фича не может напрямую использовать runtime-реализацию другой фичи, как тогда выразить такую зависимость?

Type-only зависимости

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

Поэтому type-only импорты между фичами разрешены:

В отличие от обычного импорта, import type удаляется при компиляции TypeScript и не создаёт runtime-связь между модулями. Код корзины может знать форму товара, но при этом не загружает сервисы, store или UI модуля product.

Как передавать зависимости между модулями

Type-only импорты позволяют одному модулю использовать контракты другого, но не решают вопрос передачи runtime-реализаций.

Рассмотрим упрощённый пример взаимодействия двух feature-модулей. При оформлении заказа модулю checkout необходимо рассчитать стоимость доставки, но сама логика расчёта принадлежит модулю delivery.

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

Сначала вариант с прямым созданием зависимости:

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

Теперь CheckoutService получает сервис доставки снаружи, а не создаёт его самостоятельно. Такой подход называется внедрением зависимостей — Dependency Injection или DI.

В этом примере контракт принадлежит delivery и является частью его публичного API. В другом случае контракт может определяться потребителем: например, checkout может объявить минимальный интерфейс DeliveryCalculator, который реализует сервис доставки. Выбор зависит от того, кому по смыслу принадлежит контракт.

Остаётся определить, кто создаст оба сервиса и свяжет их между собой. Это задача слоя app, который разберём дальше.

Где собираются зависимости?

Сервисы связываем в слое app. Здесь runtime-импорты нескольких фич допустимы: этот слой выбирает реализации и собирает из них приложение.

Для этого мы используем DI-контейнер.

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

В примерах контейнер доступен как di из @infra/di. Покажем простой API: register сохраняет готовый экземпляр, а get возвращает его по токену.

Токен — идентификатор зависимости в контейнере. Типовой параметр указывает, какой сервис мы ожидаем получить:

В app создаём сервис доставки и передаём его сервису оформления заказа.

Здесь зависимости передаются явно. Имя токена не обязано совпадать с параметром конструктора: нужный сервис мы получаем через get и сами передаём в CheckoutService. Порядок регистрации важен — сервис доставки должен быть зарегистрирован раньше его потребителя.

registerServices() вызывается в точке запуска до рендера React-приложения. После этого хук своей фичи может получить готовый сервис:

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

Так же передаются инфраструктурные зависимости. Например, сервисам нескольких фич можно передать общий HTTP-клиент: он отвечает за транспорт, а каждый feature-сервис — за свои запросы и обработку ответов.

Какую реализацию DI выбрать?

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

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

  • TSyringe — контейнер с внедрением через конструкторы и декораторами.

  • InversifyJS — позволяет связывать идентификаторы сервисов с реализациями и настраивать их создание.

  • Awilix — вариант без обязательных декораторов, с регистрацией классов, фабрик и готовых значений.

  • InferDI — контейнер с явной регистрацией сервисов и проверкой зависимостей на уровне типов TypeScript.

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

Жизненный цикл зависимостей тоже нужно определить. В клиентском приложении контейнер может существовать всё время работы приложения. При SSR сервисы с пользовательскими данными должны быть изолированы на запрос.

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

Где находятся пользовательские сценарии?

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

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

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

В учебном примере обойдёмся обычным useState. Предположим, что у CheckoutService есть метод placeOrder(input), который отправляет заказ и возвращает его идентификатор:

Компонент получает данные и функцию оформления заказа. Входные данные передаются при её вызове:

Где хранить состояние приложения?

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

Серверное состояние — товары, заказы, доступные способы доставки и другие данные с сервера. На клиенте хранится их копия, которая может устаревать. Для загрузки, кэширования и обновления можно использовать React Query, SWR или RTK Query.

Клиентское состояние — выбранные позиции корзины, черновик заказа и другие данные, которыми пользуются несколько компонентов. Для него подходят Zustand, Redux Toolkit или состояние общего родительского компонента, передаваемое через props либо Context.

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

Эти способы могут сочетаться на одном экране. Не нужно переносить всё в общий store или дублировать в нём данные, которыми уже управляет серверный кэш. Для каждого значения должно быть понятно, где оно хранится и кто его обновляет.

Как отделить логику от платформы?

До этого мы обсуждали зависимости между модулями приложения. Но у frontend-кода есть ещё один источник зависимостей — окружение, в котором он выполняется. Обращаясь к window, document, localStorage или браузерному SDK, мы неявно предполагаем, что код всегда будет работать в браузере и нужный API окажется доступен.

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

Под platform-agnosticподходом мы понимаем явное отделение логики приложения от таких платформенных возможностей. Это не означает, что React-компоненты автоматически смогут работать в любом runtime. Речь о том, чтобы зависимость от конкретного окружения была локализована и имела понятный контракт.

Доступ к платформенным API

В нашем приложении доступ к браузерным API сосредоточен в shared/platform. Например, вместо прямого обращения к localStorage используется функция, которая проверяет его доступность:

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

Если хранилище недоступно или сохранённого запроса нет, read() возвращает null. При записи обрабатываем возможную ошибку: например, превышение квоты. Поиск продолжает работать, даже если последний запрос не удалось сохранить.

Что остаётся зависимым от React?

Компоненты и хуки остаются связаны с React. Модели, расчёты и сервисы мы отделяем от React и браузерных API, чтобы использовать одну и ту же логику в разных окружениях. При смене платформы меняются интерфейс и платформенные адаптеры, а бизнес-логика сохраняется.

Как проверить, что границы работают?

Архитектура полезна, если её правила можно проверить. В нашем случае ESLint ограничивает направление импортов, запрещает runtime cross-импорты между фичами и прямое обращение к браузерным глобальным объектам вне платформенного слоя. TypeScript проверяет соответствие реализаций контрактам, а тесты — их поведение. Правило no-restricted-globals само по себе не перекрывает все способы доступа, например globalThis.window: такие формы тоже нужно учитывать в конфигурации проверок.

Простой практический тест для DI — можно ли создать сервис без запуска приложения. В примере оформления заказа достаточно передать небольшой объект с методом calculate:

Тест не инициализирует React, не создаёт контейнер и не импортирует реализацию delivery. Мы проверяем именно правило checkout: доставка включается в итоговую сумму. Сам расчёт тарифа проверяется отдельно, внутри delivery. Дополнительный интеграционный тест должен проверять сборку контейнера: правильно ли зарегистрированы токены и удаётся ли получить готовый CheckoutService.

Цена такого подхода

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

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

Итоги

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

Это не универсальная схема и не набор обязательных папок.

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

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