javascript

Архитектура фронтенда. Навеяно болью от использования FSD

  • понедельник, 17 ноября 2025 г. в 00:00:02
https://habr.com/ru/articles/966962/

Обо мне

Начну с краткого «кто я и с какой горы припёрся?». Зовут меня Юра и у меня немногим больше семи лет опыта разработки фронта на vue+typescript в ЛАНИТ и в МТС. Начал я, что забавно, с Angular 5 в далёком 2018, когда пятёрка ещё была актуальной версией, и работал с ним немногим больше пары месяцев, после чего перекатился во vue2.

Работал я исключительно в B2B и внутренней разработке. Системы документооборота, сервисдески, внутренние ГИС и PaaS и вот это вот всё. Благодаря этому я повидал разного. От DDD, до «паста‑болоньезе‑код».

Зачем и почему эта статья

Я понимаю, что словосочетание «архитектура фронтенда» может у некоторых вызвать приступ гомерического хохота, однако вопрос насущный. Современные SPA представляют из себя комплексные приложения с большим количеством функционала и бизнес‑логики. Однако сложность приложений выросла так стремительно, что сообщество за ним не успело, и только вырабатывает лучшие практики для построения поддерживаемых и расширяемых фронтенд приложений.

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

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

Синхронизируем понятийный аппарат

FSD (тут по причине того, что много внимания уделено критике FSD) — это архитектурная методология для проектирования фронтенд‑приложений. Из документации.

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

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

Из этого выведем определение архитектурной методологии:

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

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

Фреймворк — программная платформа, определяющая структуру программной системы. Википедия.

Тут и добавлять ничего не нужно. Фреймворк по‑умолчанию программный инструмент, что диктует построение архитектуры по определённой методологии.

Прочие определения.

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

Так всё же методология или архитектура?

Сначала давайте подведём резюме из наших определений.

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

Т.е. архитектура — это частный случай реализации архитектурной методологии.

Итого, FSD предлагает определённую методологию построения архитектуры приложения. Так же, как и Чистая архитектура, MVVM, DDD и прочие методологии. (вспоминает, что фреймворк — это конкретная программная реализация методологии, например flutter или spring).

Фух. С одной душной частью покончили. Поехали к следующей.

Хорошая и плохая архитектура

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

Для этого определим, для чего вообще нужна архитектура приложения.

Тут всё просто: архитектура приложения нужна для управления сложностью приложения. Отсюда вывод: хорошая архитектура работу над приложением упрощает, плохая — осложняет.

Очевидно, что для разных типов приложения подходят разные архитектурные подходы.

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

Для приложений с простой бизнес‑логикой и небольшим доменным пространством (например интернет‑магазины) для простоты управления сущностями уже желательно абстрагировать их от представления и разделять друг от друга. Однако абстракции вроде DI, repository всё ещё могут оказаться для таких приложений слишком неповоротливыми и неоправданно увеличивать сложность.

Для приложений с обширной доменной областью и сложной бизнес‑логикой (PaaS, ServiceDesk, Корпоративные системы документооборота) необходим архитектурный подход со строгим соблюдением SOLID. Такие приложения обычно живут долго, за время своего существования претерпевают значительные изменения как функционала, так и работающей над ним команды. При закладывании архитектуры такого приложения нужно предусмотреть вероятность смены команды, работу над ним людей разного уровня инженерной подготовки, вероятность добавления сущностей и значительного изменения существующих, вероятность добавления источников данных, изменения существующих и одновременного использования нескольких источников данных с разными парадигмами взаимодействия (REST, GraphQl, WS) и прочее, и прочее...

Ну и как же не вернуться к FSD? В конце концов именно она побудила меня к написанию этой статьи. Так давайте уделим методологии должное внимание и определим области применимости.

Область применимости FSD

