Комментарии 64
Я пробовал использовать FSD, в целом не так и плохо, но в какой-то момент каждый проект становится лень настраивать, особенно когда создаешь куча небольших проектов с фронтендом.
Как бы вы назвали предложенный подход?
Ну для относительно небольших проектов fsd весьма не плох. Вполне нормальный способ организации кодовой базы.
Проблемы начинаются, когда проект большой :)
Предложенный мой подход незачем как-то называть. Это, по сути, чистая архитектура, адаптированная под фронт
Что же делать, когда маленький проект на FSD начинает разрастаться?
Я думаю, что нужно мигрировать, пока проект не превратился в свалку :)
Я бы начал с выделения доменного слоя, потому что от него потом будет зависеть всё.
Потом я бы выделил ui слой, чтобы отвязать представление от бизнес-логики.
После я бы постепенно перетащил api в слой коннекторов. Важно сразу к каждому коннектору делать обвязку-репозиторий. Поэтому переход и постепенный. Вынесли один api коннектор => сделали к нему реализацию репозитория, занялись следующим api коннектором. Если в FSD слайсах изначально разделяли бизнес-функции и api обращения, это пройдёт легче.
В итоге в FSD слоях естественным образом должна остаться только бизнес-логика. Её останется только сгруппировать в отдельном слое приложения.
В финале доработать инфраструктурную обвязку в случае необходимости.
Кстати в последнрей версии поправили главную боль малекньких проектов. Теперь если что-то используется только на одной странице, это можно держать прям в этой странице и не размазывать по слоям. А на слои нужно выносить только в случае если сущность переиспользуемая
Про причины популярности фсд я бы дополнил: когда я года два назад гуглиларо архитектуры на фронте, ничего кроме фсд он мне предложить и не смог. Вместе с тем лично я эту архитектуру так понять и не смог, из описаний как будто следовало, что я всю фигню кладу в кучу, но делю по размеру. Пониманию так же не способствовали срачи мейнтейнеров в из чате на тему "куда положить компонент N"
Мне ваша статья показалась весьма интересной, как раз в обозримом будущем мне предстоит пилить относительно большой фронт, и у меня предостаточно времени, чтобы сразу сделать по фен-шую) Полагаю, ваша статья ляжет в основу.
Буду признателен, если вы сможете запилить минимальный туду лист с данной архитектурой для наглядности
Есть несколько интересных идей. Но в целом подход похож на джаву, такой же громоздкий и перегруженный. Меньше всего такое хочется видеть на фронте.
Да, примерно как в Джава или в Шарпах. Хотя в Рельсах, помнится, тоже похожий подход.
Все они не просто так к нему пришли. Когда монолит разрастается, за управляемость и предсказуемость кодовой базы приходится платить определённую цену. Как по мне, лучше так, чем то, во что превращается fsd на сложных проектах через полтора-два года разработки.
В последнее время на фронтенде как раз таки приходится видеть жесткий перегруз, я уже даже не особо различаю эту грань между фронтендом и бэкендом
На практике, если команда меняется и нет сильного контроля за PR, то FSD превращается в помойку и не ясно уже где искать widgets, features, e t.c
Точно также можно сказать про любую архитектуру. Контроль и чётко описанные правила кода обязательно должны быть доступны команде разработки.
Естественно.
Энтропии подвержена любая кодовая база, как бы пристально за ней не следили. Мы можем только влиять на величину энтропии во времени :)
Две большие проблемы FSD на мой взгляд:
Очень свободная трактовка принципов раскладывания сущностей по слоям
Сваливание в одну кучу разнородных этапов работы с данными, то есть мы на одном слое абстракции и получаем данные, и обрабатываем их, и обсчитываем бизнес-логику, и готовим UI. Это порождает сильную связность между этапами процесса и больно аукается со временем.
Спасибо за статью. За многие годы боли от FSD пришел примерно к такой же структуре, но с небольшими отличиями.
Приложение делится на слои
app— основной слой приложения, используется для сборки конечного результата.shared— shared слой, как и в FSD, должен быть полностью независимым и легко может быть вынесен в отдельный пакет, например.catalog/cartи т.п. другие слои приложения, в рамках FSD это можно назвать фичами.
Каждый из слоев делится на слайсы в рамках чистой архитектуры:
domainapplicationinfrastructurepresentationintegration
Здесь все так же, как у вас, за исключением:
Слой
application, а именно use-case, в вашем случае сейчас зависит от инфраструктуры (ref), что нарушает принципы чистой архитектуры. Для себя же принял решение, что use-cases должны содержать только бизнес-логику и зависеть только от доменного слоя, поэтому в моем случае use-case будет вида:JavaScript
GetUserUseCase => { // ... return repository.getSingleUser(query) }А вот в слайсе
presentationможно реализовать ужеcomposableuseGetUser, который будет обращаться к use-case и оборачивать результат вref. В простом случае для Nuxt-приложения этотcomposableбудет оберткой надuseAsyncData, в котором вызывается данный use-case.
Этими шагами удается полностью изолировать бизнес-логику от UI-слайса, и если когда-то будет принято решение переехать с nuxt на что-то другое, то достаточно будет переработать только слайс ui.
В целом сам слайс ui делится на:
components— глупые компонентыwidgets— компоненты с частью БЛpages(только если есть страницы, где используются только данные текущего слоя)
Как и в FSD, кросс-импорты между слоями запрещены, для взаимодействия между ними используется слайс integration.
В каждом слое в доменном слое определяются свои доменные модели, и даже если они частично совпадают (например, Catalog\Product и Cart\Product), это будут отдельные модели со своими полями. В слайсе integration мы можем обратиться к другому слою и вернуть что-то, что необходимо нам.
Например, в слое Cart определяется порт GetProductData(productId: number), а реализация этого порта находится в integration, где происходит вызов ProductRepository и трансформация Catalog\Product в Cart\Product. Этим мы добиваемся того, что модели в разных слоях могут развиваться по своему, и не нужно сильно связывать их.
Также в качестве DI-контейнера использую Inversify, для его поддержки в каждом слое есть bootstrap.ts, в котором выполняю регистрацию всех зависимостей для DI.
Выполняя разделение таким образом, становится возможным вести разработку каждого слайса независимо от других. Если говорить в рамках Nuxt, то можно использовать nuxt/layers под каждый слой и в целом выносить их в отдельные репы (если слой очень большой).
Слой app используется для конечной сборки приложения, здесь вся работа фактически выполняется в UI-слайсе, который делится:
pages— чистые компоненты страниц, у них есть свои пропсы и т.п. Здесь могут быть использованы компоненты с разных слайсов.routes— здесь определяются маршруты приложения, внутри вызываем компоненты изPages. В рамках Nuxt это файловая маршрутизация, но может быть заменена и наroutes.tsопределение маршрутов.layouts
По поводу независимости бизнес-логики от инфраструктуры:
Это, получается, всё состояние хранится в presentation? А что делать, если его нужно шарить между представлениями? А что делать, если бизнес-логика соседнего раздела зависит от этого состояния? Кажется, что появляется двунаправленная связность бизнес-логика <=> UI. А это, кмк, нехорошо.
Другой вариант — каждый раз высчитывать то же состояние заново, что тоже не выглядит удобным решением.
Ну либо реактивность состояния достигается не средствами фреймворка. Я недопонимаю, как именно это работает, честно говоря. Можно подробнее раскрыть тему?
Как и в FSD, кросс-импорты между слоями запрещены, для взаимодействия между ними используется слайс
integration.
Вот о том, что выше, тоже можно подробнее? Любопытно, как именно это реализовано.
Например, в слое
Cartопределяется портGetProductData(productId: number), а реализация этого порта находится вintegration, где происходит вызовProductRepositoryи трансформацияCatalog\ProductвCart\Product. Этим мы добиваемся того, что модели в разных слоях могут развиваться по своему, и не нужно сильно связывать их.
А в чём драматическое различие продуктов в Cart и в Catalog? Ну, то-есть, не разумнее ли сделать
interface Product { ... }
interface CartProduct extends Product { ... }
interface CatalogProduct extends Product { ... }Так проще сохранить консистентность домена, отслеживать связи домена и можно использовать полиморфизм, то есть условно так:
function doSmth<T extends Product>(product) { ... }
dosmth(cartProduct)
dosmth(catalogProduct)Собственно это одна из важным причин выделения домена самостоятельным слоем.
Вторая важная идея — по единому, не размазанному по слоям, домену можно быстро составить представление о бизнес-области всего приложения и сориентироваться о взаимосвязях бизнес-сущностей.
А в чём драматическое различие продуктов в Cart и в Catalog?
В том, чтобы 2 этих слоя не были зависимы друг от друга. Например (очень грубый, но понять о чем я поможет). Изначально каталог содержал товары без вариаций, и ее модель была такая
Catalog\Product { name: string }.Соответственно в корзине нам тоже достаточно просто названия товара
Cart\Product { name: string }.С ростом каталога, приходим к выводу, что нужны вариации и модель трасформируется в
Catalog\Product { name: string, sku: ProductSku[] }Если не разделять модели, то изменения в каталоге неизбежно будут «просачиваться» в корзину. Если разделять, то модели развиваются независимо, и при изменениях в каталоге достаточно подправить адаптер:
Catalog\ProductSku -> Cart\ProductОсновная идея в том, что в разных слоях требуются разные данные, и даже если они частично пересекаются — это всё равно разные сущности, имеющие разный смысл.
Например, если в каталоге появится ещё одна сущность, которую можно положить в корзину, то просто добавляется ещё один порт для её трансформации. Никакого влияния это не окажет на существующие сущности Cart.
Вот о том, что выше, тоже можно подробнее? Любопытно, как именно это реализовано
У себя это реализовываю создавая в application слое Port , например
// application/ports/GetProductPortinterface GetProductPort { getProduct(productId): Cart\Product }соответственно в integration лежит реализация этого интерфейса
// integration/catalog/GetProductAdapterclass GetProductAdapter implements GetProductPort{getProduct(id: string): Cart\Product {return this.transform(this.productRepository.findById(id));}}и далее этот адаптер получаю через DI в use-case'ax
Что касается состояния, то здесь я разделяю его на 2 вида
Хранится в
ui/stores, например в Pinia.
Это чисто визуальное/вспомогательное состояние:открытые модалки
выбранные фильтры
текущие вкладки
временные поля формы
UI state никогда не используется в domain или application.
Это состояние уровня application, влияющее на выполнение use-case’ов.
Здесь я использую RxJS (но подойдёт любой механизм реактивности, который не зависит от конкретного UI-фреймворка — Vue/React/Angular и т.п.).
Доступ к AppState также вынесен через порты, а реализации этих портов находятся в integration, точно так же, как и адаптеры для работы с внешними сервисами.
Если что, я не призываю бежать переписывать проект :)
Мне интересно поразмышлять на тему в качестве тренировки.
При выделении обощённого интерфейса для наследования, дочерние сущности останутся друг от друга независимыми (они ничего не будут знать о существовании друг друга) и всё так же можно будет для каждого отдельного типа продукта создавать собственный адаптер, а добавить новый продукт будет не сложнее.
Но можно будет создавать обобщённые функции при необходимости и можно будет проследить дерево наследования, чтобы понять общую бизнес-функциональность.
При нынешнем же подходе высок риск того, что необходимо будет местами дублировать код, потому что, хоть сущности и разные, но они однородны, то-есть бизнес-сущность у них общая, а значит с точки зрения бизнеса есть и общие ступени обработки этих сущностей в процессах.
Немного похоже на костыль, чтобы обойти ограничение FSD на кросс-импорты. Хороший костыль, но всё ещё костыль.
Кажется, что удобнее было бы разложить сам бизнес-слой на fsd и уже внутри этих слоёв запретить кросс-импорты. Тогда однонаправленный поток импортов лёг бы намного удобнее. А при необходимости спустить какую-то функцию на слой ниже можно даже безболезненно выделить новый слой, чтобы не мучиться с адаптацией импортов. Но с выделением нового бизнес-слоя нужно быть очень осторожным и прикинуть, а логично ли это, и не окажется ли это слой для одного единственного кейса.
Я думал об этом, но кажется странным решением, честно говоря. Да, так ослабляется зависимость от react или vue, но добавляется новая зависимость, увеличивается размер бандла и порог вхождения в проект (особенно если говорить об Rx). Стоит ли игра свеч?
То, что ты обозначаешь как ui состояние я бы у себя реализовал отдельными инфраструктурными сервисами. Навроде "ModalService", который бы управлял работой модальных окон. Но пока не уверен, что это закрыло бы все потребности ui.
1. А для чего нужен общий интерфейс?
В моем понимании он необходим в случаях когда, есть какой-то общий код который будет работать с этими сущностями,
либо же сами сущности являются видами одного и того же. Например возьмем тот же пример с оружием, есть метод
shotWeapon(weapon: Weapon)
и интерфейсы
interface Weapon { shot() }
interface Gun extends Weapon
interface Rifle extends Weapon
здесь наследование вполне приемлемо, т.к с помощью него мы скрываем за абстракцией конктерные реализации.
если же рассматривать текущий пример (наследование между слоями), то фактически CartProduct и CatalogProduct это не части чего-то общего,
у них совершенно различные назначения, поэтому обьединять их нельзя.
Да, на начальных этапах кажется, что это одно и тоже, и есть желание их объединить, однако, если проект долгоживущий, то развитие этих частей по отдельности даст больше преимуществ.
Например добавление новой сущности к продаже - цифровая подписка, сразу ломает это наследование, т.к это уже не продукт, но в тоже время может находиться в корзине наравне с другими товарами.
И что касается примера с оружием, я допускаю, что где-то у нас точно будет наследование, но скорее всего оно будет находится в рамках одного слоя, например тот же
CatalogProductSimple extends CatalogProduct
CatalogProductSku extends CatalogProduct
2. Это стандартная практика в гексогональной архитектуре, здесь же это ее вариация.
Разделение на слайс integration через порты и адаптеры позволяет каждый слой разрабатывать и тестировать не зависимо от других, при разработке корзины не нужно дожидаться когда окончательно будут доступны модели каталога и ее можно вести параллельно самому каталогу.
А для тестирования достаточно будет просто замокать зависимости от сюда и вся БЛ самого слоя станет доступной для тестов.
3. В нашей практике как показал опыт это достаточно удобно, но опять же я не призываю использовать именно rxjs.
Можно хоть реализовать свою вариацию на обсерверах ил подобном. Но в целом, если перехода на другую платформу не предполагается, то можно договориться и об использовании тех же ref, reactive, pinia не только в слайсе ui.
Моё предположение об общности CartProduct и CatalogProduct проистекает из незнания бизнес-области продукта и семантической общности сущностей.
Да, только с нюансом, что в гексагональной архитектуре порты используются для подключение внешних сервисов, а не для соединения сущностей внутри бизнес-логики.
То есть, чтобы обойти ограничения FSD тебе пришлось сделать вид, что элементы твоей бизнес-логики друг для друга внешние модули. Я это имею в виду костылём.
Мне очень нравится идея ограничить применение SDOM библиотеки только ui слоем, но очень не нравится идея тащить сторонний механизм реактивности. А городить самописную реактивность очень своеобразная задача, которая, прямо говоря, большинству проектов, даже крупных, совсем не нужна. При этом смена SDOM библиотеки, прямо говоря, в любом случае встанет в немалые трудозатраты. Даже если ограничить применение только UI слоем. Так что пока что, издалека, мысль идея хоть и приятная, но как будто бы оверинжиниринг и создание себе проблем на пустом месте.
Именно. Я предлагаю рассматривать взаимодействие между слоями так же, как взаимодействие между внешними модулями. И в частности поэтому модели стоит разделять и не смешивать их внутри доменной логики.
Я одной из важных задач доменного слоя (да и в целом архитектуры) вижу документирование приложения. Исходя из этого: архитектура должна строиться так, чтобы можно было легко найти однородные сущности и понять, в чём их общность. Часто этого можно добиться благодаря выстраиванию правильной иерархии сущностей через наследования и абстрактные поведенческие интерфейсы.
Ещё один важный принцип формирования домена сервиса — семантическая консистентность.
Что это значит
Если бизнес выделяет сущности CartProduct и CatalogProduct, то, следуя из семантики, можно сразу понять, что это родственные сущности. Они могут существовать, ничего друг о друге не зная, но у них в доменном слое должен быть общий предок, через которого ясно, в чём именно состоит общность, а в чём они различны.
Иначе говоря, если эти сущности друг от друга абсолютно независимы, то называться они должны так, чтобы мы из семантики не могли предположить их общность. К примеру CatalogItem и CartProduct.
Для чего это нужно?
Уменьшение когнитивной сложности приложения. Чем точнее мы мы разделяем сущности семантически, тем проще в них ориентироваться и проще погружать новых разработчиков в продукт. А в идеале, если синхронизировать терминологическое поле между фронтендом, бэкендом и бизнесом, то намного проще общаться с бэком и бизнесом: эффективность коммуникации заметно возрастает.
Так что правильное формирование доменной модели не стоит недооценивать.
Правильно понимаю, вы, вместо того чтобы БЛ-состояние хранить, применяя механизмы фреймворка, и напрямую использовать в UI, держите его в RxJS-объектах и прокидываете через прослойки, где мэппите одни события обновления в другие события обновления?
Да, что-то вроде этого. Но важно понимать, что мы так делаем только для состояния, которое используется в бизнес-логике (например, в use-case’ах), а не для UI-only state.
например
// order/application/ports/AuthPort
interface AuthPort {
getUserDataStream(): Observable<UserDto | null>
getCurrentUser(): UserDto | null;
}
// order/integration/Auth/AuthAdapter
class AuthAdapter implements AuthPort {
getUserDataStream(): Observable<UserDto | null> {
return authState$.pipe(
// ...
);
}
getCurrentUser(): UserDto | null {
const state = authState$.getValue();
return state;
}
}
тогда в БЛ используем напрямую этот порт, а если состояние нужно в UI то делаем composable
function useAuthData()
{
const userData = ref<UserDto | null>(AuthAdapter.getCurrentUser());
onMounted(() => {
const subscription = AuthAdapter.getUserDataStream.subscribe(data => ref.value = data);
onUnmounted(() => {
subscription.unsubscribe();
});
});
return { userData }
}
это утрированный пример, но принцип понятен.
const subscription = AuthAdapter.getUserDataStream.subscribe(data => ref.value = data);
Тут производительность не может пострадать из-за постоянных переприсваиваний и копирований в случае больших и/или вложенных объектов, особенно с учётом того, что на данные навешивается реактивность фреймворка?
function useAuthData()
Это только для чтения, а как решаете, если состояние в бизнес-логике надо поменять из UI?
Для малых проектов fsd это оверхед, для больших превращается в один большой головняк из за протекания абстракций и дублирования кода. Создаётся впечатление, что fsd придумали просто потому что нужно было что то придумать, для людей которые с трудом осилили(или нет) документацию своего стека, что бы они могли почувствовать себя архитектороми приложениий
Ну для средних проектов FSD вполне может быть применим.
Да и для малых ок в минималистичном исполнении из трёх слоёв. Просто для минимальной структуризации кода.
Ну, я считаю, что для интернет-магазинов или личных блогов самое вот оно.
Не смогу с вами согласиться, тк был опыт на разного масштаба проектах. Везде были проблемы и проблемы разные.
О средних проектах моё предположение умозрительно. Я на средних проектах с FSD не сталкивался.
Вообще у меня было всего два случая общения с FSD, и оба — PaaS. Один ещё и разбитый по домену на несколько команд по 2-4 человека.
Я подумал, что на средних проектах FSD может быть применим от того, что там сама сложность функционала не разрастается настолько, чтобы начинать путаться в FSD.
Понимаю, что могу ошибаться.
Расскажите подробнее, какие проблемы с FSD возникают на проектах среднего размера?
самая большая проблема fsd, что он по своей сути идет от UI. т.е. текущий макет интерфейса диктует вам как и из чего будут состоять фичи. поэтому каждое изменение структуры макета ui интерфейса, делает ваши фичи и слайсы, виджеты не консистентными к приложению. Я сам с этим сталкивался, решается двумя путями - забить и страдать или овертаймами работ по синхронизации всего этого дела. Говоря математическим языком, core_logic = f(ui), что само по себе не лепо, тк это аналогично ситуации, когда выбор шаблонизатора, определял схему данных бд. FSD запрещает кроссфича импорты, заявляя что это как своего рода инкапсуляцию, но по факту одна фича в другую может проникнуть по средствам какого нибудь селектора слайса одной модели в другую. как пример - была форма многоэкранная, на первом экране была фича принимашая строковый адрес, на втором экране фича которая рисовала адрес на карте. и они были друг на друга завязаны, типы и селекторы приходилось дублировать, что бы соблюсти запрет кросс импртов, но это потом стрельнуло нам, когда мы забыли пофиксить один из селекторов, при смене справочника адресов. При переходе вашего проекта из состояния среднего к большому 100% будете резать свои фичи на микрофронты, и удивитесь что они не отрежутся, там будет сильная зависимость от окружения. Когда всеже в микрофронтах вы избавитесь от fsd, вы удивитесь что и на хостовом приложении от него ничего не осталось, тогда возникает вопрос - зачем оно все это нужно???
Зачем тащить на малые приложения фсд, если для личного блога или магазина хватит иерархии components, hooks, hocs, store.
Мне не очень понятно ваше рвение защищать этот подход, он действительно мертворожденный и я считаю его выбор абсолютно безграмотным с точки зрения архитектуры, его выбор просто докажет что разработчик имеет узкий кругозор и мизерную насмотренность. Исключительно мое мнение, прошу не принимать его как оскорбление, а воспринимать как возможную зону роста в будущем. Вы можете возразить на каждый из тезисов, что нужно было бы в каждом случае делать то то или это, я категорически не согласен. Архитектура приложения должна быть интуитивно понятной, а не обьясняться документацией которая сама себе противоречит местами и живет в вакууме
Не то, чтобы я его защищал. В статье, так я его вообще критиковал :)
Я просто допускаю его применимость с той точки зрения, что я... не во всякого размера проектах его использовал. Да и в силу того, что я, возможно, чего-то в нём недопонял-таки.
В целом-то я с претензиями к fsd согласен. Как и к критике ui-ориентированного подхода к проектированию в целом.
путаница случается между слоями Widgets и Features
Если компонент можно назвать существительным - это виджет. Глагол(действие) - фича
И однажды, когда вам понадобится переместить компонент с уровня widget на уровень features вы проклянете всё
Тут проблема не в FSD а диких требованиях бизнеса если вам понадобилось перемещать целый widget в features. Ну либо в изначальной ошибке. И подобные изменение в любой методологии потребуют кучу изменений
Не знаю, даже в крупных проектах FSD при правильной готовке не вызывает боли. Особенно в связке с effector & farfetched. Я так же предпочитаю немного нарушить методологию положив особенно важные сторы которые влияют на все приложение в shared слой.
Если компонент можно назвать существительным - это виджет. Глагол(действие) - фича
Ситуация: у нас есть функция создания пользователя в настройках в управлении пользователями и, допустим, в добавлении пользователя в проекту (допустим, у нас SaaS продукт).
В списке настроек оно висит на собственном URL, а из проекта открывается в модалке. Такое ТЗ, так это увидели дизайнеры.
В интерфейсе оно в зависимости от места применения называется "Создать пользователя".
То есть, это глагол "создать", однако там много логики: нам нужно проверить пользователя на существование, запросить список проектов, в которые нужно добавить пользователя и определить ему роли (для этого нужно запросить ролевую модель), провалидировать форму, отправить данные на сохранение, обработать результат операции.
Мы можем это назвать "Создание пользователя" (существительное) или "Создать пользователя" (глагол). Если переходим на английский, то это createUser либо userCreation. Тоже не особо помогло.
Попробуем что-то попроще. Кнопка "Загрузить проекты" в интерфейсе. По сути, оно дёргает конкретный Get с параметрами и докидывает в интерфейс список проектов.
Назвать можно "Загрузка проектов" или "Загрузить проекты".
И так, по сути, со всем. Виджет, по сути, та же фича, только более комплексная. И вопрос в том, а где провести границу, что "вот до такой комплексности фича, а выше — виджет"?
Не знаю, даже в крупных проектах FSD при правильной готовке не вызывает боли.
Вероятно. Я не могу претендовать на истину в последней инстанции. Да и проектов на FSD видел только два. Однако я не могу придумать, как в крупном проекте приготовить FSD — не модифицируя его — так, чтобы это не было больно.
Про эффектор мне сказать и вовсе нечего. Я с ним не работал т.к. не работаю в экосистеме React, а для vue подход не распространён.
Особенно в связке с effector & farfetched.
К сожалению они приносят свои боли. Да это сильно лучше чемсм redux/mobx. Но эфектор Живёт в парадигме статических сторов, и для динамичски создаваемых сторов нужно писать костыли с фабриками.
Последнее время инфраструктура особо не развивается. Роутер так и не добрался до релиза.
Farfetched тоже не добрался до релиза, и тоже не развивается, pr с поддержкой zod 4 висит не счмёрженный несколько месяцев(а могли бы просто Standard Schema поддержать)
Effector лучше mobx, это в каком месте?
Холиварный вопрос, но я считаю mobx плохим стейт менеджером
Ну считать можно что угодно, объективную реальность это не отменяет. Mobx это отличная система реактивности, на базе которой можно построить уже ту архитектуру, которую вы хотите, в том числе стейт менеджер
У всех разные вкусы. Это нормально.
Считать что твоё мнение единственная возможная истина — не оч.
Тут проблема не в FSD а диких требованиях бизнеса если вам понадобилось перемещать целый widget в features. Ну либо в изначальной ошибке. И подобные изменение в любой методологии потребуют кучу изменений
Как раз в этом и есть проблема FSD проектов находящихся на этапе перехода бизнеса от малого к среднему. FSD не адаптируем. Хорошая архитектура в буквальном смысле ждет изменений. фичи, слайсы, виджеты, как по мне это сущности приятнутые за уши и прибитые гвоздями к компонентам. Отсюда и проблема, что когда статус компонента меняется, становится непонятно что с ним делать. начинает или течь абстракция или лететь костыли. Слоистая архитектура в ствязке с атомик дизайном(как пример) в этом плане сильно гибче. Можете менять ядро системы, а ui и знать об этом не будет
Много лет строю достаточно большое и сложное приложение. Жили без fsd — было больно. Перешли на fsd — всё ещё больно. Как бы ты не старался, а сущности связанны друг с другом и влияют друг на друга. Да, по fsd не должно быть кроссимопртов между слоями, но в реальном мире это не возможно. А с кроссимпортами ещё и циклические зависимости приходять, которые стреляют неожиданно, и отлавливать их очень сложно.
И есть подозрение что юз кейсы с доменами проблему тут не решат. Плюс менять архитектуру в существующем приложении очень больно и сложно. Не понятно как жить дальше
Переписывать проект я не призываю :)
Вообще, прежде чем избавляться от болей, стоит сперва определить, какие именно аспекты организации проекта причиняют боль. И только определив источники боли думать, как же от них избавиться.
А на счёт FSD... С одной стороны мне хотелось бы взглянуть на крупный проект, в котором FSD работает как надо и помогает легче разрабатывать продукт. С другой стороны мне больше не очень хочется вообще трогать FSD :)
Было бы здорово увидеть реализацию вашего представления архитектуры, пример бы. В целом у вас здравые мысли, которые уже давно крутятся в голове, но никак не оформляются в релиз ;)
Ну, кодовая база таких проектов зачастую под NDA, да и я сейчас без работы сижу. Благодаря чему у меня и появилось время на то, чтобы склепать эту статью :)
А пет-проект такого масштаба, чтобы показать плюсы предложенной архитектуры, кажется чем-то очень долгим в исполнении.
Разве что сделать mock-boilerplate для какой-то минимальной наглядности...
Есть пакеты в NPM для отслеживания циклических импортов, можете прикрутить их к пауплайну тестов. Как разберётесь поднять до уровня Error.
И пусть вам кажется, что все эти функции должны лежать в одном файле и по другому его тяжело назвать, но прям вообще никак, используйте приставку part1. Думаю проблемы с неймингом не так критичны как с циклами.
Разделение на слои - это по-факту декомпозиция "для галочки". Ну типа всё красиво разложено по папочкам, в папочках - файлы с похожим содержимым. Но это не дает каких-то прям сильных плюшек. Каждое изменение затрагивает кучу файлов, которые непонятно кто еще использует. Тестировать - непонятно как, получаются дебильные тесты в стиле "если позвали API и fetch отдал объект X, то API отдал объект X".
"Вертикальное" разделение - когда в каждой папочке-подсистеме какой-то понятный кусок функционала, с выставленным API - делать сложнее, но оно даёт плюшки. Можно какие-то вещи делать не выходя из этой папочки (если её API не меняется). На чанки приложение лучше разбивается. И - если вообще всё идеально - что-то можно выносить в библиотеки и использовать в нескольких приложениях. А может даже даже будет просто распилить приложение на два.
Ошибка FSD - это чрезмерно настаивать каких видов нужно делать папочки-модули, и как они должны быть связаны. Ну типа различать features и widgets. Можно ведь делить как угодно, оставляя саму идею.
По-факту, в реальных приложениях часто микс обоих подходов. Общие компоненты - почти всегда выделены как модули, правда не всегда следят чтобы API был нормальный. Какие-нибудь react hooks - сваливают в общую папку по принципу "это же хуки" - и те что общие, и те что нужны одной страничке.
В общем, мысль в том, что думать надо чуть выше абстракциями. Ну, что каждая папочка - это модуль, от него должна быть какая-то польза (проще взять его, чем написать прям по месту), хороший API. И поменьше придумывать синтетические ограничения себе, в стиле "давайте заранее придумаем какие у нас папочки будут, и что будет в них".
Я вижу центральной ошибкой FSD неправильную основу для абстракции.
То есть FSD сосредотачивается на том, чтобы изолировать друг от друга бизнес-функции. В то время, как в первую очередь нужно изолировать бизнес-логику от внешних факторов: источников данных и представления. Это залог управляемости, масштабируемости и тестируемости бизнес-логики.
FSD же наоборот связывает данные, бизнес-логику и представление в один комок, а потом размазывает это по нескольким слоям приложения. В результате получается нечто, чем довольно трудно управлять, и что весьма не просто масштабировать.
Программа — это оцифровка бизнес-процессов, а сущности в бизнес-процессах всегда достаточно тесно переплетаются друг с другом.
FSD работал бы в мире, где сущности можно чётко друг от друга сепарировать, но, к сожалению, работать FSD приходится с реальным миром. А тут всё... немного сложнее. И его гибкости в конечном счёте не хватает.
То есть FSD сосредотачивается на том, чтобы изолировать друг от друга бизнес-функции. В то время, как в первую очередь нужно изолировать бизнес-логику от внешних факторов: источников данных и представления. Это залог управляемости, масштабируемости и тестируемости бизнес-логики.
Если FSD порождает столько проблем, которые описаны в статье и комментах, то возникает естественный вопрос, а зачем FSD вообще использовать. Берите многослойную архитектуру - она работает на всех масштабах от простейших приложений до огромных.
Это нужно обдумать, честно говоря. Пока что сложным выглядит вопрос организации UI слоя и получения им информации от слоя бизнес-логики.
Навскидку хорошим решением вижу объединением в слое UI подслоёв логики и получения данных в единый подслой module. Тогда получится
views — для всей композиции компонентов
model — для ui-логики и хранения ui состояния.
В остальном укладывается хорошо.
А, ещё непонятно, куда инфраструктуру укладывать...
В общем, адаптация слоистой архитектуры под фронт — хороший вопрос на подумать, кмк.
Типичный сценарий для современного IT:
- берем общий термин или идею (FSD, или там Immutable State)
- делаем как-получится реализацию (feature-sliced.design, или там Redux)
- пиарим люто бешенно
- у людей создаётся ассоциация FSD=feature-sliced.design, Immutable State = Redux
- люди начинают считать изначальную идею - плохой, хотя плохая была - реализация
FSD как идея, в том же реакте живёт прям от входа: компоненты лежат в папочке со своими хуками, CSS, и логикой. И отлично это работает. И по-инерции, народ пишет так не только компоненты. В общем, многие проекты на React (особенно без Redux) - они уже Feature-Sliced.
Но нет - пришли люди с папочками, линтерами, и красивым веб-сайтом. Решили научить жить правильно. И вот - пошла справедливая контр-реакция. Но направленная теперь - против самой идеи.
И вот, на полном серьезе людям предлагают переходить на Layered Architecture. Как на беке, да. Где, если что, вообще за все годы не научились делать большие проекты. Где с этими нашими слоями, всю дорогу не получалось ничего кроме лапши. И где единственный способ как-то себе запретить делать лапшу - прям физически разнести код по репозиториям. Это называется "микросервисы", и "микросервисы" переводится как "мы так и не научились разложить код по папочкам".
Не надо тащить эту ошибку на фронт. Я вот сейчас наоборот пытаюсь эти идеи забирать в бек. Чем плоха монорепа, где у тебя те внутри сервисы, взаимодействующие друг с другом через понятные API? Разве это хуже, чем один гигантский DataLayer на сотни таблиц, и бизнес-логика - ходящая рандомно в любую часть БД?
И да, и нет.
Монолиты на бэке научились-таки готовить. Да, это трудно, однако микросервисы не стали панацеей. Там тоже достаточно своих проблем. И со сложностью инфраструктурной ахитектуры, и с синхронизациями баз, либо вообще со множественной миграцией микросервисов в одну базу. Для этого даже специальные инструменты придумывают, потому что сложна. В общем, оркестрация большого количества микросервисов та ещё головная боль, которая породила аж целую новую профессию девопса.
И ещё большой вопрос, что хуже — с микросервисами сношаться или с монолитом.
С фронтом, в целом, проблема похожая. Нам либо оркестрировать микрофронты (которые, так-то, тоже далеко не везде применимы), либо хреначить монолит.
Да, можно напилить свой фронт под каждый эндпоинт, держать это в монорепе, но всё ещё остаётся вопрос с консистентностью этого зоопарка, с кэшированием, размерами бандла и т.д. и т.п...
Для многих задач на фронте монолит всё ещё хороший, если и вовсе не единственный, вариант.
А на счёт FSD... Ну, если фронт занимается только тем, что отправляет запросики в БД, то, да, особо запариваться с архитектурой правда не нужно. Можно напилить компонентики, пусть они запросы отправляют, данные получают и отображают. Логика плоская, всё будет работать чётко.
Только вот не любой фронт ограничивается тем, что гоняет сетевые запросы.
Всё очень сильно зависит от приложения. Я вот тоже пишу уже лет 18 бизнес-приложения, и какой-то прям отделяемой бизнес-логики на фронте - толком и не видел. Логика самого UI - да, бывает прям очень навороченной, всякие там WYSIWYG-билдеры, редактируемые диаграммы Ганта, и т.п. Но вот чтобы прям какую-то сложную функцию про бизнес, не привязанную к UI выделить - это скорее редкость. Это на беке всё обычно.
Но я представляю бизнес-логики может быть много. У всех по-разному. У кого-то там API ходит в 50 микросервисов, постоянно бек просит переделать, и надо слой абстракции. Джависты любят нафигачить по REST своих GET/POST/PUT, и там даже для странички данные достать - уже на пол-страницы кода. А у кого-то - какой-нибудь один стабильный GraphQL, всё что хочешь достаётся одним запросом, и куда удобнее запросы эти класть рядом с компонентом.
Из-за того, что у всех разное - рассуждения о том, какие именно должны быть папки прям в каждом приложении - скорее вообще губительны. Я видел классные, понятные, проекты, где рядом лежат папки с визуальными компонентами, хелперами про бизнес, и т.п - у любителей FSD волосы на жопе зашевелятся, но всё понятно и круто. И видел проекты где лид любит вот эти все папочки, и там пока что-то поймешь - через 10 слоёв надо пройти, менять что-то страшно, а разобраться что где - нереально.
Работает понимание где код разбивать на модули, приёмы как это делать, здоровый перфекционизм, и трудолюбие чтобы постоянно что-то рефачить.
куда удобнее запросы эти класть рядом с компонентом
Когда запросы можно пересчитать по пальцам двух рук — да, удобно. Когда количество запросов разрастается и в разных местах приложения тебе для них нужно по-разному готовить данные, намного проще иметь унифицированный интерфейс и функции, которые будут заниматься адаптацией данных и брать на себя логику обработки пользовательского ввода, условий отображения и т.д...
А ещё прикольно, когда у тебя запрос в тридцати компонентах используется и внезапно меняется контракт его использования. И сиди потом правь это всё говно в тридцати компонентах.
Сколько сайд-эффектов так словишь по дереву?
Нет, нужна инверсия ui от источников данных. Не всегда, но в сложных приложениях без этого повесишься через год. Максимум через два.
А что делать, если команда решила что FSD это про папочки? 1 слой - одна папка, 1 компонент. Хочешь декомпозировать большой компонент? создавай отдельный слой. Не подходит по смыслу? Создавай абстрактный слой с префиксом (subFeature, subWidget, etc...)
Переубедить не получается
Можно попробовать обратиться к архитектору, если таковой имеется и адекватен, чтобы он команде прописал сеанс групповой терапии.
Можно обратиться к руководителю отдела и объяснить, чем чревата такая "архитектура". Лучше всего работает через "ожидаемая стоимость фичи будет n, тогда как при адекватной архитектуре можно снизить до n-m". Но будь готов в случае успеха (если получится защитить своё видение архитектуры) взять ответственность за провал (если команда будет срывать сроки, шишки посыпятся в первую очередь на тебя).
Можно смириться и страдать. Это если рычагов влияния больше не осталось или ты не готов использовать те, что есть.
Добрый день! Очень понравилась статья, сам думал в эту сторону, после прочтения синей книги. Подскажите, как вы видите взаимодействие между сущностями? Вроде как в DDD для тесно связанных между собой сущностей используются агрегаторы.
Возьмем такой кейс: есть канбан-доска с задачами. У задачи есть статусы, описание, комментарии, прикрепленные файлы. Для задачи может быть назначен исполнитель, автор и наблюдатель.
Бизнесом закладываются определенные инварианты, например, что прикрепленный файл может удалить только автор задачи. Как бы подобная бизнес-логика выглядела в рамках вашей архитектуры?
Меня тянет в сторону MVVM-паттерна, но как-будто бы наличие модели как класса с валидацией внутренних значений выглядит излишним, из-за того, что мы уже проверяем данные через ViewModel.
Ну у меня тут от DDD только доменный слой.
В кейса канбан-доска представляет из себя сложную сущность, которая состоит из:
ui
kanbanDeskPage— страница отображения доски. Состоит из глупых компонентов KanbanState, в которых рендерятся виджеты задач. Получает объект kanban доски, потом запрашивает список задач.taskWiget— виджет задачи, принимает на вход объект задачи. Дальнейшую работу с задачей производит самостоятельно, чтобы не перегружать логикой страницу.
use-cases
useKanbanDesk обращается к репозиторию KanbanDeskRepository
getDesk(uuid)— в use-case слое. Содержит модель доски и функция дёргается из view. Модель доски содержит список задач по uuidgetDeskColumns(uuid)— use-case. Берёт из репозитория модель доски и вытаскивает из неё список столбцов.
useTasks
getGasks(ids: uuid[])— возвращает массив объектов задач либо по PromiseAll, либо по PromiseAllSettled в зависимости от требованийuseUpdateTask(uuid, task: Task)— метод управляет обновлением задачи. Если логика обновления исполнителя или наблюдателя сложная, можно вынести её в отдельную функциюuseUpdateTaskExecutorилиuseUpdateTaskObserver.
repositories
KanbanDeskRepository — выполняет весь crud для kanban досок.
taskRepository — выполняет весь crud для задач. Занимается кэшированием в реактивном store, политиками оптимистичного и пессимистичного апдейта и т.п. Т.е., когда мы запрашиваем список задач доски, репозиторий этот список задач кэширует. Когда обновляем исполнителя, репозиторий отправляет запрос на обновление на сервер и обновляет кэш, когда получит ответ, чтобы сохранять консистентность данных и отображения.
Прелесть этой структуры в том, что ui никак не органичен в использовании бизнес-логики, а бизнес-логика не имеет строгого ограничения на использование репозиториев. Если при удалении файла нам нужно проверить права пользователя, то мы в бизнес-функцию удаления файла можем передать идентификатор файла и идентификатор пользователя. Функция удаления запросит все данные, необходимые ей для работы и отправит запрос на выполнение в нужный репозиторий.
При этом мы всё ещё не получили высокую связность, потому что ui ничего не знает о самих данных, только о контрактах взаимодействия, а бизнес-функции ничего не знают о том, для кого они выполняют работу.
Конечно, некоторые изменения могут потребовать доработок, но пока что для меня это выглядит так, что доработки будут прозрачными, управляемыми и не чрезмерно объёмными.
Возможно, не совсем точно выразился :) Взаимодействие между API, моделью и UI для меня выглядит вполне логично.
Мне интересно, как идет взаимодействие между связанными сущностями. На бэкенде есть таблицы Tasks, Users, Files. При запросе конкретной задачи мы получаем следующие данные:
{
"id": 1,
"title": "Моя задача",
"description": "Описание задачи",
"author": {
"name": "Иванов Иван Иванович",
"position": "Директор",
},
"files": [{
"title": "document.docx",
"size": 1234,
// Прочие поля
}],
}Наша модель является классом, соответственно, как нам в конструкторе модели Task использовать эти сущности? Насколько корректно делать так, не вызывает ли это лишней связанности между моделями?
import { User } from '@/domain/user'
import { UUID, File } from '@/domain/shared'
class Task {
id: UUID,
title: string,
description: string,
author: User,
reviewer: User,
performer: User,
files: File[],
}Или лучше под вместо моделей User и File использовать TaskUser и TaskFile, которые будут лежать вместе с Task, хранить специфическую для задачи логику, и не будут доступны через публичный интерфейс?
import { UUID, File } from '@/domain/shared'
import { TaskAuthor } from './task-author'
import { TaskReviewer } from './task-reviewer'
import { TaskPerformer } from './task-performer'
import { TaskFile } from './task-file'
class Task {
id: UUID,
title: string,
description: string,
author: TaskAuthor,
reviewer: TaskReviewer,
performer: TaskPerformer,
files: TaskFile[],
}
class TaksFile {
canDelete(task, user) {
// какая-то логика
}
}Мне кажется, тут есть некоторая недоговорённость в данных :)
Как минимум, у задачи всегда есть создатель и всегда должен быть исполнитель в формате userId, User или null | undefined.
Вот от этого и живём.
Ты, как я понимаю, имеешь в виду "нам нужно добавить исполнителя. Это взаимодействие пользователя и задачи. Как такое реализуется в предложенной модели?"
Итак, у нас карточка задачи (держим в уме, что карточка задачи не связана напрямую с сущностью "задача", потому что ui и данные независимы друг от друга).
В ней есть поле поиска пользователей.
Мы когда хотим выбрать пользователя, забираем их список через useGetUsers() в use-cases (то в свою очередь забирает данные из репозитория)
Таким образом, у нас в UI на странице задачи есть и id пользователя, и id отображаемой задачи.
Так как логику мы отделяем от представления, задача страницы вызвать функцию useSetTaskExecutor(taskId, userId) и передать данные. Дальше useSetExecutorId, не зная ничего о пользователях, добавляет в task.executor переданные данные от говорит репозиторию "тут нужно данные о задаче обновить. Сделай."
Получается, взаимодействие сущностей в определённом виде происходит только на стороне UI.
Ещё взаимодействие связанных сущностей может происходить на стороне use-cases. Но мне, честно говоря, сложно сходу придумать пример :)
Тут скорее вопрос, как превратить JSON в классы бизнес-логики :)
Нам приходит задача с бэкенда в формате «анемичной» модели. Мы хотим ее расширить, превратить в модель, которая имеет внутренние механизмы для сохранения инвариантов.
Для этого в репозитория/коннекторе вызываем конструктор класса Task. И вот на этом этапе у меня вопрос: может ли конструктор Task использовать конструкторы User и Files для создания своего класса или правильнее будет создать специальные классы для задачи (TaskAuthor, TaskFile и пр.)?
// task.repository.ts
class TaskRepostirory {
fetchTack(id: string): Task {
const data = axios.get(`/tasks/${id}`);
// Тут может дополнительно написан адаптер
const model = new Task(data);
return model
}
}
// task.model.ts
class Task {
constructor(data: TaskData) {
this.id = data.id;
this.title = data.title;
this.description = data.description;
// Не появляется ли здесь связаннность,
// которую мы бы не хотели?
this.author = new User(data.author);
this.performer = new User(data.performer);
this.reviewer = new User(data.reviewer);
this.files = data.files.map(m => new File(m))
}
}Ну и в модели могут быть value objects типа email, phone, содержащие в себе доп. валидацию, методы для форматирования и пр.
В описанном тобой примере, да, в маппере придётся откуда-то забирать пользователей или создавать самостоятельно. (Мне кажется, что разумнее забрать. А если забирать неоткуда — вызвать репозиторий пользователя, чтобы закэшировать полученного пользователя. Так у нас, если поменяется, например, имя пользователя, это реактивно отобразится на карточке задачи)
И я даже так скажу, это совсем не плохо. Репозиторий для того и нужен, по сути, чтобы подготавливать данные к использованию бизнес-логикой.
Ты можешь придумать ситуацию, когда эта связность нам мешает? Я, честно говоря, нет.
Не появляется, так как все это части одной модели. Можно конечно усложнить, создать агрегат Users и на основе этой дтошки создавать помимо тасок еще и User-ов и помещать их в агрегат, а в таске или ссылку указывать на инстанс юзера или айдишник, а когда надо искать уже по идентификатору в агрегате. Но в целом кажется так будто это усложнение на ровном месте. Я не вижу ничего плохого в текущем варианте, он правильный по сути. А если предположить, что в будущем появятся какие-то отдельные User-ы как сущность, а те, что в тасках это какой-то их подкласс и их нужно как-то связывать логически, то можно будет добавить линковку между ними в виде мапы какой-то и тп, но это уже забегая далеко вперед
Сама идея совмещение репозитория с агрегатом видится не очень практичной. ИМХО. Не всегда это хорошо ложится на фронт. Насколько помню, в рамках бэкенд разработки, обычный цикл взаимодействия с какой-то моделью будет включать в себя чтение данных из БД для того, чтобы получить данные для инициализации этой модели. Репозиторий насколько я помню это что-то про инкапсуляцию получения и взаимодействия с этой самой модели. То есть у тебя есть слой, который получив данные преобразует их в entity и возвращает тебе в рамках агрегата, ты уже взаимодействуешь с этим как хочешь, реаозиторий инкапсулирует низкоуровневые операции по изменению связанных с этой моделью данных от клиентского кода. На фронте мы очень часто имеем дело не только с тупыми дтошками, которые надо отобразить, но и с долгоживущими данными, например живущими в рамках цикла взаимодействия пользователя с системой до сохранения промежуточного или конечного результата этого взаимодействия. Для себя я выделил в отдельный слой то, что связано с получением данных через апи и созданием на основе этих данных моделей. То есть есть слой условно самой модели, которая представляет собой какие-то инварианты, бизнес логику и круд операции в рамках агрегата + возможно какие-то специфичные для данной модели или агрегата операции, и useCase который дергает апи и заполняет этими данными агрегат, который в свою очередь создает на их основе какую-то модель.
В моей видение архитектура приложения в рамках фронтенд разработки неплохо согласуется с чистой архитектурой, если конечно не укапываться. Выделяется доменный слой, изменять модели можно либо напрямую через методы модели (если это тупое изменение, из разряда изменить значение поля), если же изменение сложное, требует зависимостей в виде сервисов, либо аффектит другие модели, то выделять оркестратор в виде юз кейза. Юз кейзы могут зависеть от домена, но не могут зависеть от имплементации сервисов, только от интерфейса домен не зависит ни от чего. В перспективе это позволит нам переиспользовать бизнес логику , просто выделив ее в отдельный репозиторий. Также есть ui слой, тупые вьюшки с минимальным колвом логики, из разряда, если переменная а равна значению б отобразить такой текст, если нет - другой. Если вью требует какой-то логики обработки событий, связанной с состоянием ui, или требует ui стейт, то создается вью модель, которая инкапсулирует это. Ну и одним из самых важных пунктов помимо такой организации является наличие транспортного слоя в виде di, чтобы мочь взаимодействовать сервисам друг с другом без привязки к ui и циклу рендеринга.

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