Привет! Сегодня хочу поделиться с тобой опытом перехода от Feature-Sliced Design к Clean Architecture во фронтенде. Почему я считаю Clean Architecture более подходящей для сложных приложений, и как она решает проблемы, с которыми ты точно сталкивался.
Если ты используешь FSD или до сих пор пишешь всю логику в компонентах React — эта статья точно для тебя.
FSD: популярно, но не без проблем
Feature-Sliced Design сейчас одна из самых популярных методологий во фронтенде. И не зря — она действительно помогает структурировать код лучше, чем хаотичное размещение файлов.
Что хорошего в FSD?
Понятное разделение по фичам и слайсам
Стандартизированная структура — любой разработчик быстро разберётся
Изоляция фич — теоретически фичи не должны зависеть друг от друга
Но есть проблемы, и они серьёзные
За 2 года работы с FSD я столкнулся с рядом болевых точек:
1. Cross-импорты — постоянная головная боль
// Хочется сделать так, но нельзя:
import { useAuth } from '@/features/auth/model'
import { PostsList } from '@/features/posts/ui'
// FSD запрещает прямые зависимости между фичами
// Приходится изгаляться через shared или создавать искусственные слои
2. Неясность принадлежности модулей
Куда положить NotificationService
? В shared? Но он используется только в конкретных фичах. В одну из фич? Но тогда другие фичи не могут его использовать. Постоянно возникают споры в команде о том, куда что относится.
3. Тестирование — не самое удобное
// Чтобы протестировать бизнес-логику, приходится импортировать кучу файлов
import { loginModel } from '../model'
import { api } from '../../shared/api'
import { router } from '../../shared/router'
// И мокировать каждый из них
jest.mock('../../shared/api')
jest.mock('../../shared/router')
4. Переиспользование логики — боль
Когда похожая логика нужна в разных фичах, приходится либо дублировать код, либо выносить в shared и терять контекст фичи.
Почему я выбрал Clean Architecture
Пришёл я во фронтенд из Android разработки (Kotlin + Compose), где Clean Architecture — это уже стандарт, который не просто на словах, а в самой документации от google. И понял, что многие принципы оттуда отлично работают и во фронтенде.
Основная идея Clean Architecture — инверсия зависимостей. Бизнес-логика не зависит от деталей реализации (UI, API, хранилище), а наоборот.
Моя версия Clean Architecture
В проекте я использую немного модернизированную версию Clean Architecture, адаптированную под свои нужны. Структура состоит из трёх основных частей:
core/
— базовая инфраструктура (DI, MVVM, Flow, UI компоненты)app/
— конфигурация приложения, роутинг, глобальное состояниеfeatures/
— бизнес-фичи со строгой изоляцией
При дальнейшем чтении если не сразу понятно по организации слоев, то в конце статьи есть общая структура всего проекта, можно подсматривать туда
Каждая фича строится из трёх слоёв:
1. Domain слой — сердце приложения
Здесь живут абстракции, которые не зависят от конкретных реализаций:
// domain/repository/AuthRepository.ts
export abstract class AuthRepository {
abstract tokensData: StateFlow<TokensData | null> | null
abstract getTokens(): TokensData | null
abstract setTokens(tokens: TokensData): void
abstract removeTokens(): void
}
про то что такое StateFlow мы поговорим чуть позже
Далее в domain слое находятся так же use cases приложения
Use Cases — это оркестраторы бизнес-логики. Каждый Use Case решает одну конкретную задачу:
У use case есть функция executor которую принятно обычно называть execute. Это как раз функция выполняющая действия характеризующее действие пользователя
// domain/use_case/LoginUseCase.ts
export class LoginUseCase {
constructor(
@Inject(AuthNetwork) private readonly _authNetwork: AuthNetwork,
@Inject(AuthRepository) private readonly _authRepository: AuthRepository,
@Inject(UserStorage) private readonly _userStorage: UserStorage, // Можем добавить кэширование
) {}
async execute(body: LoginBody): Promise<void> {
// 1. Проверяем кэш
const cachedUser = await this._userStorage.getUser(body.email)
if (cachedUser?.isValid()) {
this._authRepository.setTokens(cachedUser.tokens)
return
}
// 2. Делаем запрос к API
const tokens = await this._authNetwork.login(body)
// 3. Сохраняем токены
this._authRepository.setTokens({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
})
// 4. Кэшируем пользователя
await this._userStorage.saveUser(body.email, { tokens, timestamp: Date.now() })
}
}
Профит: Use Case — это сценарий использования приложения. Можешь добавить кэширование, логирование, аналитику — всё прозрачно и тестируемо.
2. Data слой — реализации
Здесь мы реализуем абстракции из Domain слоя. Сначала Network слой с проверкой типов:
// data/network/AuthNetwork.ts
import * as v from 'valibot'
// Схемы для проверки ответов от API
const LoginResponseSchema = v.object({
accessToken: v.string(),
refreshToken: v.string(),
user: v.object({
id: v.string(),
email: v.string(),
})
})
export class AuthNetwork {
constructor(@Inject(Axios) private readonly _httpClient: Axios) {}
async login(body: LoginBody): Promise<TokensDataWithRoleDto> {
const response = await this._httpClient.post("/api/signin", body)
return parseAsync(TokensDataWithRoleDtoSchema, response.data)
}
}
Runtime-валидация данных — важно не доверять только TS-типам, если данные приходят извне. В рантайме типов у нас нет. В данном случае я использую valibot для проверки типа и проброса ошибки
Теперь Repository — реализация хранения данных:
// data/repository/AuthRepositoryImpl.ts
export class AuthRepositoryImpl extends AuthRepository {
private readonly _tokensData = new MutableStateFlow<TokensData | null>(
localStorage.getItem("refreshToken")
? {
accessToken: null,
refreshToken: localStorage.getItem("refreshToken"),
}
: null,
)
public tokensData = this._tokensData.asStateFlow()
setTokens(tokens: TokensData): void {
if (tokens.refreshToken) {
localStorage.setItem("refreshToken", tokens.refreshToken)
}
this._tokensData.set(tokens)
}
// остальные методы...
}
Важно заметить что мы наследуемся от интерфейса в domain слое. Далее DI будет искать реализации именно по абстракции в domain слое. Вся реализация для бизнес слоя как черный ящик. Бизнес слою главное знать что умеет делать реализация, а как, уже не важно, хоть из локлаьной базы данные берутся, хоть запрос на сервер делается.
Легкая подмена реализаций. Захотел использовать WASM Postgres вместо localStorage?
// data/repository/PostgresAuthRepository.ts
export class PostgresAuthRepository extends AuthRepository {
constructor(
@Inject(PostgresWasm) private readonly _postgres: PostgresWasm
) {}
async setTokens(tokens: TokensData): Promise<void> {
await this._postgres.query(
'INSERT INTO tokens (access_token, refresh_token) VALUES ($1, $2)',
[tokens.accessToken, tokens.refreshToken]
)
this._tokensData.set(tokens)
}
}
Создаешь реализацию с хранением данных в postgres, меняешь одну строчку в DI контейнере — и готово! Domain слой даже не заметит разницы.
3. Presentation слой — MVVM + MVI паттерн
Тут я слишком увлекся и взял нейминг из kotlin :)
Для начала Flow — что это и зачем?
Flow (от англ. "Поток") — это реактивные потоки данных. Тут мы по сути делаем реализацию observers.
Тут для себя сделал простую реализацию. Не нужно это воспринимать как полноценную реализацию. Думаю тут даже можно попробовать взять нативную реализацию zustand например, но явно будет сложнее, да и у проекта появится зависимость от еще одной внешней библиотеки.
// core/lib/flow/Flow.ts
export type Listener<T> = (value: T) => void
// Простой поток данных на который можно подписаться
export class Flow<T> {
protected listeners: Map<Listener<T>, () => void> = new Map()
subscribe(listener: Listener<T>): () => void {
this.listeners.set(listener, () => {
this.listeners.delete(listener)
})
return () => {
this.listeners.delete(listener)
}
}
}
// Поток данных который можно изменять
// В отличии от Flow он может изменять свое состояние
// Нужно для инкапсуляции бизнес логики, чтобы избежать прямого доступа к состоянию
export class MutableFlow<T> extends Flow<T> {
constructor() {
super()
}
emit(value: T): void {
this.listeners.forEach((_, listener) => listener(value))
}
asFlow(): Flow<T> {
return this
}
}
// Поток данных который кеширует последнее значение и эмитит его при подписке
export class StateFlow<T> extends Flow<T> {
protected lastValue: T
constructor(initialValue: T) {
super()
this.lastValue = initialValue
}
get value() {
return this.lastValue
}
override subscribe(listener: Listener<T>): () => void {
this.listeners.set(listener, () => {
this.listeners.delete(listener)
})
listener(this.lastValue) // Immediately emit current value
return () => {
this.listeners.delete(listener)
}
}
asFlow(): Flow<T> {
return this
}
}
// Поток данных который кеширует последнее значение и эмитит его при подписке
// В отличии от StateFlow он может изменять свое состояние
// Нужно для инкапсуляции бизнес логики, чтобы избежать прямого доступа к состоянию
export class MutableStateFlow<T> extends StateFlow<T> {
constructor(initialValue: T) {
super(initialValue)
}
get value() {
return this.lastValue
}
update(value: Partial<T>) {
if (this.lastValue !== value) {
this.lastValue = { ...this.lastValue, ...value }
this.listeners.forEach((_, listener) => listener(this.lastValue))
}
}
set(value: T): void {
if (this.lastValue !== value) {
this.lastValue = value
this.listeners.forEach((_, listener) => listener(value))
}
}
asStateFlow(): StateFlow<T> {
return this // Upcast to immutable version
}
}
Разница между типами Flow:
Flow — обычный поток событий (клики, HTTP ответы)
StateFlow — поток состояния (всегда есть текущее значение)
MutableFlow — можешь эмитить события
MutableStateFlow — можешь изменять состояние
Далее паттерн MVVM - Model View ViewModel
ViewModel — хранитель логики с инкапсуляцией
Ключевая особенность — строгая инкапсуляция состояния. ViewModel может изменять состояние, а компонент — только читать:
// presentation/view_model/LoginViewModel.ts
export class LoginViewModel
implements ViewModel<LoginPageState, LoginPageUiEventType>
{
constructor(
@Inject(LoginUseCase) private readonly _loginUseCase: LoginUseCase,
) {}
// ПРИВАТНЫЙ MutableStateFlow — только ViewModel может изменять
private readonly _state = new MutableStateFlow<LoginPageState>({
isLoading: false,
email: '',
emailError: null,
})
// ПУБЛИЧНЫЙ StateFlow — компонент может только читать
public readonly state = this._state.asStateFlow()
// MVI паттерн: события для UI (тосты, навигация, алерты)
private readonly _uiEvent = new MutableFlow<LoginPageUiEventType>()
public readonly uiEvent = this._uiEvent.asFlow()
// Методы ViewModel НЕ пересоздаются при каждом рендере!
public login = async (data: LoginFormSchemaType) => {
try {
this._state.update({ isLoading: true, emailError: null })
await this._loginUseCase.execute({
email: data.email,
password: data.password,
})
// Успех — эмитим событие успешно. UI уже реагирует на это действие. В данном случае скорее всего это будет навигация на основную страницу приложения
this._uiEvent.emit(LoginPageUiEvent.Succsess())
} catch (error) {
// Ошибка — эмитим событие показа тоста
this._uiEvent.emit(LoginPageUiEvent.ShowToast(error.message, 'error'))
this._state.update({ isLoading: false })
}
}
public validateEmail = (email: string) => {
this._state.update({ email })
const isValid = email.includes('@') && email.includes('.')
this._state.update({
emailError: isValid ? null : 'Некорректный email'
})
return isValid
}
// Больше никаких useCallback/useMemo! ?
}
MVI (Model-View-Intent) паттерн через uiEvent
Важная концепция: разделяем состояние и события:
Состояние (
state
) — то, что отображается (loading, данные, ошибки форм)События (
uiEvent
) — то, на что UI должен отреагировать один раз (навигация, тосты, алерты) (тоесть это триггеры на которые как либо может реагировать UI)
// Типы событий
const LoginPageUiEvent = {
NavigateToMain: () => ({ type: 'navigate_to_main' as const }),
ShowToast: (message: string, type: 'success' | 'error') => ({
type: 'show_toast' as const,
payload: { message, type }
}),
OpenEmailConfirmation: () => ({ type: 'open_email_confirmation' as const }),
}
type LoginPageUiEventType =
| ReturnType<typeof LoginPageUiEvent.NavigateToMain>
| ReturnType<typeof LoginPageUiEvent.ShowToast>
| ReturnType<typeof LoginPageUiEvent.OpenEmailConfirmation>
Зачем разделять?
Тост должен показаться один раз, а не при каждом ререндере
Навигация должна произойти однократно при успешном логине
Алерт должен всплыть один раз, а не висеть в состоянии
React хуки для связи с ViewModel
ViewModel должен както жить в приложении. Поэтому мы создаем хук который будет управлять жизненным циклом вью модели.
// core/lib/mvvm/react/hooks/useViewModel.ts
export function useViewModel<T extends ViewModel>(
ViewModelClass: Constructor<T>
): T {
// ViewModel создаётся один раз и живёт пока жив компонент
const viewModel = useMemo(() => DIContainer.createInstance(ViewModelClass), [])
useEffect(() => {
viewModel.init?.()
return () => viewModel.destroy?.()
}, [])
return viewModel
}
Простые хуки для подписки на состояние и эвенты.
// core/lib/mvvm/react/hooks/useStateFlow.ts
export function useStateFlow<T>(stateFlow: StateFlow<T>): T {
const [state, setState] = useState(stateFlow.value)
useEffect(() => {
// Подписываемся на изменения состояния
const unsubscribe = stateFlow.subscribe(setState)
return unsubscribe
}, [stateFlow])
return state
}
// core/lib/mvvm/react/hooks/useFlow.ts
export function useFlow<T>(
flow: Flow<T>,
handler: (value: T) => void
): void {
useEffect(() => {
// Подписываемся на события (но не на состояние!)
const unsubscribe = flow.subscribe(handler)
return unsubscribe
}, [flow, handler])
}
Компонент — тупой отображатель без логики
Теперь самое главное: компонент становится декларативным отображателем. Никакой бизнес-логики, никаких побочных эффектов:
// view/LoginPage.tsx
export const LoginPage = () => {
const viewModel = useViewModel(LoginViewModel)
const state = useStateFlow(viewModel.state)
// Подписываемся на события UI (не состояние!)
useFlow(viewModel.uiEvent, (event) => {
switch (event.type) {
case 'navigate_to_main':
navigate('/dashboard')
break
case 'show_toast':
toast[event.payload.type](event.payload.message)
break
case 'open_email_confirmation':
openModal('email-confirmation')
break
}
})
return (
<LoginForm
// Только отображение состояния
isLoading={state.isLoading}
email={state.email}
emailError={state.emailError}
// Только передача методов ViewModel (никаких useCallback!)
onSubmit={viewModel.login}
onEmailChange={viewModel.validateEmail}
onForgotPassword={viewModel.openForgotPassword}
/>
)
}
Так же можем не переживать о useCallback или useMemo так как вью модель живет все время пока маунчен компонент. Соответсвенно ссылки на ее функции изменяться не будут.
Принципы тупого компонента:
✅ Отображает состояние из ViewModel
✅ Реагирует на события из uiEvent
✅ Передаёт пользовательские действия в ViewModel
❌ НЕ содержит бизнес-логику
❌ НЕ делает HTTP запросы
❌ НЕ управляет состоянием напрямую
Профиты архитектурные:
Никаких useCallback/useMemo — методы ViewModel стабильны по ссылке
Простое тестирование — ViewModel тестируется без UI, а UI — без логики
MVI паттерн — только односторонний поток данных. При этом разделяем состояние и триггеры
Dependency Injection — архитектурный стражник, как его любят называть
DI контейнер — это инструмент для управления зависимостями между слоями и модулями. Он позволяет изолировать фичи, внедрять моки для тестирования, реализовывать ленивое создание объектов и контролировать жизненный цикл зависимостей.
Namespaces — изоляция слоёв
Главная фича в моей реализации DI — изоляция через namespaces. Каждая фича живёт в своём пространстве имён и может зависеть только от разрешённых модулей:
// features/auth/di/AuthModule.di.ts
DiModule.register({
nameSpace: "auth",
nameSpaceDependencies: ["core"], // Можем использовать только core
builder: (builder) => {
builder.register({
token: AuthRepository,
implementation: AuthRepositoryImpl,
isSingleton: true, // указываем что объект создаться один раз и будет везде использоваться один instance
lazy: true, // Указываем что instance сздасться при первом обращении к нему, а не при регистрации в DI контейнере.
})
builder.register({
token: LoginUseCase,
implementation: LoginUseCase,
})
},
})
У нас есть статический DiModule с помощью которого можем регистрировать DI модули в registry. В методе builder мы получаем аргументом сам builder с помощью которого мы можем регистрировать наши реализации в registry.
Token - абстракция по которой можем находить реализацию.
implementation - ссылка на реализацию которую DI должен будет инжектить.
isSingleton - указывает DI что реализацию нужно создать один раз
lazy - указывает DI что создать экземпляр нужно только при первом к нему обращении (используется в паре с isSingleton: true)
// features/posts/di/PostsModule.di.ts
DiModule.register({
nameSpace: "posts",
nameSpaceDependencies: ["core", "auth"], // Можем использовать core и auth
builder: (builder) => {
builder.register({
token: PostsRepository,
implementation: PostsRepositoryImpl,
isSingleton: true,
})
},
})
Что происходит, если джун попытается нарушить архитектуру?
// features/auth/domain/use_case/LoginUseCase.ts
export class LoginUseCase {
constructor(
@Inject(AuthRepository) private readonly _authRepository: AuthRepository,
@Inject(PostsRepository) private readonly _postsRepository: PostsRepository, // ? ОШИБКА!
) {}
}
// Runtime error:
// Namespace conflict: Cannot inject 'PostsRepository' from namespace 'posts'
// into requesting namespace 'auth'. Add 'posts' to nameSpaceDependencies.
DI контейнер выбросит ошибку! Теперь архитектура защищена от случайных нарушений.
Lazy loading и performance
builder.register({
token: HeavyAnalyticsService,
implementation: HeavyAnalyticsService,
lazy: true, // Не создаём до первого использования
isSingleton: true, // Но создаём только один экземпляр
})
Будущее: Code splitting через DI
В планах — автоматический code splitting через DI модули:
// Модули будут загружаться динамически
const authModule = () => import('./features/auth/di/AuthModule.di.ts')
const postsModule = () => import('./features/posts/di/PostsModule.di.ts')
// DI контейнер сам разберётся, когда что загружать
Инъекция через декораторы
export class LoginUseCase {
constructor(
@Inject(AuthNetwork) private readonly _authNetwork: AuthNetwork,
@Inject(AuthRepository) private readonly _authRepository: AuthRepository,
) {}
}
В счет того что в js невозможно получить в рантайме необходимую информацию о требуемых типах, нам приходится использовать рефлексию. Благодаря библиотеки reflect-metadata мы можем добавлять метаданные которые в дальнейшем сможем использовать в рантайме.
За счет незамудреного самописного декоратора мы можем добавлять всю необходимую информацию для DI контейнера, чтобы в рантайме мы понимали как собрать инстанс.
import "reflect-metadata"
import { type Token } from "./DIContainer"
export const INJECT_METADATA_SYMBOL = Symbol("inject")
// Декоратор для инъекции токенов в параметры конструктора
export function Inject(token: Token): ParameterDecorator {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
target: any,
_: string | symbol | undefined,
parameterIndex: number,
) => {
const existingTokens: Token[] =
Reflect.getMetadata(INJECT_METADATA_SYMBOL, target) || []
existingTokens[parameterIndex] = token
Reflect.defineMetadata(INJECT_METADATA_SYMBOL, existingTokens, target)
}
}
Пока пишу статью задумался о том что возможно для экономии используемой памяти мы можем в виде токена использовать не сам абстрактный класс, ведь при такой реализации у нас токеном явнляется js код всего обстрактного класса в виде строки, а самому писать в виде строки название класса:
@Inject("AuthNetwork")
Но тогда и в di модулях при регистрации нужно тоекны регистрировать таким же образом. Менее удобно, но возможно имеет смысл в совсем большом проекте для экономии памяти пользователя.
Профиты архитектурные:
Защита от нарушений — DI не даст создать неправильные зависимости
Lazy loading — производительность из коробки
Изоляция — фичи действительно изолированы друг от друга
Масштабируемость — легко добавлять новые модули
Почему это круто для разработки?
1. Мокирование данных — разработка без бэкенда
Это реальная боль фронтенд разработки: бэкенд ещё не готов, а UI надо делать уже сегодня. С Clean Architecture ты можешь работать автономно:
// data/repository/MockAuthRepository.ts
class MockAuthRepository extends AuthRepository {
private _tokens = new MutableStateFlow<TokensData | null>(null)
public tokensData = this._tokens.asStateFlow()
async setTokens(tokens: TokensData): Promise<void> {
// Имитируем реальную задержку сети
await new Promise((resolve) => setTimeout(resolve, 1500))
// Можем эмулировать разные сценарии
// Например случайные ошибки для тестирования обработки
if (Math.random() < 0.1) {
throw new Error('Network timeout')
}
this._tokens.set(tokens)
}
async getTokens(): Promise<TokensData | null> {
return this._tokens.value
}
}
// data/network/MockAuthNetwork.ts
class MockAuthNetwork extends AuthNetwork {
private users = [
{ email: 'admin@test.com', password: 'admin', role: 'admin' },
{ email: 'user@test.com', password: 'user', role: 'user' }
]
async login(body: LoginBody): Promise<LoginResponse> {
await new Promise(resolve => setTimeout(resolve, 1000))
const user = this.users.find(u =>
u.email === body.email && u.password === body.password
)
if (!user) {
throw new Error('Invalid credentials')
}
return {
accessToken: `mock-access-${Date.now()}`,
refreshToken: `mock-refresh-${Date.now()}`,
user: {
id: user.email,
email: user.email,
role: user.role
}
}
}
}
Создаём development окружение:
// di/DevModule.di.ts
// это лучше не сверкой с ENV, а вынести отдельный параметр в переменные окружения типа EnvMockImplemetations: boolean
if (process.env.NODE_ENV === 'development') {
DiModule.register({
nameSpace: "auth",
nameSpaceDependencies: ["core"],
builder: (builder) => {
// Подменяем реальные реализации на моки
builder.register({
token: AuthRepository,
implementation: MockAuthRepository,
isSingleton: true,
})
builder.register({
token: AuthNetwork,
implementation: MockAuthNetwork,
isSingleton: true,
})
},
})
}
Теперь ты можешь:
Тестировать разные сценарии (успех, ошибки, таймауты)
Работать оффлайн — никаких запросов к серверу
Демонстрировать фичи заказчику без подключения к бэкенду
2. Тестирование — каждый слой изолированно
Тестируем Use Case с настоящими моками:
// Создаём моковые реализации
class MockAuthNetwork extends AuthNetwork {
private shouldFail = false
private responseDelay = 0
setShouldFail(value: boolean) {
this.shouldFail = value
}
setResponseDelay(delay: number) {
this.responseDelay = delay
}
async login(body: LoginBody): Promise<LoginResponse> {
if (this.responseDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.responseDelay))
}
if (this.shouldFail) {
throw new Error('Network error')
}
return {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
user: {
id: 'mock-user-id',
email: body.email
}
}
}
}
class MockAuthRepository extends AuthRepository {
private tokens: TokensData | null = null
private readonly _tokensData = new MutableStateFlow<TokensData | null>(null)
public tokensData = this._tokensData.asStateFlow()
getTokens(): TokensData | null {
return this.tokens
}
setTokens(tokens: TokensData): void {
this.tokens = tokens
this._tokensData.set(tokens)
}
removeTokens(): void {
this.tokens = null
this._tokensData.set(null)
}
// Методы для тестирования
getStoredTokens(): TokensData | null {
return this.tokens
}
}
class MockUserStorage {
private users = new Map<string, any>()
async getUser(email: string) {
return this.users.get(email) || null
}
async saveUser(email: string, data: any) {
this.users.set(email, data)
}
// Методы для тестирования
clearUsers() {
this.users.clear()
}
getUserCount() {
return this.users.size
}
}
// Тесты
describe('LoginUseCase', () => {
let useCase: LoginUseCase
let mockAuthNetwork: MockAuthNetwork
let mockAuthRepository: MockAuthRepository
let mockUserStorage: MockUserStorage
beforeEach(() => {
// Создаём моковые экземпляры
mockAuthNetwork = new MockAuthNetwork()
mockAuthRepository = new MockAuthRepository()
mockUserStorage = new MockUserStorage()
// Создаём Use Case с моками
useCase = new LoginUseCase(mockAuthNetwork, mockAuthRepository, mockUserStorage)
})
it('should use cached user if available', async () => {
// Arrange
const cachedUser = {
tokens: { accessToken: 'cached', refreshToken: 'cached' },
isValid: () => true
}
await mockUserStorage.saveUser('test@test.com', cachedUser)
// Act
await useCase.execute({ email: 'test@test.com', password: '123' })
// Assert
expect(mockAuthRepository.getStoredTokens()).toEqual(cachedUser.tokens)
expect(mockUserStorage.getUserCount()).toBe(1)
})
it('should make API call if cache is empty', async () => {
// Arrange
mockAuthNetwork.setResponseDelay(100) // Имитируем задержку сети
// Act
await useCase.execute({ email: 'test@test.com', password: '123' })
// Assert
expect(mockAuthRepository.getStoredTokens()).toEqual({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token'
})
expect(mockUserStorage.getUserCount()).toBe(1)
})
it('should handle network errors', async () => {
// Arrange
mockAuthNetwork.setShouldFail(true)
// Act & Assert
await expect(
useCase.execute({ email: 'test@test.com', password: '123' })
).rejects.toThrow('Network error')
})
})
Тестируем ViewModel с настоящими моками:
class MockLoginUseCase {
private shouldFail = false
private executionTime = 0
setShouldFail(value: boolean) {
this.shouldFail = value
}
setExecutionTime(time: number) {
this.executionTime = time
}
async execute(body: LoginBody): Promise<void> {
if (this.executionTime > 0) {
await new Promise(resolve => setTimeout(resolve, this.executionTime))
}
if (this.shouldFail) {
throw new Error('Login failed')
}
}
}
describe('LoginViewModel', () => {
let viewModel: LoginViewModel
let mockUseCase: MockLoginUseCase
beforeEach(() => {
mockUseCase = new MockLoginUseCase()
viewModel = new LoginViewModel(mockUseCase)
})
it('should show loading during login', async () => {
// Arrange
mockUseCase.setExecutionTime(100)
// Act
const loginPromise = viewModel.login({ email: 'test@test.com', password: '123' })
// Assert - сразу после вызова должно быть loading
expect(viewModel.state.value.isLoading).toBe(true)
// Ждём завершения
await loginPromise
// После завершения loading должен исчезнуть
expect(viewModel.state.value.isLoading).toBe(false)
})
it('should emit success event on successful login', async () => {
// Arrange
const events: any[] = []
viewModel.uiEvent.subscribe(event => events.push(event))
// Act
await viewModel.login({ email: 'test@test.com', password: '123' })
// Assert
expect(events).toContainEqual({ type: 'success' })
})
it('should emit error event on failed login', async () => {
// Arrange
mockUseCase.setShouldFail(true)
const events: any[] = []
viewModel.uiEvent.subscribe(event => events.push(event))
// Act
await viewModel.login({ email: 'test@test.com', password: '123' })
// Assert
expect(events).toContainEqual({
type: 'show_toast',
payload: { message: 'Login failed', type: 'error' }
})
})
it('should validate email correctly', () => {
// Act & Assert
expect(viewModel.validateEmail('valid@email.com')).toBe(true)
expect(viewModel.validateEmail('invalid-email')).toBe(false)
expect(viewModel.state.value.emailError).toBe('Некорректный email')
})
})
Тестируем с DI контейнером:
describe('LoginViewModel with DI', () => {
beforeEach(() => {
// Очищаем контейнер перед каждым тестом
DIContainer.clear()
// Регистрируем моковые реализации
DIContainer.register({
token: AuthNetwork,
implementation: MockAuthNetwork,
isSingleton: true,
})
DIContainer.register({
token: AuthRepository,
implementation: MockAuthRepository,
isSingleton: true,
})
DIContainer.register({
token: LoginUseCase,
implementation: LoginUseCase,
lazy: true,
})
})
it('should work with DI container', async () => {
// Arrange
const viewModel = DIContainer.createInstance(LoginViewModel)
const events: any[] = []
viewModel.uiEvent.subscribe(event => events.push(event))
// Act
await viewModel.login({ email: 'test@test.com', password: '123' })
// Assert
expect(events).toContainEqual({ type: 'success' })
})
})
Примеры тестов сгенерировал через ИИ как пример использования, в целом для примера более чем достаточно. С реального проекта не стал вытаскивать чтото, убирать оттуда все лишнее. Думаю суть понятна.
3. Подмена реализаций — гибкость на максимум
Начал с localStorage, но проект вырос и нужна более мощная система хранения?
// Было
class LocalStorageAuthRepository extends AuthRepository {
setTokens(tokens: TokensData): void {
localStorage.setItem('tokens', JSON.stringify(tokens))
}
}
// Стало
class IndexedDBAuthRepository extends AuthRepository {
async setTokens(tokens: TokensData): Promise<void> {
const db = await this.openDB()
const transaction = db.transaction(['tokens'], 'readwrite')
await transaction.objectStore('tokens').put(tokens, 'current')
}
}
// Ещё лучше — WASM Postgres
class PostgresAuthRepository extends AuthRepository {
async setTokens(tokens: TokensData): Promise<void> {
await this._postgres.execute(
'INSERT OR REPLACE INTO auth_tokens (id, access_token, refresh_token) VALUES (1, ?, ?)',
[tokens.accessToken, tokens.refreshToken]
)
}
}
Меняешь одну строчку в DI конфигурации — всё остальное работает без изменений!
Масштабные изменения — архитектура выдерживает
Пример 1: Добавляем микрофронтенды
Проект вырос, команд стало больше. Нужно разделить на микрофронтенды:
// Каждый микрофронтенд экспортирует свои модули
// micro-auth/src/AuthMicroapp.ts
export class AuthMicroapp {
static init() {
// Регистрируем только auth модули
import('./di/AuthModule.di.ts')
return {
routes: authRoutes,
diModules: ['auth']
}
}
}
// micro-posts/src/PostsMicroapp.ts
export class PostsMicroapp {
static init() {
// Зависим от auth, используем его через DI
import('./di/PostsModule.di.ts')
return {
routes: postsRoutes,
diModules: ['posts'],
dependencies: ['auth'] // DI namespaces защитят от неправильных зависимостей
}
}
}
Профит: Clean Architecture позволяет легко разделить код на независимые части. DI namespace'ы обеспечивают правильные зависимости между микрофронтендами.
Реализации с микрофронтами у меня нет, сделать рабочую версию времени нет, код выше расценивать как псевдокод
Пример 2: Меняем React на Vue — никого не спрашиваем
Самый жирный пример. Нужно мигрировать на Vue?
VUE не мой основной стек, возможно гдето чтото можно сделать лучше.
1. Создаём Vue реализацию MVVM хуков:
// core/lib/mvvm/vue/composables/useViewModel.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { DIContainer } from '../../di'
export function useViewModel<T extends ViewModel>(ViewModelClass: Constructor<T>): T {
const viewModel = DIContainer.createInstance(ViewModelClass)
onMounted(() => {
viewModel.init?.()
})
onUnmounted(() => {
viewModel.destroy?.()
})
return viewModel
}
// core/lib/mvvm/vue/composables/useStateFlow.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useStateFlow<T>(stateFlow: StateFlow<T>) {
const state = ref(stateFlow.value)
onMounted(() => {
const unsubscribe = stateFlow.subscribe((newValue) => {
state.value = newValue
})
onUnmounted(() => {
unsubscribe()
})
})
return state
}
2. Переписываем компонент на Vue:
<!-- view/LoginPage.vue -->
<template>
<LoginForm
:isLoading="state.isLoading"
@submit="viewModel.login"
@validate-email="viewModel.validateEmail"
/>
</template>
<script setup lang="ts">
import { useViewModel, useStateFlow, useFlow } from '@/core/lib/mvvm/vue'
import { LoginViewModel } from '../view_model/LoginViewModel'
// Та же ViewModel! Никаких изменений!
const viewModel = useViewModel(LoginViewModel)
const state = useStateFlow(viewModel.state)
useFlow(viewModel.uiEvent, (event) => {
if (event.type === "success") {
router.push("/dashboard")
} else if (event.type === "error") {
toast.error(event.payload)
}
})
</script>
3. ViewModel остается неизменной:
// Тот же самый файл! Ни строчки кода не меняем!
export class LoginViewModel implements ViewModel<LoginPageState, LoginPageUiEventType> {
// ... вся логика остается такой же
}
4. Use Cases, Repository, Network — ничего не трогаем:
// Все слои данных остаются идентичными
export class LoginUseCase { /* без изменений */ }
export class AuthRepositoryImpl { /* без изменений */ }
export class AuthNetwork { /* без изменений */ }
Что изменилось? Только UI слой! Вся бизнес-логика, все данные, всё состояние — работает точно так же.
В таких ситуациях следует создавать framework специфичные папки. В данном случае я заранее создал папку core/lib/mvvm/react/
для React-специфичных хуков. В дальнейшем если вдруг понадобиться использовать другой framework то мне достаточно будет создать еще одну папку под ее специфику.
И тут мы уже ярко видим почему важно делать абстракции, в даннос случае почему мы использовали MVVM + MVI и полностью отделили Ui бизнес слоя. Ведь UI очень часто меняется, а если меняется на столько что мы уходим от реакт на вью, то очень больно было бы менять везде и бизнес логику, ведь обычные хуки реакта не получится просто использовать во VUE.
Заключение
Clean Architecture — это не серебряная пуля. Это инструмент, который отлично работает для сложных, долгоживущих и масштабируемых проектов, но может быть избыточен для простых задач. Важно уметь выбирать архитектуру под свои реалии, не бояться миксовать подходы и не гнаться за модой. Делитесь своим реальным опытом, обсуждайте архитектурные решения с командой и не забывайте: главное — чтобы проект было удобно поддерживать и развивать твоей команде.
Когда НЕ нужна Clean Architecture:
Маленькие проекты — лендинги, простые сайты, прототипы
Создай базовые папки
app/
,components/
,pages/
,hooks/
— этого скорее всего достаточно (опять же это не панацея, все адаптируй под себя)Не усложняй там, где сложность не нужна
Короткосрочные проекты / маленькие команды — MVP, тестовые версии, где скорость разработки важнее архитектуры. MVP это не тот проект который нужно вылизывать, это то что все равно будет переписано.
Проекты "отображалки" - приложения которые не имеют никакой логики, просто получили данные с сервера и показали. Clean явно будет избыточным в таком проекте.
Когда Clean Architecture оправдана:
Средние/большие проекты — SaaS, админки, сложные приложения
Долгосрочная поддержка
Команда из множества разработчиков, и уж тем более если из нескольких команд
Сложная бизнес-логика
Долгосрочные проекты — код будет жить годами
Меняются требования, технологии, команды
Нужна гибкость и предсказуемость
Долгий старт, но простая поддержка
Да, первое время будет непривычно. Да, придётся создавать больше файлов. Да, нужно время на настройку DI и изучение паттернов.
Так же не получится набрать джунов и поддерживать такой проект.
Но через пару лет когда не придется тратить кучу времени на дебаг. Когда при изменении одного не будет ломаться другое. Очень много сэкономит вам ресурсов и времени.
Что не раскрыто в статье
Это базовая концепция. В реальных проектах есть ещё много нюансов:
DI контейнер:
Почему не готовые решения? — потому что нужны специфичные фичи (namespaces, lazy loading)
Дополнительные слои:
Analytics слой для аналитики
Error handling слой для обработки ошибок
Logging слой для логирования
Caching слой для кэширования
Оптимизации:
Code splitting через DI модули
Lazy loading компонентов
Memoization для тяжёлых вычислений
Так же если хотите совмещать react, vue или другие фреймворки в одном проекте, то еще нужно добавить роутер не зависящий от фреймворка.
Mappers между слоями (DTO's) - делаем DTO для данных с хранилища (API, wasm postgress, indexed db) и мапим в бизнес модели (по типу toUserModel маппер который dto мапит в domain модель данных).
Итоговая архитектура проекта
src/
├── core/ # Базовая инфраструктура
│ ├── lib/
│ │ ├── di/ # DI контейнер с namespaces
│ │ ├── flow/ # Реализация flow
│ │ ├── mvvm/
│ │ │ ├── react/ # React-специфичные хуки
│ │ │ └── vue/ # Vue-специфичные хуки
│ │ └── logger/ # Логирование
│ ├── ui/ # Переиспользуемые UI компоненты (UI kit\lib)
│ └── network/ # Базовые HTTP клиенты
├── app/ # Конфигурация приложения
│ ├── bootstrap.tsx # Инициализация приложения, DI, роутера
│ ├── router/ # Роутинг
│ └── styles/ # Глобальные стили
└── features/ # Бизнес-фичи
├── auth/ # Аутентификация
│ ├── domain/ # Бизнес-логика
│ │ ├── model/ # Доменные модели
│ │ ├── repository/ # Абстракции репозиториев
│ │ └── use_case/ # Use Cases
│ ├── data/ # Реализации
│ │ ├── network/ # API клиенты
│ │ └── repository/ # Реализации репозиториев
│ ├── presentation/ # UI слой
│ │ ├── view_model/ # ViewModels
│ │ └── view/ # UI компоненты
│ └── di/ # DI модуль фичи
└── posts/ # Другая фича (аналогично)
Изоляция фич:
Каждая фича в своём namespace
DI контролирует зависимости между фичами
Можно легко вынести в отдельный пакет
Тестируемость:
Каждый слой тестируется изолированно
Моки создаются через наследование от абстракций
Интеграционные тесты через DI контейнер
Масштабируемость:
Новые фичи добавляются по проверенным паттернам
Легко менять технологии (React → Vue)
Можно разделить на микрофронтенды
P.S. Если ты уже используешь clean во forontend или что-то похожее - делись опытом в комментариях, буду только рад узнать для себя чтото новое, или сделать лучше уже в том что есть! ?
Комментарии (30)
lear
20.08.2025 13:45У меня эволюция проекта происходила так:
Взял за основу FSD и CA, но перед этим всем я изучал и другие архитектуры на другом стэке (тот же BLoC, HA, VIPER). Получилось что-то типо:
src ├── app ├── pages ├── shared └── [name] └── features (бл) └── [name] ├── datasources ├── stores ├── entities ├── usecases (старое features) └── widgets
Т.е. в shared попадали переиспользуемые компоненты, которые дополнялись абстракциями, чтобы не зависеть от фич.
Появился бойлерплейт и бриджи, но связанность уменьшилась, абстракция увеличилась.Папка shared начала разрастаться. Я стал использовать nx и у меня многое из shared переехало как отдельные пакеты в папку libs. Тем самым уже на уровне кода я абстрагировал полноценные блоки (Плееры, UI библиотеку, сторы - не фичи, а именно надстройки над сторами для хранения, и т.д.)
ya_araik Автор
20.08.2025 13:45Именно для болшей изолированности у меня было принято решение раздеить каждую фичу на слои и чтобы у каждой был свой доменный слой, что как можно сильнее минимизирует какието связности между фичами и можно сказать связывает руки разработчику чтобы так не сделать
конечно если брать тот же самый андроид можно настроить зависимости на уровне gradle что разработчикам вообще не позволит сделать какой либо кросимпоррт даже случайно
но мы в вебе, так что имеем что имеем)lear
20.08.2025 13:45Именно для болшей изолированности у меня было принято решение раздеить каждую фичу на слои и чтобы у каждой был свой доменный слой
Да, Я привел похожее что у меня получилось, потом дополнил про nx как способ для очистки shared.
Ну дефолтный fsd совсем не годится уже на средних проектах, т.к. папки быстро разрастаются и нужно инвертировать фичи и слои для того, чтобы был порядок в доменах.По поводу контроля импортов - есть плагин для eslint, но я им не пользовался. Но он легко гуглится =)
cmyser
20.08.2025 13:45Попробуйте $mol
Там совершенно другой подход - компоненты с логикой + fnq , где имя класса равно его местоположению
ya_araik Автор
20.08.2025 13:45Буду признателен ссылкам на рекомендуемые ресурсы где лучше почитать. С радостью почитаю в свободное время!
cmyser
20.08.2025 13:45https://habr.com/ru/articles/820871/ Можно с этой статьи начать, там и ссылки на все остальные ресурсы
P.S в этом фреймворке даже импорты писать не нужно)
Rev1le
20.08.2025 13:45Cross-импорты — постоянная головная боль
Как будто не хватает в данном примере контекста. Нет ничего критичного в таком импорте в слоях widgets, pages, app(странно, но бывают случаи).
// Находимся например в @/pages/home import { useAuth } from '@/features/auth' import { PostsList } from '@/features/posts'
В проекте у себя использовал FSD, но чуть изменил её под рекурсивное отображение страниц с табами. Также многие запросы сущностей легли в enitites, делая отсылку на CA.
ya_araik Автор
20.08.2025 13:45Проблемы в импорте разных фичей на уровне выше нет. Это абсолютно нормальная практика. Проблема возникает когда в рамках одного слоя появляется зависимость между сущностями
Rev1le
20.08.2025 13:45есть решение работать через @x. Хотя если надобность этого появляется слишком часто - то тут уже без пересмотра архитектуры не обойтись.
ya_araik Автор
20.08.2025 13:45Об этом я уже упоминал при ответе на комментарий выше. Это больше выглядиь как костыль чтобы залотать дыру.
Опять же когда в проекта 1-2 раза встречается можнт стерпеть
Конечно как я и писал клин тянуть во все проекты не нужно. Для большинства задач достаточно и fsd
На клин нужно смотреть когда либо уже чувствуешь что команда разрослась и в проекте хаос. Либо на этапе проектирования если уже понимаешь что в недавнем будущем уже прижется сильно масштабироваться, можно сразу закладывать архитектуру.
Все крупные компании так или иначе начинали с монолитов, потом безизбежно все переписывали на микросервисы, микрофронты и так далее и тому подобное. И от части это все тоже про архитектуру
Googlonator
20.08.2025 13:45"куда положить NotificationService" - если ты правильно используешь fsd сервисов обычно вообще нет. Если нужны уведомления во всем приложении в шаред пишешь lib, в app заворачиваешь все приложение. А ещё лучше - заверни это в пакет, устанавливай как либу и юзай в app
ya_araik Автор
20.08.2025 13:45В ответе на комментарий выше привел пример с уведомлениями. Бизнес редко требует что-то стандартизированное. И как пример уведомления завязанные как раз на конкретных сущностях
dominus_augustus
20.08.2025 13:45Интересен такой момент, как работаете с доменными моделями, позволяете ли их читать, писать в них напрямую или нет, если нет, то как гарантируется соблюдение бизнес логики, когда например для изменения модели необходимо делать это через сервис, потому что он дополнительно агрегирует в себе информацию, которая нужна для изменения модели? Я имею в виду защиту от того, что кто-то решит изменять модель в обход бизнес логики. Также интересует вопрос о том, как организовано взаимодействие моделей между друг другом, если оно необходимо, когда изменения данных одной модели влияет на данные другой, при этом эти модели относятся к разным доменным сущеостям
ya_araik Автор
20.08.2025 13:45Первый вопрос не понял, можете пожалуйста подробнее описать. Не пойму о каких изменениях модели в обход бизнес логики вы говорите.
А на счет взаимодействия между друг другом. Либо у нас связующее app, либо если они связаны, то скорее всего это изначально не имело смысла делить на разные доменные слои. Но бывает и такое что они неизбежно связаны друг с другом. Тогда мы явно указываем в di модулях что они связаны между друг с другом. Это не рекомендованно, но не запрещено. Поэтому и попробовал для себя использовать не готовую DI, а сделать свой вместе с явным указанием зависимостей.
В android разработке такие фичи можно сказать разделяются на отдельные приложения, там создаются отдельные пакеты со своей конфигурацией сборщика который наследуется от основного конфига. И там мы можем настроить зависимости между модулями.
Для более простого понимания можно сделать аналогию с микрофронтами. В большинстве случаев разделение в фичах можно сделать так же как делили бы на микрофронты.
В микрофронтах явно указываем откуда что мы тянем.dominus_augustus
20.08.2025 13:45Представим что есть какие то дто которые бэк присылает на клиента, эти дто становятся основой для моделей. У моделей есть свои методы и поля. Есть слой вью модели, который связывает наше вью с моделью и изменяется модель через этот слой, предположим появилось бизнес правило, после изменение каких-то данных в модели, обновлять данные в другой доменной модели. Это бизнес правило должно соблюдаться всегда. Чтобы не дублировать его между разными вью моделями мы выносим эту логику в какой-то сервис и теперь, чтобы изменить поле модели, необходимо вызвать соответствующий метод сервиса. Как вы обеспечиваете гарантию того, что кто-то из разработчиков не нарушит эту логику и не получит доступ к модели напрямую в каком-то другом сервисе или вью модели и не изменит ее обойдя бизнес правило? Есть ли какая-то изоляция моделей, когда ты не можешь получить к ним доступ обойдя бизнес правила?
Vitaly_js
Очень много возникло вопросов и по форме и по содержанию, поэтому попробую предложить свой отзыв.
Вот тут бы хотелось сразу получить наглядный пример. Потому что с одной стороны для кроссимпортов есть ясных механизм. А с другой стороны импорты между фичами в принципе конфликтуют с идеей, что фичи это код объединенный общей целью и не связный с другими фичами.
Пример который вы предоставили сразу ставит под сомнение, что предложенный функционал написан с пониманием фсд.
Тут тоже хотелось бы ясный пример. Если вы сами пишете, что он используется только в фичах, то располагать его нужно на уровнях ниже. И судя по названию, ему действительно прямая дорога в shared.
Вот такая же фигня. Вы даете пример моков на уровне модулей! Т.е. проблема вообще с fsd никак не связана. Данный пример говорит о том, что проблема в написании кода, который полностью зависит от реализации модулей. И даже в этом случае, скорее всего у вас будут моки в файлах, а в самих тестах просто какая-то конфигурация замоканного модуля под конкретный тест.
Понять проблему вообще не удалось. И понять причем тут fsd тем более. Причем тут shared? О каком контексте фичи идет речь? А это точно две разные фичи? Зачем дублировать код?
Неплохо было бы добавить врезку об Чистой архитектуре гугл в андроид приложениях. Потому что основная идея там ровно такая же, т.е. прям один в один как и в fsd. Т.е. приложение состоит из ясного набора слоев, каждый из которых имеет ясное назначение и отношения к другим слоям. И названы эти слои и их назначение.
Если я правильно понял у вас получилась: app->features->core, что очень похоже на fds: app->features->share. Если я правильно понял, то отношение между слоями у вас ровно такие же, но вашей архитектуре нет слайсов (или вы так думаете)
Вот поэтому мне и не хватает врезки по Чистой архитектуре гугл. Потому что получается у вас приложение делится на Части, Часть с названием feature делится еще на Части, при этом каждая Часть делится на Слои.
У нас сердце приложения, по идее, это Часть с название core, а Domain слой - это слой в конкретной Части в Части под название feature. У вас даже название выбрано соответствующее: core - ядро.
Вот тут любопытно поискать аналогии в fsd. Все таки цель статьи уйти от fsd к clean arch...
Думаю если проводить аналогию, то абстракции, которые никак не зависят от реализации и при этом используются в разных частях приложения - это сущности из слоя shared в fsd. Они могут быть навеяны конкретной предметной областью, но не являются конкретными реализациями.
Вообще, если у вас эта штука является частью архитектуры, то должна быть какая-то абстракция, которая контроллирует форму всех use cases. По моему, такая себе история писать
export class LoginUseCase {}
при этом форма LoginUseCase никак не контроллируется.Если в рамках fsd вы так же создавали сущности, которые на вход принимают зависимости там бы тоже все было прозрачно и тестируемо и не пришлось бы мокать модули.
Очень похоже на слой Entity из fsd
По моему, выглядит несколько избыточным. Я бы присмотрелся к useSyncExternalStore, тогда можно было бы убрать useStateFlow, useFlow.
Скрытый текст
Вот это я не очень понял. Вы начали статью:
Но в вашем примере вы написали логику обработки события модели прямо в компоненте при том, что эта логика вообще не имеет ни одного замыкания на LoginPage.
И второе, а зачем вообще в таких случаях нужен useFlow?
Скрытый текст
Это, конечно дело вкуса, но вроде так куда выразительнее.
Тут другой вопрос, а нужно ли их тестировать без компонентов? Их существование обусловлено требованием какого-либо компонента. Если в системе нет компонента, который бы требовал какой-то метод этого объекта, то тестировать его не нужно, по идее.
Далее уже не буду комментировать ограничен во времени, а вовсе не пренебрегаю написанным.
Но в целом выглядит так, что можно взять fsd и добавить туда какой-нибдуь вид DI и будет ничем не хуже. А даже лучше, потому что не нужно будет самому придумывать терминологию, и выбирать иерархию строительных блоков.
Что-то типа в app идет линковка токенов и конкретных классов для di. Абстракции без реализации в shared или entity. Количество кроссипортов будет сведено к минимуму.
lear
Я буду говорить про мобильное приложение (это для того, чтобы было представление о визуале).
К примеру:
Имеем: Чат, Урок, Плеер.
Чат это полноценная фича. Чат может быть типов: Учебный, Поддержка (Консультант), Групповой.
Урок это тоже полноценная фича.
Плеер это не фича, но имеет большую кодовую базу. Имеет превьюшки и открывается поверх как в Ютубе.
Теперь у нас задача от бизнеса:
В компоненте урока должен быть групповой чат (не на странице, урок переиспользуется на разных страницах).
В учебных чатах (не на странице, не на списке чатов) должен быть список уроков из той же дисциплины, к которой принадлежит урок. Это для того, чтобы быстро навигироваться между чатами и можно было открыть урок. Так же нужно в этом списке отображать статус задания (сдал/отклонено/на проверке).
Когда видео открывается на весь экран, оставшуюся часть экрана должен занимать чат по уроку.
И вот если с 3 проблем особых нет и решается через рендер функцию, то в 1-2 появляется цикличная зависимость.
И вот тут начинаются пляски с тем, как сделать лучше, кого куда вынести, где что создать, как меньше написать бойлерплейта =)
Vitaly_js
К сожалению не удается представить проблему. Если у вас есть набор фичей чат, урок, плеер, то зачем в урок добавлять плеер? Звучит как то, что у вас должен быть разный набор виджетов.
Если вы в урок добавляете чат - это уже не фича, а две фичи в одной. И звучит так, что в слое фичей такой конфигурации делать нечего. Фичи переиспользуются на уровнях виджетов и страниц.
lear
Проблема в терминологии =)
В данном случае под фичами я имел ввиду не фичи из fsd, а фичи как полноценные модули. Они же слайсы из fsd.
Если мы берём fsd:
У нас получается два слайса: Чат и Урок.
Фичи это аналог usecase (mutation/action).
Виджеты это полноценная рабочая единица.
И вот у нас есть два виджеты: Урок и Чат, которые принимают в качестве параметра ИдУрока и ИдЧата соответственно.
Внутри себя они реализуют логику.
Vitaly_js
Фичи из features - это и есть слайсы.
По идее, и Урок и Чат состоят из компонентов, которые нигде больше не переиспользуются. Или же наоборот это могут быть фичи, которые переиспользуют entities. Т.е. до уровня widgets еще есть два уровня на которых можно выбрать что и где будет переиспользоваться. И опять же не забываем, что и Урок и Чат состоят из сильно связных сегментов. Иными словами, нужно прямо упереться в конкретную задачу, что бы понять где и какая проблема.
lear
Я и написал об этом, чтобы был мост между моим комментом и дальнейшим обсуждением. В дальнейшем используется терминология fsd.
Entities - это компоненты, которые используются для отображения, а не для взаимодействия.
Features - это компоненты действий.
Widgets - это полноценные компоненты.
Что чат, что уроки - полноценные компоненты, в которые достаточно передать только ид для дальнейшей полноценной работы, будут располагаться в widgets.
Конкретные задачи были описаны в комменте выше:
ya_araik Автор
Спасибо за подрбный фидбек. Мой первый опыт написания статьи, поэтому могу гдето в тексте теряться в повествовании :)
Сейчас попробую более подробно довести свою мысль
Разъясню на счет кроссимпортов. Самое банальное что можно предложить у нас есть некая admin панель для работы с товарами в интернет магазине. У нас есть сущности для материалов и товаров.
и тут у нас уже на уровне сущностей появляется связность
возникает вопрос как это обходить
либо мы делаем кроссимпорты на прямую
либо делаем кроссимпорты через @x
Кроссимпорты хоть и запрещены, но неизбежны. С этим методология давно борется и до сих пор в документации раздел по кроссимпортам не опубликован. При этом там же в этом разделе есть сноски на сообщения в телеграмме, где тоже описаны эти моменты.
При этом по итогу мы имеем лишь костыль с @x нотацией. Возможно кому-то покажется что это нормально, но лично мое мнение это просто костыль который придумали чтобы залатать дыру
Ну и далее уже понятно что раз на уровне сущностей появилась связность, то и на уровне фичей автоматически появляются кроссимпорты при взаимодействии этих двух сущностей.
---
Далее на счет сервиса уведомлений. Опять же это тоже можно косвенно отнести к связности между сущностями.
Допустим на том же примере с админкой в котором есть материалы и товары. И у нас появляется сервис уведомлений который ловит уведомления об изменении товаров и материалов (допустим чтобы другие админы видели изменения) и эти уведомления в себе держат материал или продукт который изменили, так как по дизайну предусмотрено отображение карточки товара прямо в списке уведомлений. И получается что это не shared слой который не привязан к бизнес сущностям, но добавляя его в слои выше получаем дополнительную связность.
---
Далее замечание по тестированию. Тут больше момент того что в FSD работа с сущностью скажем так размазана по нескольким слоям, не изолирована в одном слое. Часть работы с сущностью лежит в entity, часть в feature и так далее
И тестирование уже сводится к тому что нужно всю эту цепочку найти и правильно это все организовать при тестировании.
Тут же благодаря абстракциям все части приложения друг о друге знаю только в рамках абстракции, вся реализация для друг друга это просто "черный ящик". Что упрощает поиск логической цепочки при тестировании. При этом появляется более гибкая возможность тестировать отдельные части приложения более изолированно.
Возможно как Вы упоминали, добавив DI в тот же FSD, то множество проблем решилось. Возможно это так и есть, но опять же мы решаем не все проблемы. В fsd нет конкретных абстракций над реализациями, у нас фича это и есть use case который использует "то что дали", это я про entity слой, который часто так же подвержен изменениям.
Немного тут наверное тоже отошел от темы, но надеюсь смог объяснить.
Так же хочу заметить что добавление абстракций, инкапсулирования данных и так далее так же дает некую защиту от дурака. Нужно писать так и никак иначе. Что немного минимизирует плохой код. А чем меньше плохого кода, тем проще становится и тестирование приложения.
---
На счет врезки с документации гугла по чистой из андроид не совсем понял. Если зайти на документацию по архитектуре то ясно видно что говорится в первую очередь об однонаправленном потоке состояния. У нас есть четкое и ясное разделение ui, логики и хранилища данных
А в случае с FSD эти слои размазаны по всему приложению. Банально есть entity в котором и состояние и ui и некая логика для сущности, при этом далее в фиче реализована дополнительная функциональность. Как будто сходится, фича - это юз кейс, но при этом у нас в фиче так же может быть своя ui которая связана так же как и с фичами, так же и со слоем ниже с сущностью отделенной ей
---
core слой это скорее как shared в FSD если уж брать аналогию. Это слой полностью не зависимый от бизнес сущностей. При этом никак не зависящие от внешних каких то библиотек (в идеальном мире опять таки, часто всетаки встречаются что выкрадываются реализации которые зависят от чегото внешнего, это уже минус в карму разработчика который это сделал. если мы зависим от чегото внешнего нужно от этого абстрагироваться) (опять же это все про идеальный мир, утопии не существует, нужно понимать где можно чем то пренебреч, а где нет)
А domain слой это как раз уже сердце приложения. Тут возможно возникает путанница из за дополнительного добавления изолирования фичей через папку features. В стандартном понимании чистой архитектуры мы не имеем этой папки и у нас domain это сплошная абстракция всего и вся в нашем приложении.
Даже если уж мы идем еще дальше в андроид, то там фичи изолируются еще лучше, там есть практика изолирования фичей таким образом, что мы делаем отдельный условно java модуль в котором мы отдельно настраиваем так сказать мини сборщик для этой фичи. Далее углубляться я думаю не стоит, потому что там все на много сложнее и уйду в дебри.
Если уж идти прямо действительно по клин, то даже обращение к window должно быть абстрагировано. У нас весь доменный слой должен быть полностью платформонезависемым. Опять же как пример показывал, что можно поменять все на vue. Можно так же без проблемно мигрировать все на ReactNative просто переписав ui и добавить реализации для платформозависемых фичей.
---
Для юз кейсов не нужна дополнительная абстракция. UseCases это можно сказать интеракторы в нашей бизнес логике. Они и так не зависят от реализаций. Это действия которые мы выполняем на основе исключительно доменного слоя. Это как должны работать и взаимодействовать наши абстракции. Мы тут на уровне абстракции как раз говорим как должен работать сценарий. А делать абстракцию для абстракции это уже чушь.
Если же вы имели ввиду добавить абстракцию чтобы стандартизировать названию функции экзекьютора, то по моему мнению это уже будет лишним. Тут больше уже код стайл в команде скорее. Если интереснее наследоваться дополнительно чтобы именовать экзекьютор - дело ваше
---
Не до конца понял вашу мыль тут. Но опять же подмечу что в случае с fsd use cases аркестрирует реализациями а не на уровне абстракций. FSD в целом как будто это только про ФП. Clean базируется на ООП и принципах SOLID. Брать аналогии у друг друга в целом очень холеварное месево получится.
---
далее вы говорите про data слой и network в нем и что похоже на entity слой из фсд. не понял к чему это, но вы правы, это в рамках fsd должно лежать именно в entity слое
---
можно было бы адаптировать и для useSyncExternalStore
но он ждет просто эмита чтобы далее забрать состояние самому
у меня же в flow реализовано так что колбэк подписки аргументом принимает новое значение. Можно взять аналогию с делегатов в шарпе
---
Логика в компонентах имеется ввиду какая либо бизнес логика приложения. Отображение это не логическая составляющая приложения, так же как навигация. Это слой UI. Тут как раз таки для этого и используется паттерн MVI. Мы с контроллера (в нашем случае ViewModel) отправляем UI триггеры на которые он должен отреагировать. Ну или не отреагировать. Вью модели в данном случае без разницы. Он уведомил о каком либо действии, а UI либо както реагирует либо нет.
---
Как вы и сказали это дело вкуса. Я же вынес это отдельно чтобы каждый раз не писать subscribe, unsibscribe
---
на счет тестирования не совсем вас понял. почему нет смысла отдельно тестировать. Мы отдельно протестировали логику чтобы она корректно работала. И отдельно протестировали UI чтобы он корретктно отображал состояние и корректно реагировал на эвенты
как будто тут мне больше нечего добавить либо я так вас и не понял
---
И на счет добавления Di в FSD и дополнительная эта вся реорганизация про которую вы говорите это уже немного не в ту сторону. Это уже все так же будет выглядеть как дополнительные костыли над FSD чтобы както походить на давно проверенные паттерны проектирования ООП
При том такая модернизированная FSD возможно будет не сильно проще, если вообще не еще сложнее в понимании чем та же самая clean
Надеюсь ответить на вопросы я смог достаточно хорошо. Так же большое спасибо за такой подробный фидбэк, очень приятно было подготовить вам ответ. Всегда рад когда можнно обменяться опытом. Даже пока отвечал еще пару раз заглянул в документацию андроида и вспомнил некоторые уже забытые для меня вещи оттуда. Такие дискуссии всегда полезны, получается как некая рефлексия на всем изученным и не изученным :)
lear
Про core папку несогласен.
Те же стандартные библиотеки и внешние библиотеки для вычислений - не вижу ничего плохого в том, чтобы использовать их в core.
Core скорее должен быть абстрагирован от бл, но требовать, чтобы он не зависел от внешних библиотек - изобретать велосипеды на каждый чих.
Ровно как и DI - не вижу ничего плохого сам DI не писать, а использовать готовый, а в core добавить для него абстракции, чтобы в бл было меньше связей на внешнее и больше на core. Не инициализация, а именно сам сервис DI.
ya_araik Автор
core по хорошему должен быть платформонезависемым. конечно же я уже упоминал что утопии не бывает и на каждый чих делать абстракции абсолютно с вами согласен это избыточно
главное понимать проект. Скажем так, чувствовать момент когда нужно делать дополнительную абстракцию, а когда можно этим пренебречь
А на счет DI я помоему не говорил что нужно писать самому. Вроде даже упоминал что можно взять готовый. Если я этого не упомянул, то каюсь :)
Свой DI написать скорее было вызовом для себя.
У каждого проекта свои требования. Слышал от знакомых случай где по требованиям в целом не было возможности использовать какие либо внешние библиотеки вовсе и работать система должна исключительно под локальной сетью. Но это уже совсем другая история)
Vitaly_js
Если речь идет про fsd, то выбора вариантов нет. Линтер fsd будет ругаться на прямой импорт, а занчит только такой, который предусмотрен архитектурой, т.е. через @x.
Данная версия документации появилась буквально в ближайшем прошлом. До этого в этом же году у них была другая документация. Поэтому он не до сих пор не опубликован, а пока не заполнен.
При этом старая версия никуда не делась. И выразительно показывает как слайсы должны взаимодействовать друг с другом.
https://feature-sliced.design/docs/reference/public-api
Т.е. все нормально и с документацией и с пониманием того как решать такие вопросы в fsd
Я пробежался по телеге и там нет ничего такого чего нет в документации. Они решили перекроить старую документацию, но ответы на все это там уже есть.
Они объяснили почему выделили слой entities и widgets. Как раз, что бы слой features не брал на себя слишком много. А в entities кроссимпорт между слайсами выглядит вполне логично. А вот если вы продолжаете использовать кроссимпорты в других слоях, то это скорее всего будет костыль. Иными словами не нужно мешать все в одну кучу.
Например, если использовать widgets/entities они как раз хотели уйти от перегруженного слоя features, где связность между слайсами создает проблему. По сути, в вашей архитектуре как раз и можно наблюдать подобное решение, когда этот слой превращается в универсальный комбайн.
-----
Не появляется, потому что на уровне фичей можно спокойно импортировать все из нижестоящего уровня. То что на уровне сущностей было кроссимпортами, на уровне фичей обычное переиспользование.
-----
То, что вы называете сервисом уведомлений, работает где-то на уровне виджетов. Потому что это комбайн, который может переиспользовать разные фичи.
-----
Вы упускаете идею fsd при которой приложение состоит из четкой иерархии строительных блоков. Вам не нужно искать всю цепочку. И важно не забывать, что в fsd не все нужно располагать по разным слоям. Например, какой-то функционал может лежать прямо в слое pages потому что больше нигде он не нужен. Но, если как вы говорите у нас что-то "размазанно" по слоям, то к каждой сущности на каждом уровне нужно относиться как к отдельному самостоятельному строительному блоку.
Так, ничто не мешает использовать этот подход в fsd.
Так и у вас тоже нет. Вы можете использовать конкретный класс, абстрактный класс, интерфейс, токен. И с тем же успехом при fsd можно использовать из слоя entites/shared конкретный класс, абстрактный класс, интерфейс, токен. Di действительно является опцией в fsd, но ничто не мешает вам внедрять зависимости любым другим способом. По сути, отсутствие контейнера приложения делает работу не централизованной и соответственно менее удобной. Но это никак не мешает вам работать по контракту.
Возможно мне только кажется, но если у вас нет каких-то автоматических средств, что бы указывать, что в данном месте нужно использовать di или какой-то другой шаблон, то все это остается на совести разработчика. И он сам решает должен быть тут конкретный класс или зависимость.
-----
Я что-то такое вчера и посмотрел.
Давайте посмотрим на эту архитектуру:
Здесь нет никакого уровня features. А это значит что любой state holder имеет доступ к любому репозиторию. А любой репозиторий к любому источнику данных. Добавив такой слой вы нарушили архитектуру гугла. Потому что если у вас репозиторий захочет иметь доступ к нескольким источникам данных он этого сделать не сможет.
С этого начинается архитектура уровня данных. Поэтому я и написал:
-----
Я это понял, но именно поэтому он и называется shared, а не core.
И это понятно, но вы этот слой выкинули и размазали его по подслоям features.
Так вот поэтому я и обратил внимание, что основная идея и в fsd и в гугловской теме похожие.
-----
Я имел в виду, что если у нас в приложении используется какой-то шаблон, то нужно понимать, что это за шаблон. Например, какой смысл называть все функции executor "execute", если его сигнатура всегда разная и зависит от конкретного класса? Или вы имеете в виду, что каждый раз ее можно называть по своему? Логика подсказывает, что название метода должно отображать семантику того, что оно делает или иметь общий интерфейс с любым use case, тогда все они будут execute.
-----
fsd никак не принуждает, что нужно использовать. Можете воспринимать написанное, как добавление di в проект и использование fsd.
Для обновления компонента вы добавляете хук на состояние. useSyncExternalStore как раз и служит для того, что бы не добавлять подобное. Поэтому я и предложил рассмотреть.
-----
Поведение страницы логина в зависимости от состояния авторизации и есть бизнес логика приложения. Например, в вашем случае у вас есть бизнес правила при которых страница переходит на страницу дашбоард, или показывает пользователю тостик. В других приложениях будут свои описанные поведения.
Сокрыв реактовую рутину создали ситуацию при которой возможна ошибка и тут же эту ошибку допустили. Добавив литерал в useFlow вы забыли, что этот литерал идет в зависимости useEffect [flow, handler], поэтому всякий раз при изменении состояния у вас подписка и отписка от этого литерала. Иными словами вам нужно использовать useCallback и только после этого прокидывать ваше действие.
-----
Fsd подразумевает, что далеко не все будет раскидано по уровням. Поэтому одного теста компонента может хватить, что бы протестировать все функциональные требования. При этом тестировать "мелкие" строительные блоки может быть не обязательно. Все зависит от того как и что переиспользуется.
Не понял почему. fsd - это про иерархию строительных элементов и отношения между элементами этой иерархии. А что это будут за элементы, т.е. классы для Di, слайсы для redux значения не имеет. fsd для этого модернизировать не нужно.
ya_araik Автор
Ну с FSD я с момента когда у них и документации толком не было. И сейчас абсолютное большинство проектов делаются на FSD
Не подумайте что я как-то хейчу FSD
Мне он до сих пор нравится и я пишу и буду писать на нем проекты.
Методология еще молодая и еще все дополняется. И многие непонятные моменты они стандартизируют.
При этом не могу сказать точно приняли этот подход или нет, но видел что в версии 2.1 методологии должно было появиться описания page first подхода. Это как раз что вы и описали, что можно логику оставить в слое page
Опять же считаю, что это сильно ломает принципы FSD
Это еще сильнее размазывает логику по приложению.
У всего должно быть свое место.
Новый разработчик приходит видит что везде все вынесено в сущности.
Допустим хочет посмотреть как работать с какойто сущностью а в папке entities ее нет. А оказывается он маленький и разработчик засунул в pages
Так же далее при масштабировании она может разрасититься и эта сущность останется раздутой в рамках pages слоя
---
Не совсем понял что вы имеете ввиду. Да и как понять репозиторий имеет доступ к источникам данных? Репозиторий это в целом реализация как источник данных. У нас для работы с сущностью может быть несколько репозриториев. Например у сущности будет репозиторий для получения данных с удаленного сервера и будет репозиторий для работы с этой сущностью в локальном хранилище. А уже use case будет оркестрировать ими. Допустим будет брать с удаленного сервера данные, кешировать их через репозиторий для работы с локальным хранилищем. И при последующем запросе на получение этих данных мы можем сразу отдать закешированные данные. Как один из кейсов.
Возможно не правильно понял вашу мысль, если что поправьте меня.
---
если мы выносим его полностью в виджеты то получается что мы опять же размажем логику по слоям.
как вы делали аналогию с чистой, entity слой тоже что и domain, не совсем согласен конечно с этим, но допустим
получается что мы в данном случае вытащим все в слой виджетов оставив доменный слой пустым
Тут тоже не пойму. Это в целом относится ко всем архитектурам. Суть любой архитектуры это разделить области приложения таким или иным образом и определить их отношения между друг другом.
Суть архитектур в целом везде чем то похожа друг на друга. В прямое сравнение FSD и чистой, что мы сейчас делаем, не самое удачное. Каждая из них решает свой спектр задач. Опять же допустим тот же самый МТС часто любят менять координально технологии судя по их рассказам с конференций и митапов. Они как раз активно используют в своих проектах именно чистую архитектуру. Если интересно можете поискать их статьи, выступления. Если быстро смогу найти, то прикреплю.
---
на счет use cases опять же если я правильно понял о чем вы говорите, то ответ остается тот же что и в прошлый раз. создание интерфейса тут мне просто напросто кажется избыточным, хотя тоже имеет место быть
execute просто общепринятое название для выполнения действия
у каждой команды он может быть свой
тут больше момент уже договоренности внутри команды
видел и такое что создается LoginUseCase с функцией экзекьютором login
---
ну так useSyncExternalStore тоже хук, просто с другой сигнатурой можно сказать
дали инструмент из под коробки
я либо делаю сам себе, либо подгоняю свою реализацию под то что дает реакт
опять же во вью на сколько я знаю нет аналога этому хуку в реакте и там придется все равно добавлять чтото
если чтото в реакте сделано из под коробки не значит что есть везде, тоже самое и наоборот
статья это не гайд как нужно делать
это как можно сделать
и у все равно как бы мы не хотели стандартизировать все и вся невозможно, везде будет чтото отличаться
у меня ход мыслей пошел сначала с создания логики потом дошел до ui
тут же вы пошли получается от обратного
взять ui, в данном случае react и подогнать под него, что конечно же тоже имеет смысл
никто не мешает потом в том же vue тоже создать инструмент который будет подогнан под реализацию которая уже сделана
---
Тут с вами соглашусь, пример привел не совсем правильный, почему то я сразу этого не заметил. NavigateToLogin, ShowToast и так далее согласен это не правильно
Тут нужно не говорить что нужно сделать ui, а что произошло
допустим LoginSuccsess, ConfirmationSuccsess, Error
А ui уже каким либо образом реагирует на эти действия
Тут конечно моя ошибка, посмотрю есть ли функионал редактирования статьи, исправлю ошибку
---
Тут же не согласен, так как view model создается один раз и живет на протяжении всего жизненного цикла компонента
А flow и handler находятся именно в view model. Получается нет пересоздания view model нет и пересоздания ее полей и методов, а значит ссылки те же
---
тут как раз таки и идет координальное отличие fsd от чистой
так как суть разделения на слои у них разный, то и методологии тестирования могут отличаться
если в fsd мы берем компонент и его тестируем вместе слогикой вместе
хотя тут тоже было бы хорошо даже в рамках того же fsd логику тоже тестировать отдельно
то в чистой тестирование происходит изолированно по слоям
слои имеется ввиду что мы ui тестируем изолированно от логики
а логику изолированно от ui
---
а на счет того что в FSD без проблем можно добавить DI инверсию зависимостей адекватную и так далее я сомневаюсь
возможно это так, но я у себя в голове не могу представить себе как это адекватно должно выглядеть
было бы больше свободного времени я бы с радостью попробовал бы чтото сделать
все равно это так же очень интересная тема и можно было бы попробовать
да и если брать тот же самый редакс как стейт менеджер, то тут тоже возникают различия в presentation слое если берем чистую
так как redux реализует скорее flux подход чем какой либо из MV
но это тоже отдельная тема для разговора
Vitaly_js
Скрытый текст
По идее, все что угодно можно раздуть XD
Я имел в виду архитектуру гугла:
Скрытый текст
Что значит размазываем логику по слоям? Выделяя логику в отдельные слои мы делаем эту логику доступной для Всего вышестоящего слоя. Поэтому мы ничего не размазываем, а создаем независимые строительные элементы. Если у нас строительный элемент переиспользуется мы уже не можем говорить, что что-то размазываем.
Скрытый текст
Так вот и смотрите, у вас app->feature->core, что находится внутри каждого слоя?
Повторю архитектуру гугла:
Уровень пользовательского интерфейса (ui elements -> state holders) -> Уровень данных (repositiries -> data source)
По сути, четыре уровня. При этом четко обозначено, что находится на каждом уровне и направление зависимостей. Если проводить аналогии с гуглом, то у вас data source из разных фичей не могут быть целью для обращений репозиториев, которые не являются частью фичи, а у гугла такого ограничения нет. Для них как вы говорите на Уровне данных вся функциональность размазана по всему уровню и к ней имеет доступ любой state holder из Уровня пользовательского интерфейса.
Скрытый текст
Мой вопрос, у вас тут шаблон или нет? Потому что судя по всему тут шаблона нет. И функцию execute можно называть по семантике работы, которую она делает.
Скрытый текст
Честно говоря, впервые об этом слышу. Но я знаю шаблон Команда. Там действительно есть метод execute. По идее, как мне это представляется. Вот если взять архитектуру гугла. Единственным источником данных является репозиторий. В вашем примере - этот репозиторий отвечает за хранение информации формы регистарции. Он такой один, поэтому вы его задаете в зависимости для Di, и тогда передавать в execute LoginBody вам не нужно. И соответственно, если если любую команду конфигурировать только через Di у вас элемента любого класса команды будет иметь один интерфейс execute. Если у вас шаблона нет, то execute лучше называть по семантике выполняемой операции.
Если шаблон есть, то это уже не зависит от разработчика. Нужно следовать интерфейсу шаблона.
Скрытый текст
Да этот пример со vue можно не учитывать, потому что он крайне специфичен.
Суть фреймворков как раз в том, что они меняются крайне редко.
Модель создается один раз. А вот весь компонент выполняется постоянно, когда идет обновление. Вы использовали литерал и поместили его в зависимости хука useEffect, поэтому данный useEffect будет выполняться каждый раз когда идет обновление компонента.
Di будет выглядеть ровно так же как и у вас) fsd вообще до лампочки, какие вы там используете шаблоны в своих слоях.
ya_araik Автор
Ну опять же я и говорил что используется не изначальная концепция чистой, а немного модернизированная
Если уж брать изначально как должно быть, то как у меня сделано, папки features не должно быть
Есть единый доменный слой всего приложения и все
Там вообще никаких проблем не возникает, кому что надо тот то и использует
Отдельный features вынесен для более четкого разделения логики
При этом тут если же смотреть если мы делаем такое разделение по фичам, то так же как и в FSD кроссимпорты между фичами тоже могут быть. В случае с андроид мы на уровне сборщика указываем зависимости между пакетами. Тут же только если прибегать к настройкам линтера. С разделением на фичи просто удобнее работать с разделением на несколько команд разработки.
Ну и в большинстве случаев app слой является связующим, где так же можно хранить логику
Тоесть грубо говоря если бы мы убрали слой фичей
то был бы в app domain data presentation и там все хранилось бы
везде будут свои сложности. утопии нет) ничего не идеально)
чем сильнее усложнять тем больше проблем
тут же чтобы понимать как делить фичи наверное можно взять аналогию с микрофронтов
фичи это микрофронты, а app собирает их как нужно
---
размазано в данном случае говорил о том что мы вынесем всю сущность этих уведомлений в слой виджета пропустив банально entity слой
что размажет дополнительно логику
так мы отходим от стандартной практики разработки
по хорошему мы идем снизу вверх
сделали утилиты в шаред
описали сущность в энтити
написали можно сказать юз кейсы в фичах
скомпоновали в логические блоки в виджетах
собрали в страницы
мы же пропустили тут 2 слоя запихнув все в виджет
---
ну тут core как shared из fsd
а app и features
тут как написал уже выше
можно было бы сделать один доменный слой для всего приложения
тут же мы просто разбиваем приложение на отдельные логические блоки
а app их объединяет
опять же аналогия с микрофронтами
---
по use cases шаблона тут какогото нет
мы просто в рамках проекта договариваемся на нейминг
если брать документацию андроида там функцию executor'a назвали invoke
---
на счет переподписки в useFlow абсолютно правы, почему туда закрался эндлер в депсы не пойму, скорее всего линтер ругнулся что в хуке используется, а в депсы не добавлены
там хэндлер не должен быть в депсах
да и сам flow можно оттуда убрать, но как защита от дурака пусть там лежит
а на счет этого всего у меня давно закралась идея попробовать использовать zustand для этих целей, но никак руки не доходят
Vitaly_js
Думаю все обсудили. Удачи)
ya_araik Автор
Спасибо вам за очень интересную дисскуссию! Было очень здорово получить такой фидбэк!