Если мы говорим о малых приложениях (лендинги, PoC, MvP). Здесь FSD может иметь ограниченную применимость в минимальном своём исполнении, то есть в трёх слоях: App, pages, entity. В таком виде архитектура не вносит в работу над продуктом лишней головной боли и добавляет базовую структуру для удобства разделения инфраструктуры, бизнес‑логики и ui.

Само собой, применимо это к PoC и MVP. Лендингам оно нафиг не надо :-)

Приложения с простой бизнес‑логикой и небольшим доменным пространством

Вот где на мой взгляд идеальный «дом» для FSD. UI средней сложности легко поддаётся декомпозиции «сверху вниз», доменная модель легко укладывается в entity слой. Функции, приносящие пользователю бизнес‑ценность легко определить и выделить в features. При этом такого размера приложения редко бывают зависимы от большого количества источников данных, а само api зачастую довольно статично.

Enterprise приложения с объёмным доменом и сложной бизнес‑логикой

Это на мой взгляд тот случай, когда FSD применять не стоит. Я видел примеры попыток и это, прямо говоря, было больно.

Почему FSD плох для таких больших приложений?

  • FSD не предлагает никакой абстракции над источником данных.

    Наоборот, запросы к API идут из всех слоёв напрямую. Из‑за этого:

    • При росте приложения неизменно будет происходить дублирование кода запросов к api (спасибо «человеческий фактор»);

    • изменение и добавление новых источников данных принесёт много боли и страданий;

    • Отсутствие единой точки обработки api ошибок. Это, конечно, можно добавить в HTTP клиенте, но что, если у нас несколько источников данных, обработка ошибок от которых должна различаться? Кажется, что такое смешение ответственностей в HTTP клиенте не лучшая идея.

  • FDS осложняет декомпозицию.

    На самом деле эту претензию к FSD я слышал чаще всего. Да и сам её заметил. Когда приложение перешагивает определённый порог сложности, становится очень сложно определить, в какой слой сложить тот или иной функционал. Чаще всего путаница случается между слоями Widgets и Features, но ими не ограничивается. И чем больше команда, тем больше путаницы происходит. Отчасти это можно исправить строгой документацией, регламентирующей правила декомпозиции, но полностью оно проблему не решит.

  • FSD порождает высокую связность.

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

    И однажды, когда вам понадобится переместить компонент с уровня widget на уровень features вы проклянете всё. И проблема не в том, что придётся исправлять импорты. Это можно сделать самостоятельно. А в том, что ваш компонент зависел от трёх фич, которые вы больше не можете использовать, потому что импорт между слайсами внутри одного слоя запрещён. А значит вам нужно либо перемещать фичи ниже (что гарантированно приведёт к конфликтам уже на уровне entity), либо дублировать код. Гарантирую, вы предпочтёте просто продублировать код.

    На последний пункт можно возразить: «ты просто изначально неправильно разделил сущности». И да, это одна из потенциальных причин. Но вспомните предыдущий пункт и то, что в FSD бывает очень не просто правильно разделить сущности по слоям.

    Однако есть и вторая причина: доработка функционала.

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

Критикуешь, предлагай

На самом деле всё уже давно придумано до нас. Однако я в статье пойду последовательно. И начну с определения компонентов, из которых состоит любое крупное frontend приложение.

  • UI — то, что пользователь видит в браузере

    • Вьюхи — конечные страницы на url, которые

    • виджеты (группы компонентов, объединённые общим функционалом)

    • компоненты (компоненты, отвечающие за изолированный функционал)

  • Бизнес‑логика (use cases)

    • Реализация сценариев использования пользователем приложения

    • Хранилище бизнес‑сущностей (store)

  • Модель данных (Domain layer)

    • Бизнес — бизнес‑сущности, описанные в TS типах

    • Репозитории — унифицированные интерфейсы, между коннекторами и view контроллерами, оперирующие бизнес‑моделями.

  • Инфраструктура

    • DI container;

    • command/query bus;

    • контроллер управления модальными окнами;

    • контроллер управления тостами и так далее..

  • Коннекторы (источники данных)

    • Запросы к api (axios/fetch/tanstack)

    • auth провайдеры (google auth/yandex auth)

    • indexdb

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

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

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

