ESPN DeportesCalificaciones de los mexicanos en el extranjeroInquirerDILG chief Remulla visits QC jail ahead of looming Romualdez transferThe Jerusalem PostA good deal in bad neighborhoods: Why the Gulf’s best bet remains in Jerusalem - opinionESPNPower Rankings: Everything we learned from the top 25 in Week 2RTP DesportoEuroVolley 2026. Portugal soma quarta derrota consecutiva frente à UcrâniaBBC NewsI had 11 years of chemotherapy for a cancer I didn't have20 MinutenUrsache unklar: Fischsterben im MühlebachComplete SportsBlackburn Give Injury Update On Super Eagles StarESPN CricinfoFleming to link up with T20I squad in preparation for Test coaching stintBBC عربيجماعة أنصار الله تعلن استهداف قاعدة ثانية في السعودية، ومحمد بن سلمان يلتقي قائد القيادة المركزية الأمريكيةIl Fatto QuotidianoKimi Antonelli ora ha in mano il titolo di Formula 1: quel vantaggio su Russell e il sogno di una passerella trionfaleABC News4 people hospitalized after a crane collapsed at a Miami construction site: Officials
The Daily Newsstand · Free, Always
Monday, September 14, 2026

Как разделить фронтенд на модули и связать их между собой: на примере маркетплейса

Translate

Привет, меня зовут Иван Некипелов, я технический руководитель команды фронтенд-инфраструктуры в 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, которой вы поручаете изменения. Если для небольшой правки приходится объяснять модели устройство всего проекта и загружать десятки файлов, стоит проверить, не усложнили ли вы архитектуру. Добавлять абстракцию стоит тогда, когда она решает конкретную проблему и с ней действительно проще работать.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.