
UPD 1: Статья устарела
Используйте feature-sliced.design
UPD 2: ещё у меня есть Telegram-канал, где я собираю ссылки на свои статьи про разработку, развитие SaaS-проектов и управление IT-проектами.
Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию The Clean Architecture для построения архитектуры в своих проектах Frontend проектах. Изначально я взял ее на вооружение с переходом на TypeScript, так как не нашел других подходящих общепринятых архитектурных подходов в мире разработки на React (а пришел я из Android-разработки, где давным-давно, еще до Kotlin, наделала шумихи статья от Fernando Cejas, на которую я до сих пор иногда ссылаюсь).
В данной статье я хочу рассказать вам о нашем опыте применения The Clean Architecture в React-приложениях с использованием TypeScript. Зачем я это рассказываю? — Иногда мне приходится разъяснять и обосновывать ее использование разработчикам, которые еще не знакомы с таким подходом. Поэтому здесь я сделаю детальный разбор с наглядными пояснениями на которое я смогу ссылаться в будущем.
Содержание
- Введение
- Теоретическая часть
- Для чего вообще нужна архитектура?
- Оригинальное определение The Clean Architecture
- The Clean Architecture для Frontend
- Практическая часть
- Описание веб-приложения авторизации
- Структура исходного кода
- UML диаграмма проекта
- Разбор кода
- Заключение
- Ресурсы и источники
1. Введение
Архитектура — это, прежде всего, глобальная вещь. Ее понимание необходимо не в разрезе конкретного языка программирования. Вам необходимо понимание ключевых идей в целом, чтобы значит за счет чего достигаются преимущества от использования той или иной архитектуры. Принцип тот же, что и с паттернами проектирования или SOLID — они придуманы не для конкретного языка, а для целых методологий программирования (как, например, ООП).
Разобраться в архитектуре проще всего, когда видишь всю картину целиком. Поэтому в данной статье я расскажу не только о том, как должно быть “в теории” — а и приведу конкретный пример проекта. Сначала разберемся с теоретической частью применения The Clean Architecture во frontend’e, а потом рассмотрим веб-приложение с UML диаграммой и описанием каждого класса.
Важное уточнение: The Clean Architecture не устанавливает строгих правил организации приложений, она дает только рекомендации. У каждой платформы и языка будут свои нюансы. В данной статье преподносится подход, который я использовал со своими коллегами и использую сейчас — он не является панацеей.
Также хотелось бы отметить, что использование подобных архитектурных подходов может быть избыточно для маленьких проектов. Основная задача любой архитектуры — сделать код понятным, поддерживаемым и тестируемым. Но если ваше приложение быстрее написать на JS без архитектур, тестирования и прочего — это вполне нормально. Не занимайтесь overengineering'ом там, где это не нужно. Помните, что основную силу архитектуры\тестирование приобретают в больших проектах с несколькими разработчиками, где нужно понимать и изменять чужой код.
UPD_0
UPD_0: несомненно существует много подходов и данная архитектура может быть улучшена и доработана. Суть этой статьи и основная причина, почему мы ее используем — данная архитектура работает, вытягивая большие проекты, и она понятная другим разработчикам. Поэтому не стесняйтесь добавлять в нее новые правила и что-то изменять, но главное сохраняйте работоспособность и понятность. Не гонитесь за архитектурой ради архитектуры, ведь ваша задача только выстроить понятные правила, которые будет просто поддерживать в будущем.
При излишнем улучшении того, что работает, я вспоминаю следующую цитату:
Как говорят, «лучшее — врал хорошего». Поэтому не пытайтесь создать что-то идеальное, когда Вам просто нужно решить проблему (в нашем случае, решить проблему сопровождения за счет архитектуры).
При излишнем улучшении того, что работает, я вспоминаю следующую цитату:
Преждевременная оптимизация — корень всех (или большинства) проблем в программировании.
— Дональд Кнут, «Computer Programming as an Art» (1974)
Как говорят, «лучшее — врал хорошего». Поэтому не пытайтесь создать что-то идеальное, когда Вам просто нужно решить проблему (в нашем случае, решить проблему сопровождения за счет архитектуры).
2. Теоретическая часть
2.1. Для чего вообще нужна архитектура?
Ответ: архитектура необходима для экономии времени в процессе разработки, поддержания тестируемости и расширяемости системы на протяжении долгого периода разработки.
Более детально о том, что бывает, если не закладывать архитектуру для больших приложений — Вы можете прочитать, например, в книге The Clean Architecture Боба Мартина. Для краткого объяснения, я приведу следующий график из этой книги:

На данном графике мы видим, что с каждой новой версией (допустим, они выпускаются с равными промежутками времени) в систему добавляется все меньшее количество строк, а также рост стоимости одной строки. Это происходит ввиду усложнения системы, а внесение изменений начинает требовать неоправданно большого количества усилий.
В книге The Clean Architecture этот график приводиться в качестве примера плохой архитектуры. Такой подход рано или поздно приведет к тому, что стоимость расширения системы будет стоить дороже, чем выгода от самой системы.
Еще раз о соотношении времени разработки
Как раз сегодня читал статью на Хабре и встретил следующую цитату (с которой полностью согласен):
Вывод: систему дороже изменять, поэтому нужно заранее думать о том, как вы будете изменять ее в будущем.
«На первые 90 процентов кода уходит 10 процентов времени, потраченного на разработку. На оставшиеся 10 процентов кода уходит оставшиеся 90 процентов»
— Том Каргилл, Bell Labs
Вывод: систему дороже изменять, поэтому нужно заранее думать о том, как вы будете изменять ее в будущем.
А теперь мой “идеальный” вариант, какой мы (разработчики, PM'ы, заказчики) хотели бы видеть в наших проектах:

На графике наглядно показано, что скорость роста количества строк не меняется в зависимости от версии. Стоимость строки кода (в зависимости от версии) увеличивается, но незначительно с учетом того, что речь идет о миллионах строк. К сожалению, такой вариант маловероятен, если мы говорим о большой Enterprise системе, так как продукт расширяется, сложность системы увеличивается, разработчики меняются, поэтому затраты на разработку неизбежно будут расти.
Однако я могу Вас и обрадовать — мы говорим о Frontend приложениях! Давайте смотреть правде в глаза — как правило, подобные приложения не вырастают до миллионов строк, иначе браузеры бы банально долго загружали такие приложения. В крайнем случае, они разбиваются на разные продукты, а основная логика лежит на backend стороне. Поэтому мы в какой-то мере можем стремиться к приведенной выше тенденции роста стоимости кода (с разной успешностью, в зависимости от размера приложения). Если наш проект даже на 50% дешевле сопровождается, чем мог бы без хорошей архитектуры — это уже экономия времени разработчиков и средств заказчика.
Изначально выстроив хорошую и понятную архитектуру, в результате получаем следующие преимущества:
- дешевле сопровождения кода (следовательно, меньше временных и финансовых затрат);
- упрощение тестируемости кода (следовательно, потребуется меньше тестировщиков и ниже потери из-за пропущенных “багов на проде”);
- ускорение внедрения новых разработчиков в проект.
Думаю, на вопрос “а зачем это нужно?!”, я ответил. Далее переходим к технической части вопроса.
2.2. Оригинальное определение
Я не буду углубляться в детальное описание The Clean Architecture, так как эта тема раскрыта во многих статья, а только коротко сформулирую суть вопроса.
В оригинальной статье Боба Мартина 2012-го года показана следующая диаграмма:

Ключевая идея данной диаграммы заключается в том, что приложение делиться на слои (слоев может быть любое количество). Внутренние слои не знают о внешних, зависимости обращены в центр. Чем дальше слой от центра, тем больше он знает о “небизесовых” деталях приложения (например, что за фреймворк используется и сколько кнопок на экране).
- Entities. В центре у нас находятся Entities (сущности). В них заключена бизнес-логика приложения и здесь нет зависимостей от платформы. Entities описывают только бизнес-логику приложения. Например, возьмем класс Cart (корзина) — мы можем добавить товар в корзину, удалить его и т.д. Ничего о таких вещах, как React, базы данных, кнопках — данный класс не знает.
Говоря о незави��имости от платформы имеется ввиду, что здесь не применяются специфические библиотеки как React\Angular\Express\Nest.js\DI и т.д. Если, например, возникнет необходимость, мы сможем взять цельную сущность из Web-приложения на React’e — и вставить в код для NodeJS без изменений. - Use cases. Во втором слое диаграммы расположены Use Cases (они же — сценарии использования, они же — Interactors). Сценарии использования описывают, как взаимодействовать с сущностями в контексте нашего приложение. Например, если сущность знает только о том, что в нее можно добавить заказ — сценарий использования знает, что из сущности можно взять этот заказ и отправить в репозиторий (см. далее).
- Gateways, Presenters, etc. В данном контексте (Gateways = Repositories, Presenters = View Models) — слои системы, которые отвечают за связь между бизнес-правилами приложения и платформенно зависимыми частями системы. Например, репозитории предоставляют интерфейсы, которые будут реализовывать классы для доступа к API или хранилищам, а View Model интерфейс будет служить для связи React-компонентов с вызовами бизнес-логики.
Уточнение: в нашем случае Use Cases и Repositories, как правило, будут находиться в ином порядке, так как большая часть работы frontend приложений заключается в получении и отправке данных через API. - External interfaces. Платформенно зависимый слой. Здесь находятся прямые обращения к API, компоненты React'а и т.д. Именно этот слой труднее всего поддается тестированию и абстрагированию (кнопочка в React’e — есть кнопочка React’e ).
2.3. Определение в контексте frontend’a
А теперь перейдем к нашей frontend области. В контексте Frontend’a, диаграмму выше можно представить вот так:

- Entities. Бизнес сущности такие же, как и в оригинальном варианте архитектуры. Обратите внимание, что сущности умеют хранить состояние и часто используются для этой цели. Например, сущность “корзина” может хранить в себе заказы текущей сессии, чтобы предоставлять методы работы с ними (получение общей цены, суммарного количества товаров и т.д.).
- Repository interfaces. Интерфейсы для доступа к API, БД, хранилищам и т. д. Может показаться странным, что интерфейсы для доступа к данным находятся “выше” сценариев использования. Однако, как показывает практика, сценарии использования знают о репозиториях и активно используют их. А вот репозитории ничего не знают о сценариях использования, но знают о сущностях. Это пример инверсии зависимостей из SOLID’a (возможность определения интерфейса во внутреннем слое, сделав реализацию во внешнем). Использование интерфейсов добавляет абстракцию (например, никто не знает, делает ли репозиторий запросы к API или берет данные из кеша).
- Use Cases. Аналогично оригинальной диаграмме. Объекты, которые реализуют бизнес-логику в контексте нашего приложения (т. е. понимают, что делать с сущностями — отправлять, загружать, фильтровать, объединять).
- View Models и View Interfaces.
ViewModel — это замена Presenters из оригинальной диаграммы. В своих проектах я применяю архитектуру MVVP вместо MVP\MVC\MV*. Если описывать кратко, разница с MVP лишь в одном: Presenter знает о View и вызывает ее методы, а ViewModel не знает о View, имея только один метод уведомлен��я об изменениях. View просто “мониторит” состояние View Model. MVVP имеет однонаправленную зависимость (View → ViewModel), а MVP — двунаправленную (View ︎ Presenter). Меньше зависимостей — проще тестировать.
View Interfaces — в нашем случае, один базовый класс для всех View, через который View Model уведомляет конкретные реализации View об изменениях. Содержит метод по типу onViewModelChanged(): void. Еще один пример инверсии зависимостей. - 5. External interfaces. Аналогично оригинальной диаграмме, в этом слое находятся платформенно зависимые реализации. В случае приложения ниже — это компоненты React’a и реализация интерфейсов для доступа к API. Однако также здесь может быть любой другой фреймворк (AngularJS, React Native) и любое другое хранилище (IndexDB, local storage и т.д.). The Clean Architecutre позволяет изолировать применение конкретных фреймворков, библиотек и технологий, тем самым давая возможность в какой-то мере заменять их.
Если представить диаграмму выше в виде трехслойного приложения, она приобретает следующий вид:

Красные стрелки — поток течения данных (но не зависимостей, диаграмма зависимостей отображена на круговой диаграмме выше). Изображение в виде прямоугольной диаграммы позволяет лучше понять, как движется поток данных внутри приложения. Идею описания в виде такой диаграммы я увидел в ЭТОЙ статье.
Имейте ввиду, что в более сложных приложениях структура слоев может меняться. Например, распространенная практика, когда каждый из слоев выше, чем domain — может иметь свои мапперы для преобразования данных.
3. Пример приложения
3.1. Описание веб-приложения авторизации
Чтобы применение архитектуры было более наглядным и понятным, я создал веб-приложение, построенное ее основе. Исходный код приложения Вы можете посмотреть в репозитории GitHub. Приложение выглядит так:

Приложение представляет собой простое окно авторизации. Для усложнения самого приложения (чтобы архитектура была уместна), делаем следующие вводные:
- Поля не должны быть пустыми (валидация).
- Введенная почта должна иметь корректный формат (валидация).
- Данные доступа должны пройти валидацию на сервере (заглушка API) и получить ключ валидации.
- Для авторизации методу API нужно предоставить данные валидации и ключ валидации.
- После авторизации ключ доступа должен быть сохранен внутри приложения (слой сущностей).
- При выходе ключ авторизации должен стираться из памяти.
3.2. Структура исходного кода
В нашем примере структура папки src выглядит следующим образом:

- data — содержит классы для работы с данными. Эта директория является крайним кругом на круговой диаграмме, так как содержит классы для реализации интерфейсов репозиториев. Следовательно, эти классы знают об API и платформенно зависимых вещах (local storage, cookie и т.д.).
- domain — классы бизнес логики. Здесь находятся Entities, Use Cases и Repository Interfaces. В подкаталоге entities есть разделение на две директории: models и structures. Разница между этими директориями в том, что models — это сущности с логикой, а structures — простые структуры данных (по типу POJO в Java). Это разделение сделано для удобства, так как в models мы кладем классы, с которыми мы (разработчики) непосредственно и часто работаем, а в structures — объекты, которые возвращает сервер в виде JSON-объекта (json2ts, «привет») и мы их используем для передачи между слоями.
- presentation — содержит View Models, View Interfaces и View (фреймворковские компоненты), а также util — для различных валидаций, утилит и т.п.
Разумеется, структура может меняться от проекта к проекту. Например, в корне presentation в одном из моих проектов находятся классы для контроля состояния сайдбара и класс для навигации между страницами.
3.3. URM диаграмма проекта

Исходники для увеличения — GitHub.
Разделение классов по слоям наглядно показано прямоугольниками. Обратите внимание, что зависимости направлены в сторону слоя Domain (в соответствии с диаграммой).
3.4. Разбор кода
Entities layer
В данном разделе мы пройдемся по всем классам с описанием их логики работы. Начнем с самого дальнего круга — Entities, так как на его основе базируются остальные классы.
AuthListener.tsx
// Используем для обновления слушателей // в классе AuthHolder export default interface AuthListener { onAuthChanged(): void; }
AuthHolder.tsx
import AuthListener from './AuthListener'; // Данный класс хранит состояние авторизации (п. 3.1.5). Для того, чтобы // обновлять presentation слой, мы используем паттерн Observer // со слушателями AuthListener export default class AuthHolder { private authListeners: AuthListener[]; private isAuthorized: boolean; private authToken: string; public constructor() { this.isAuthorized = false; this.authListeners = []; this.authToken = ''; } public onSignedIn(authToken: string): void { this.isAuthorized = true; this.authToken = authToken; this.notifyListeners(); } public onSignedOut(): void { this.isAuthorized = false; this.authToken = ''; this.notifyListeners(); } public isUserAuthorized(): boolean { return this.isAuthorized; } /** * @throws {Error} if user is not authorized */ public getAuthToken(): string { if (!this.isAuthorized) { throw new Error('User is not authorized'); } return this.authToken; } public addAuthListener(authListener: AuthListener): void { this.authListeners.push(authListener); } public removeAuthListener(authListener: AuthListener): void { this.authListeners.splice(this.authListeners.indexOf(authListener), 1); } private notifyListeners(): void { this.authListeners.forEach((listener) => listener.onAuthChanged()); } }
AuthorizationResult.tsx
// Простая структура данных для передачи между слоями export default interface AuthorizationResult { authorizationToken: string; }
ValidationResult.tsx
// Еще одна структура данных для передачи между слоями export default interface ValidationResult { validationKey: string; }
На этом слой сущностей заканчивается. Обратите внимание, данный слой занимается исключительно бизнес логикой (хранение состояния) и используется для передачи данных во всем остальном приложении.
Часто состояние не нужно хранить в классах бизнес-логики. Для этой цели хорошо подходит связка репозитория со сценарием использования (для преобразования данных).
Repository interfaces
AuthRepository.tsx
import ValidationResult from '../../entity/auth/stuctures/ValidationResult'; import AuthorizationResult from '../../entity/auth/stuctures/AuthorizationResult'; // Здесь мы объявляем интерфейс, который потом реализует класс для доступа к API export default interface AuthRepository { /** * @throws {Error} if validation has not passed */ validateCredentials(email: string, password: string): Promise<ValidationResult>; /** * @throws {Error} if credentials have not passed */ login(email: string, password: string, validationKey: string): Promise<AuthorizationResult>; }
Use Cases
LoginUseCase.tsx
import AuthRepository from '../../repository/auth/AuthRepository'; import AuthHolder from '../../entity/auth/models/AuthHolder'; export default class LoginUseCase { private authRepository: AuthRepository; private authHolder: AuthHolder; public constructor(authRepository: AuthRepository, authHolder: AuthHolder) { this.authRepository = authRepository; this.authHolder = authHolder; } /** * @throws {Error} if credentials are not valid or have not passed */ public async loginUser(email: string, password: string): Promise<void> { const validationResult = await this.authRepository.validateCredentials(email, password); const authResult = await this.authRepository.login( email, password, validationResult.validationKey, ); this.authHolder.onSignedIn(authResult.authorizationToken); } }
В данном случае Use Case имеет только один метод. Обычно сценарии использования имеют только один публичный метод, в котором реализована сложная логика для одного действия. В данном случае – необходимо сначала провести валидацию, а потом отправить данные валидации в API метод авторизации.
Однако также часто используется подход, когда несколько сценариев объединяются в один, если имеют общую логику.
Внимательно следите, чтобы сценарии использования не содержали логику, которая должна находится в сущностях. Слишком большое количество методов или хранение состояния в Use Case часто служит индикатором того, что код должен находиться в другом слое.
Repository implemetation
AuthFakeApi.tsx
import AuthRepository from '../../domain/repository/auth/AuthRepository'; import ValidationResult from '../../domain/entity/auth/stuctures/ValidationResult'; import AuthorizationResult from '../../domain/entity/auth/stuctures/AuthorizationResult'; // Класс, имитирующий доступ к API export default class AuthFakeApi implements AuthRepository { /** * @throws {Error} if validation has not passed */ validateCredentials(email: string, password: string): Promise<ValidationResult> { return new Promise((resolve, reject) => { // Создаем правило, которое должен был бы поддерживать сервер if (password.length < 5) { reject(new Error('Password length should be more than 5 characters')); return; } resolve({ validationKey: 'A34dZ7', }); }); } /** * @throws {Error} if credentials have not passed */ login(email: string, password: string, validationKey: string): Promise<AuthorizationResult> { return new Promise((resolve, reject) => { // Имитируем проверку ключа валидации if (validationKey === 'A34dZ7') { // Создаем пример подходящего аккаунта с логином user@email.com и паролем password if (email === 'user@email.com' && password === 'password') { resolve({ authorizationToken: 'Bearer ASKJdsfjdijosd93wiesf93isef', }); } } else { reject(new Error('Validation key is not correct. Please try later')); return; } reject(new Error('Email or password is not correct')); }); } }
В данном классе мы сделали имитацию доступа к API. Мы возвращаем Promise, который вернул бы настоящий fetch-запрос. Если мы захотим заменить реализацию на реальный API — просто изменим класс AuthFakeApi на AuthApi в файле App.tsx или инструменте внедрения зависимостей, если такой используется.
Обратите внимание, что мы аннотируем методы описанием ошибок, чтобы другие программисты понимали потребность обработки ошибок. К сожалению, TypeScript в данный момент не имеет инструкций по типу throws в Java, поэтому мы используем простую аннотацию.
util (presentation слой)
В данную директорию мы кладем классы, которые осуществляют логику “превентивной” валидации данных, а также другие классы для работы с UI слоем.
FormValidator.tsx
export default class FormValidator { static isValidEmail(email: string): boolean { const emailRegex = /^\S+@\S+\.\S+$/; return emailRegex.test(email); } }
View interfaces
BaseView.tsx
Класс, которые позволяет View Model уведомлять View об изменениях. Реализуется всеми View компонентами.
export default interface BaseView { onViewModelChanged(): void; }
View Models
BaseViewModel.tsx
Класс, который предоставляет базовые методы для связи View Model и View. Реализуется всеми View Models.
import BaseView from '../view/BaseView'; export default interface BaseViewModel { attachView(baseView: BaseView): void; detachView(): void; }
AuthViewModel.tsx
import BaseViewModel from '../BaseViewModel'; // Интерфейс ViewModel, который будет доступен View. Здесь // объявлены все публичные поля, которые будет использовать View export default interface AuthViewModel extends BaseViewModel { emailQuery: string; passwordQuery: string; isSignInButtonVisible: boolean; isSignOutButtonVisible: boolean; isShowError: boolean; errorMessage: string; authStatus: string; isAuthStatusPositive: boolean; onEmailQueryChanged(loginQuery: string): void; onPasswordQueryChanged(passwordQuery: string): void; onClickSignIn(): void; onClickSignOut(): void; }
AuthViewModelImpl.tsx
import AuthViewModel from './AuthViewModel'; import BaseView from '../../view/BaseView'; import LoginUseCase from '../../../domain/interactors/auth/LoginUseCase'; import AuthHolder from '../../../domain/entity/auth/models/AuthHolder'; import AuthListener from '../../../domain/entity/auth/models/AuthListener'; import FormValidator from '../../util/FormValidator'; export default class AuthViewModelImpl implements AuthViewModel, AuthListener { public emailQuery: string; public passwordQuery: string; public isSignInButtonVisible: boolean; public isSignOutButtonVisible: boolean; public isShowError: boolean; public errorMessage: string; public authStatus: string; public isAuthStatusPositive: boolean; private baseView?: BaseView; private loginUseCase: LoginUseCase; private authHolder: AuthHolder; public constructor(loginUseCase: LoginUseCase, authHolder: AuthHolder) { this.emailQuery = ''; this.passwordQuery = ''; this.isSignInButtonVisible = true; this.isSignOutButtonVisible = false; this.isShowError = false; this.errorMessage = ''; this.authStatus = 'is not authorized'; this.isAuthStatusPositive = false; this.loginUseCase = loginUseCase; this.authHolder = authHolder; // Делаем наш класс слушателем событий авторизации this.authHolder.addAuthListener(this); } public attachView = (baseView: BaseView): void => { this.baseView = baseView; }; public detachView = (): void => { this.baseView = undefined; }; // Данный метод является методом интерфейса AuthListener public onAuthChanged = (): void => { // Изменяем данные модели, чтобы View // отобразила изменения при входе и выходе if (this.authHolder.isUserAuthorized()) { this.isSignInButtonVisible = false; this.isSignOutButtonVisible = true; this.authStatus = 'authorized'; this.isAuthStatusPositive = true; } else { this.isSignInButtonVisible = true; this.isSignOutButtonVisible = false; this.authStatus = 'is not autorized'; this.isAuthStatusPositive = false; } this.notifyViewAboutChanges(); }; public onEmailQueryChanged = (loginQuery: string): void => { this.emailQuery = loginQuery; this.notifyViewAboutChanges(); }; public onPasswordQueryChanged = (passwordQuery: string): void => { this.passwordQuery = passwordQuery; this.notifyViewAboutChanges(); }; public onClickSignIn = async (): Promise<void> => { if (!this.validateLoginForm()) { this.notifyViewAboutChanges(); return; } try { await this.loginUseCase.loginUser(this.emailQuery, this.passwordQuery); this.isShowError = false; this.errorMessage = ''; } catch (e) { this.errorMessage = e.message; this.isShowError = true; } this.notifyViewAboutChanges(); }; public onClickSignOut = (): void => { // Удаляем данные авторизации без посредника в виде сценария использования this.authHolder.onSignedOut(); }; private validateLoginForm = (): boolean => { if (!this.emailQuery) { this.isShowError = true; this.errorMessage = 'Email cannot be empty'; return false; } // Убираем ошибку, если раньше ставили для этого условия if (this.errorMessage === 'Email cannot be empty') { this.isShowError = false; this.errorMessage = ''; } if (!FormValidator.isValidEmail(this.emailQuery)) { this.isShowError = true; this.errorMessage = 'Email format is not valid'; return false; } if (this.errorMessage === 'Email format is not valid') { this.isShowError = false; this.errorMessage = ''; } if (!this.passwordQuery) { this.isShowError = true; this.errorMessage = 'Password cannot be empty'; return false; } if (this.errorMessage === 'Password cannot be empty') { this.isShowError = false; this.errorMessage = ''; } return true; } private notifyViewAboutChanges = (): void => { if (this.baseView) { this.baseView.onViewModelChanged(); } }; }
Обратите внимание на метод
onClickSignOut — в нем мы напрямую обращаемся к классу AuthHolder. Это один из тех случаев, когда посредник в виде сценария использования был бы лишним, потому что логика метода довольно тривиальна. Аналогично можно обращаться напрямую к интерфейсу репозиториев. Однако при усложнении кода, для выполнения выхода — необходимо вынести его в отдельный сценарий использования.
UI (views)
AuthComponent.tsx
import React from 'react'; import './auth-component.css'; import BaseView from '../BaseView'; import AuthViewModel from '../../view-model/auth/AuthViewModel'; export interface AuthComponentProps { authViewModel: AuthViewModel; } export interface AuthComponentState { emailQuery: string; passwordQuery: string; isSignInButtonVisible: boolean; isSignOutButtonVisible: boolean; isShowError: boolean; errorMessage: string; authStatus: string; isAuthStatusPositive: boolean; } export default class AuthComponent extends React.Component<AuthComponentProps, AuthComponentState> implements BaseView { private authViewModel: AuthViewModel; public constructor(props: AuthComponentProps) { super(props); const { authViewModel } = this.props; this.authViewModel = authViewModel; this.state = { emailQuery: authViewModel.emailQuery, passwordQuery: authViewModel.passwordQuery, isSignInButtonVisible: authViewModel.isSignInButtonVisible, isSignOutButtonVisible: authViewModel.isSignOutButtonVisible, isShowError: authViewModel.isShowError, errorMessage: authViewModel.errorMessage, authStatus: authViewModel.authStatus, isAuthStatusPositive: authViewModel.isAuthStatusPositive, }; } public componentDidMount(): void { this.authViewModel.attachView(this); } public componentWillUnmount(): void { this.authViewModel.detachView(); } // При каждом обновлении ViewModel, мы обновляем // state нашего компонента public onViewModelChanged(): void { this.setState({ emailQuery: this.authViewModel.emailQuery, passwordQuery: this.authViewModel.passwordQuery, isSignInButtonVisible: this.authViewModel.isSignInButtonVisible, isSignOutButtonVisible: this.authViewModel.isSignOutButtonVisible, isShowError: this.authViewModel.isShowError, errorMessage: this.authViewModel.errorMessage, authStatus: this.authViewModel.authStatus, isAuthStatusPositive: this.authViewModel.isAuthStatusPositive, }); } public render(): JSX.Element { const { emailQuery, passwordQuery, isSignInButtonVisible, isSignOutButtonVisible, isShowError, errorMessage, authStatus, isAuthStatusPositive, } = this.state; return ( <div className="row flex-grow-1 d-flex justify-content-center align-items-center"> <div className="auth-container col bg-white border rounded-lg py-4 px-5"> <div className="row mt-2 mb-4"> Status: <span className={`${isAuthStatusPositive ? 'text-success' : 'text-danger'}`}> {authStatus} </span> </div> <div className="row mt-2"> <input type="text" placeholder="user@email.com" onChange={(e: React.FormEvent<HTMLInputElement>): void => { this.authViewModel.onEmailQueryChanged(e.currentTarget.value); }} value={emailQuery} className="form-control" /> </div> <div className="row mt-2"> <input type="password" placeholder="password" onChange={(e: React.FormEvent<HTMLInputElement>): void => { this.authViewModel.onPasswordQueryChanged(e.currentTarget.value); }} value={passwordQuery} className="form-control" /> </div> {isShowError && ( <div className="row my-3 text-danger justify-content-center">{errorMessage}</div> )} {isSignInButtonVisible && ( <div className="row mt-4"> <button type="button" className="col btn btn-primary" onClick={(): void => this.authViewModel.onClickSignIn()} > �� Sign in </button> </div> )} {isSignOutButtonVisible && ( <div className="row mt-4"> <button type="button" className="col btn btn-primary" onClick={(): void => this.authViewModel.onClickSignOut()} > Sign out </button> </div> )} </div> </div> ); } }
Данный компонент является зависимым от фреймворка и, следовательно, находиться в самом крайнем слое диаграммы.
AuthComponent при монтировании (
componentDidMount) прикрепляется к AuthViewModel и открепляется при исчезновении (componentWillUnmount). При каждом изменении ViewModel, AuthComponent обновляет свое состояние для дальнейшего обновления разметки.Обратите внимание на условный рендеринг в зависимости от состояния:
{isSignOutButtonVisible && ( <div className="row mt-4"> <button type="button" className="col btn btn-primary" onClick={(): void => this.authViewModel.onClickSignOut()} > Sign out </button> </div> )}
А также на обращение к методам ViewModel для передачи значений:
onClick={(): void => this.authViewModel.onClickSignOut()}
Entry point
Для входа в приложение, мы используем файлы index.tsx и App.tsx.
index.tsx
import React from 'react'; import ReactDOM from 'react-dom'; import 'bootstrap/dist/css/bootstrap.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root'), ); serviceWorker.unregister();
App.tsx
import React from 'react'; import './app.css'; import AuthComponent from './presentation/view/auth/AuthComponent'; import AuthViewModelImpl from './presentation/view-model/auth/AuthViewModelImpl'; import AuthFakeApi from './data/auth/AuthFakeApi'; import LoginUseCase from './domain/interactors/auth/LoginUseCase'; import AuthHolder from './domain/entity/auth/models/AuthHolder'; function App(): JSX.Element { // data layer const authRepository = new AuthFakeApi(); // domain layer const authHolder = new AuthHolder(); const loginUseCase = new LoginUseCase(authRepository, authHolder); // view layer const authViewModel = new AuthViewModelImpl(loginUseCase, authHolder); return ( <div className="app-container d-flex container-fluid"> <AuthComponent authViewModel={authViewModel} /> </div> ); } export default App;
Именно в файле App.tsx происходит инициализация всех зависимостей. В данном приложении мы не используем инструменты внедрения зависимостей, чтобы излишне не усложнять код.
Если нам потребуется изменить какую-то зависимость, мы будем заменять ее в этом файле. Например, вместо строки:
const authRepository = new AuthFakeApi();
Напишем:
const authRepository = new AuthApi();
Также обратите внимание, что мы используем только интерфейсы, а не конкретные реализации (все основывается на абстракции). При объявлении переменных, мы подразумеваем следующее:
const authRepository: AuthRepository = new AuthFakeApi();
Это позволяет скрывать детали реализации (чтобы потом заменять ее без изменения интерфейса).
4. Заключение
Надеюсь, в ходе чтения статьи у вас сложилось понимание, как можно применять The Clean Architecture в React (и не только проектах), и наш опыт поможет сделать ваши приложения более качественными.
В данной статье были описаны теоретические и практические основы использования The Clean Architecture в frontend проектах. Как говорилось ранее, The Clean Architecture дает только рекомендации о том, как строить Вашу архитектуру.
Выше был приведен пример простого приложения, которое использует данную архитектуру. Учтите, что по мере роста приложения, архитектура может меняться, поэтому приведенный выше код — не является панацеей (как говорилось вначале), в этой статье лишь передача части нашего опыта.
5. Ресурсы
Исходный код
UML диаграмма