UI

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

Для удобства работы UI слой разумно разделить более тонко по уровню сложности элементов. Например так:

  • pages — страницы, завязанные на конкретные роуты;

  • views — умные компоненты, использующие несколько виджетов и обращающиеся к слою бизнес‑логики;

  • wigets — глупые композитные компоненты (объединяющие в себе несколько components);

  • components — глупые компоненты, реализующие атомарную функциональность.

Предложенное выше разделение не догма, а только пример.

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

<template>
  <UserSimpleView :user="user" :loading="loading" /> <!-- Принимает интерфейс User из @/domain/models/user -->
  <MyButton :disabled="loading" @click="handleGetUser">Загрузить</MyButton> 
</template>
<script setup lang="ts">
import { useGetUser } from '@/use-cases/user'

const { uuid } = defineProps<{ uuid: string }>()
const { user, getUser, loading } = useGetUser()

// Максимально просто. Вся бизнес-логика в use функции.
function handleGetUser() {
  getUser(uuid)
}
</script>

Бизнес-логика (use-cases)

Слой, на котором обрабатывается взаимодействие пользователя с данными и хранятся эти самые данные. То, что FSD называет фичами.

  • Бизнес‑логика ничего не знает о том, что её будут применять в UI и ничего не знает о том, откуда берутся данные и кем и как они меняются.

  • Бизнес‑логика типизируется из слоя domain.

  • Бизнес‑логика получает данные из repositories, но ничего не знает о том, откуда сами repositories получают данные, и как они их обрабатывают.

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

Внутри можно разделять как удобно. К примеру по доменной области.

Тут прекрасно ложится взаимодействие между компонентами через publicApi, типизированное доменным слоем. Это позволит стандартизировать контракты взаимодействия и уменьшить степень связности.

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

// описание логики конкретного use-case — запроса данных о пользователе.
// Use case не должен знать ничего о коннекторе, который фактически лезет в api,
// вместо этого он обращается к абстрактному провайдеру из domain слоя.
export function useGetUser() {
  // Контейнер репозиториев лишь пример реализации инверсии зависимостей.
  // Не обязательно использовать именно его. 
  // Отталкиваться стоит от специфики вашего приложения.
  const repository = useRepositoryContainer().getRepository(Symbol("GetUsersByApi"))
  const user = ref<User | undefined>()
  function getUser(userUUID: string) {
    const query = {
       // логика формирования запроса
    }
    const result = repository.getSingleUser(query)
    // предполагаем, что пользователь возвращается единственным элементом в массиве
    user.value = result [0]
  }
}

Repositories

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

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

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

