Привет! Сегодня хочу поделиться с тобой опытом перехода от 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 или что-то похожее - делись опытом в комментариях, буду только рад узнать для себя чтото новое, или сделать лучше уже в том что есть! ?

Комментарии (0)