// Простой пример реализации репозитория.
// Под капотом может быть Tan stack, Pinia и в целом что удобно вам,
// и что наилучшим образом подходит вашему приложению.
export function userRepositoryBuilder(connector: AbstractConnector): UserRepository  {
  return () => (
    getUsers: (query: GetUsersQuery) => { /* ... */ }
    getSingleUser: (query: GetSingleUserQuery) => { /* ... */ }
    createUser: (command: CreateUserCommand) => { /* ... */ }
    // реализация остальных методов интерфейса репозитория
  )
// Простой пример инициализации репозитория в DI контейнере.
// Как я говорил выше, DI контейнер можно заменить на более удобную вам реализацию.
import useRepositoryContainer from '@/infrastructure/repositoryContainer'
import userRepositoryBuilder from '@/repositories/userRepository'

// Использование контейнера опционально, но удобно для синглтон репозиториев. К тому же
// контейнер можно типизировать и получать на метод getRepository автокомплит со
// списком существующих в контейнере репозиториев.
useRepositoryContainer().registerRepository(Symbol('UserRepository'), userRepositoryBuilder())

Domain

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

// src/domain/user/model
export interface User {
  uuid: UUID
  name: string
  email: string
  // прочие поля
}
// src/domain/user/abstractRepository
export interface UserRepository {
  getUsers(query: GetUsersQuery): User[]
  getSingleUser(query: GetSingleUserQuery): User
  createUser(command: CreateUserCommand): CommandResult<User>
  updateUser(command: UpdateUserCommand): CommandResult<User>
  deleteUser(command: DeleteUserCommand): CommandResult<Boolean>
}

Infrastructure

Тут все вспомогательные инструменты для функционирования приложения. Реализации контроллеров, шин, инициализация сторов. Всё это тут.

Connectors

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

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

Именно для этого между коннекторами и бизнес‑логикой мы помещаем слой провайдеров, зафиксированный AbstractRepositories контрактами из домена.

Схема зависимостей

Ниже прикладываю схему зависимостей, где стрелками указаны направления зависимостей.

Зелёным обозначен бизнес-слой, серым — слой работы с данными
Зелёным обозначен бизнес‑слой, серым — слой работы с данными

Видно, что от инфраструктурного слоя зависит всё приложение, однако это набор независимых вспомогательных инструментов. Так, для ui инфраструктурный слой может реализовать систему управления тултипами и модальными окнами, для репозиториев — обработчик ошибок, для use cases шину событий.

Самое главное, что слой домена полностью независим.

UI зависим только от домена и от use‑cases, который, по сути, представляет из себя view‑model.

Use cases зависят от Repositories как от основного источника данных. По сути, Repositories представляет из себя model из архитектуры MVVM. Use cases и repositories стандартизируют общение друг с другом посредством интерфейсов, декларированных в domain.

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

Коннекторы могут быть независимы полностью, либо зависеть от инфраструктуры а части, например, http клиента.

Теперь сверим получившуюся архитектуру с SOLID

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

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

  • Принцип подстановки Барбары Лисков: больше про конкретные реализации и декомпозицию типов. Однако благодаря выделению домена, мы получаем единую точку истины по типам — удобнее типизировать и управлять контрактами.

  • Принцип разделения интерфейса: декларируем интерфейсы в доменном слое и сепарируем их по бизнес‑сущностям.

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

Тестирование

Тут много говорить не буду.

Я считаю, что самое главное покрыть тестами бизнес‑логику, и в предложенной структуре приложения сделать это достаточно просто. Бизнес‑логика зависит только от репозиториев, которые достаточно просто подменить моками и убедиться, что всё работает как надо.

Сложнее будет с UI. Но с ним всегда сложнее. Благо, в предложенной реализации нет зависимости UI от сторов и не придётся мучиться с тем, чтобы мокнуть импорт вызовы Пиньи. Достаточно подменить реализации use‑cases и ненужные для теста компоненты.

Ограничения

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

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

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

В заключение

Очевидно, что предложенная мной архитектура, которая, по сути, представляет из себя переложение на frontend чистой архитектуры Роберта Мартина, далеко не универсально. Я пришёл у ней, когда пытался отрефлексировать боль от использования FSD в разработке крупных и комплексных приложений.

Я не говорю о том, что FSD однозначно плоха. Вовсе нет. У неё есть своя зона применения, где она удобна. Помимо этого авторы применяют ряд отличных методов, которые можно применять в других архитектурах для их усиления, увеличения гибкости или удобства работы с ними.

Я лишь призываю подходить с умом к выбору архитектурного фреймворка или паттерна и если вы решили использовать FSD, потому что слышали, что она хороша. Возможно стоит подумать ещё раз, а точно ли она отвечает нуждам именно вашего продукта.

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

PS: обоснованная критика и дискуссия приветствуется. Если нашли ошибки или опечатки — говорите. Я исправлю :-) Если Хабр позволяет... а то первая статья, фукнционал ещё не знаю