The Clean Architecture на TypeScript и React. Часть 1: Основы



    Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию The Clean Architecture для построения архитектуры в своих проектах Frontend проектах. Изначально я взял ее на вооружение с переходом на TypeScript, так как не нашел других подходящих общепринятых архитектурных подходов в мире разработки на React (а пришел я из Android-разработки, где давным-давно, еще до Kotlin, наделала шумихи статья от Fernando Cejas, на которую я до сих пор иногда ссылаюсь).

    В данной статье я хочу рассказать вам о нашем опыте применения The Clean Architecture в React-приложениях с использованием TypeScript. Зачем я это рассказываю? — Иногда мне приходится разъяснять и обосновывать ее использование разработчикам, которые еще не знакомы с таким подходом. Поэтому здесь я сделаю детальный разбор с наглядными пояснениями на которое я смогу ссылаться в будущем.

    Содержание

    1. Введение
    2. Теоретическая часть
      • Для чего вообще нужна архитектура?
      • Оригинальное определение The Clean Architecture
      • The Clean Architecture для Frontend
    3. Практическая часть
      • Описание веб-приложения авторизации
      • Структура исходного кода
      • UML диаграмма проекта
      • Разбор кода
    4. Заключение
    5. Ресурсы и источники

    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. Приложение выглядит так:



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

    1. Поля не должны быть пустыми (валидация).
    2. Введенная почта должна иметь корректный формат (валидация).
    3. Данные доступа должны пройти валидацию на сервере (заглушка API) и получить ключ валидации.
    4. Для авторизации методу API нужно предоставить данные валидации и ключ валидации.
    5. После авторизации ключ доступа должен быть сохранен внутри приложения (слой сущностей).
    6. При выходе ключ авторизации должен стираться из памяти.

    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 диаграмма
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 40

      +1
      Классно! Я уже много лет пишу на чистой архитектуре на реакте. Всегда идеально отрабатывает какой бы специфичный или огромный проект ни был.

      А вот со статьёй вы меня опередили, моя только наполовину написана, и она очень пересекается с вашей.

      Интересно какой процент разработчиков используют чистую архитектуру в веб-приложениях. У меня есть проблема с подбором разработчиков, большая часть кроме редакса ничего не знают и знать не хотят. Как вы решаете эту проблему?
        0
        В любом случае, интересно увидеть Вашу статью. Подчерпнуть что-то новое всегда можно (почему-то так выходит, что чистая архитектура у всех немного своя :)).

        «У меня есть проблема с подбором разработчиков, большая часть кроме редакса ничего не знают и знать не хотят.» — честно говоря, я не сильный фанат Redux'a. Если приложение пишет больше, чем 1-2 человека — может начать путаница из-за сваливания состояния. Кто-то где-то что-то отправляет (не дай Бог из UI'я) — и искать это проблематично, все состояние находится в куче. К тому же, Redux особо-то и не отобразишь на UML диаграмме, например. Какой-нибудь Repository\Entity с шаблоном Observer лучше поддается понимаю и все состояние не сваливается в кучу. Это лично мое мнение, но я его избегаю. Аналогичный подход с Redux Saga — UseCase\Repository + View Model на async\await более красиво решают эту проблему и цепочку вызовов видно напрямую.

        «Как вы решаете эту проблему?» — только учить. Специалисты стоят довольно дорого и проекты не всегда имеют для них бюджет. Поэтому объяснение + код ревью. Чистая архитектура не сложная вещь (вообще, сложные вещи в разработке с трудом приживаются и тяжело поддерживаются) — поэтому разработчики довольно быстро ее понимают и со временем начинают использовать без проблем.
        +1

        Ну не знаю…
        Пилю средне-большие проекты на react-redux и никаких архитектурных проблем не встречал. Как по мне это overengeenring, т.к. в основном все правила бизнес логики должны быть реализованы на бекенде. А ради валидации формы пилить такой огород классов и связей…
        ИМХО на бекенд/native приложения это ложится гораздо лучше.

          +1
          Несомненно полно проектов, которым это не нужно. Но, как я написал в комментариях выше, с размером и увеличением количества людей начинают возникать проблемы. Также играет роль скорость изменения проекта (правок-то может быть и много).

          Изначально я перешел на такую архитектуру, потому что логика в UI крайне тяжело поддается тестированию, а UI\ViewModel\Presenter слои начинают становиться God-объектами с дублирующимся кодом.

          «ИМХО на бекенд/native приложения это ложится гораздо лучше.» — вообще, эту архитектуру придумали изначально для сложных бекенд приложений :). Поэтому часто для мелких фрондент проектов она избыточна — но, опять же, зависит от проекта. Как писать большой проект без чего-то подобного, оставляя его поддерживаемым, я не представляю (а ведь кто-то и fetch из onClick делает...)
            +1

            А в чём собственно сложность тестирования? Если мы например берём redux, то там всё достаточно просто. Это ж просто чистые функции.


            И ещё интересно, где в такой архитектуре хранилище? В бекендах хранилищем обычно выступает БД. Вам же всяко информацию где-то надо хранить (надеюсь не предполагается этого делать в моделях). И тут приходим к тому что всё равно нужно что-то типа redux (единый источник правды), иначе будут проблемы с неконсистеными состояниями и всем тем что react сообщество успешно побороло 4+ лет назад


            Мне кажется, даже размер команды, и размер приложения это ещё не повод выбирать именно чистую архитектуру. Как вариант, приложение можно разбить на несколько разных модулей (lerna/monorepo), контролируя тем самым их размер. И это будет тоже довольно просто поддерживать и расширять.

              0
              «А в чём собственно сложность тестирования?» — я имел ввиду не только передачу состояния, а тестирования для проекта в целом (включая передачу данных, обработку данных, изменение View в зависимости от изменения данных и т.д.).

              «И ещё интересно, где в такой архитектуре хранилище?» — слой Repository в 95% случаев. И тем, кому нужна авторизация — дергают авторизацию по интерфейсу.

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

              «Как вариант, приложение можно разбить на несколько разных модулей (lerna/monorepo), контролируя тем самым их размер.» — возможно. Скажу честно, я таким не пользовался, а мой подход с чистой архитектурой лишь один из многих. Как я сказал, я лишь описываю свой опыт и лично я не люблю Redux, потому что для меня и моей команды было проще работать, используя другой подход. Но! Redux зарекомендовал себя и ничего против его использования я не имею. Так что я не отрицаю другие подходы, хотя и использую приведенную выше архитектуру, потому что она оказался самым удобным для нас решением.
          +2
          Сложность проектирования архитектуры UI не в том, чтоб написать MVP со всеми рюшечками на каждую форму. А чтоб иметь возможность выкинуть произвольное количество рюшечек (бойлерплейта) вплоть до 100%, сообразно сложности той или иной формы.

          То, что вы написали — это прекрасно и часто абсолютно необходимо для очень сложных UI, и лютый overengineering для простых вещей, типа формы о двух инпутах.

          Плюс, если отойти чисто от архитектуры и перейти к вашему коду — мне, например, страшно не нравятся ваши ViewModelImpl. Там кругом мутабельность и неконсистентный стейт (дёрнули validate() — в модели одно, не дёрнули — другое), разбираться в этом всём при росте кода — будет убийственно. Если у вас в модели нет single source of truth — ни к чему хорошему это никогда не ведет.
            0
            «То, что вы написали — это прекрасно и часто абсолютно необходимо для очень сложных UI, и лютый overengineering для простых вещей, типа формы о двух инпутах.» — несомненно. В этом и суть.

            «и лютый overengineering для простых вещей, типа формы о двух инпутах.» — разумеется, пример сильно надуманный, чтобы показать использование. Но! Если предположить, что этот код будет сильно меняться, а также писаться большим количеством людей — мы можем начать покрывать тестами на разных слоях, и избежать будущих проблем. Вообще, архитектуры\тестирование приобретают большую силу, именно когда проект пишется большим количеством людей на протяжении 1+ лет. Разработчики как раз начинают забывать свой код...

            В общем, it depends. Но пример и правда с overengineering'ом, но только для того, чтобы объяснить архитектуру на примере.

            «Плюс, если отойти чисто от архитектуры и перейти к вашему коду — мне, например, страшно не нравятся ваши ViewModelImpl.» — разумеется, чем больше код и сложнее логика, тем сложнее его читать — но глобальных проблем с этим я раньше не замечал. God-объектов в View Model я избегаю за счет Use Case'ов и внутреннего слоя. Можете привести пример, как бы Вы это реализовали?
              0

              А что на *Impl ответите? Я бэкендщик, но иногда приходится писать и на фронте, описанный вами подход много ближе ложится в привычные бэкенд подходы, чем то, что продвигает редакс.
              Но вот этот момент и *Impl мне кажется несколько оверинжиниринговым.

                0
                *Impl — чтобы во время тестирования подсовывать классам интерфейсы. Редко эти *Impl меняются на что-то другое, поэтому подход исключительно ради удобства тестирования. Тем более, интерфейсы с одной реализацией они используются не везде — а только в ViewModel\Repository.

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

                Опять же, как я написал выше, "основную силу архитектуры\тестирование приобретают в больших проектах с несколькими разработчиками, где нужно понимать и изменять чужой код". Если вы говорите, что это оверинжениринг для чего-то относительно маленького и даже среднего — я с Вами абсолютно согласен
                +1
                Пардон, я не проснулся толком и не прочитал, что у вас валидация private и вызывается из самой ViewModelImpl. Так — нормально, да.

                Ну, с тем замечанием, что вся ViewModel — это первейший кандидат на вылет при упрощении кода.
              +7
              Как обычно, сами себе проблем навыдумывают и потом пытаются их решать.

              Весь фронтенд это:
              — Слой view
              — Слой состояния
              — Слой бизнес логики

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

              P.S. посмотрите в сторону MobX, уверен от этого ваша жизнь станет гораздо проще. То, как вы используете локальный стейт компонентов и то, как из вне им манипулируете используя всякие подписки и колбэки, это конечно жесть.
                +3

                От статьи дыхнуло практиками как минимум 13 — летней давности, которые использовались в ActionScript и то не всеми. Объекты мы видимо разучились возвращать. То, что вы изобразили это очень плохо для 2020. Я не знаю, что за компания поощряет такой подход.

                  0

                  https://github.com/RostislavDugin/clean-architecture-react-typescript/pull/3/files вот набросал по быстрому использование mobx. Может кому будет интересен такой более прагматичный подход

                    0

                    action всё же лучше не забывать, потому что здесь, например, у вас семантика поменялась — с action неявный notifyListeners будет вызван после завершения метода (как и у автора), а у вас — дважды, после каждого присваивания в observable, так что утекает неконсистентный стейт.


                      public onSignedOut(): void {
                        this.isAuthorized = false;
                        this.authToken = '';
                        //this.notifyListeners();
                      }

                    А в целом да, с mobx (и mobx-state-tree) подход получается очень похожий на описанный автором. Отдельно — store с моделями, которые те же entities, зависящие от них контроллёры частей приложения, далее — зависящие от них реакт-контейнеры, которые рендерят тупые реакт-компоненты. API и storage так же можно вынести за интерфейсы и инжектить.

                      0
                      Есть один не хитрый прием, уже давно им пользуюсь для того, чтобы автоматом всегда все изменения батчились и не нужно использовать action и runInAction.
                      import { configure } from 'mobx';
                      
                      // Configure MobX to auto batch all sync mutations without using action/runInAction
                      setTimeout(() => {
                          configure({
                              reactionScheduler: (f) => {
                                  setTimeout(f, 1);
                              },
                          });
                      }, 1);
                      

                      P.S. В некоторых кейсах, если это сразу синхронно объявить, то реакции могут барахлить, поэтому нужен setTimeout.
                      Проверка в действии — codesandbox.io/s/sad-turing-7vkpz?file=/src/index.js
                        0

                        Хм, интересный хак. Но например с тем же реактом есть определённый плюс в синхронной отрисовке — можно поменять стейт в методе компонента через экшены и потом при необходимости сразу ручками лезть в dom, без всяких заморочек с ожиданием апдейта компонента. Да и явное, как известно, лучше неявного :)
                        Но за трюк спасибо, может и пригодится как-нибудь.

                          0
                          Вообще не понял в чем смысл того, что вы описали и для чего всё это?
                            0

                            Допустим, у вас есть какой-то достаточно сложный компонент (например, какой-то список), который изменяет стейт, а изменение стейта вновь изменяет этот компонент (добавляет элементы в верх списка). И вы хотите, например, запоминать позицию скролла.


                            addElements() {
                              const scrollH = this.el.current.scrollHeight // старая высота элемента со списком
                              this.props.store.addElementsToTop() //action в сторе, который триггерит ререндер текущего компонента
                              this.el.current.scrollTop += this.el.current.scrollHeight - scrollH  //прокручиваем список на старую позицию
                            }

                            Если у вас честный action, сразу после его вызова (2 строчка) компонент (если он observer, конечно) синхронно перерисуется и в третьей строчке scrollHeight будет уже актуальным. С вашим же трюком на третьей строчке компонент будет ещё не перерисован (потому что observer внутри — это та же реакция).

                              0
                              Для этих дел есть жизненный цикл компонента, componentDidUpdate или useEffect внутри которых выполняем:
                              this.el.current.scrollTop += this.el.current.scrollHeight - this.lastScrollH;
                              

                              Будет работать ровно так, как и ожидается.
                              Не знаю в чем вы проблему увидели)
                                +1

                                Да оно понятно, но всё равно не так удобно, как когда всё в одном месте. Опять же, в одном компоненте таких эффектов и больше одного может быть, и все они будут в didUpdate, оторванные от контекста действия, плюс ещё дополнительные поля в классе… В общем, синхронное выполнение как-то лучше выглядит. Ну и с явными экшенами появляется дополнительное разделение на действие (когда что-то изменяется) и просто параметризованные геттеры — то есть функции, которые просто что-то вычисляют.

                                  0
                                  В реакте давно добавили свой батчинг рендера, проверьте, актуальные версии реакта и mobx по крайней мере так не работают)
                                  Вот посмотрите codesandbox.io/s/sweet-kowalevski-wursj
                        0

                        Да вот сидел и твердил себе"не забыть проставить, когда их переименовывать буду", но решил не переименовывать, хотя и режет глаз on*

                        +1
                        Увидел Ваши пулл реквесты, спасибо за них. Просмотрел код и, если в слой presentation добавить MobX — действительно, код становиться понятнее (но, опять же, плюс The Clean Architecture — MobX можно положить только в один слой :), не трогая остальные. В Entities я его добавлять бы не стал)

                        На след. выходных я обязательно в нем разберусь. Спасибо, что потратили время и показали свое видение

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

                        И Ваш код с MobX, как по мне, очень даже сильно дополнит архитектуру.
                      +8

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

                        0
                        Архитектура в большинстве случаев определяется структурой команд и тех.процессами. Возьмём например слоёный торт: корж, сгущенка, корж, крем, вишенка. С точки зрения производства, эти элементы эффективнее производить отдельно — один лепит коржи, другой варит сгущенку и т.д. Потом все это собирается вместе. На выходе получается кусок солёной еды.

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

                        Честно говоря, я ни разу не встречал подобных решений на фронте. Здесь разработка делится по фичам/страницам/экранам и для каждой ведётся в одну каску, максимум две — CSS и JS. По аналогии, это больше напоминает пельмени: тесто, фарш. Поэтому ритуалы с дополнительными слоями внутри, как в тортах, кажутся несколько избыточными.

                        Clean Architecture это крутая книга, где автор рассказывает о принципах разработки сложных систем, показывая примеры решений на разных уровнях абстракции — от самых нижних на уровне кодинга, до самого верха на уровне управления конфигурациями и релизами. Только не нужно принимать примеры за универсальные догмы, как это произошло с шаблонами проектирования.
                          0
                          Архитектура в большинстве случаев определяется структурой команд и тех.процессами.

                          Скорее они стремятся друг к другу.


                          Честно говоря, я ни разу не встречал подобных решений на фронте. Здесь разработка делится по фичам/страницам/экранам и для каждой ведётся в одну каску, максимум две — CSS и JS

                          Если фронтов несколько (хотя бы пользовательское приложение и админка), то вполне может быть что в три и более "каски": связь с бэком в том или ином виде, расшариваемая между фронтами и два UI.

                          0
                          Спасибо за статью, было интересно.
                          Какое же огромное количество подходов крутится вокруг разработки на React.
                          Я не видел нигде полного руководства о том, как стоит строить гибкую архитектуру в React. Эти знания раздроблены по разным местам и все делают этот как хотят. И это очень хорошо, что автор поднял этот вопрос. Если кто-то знает такие ресурсы скиньте пожалуйста в комментарий.

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

                          Он слишком сильно завязан на ООП. React же больше не об ООП. Концепция обработки логики с помощью redux + saga куда более близка к духу React. Та же иммутабельность, те же редьюсеры и чистые функции. Если хотите ООП используйте идею сервисов из Angular и MobX.

                          В статье очень много рассказывается о том как строить архитектуру для организации логики. Но что насчет UI компонентов? Об этом ничего не написано, но если не думать о UI компонентах, то они очень скоро начнут дублироваться, появится 10 реализаций одного и того же в проекте и это усложнит поддержку. UI компоненты тоже нужно распределять по уровням абстракций и переиспользовать. Я больших проектах я рекомендую Атомарный дизайн

                          Я переписал это приложение, сделав ближе к тому, что я считаю чистой архитектурой, но при этом оставил точно такое же поведение. Дополнительных библиотек не использовал, логику вынес в сервис. Вот, что у меня получилось:
                          https://github.com/VladislavMurashchenko/clean-architecture-react-typescript/
                            0
                            «Создается огромное количество абстракций которые создаются не потому, что они нужны и улучшают читаемость, а потому, что так требует архитектура.» — да, проект надуманный. Но, опять же, чем больше появляется логики — тем большее ее смысл.

                            «Я больших проектах я рекомендую Атомарный дизайн» — можете продублировать ссылку? К сожалению, не открывается

                            «Я переписал это приложение, сделав ближе к тому, что я считаю чистой архитектурой» — на след. выходных я выделю время, чтобы разобраться, интересный подход
                          +3

                          Проблема таких статей всегда в том что плохо когда удается показать сложные подходы на простых примерах. Это как собирать лунный велосипед, а используемые подходы демонстрировать на обычном. Оно будет смотреться очень нелепо, переусложненно а иногда и забавно. Оценят демонстрируемые решения только те кто уже занимался похожими разработками. В то-же время выложить целый проект в open-source для демонстрации подхода очень затруднительно.

                            +3

                            Почувствовал себя в 2005. Лютый over engineering. Ну да бог с ним. Почему у вас:


                            • нет автоматической реактивности. Вы всё делаете руками. Все подписки, все привязки, все уведомления. Как в каменном веке. Любая механическая ошибка и сиди дебаж что и почему сломалось
                            • почти нет декларативности (кроме типов). Уже буквально любой современный подход предполагает либо указание зависимостей (react), либо детектирование зависимостей от их использования (vue, mobX, knockout). Это сильно упрощает жизнь и даёт большие возможности в оптимизациях производительности.
                            • зачем вам React? Если вы застряли в 2005г и пишете в духе Backbone, то возьмите легковесный строковый шаблонизатор, зачем тащить целый React туда, где вы его на 3% используете. React + React.dom отнюдь не невесомые. Это очень большие и довольно сложные кодовые базы. Они решают те задачи, которые вы на них не накладываете.
                            • так много бойлерплейта. Вы даже redux переплюнули в этом деле

                            По сути вы добились того, что у вас можно изъять React из приложения и заменить его чем-нибудь другим. Сделали вы это за счёт того, что используете React как dump components renderer. Из пушки по воробьям. Зачем? Ну да, теперь его можно выкинуть. Дак выкинули бы с самого начала тогда. Хорошо хоть не Angular. Вместо привязки к, о боже, чужим фреймворкам, у вас теперь большая привязка именно к вашему собственному фреймворку. Не то чтобы это было плохо, но записать в преимущества это будет сложно.


                            Из позитивных моментов:


                            • У вас много изолированных друг от друга слоёв. Это правда про clean code

                            Из негатива:


                            • Вы перестарались, и цена фичи выросла раза в 4. Даже в Redux меньше "капусты" и копипасты
                            • Высокий уровень входа для новичков (в меру высокий)
                            • Слишком много ручной работы, а следовательно высокая цена кода и большое пространство для формирования багов

                            Добавьте хотя бы какой-нибудь dependency tracking и готовый (да пусть даже самописный) observable. Не пишите notify руками. Пожалейте ваших коллег.


                            Ещё рекомендую, если вам таки нужен React, именно в компонентах и писать ViewModel. Он собственно для этого и создан. В качестве разделения сложной логики (когда надо) можно исопльзовать кастомные хуки-зонтики. Их же в итоге можно и тестировать изолированно. Ну и реактивность из коробки.


                            P.S. Прошу не воспринимать близко к сердцу. Просто сложилось впечатление что про clean code и паттерны вы читали, а про современные подходы во фронтенде нет. В итоге на слои вы нарезали, но удобного и практичного решения в духе времени не получилось. Примерно так и писали во времена Backbone.js

                              0
                              Спасибо за комментарий!

                              «Вы перестарались, и цена фичи выросла раза в 4. Даже в Redux меньше „капусты“ и копипасты» — опять же, пример надуманный и здесь действительно много overengeneering'a.

                              «Высокий уровень входа для новичков (в меру высокий)» — не согласен. Вопрос изучения — максимум пару дней + поправка на общий опыт. В сравнении с тем, что пишут вообще без какого-либо подхода — я считаю, что архитектура выше большой шаг вперед. Собственно, и статью я написал потому, что не увидел других общепринятых подходов. Или только с акцентом на библиотеку, или вообще никак.

                              «Слишком много ручной работы, а следовательно высокая цена кода и большое пространство для формирования багов» — это тема для второй статьи. Собственно, во второй статье будет добавлен MobX и часть ручной работы отпадет.

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

                              Если у Вас будет время, можете рассказать, как бы Вы изменили архитектуру выше? Может просто созвониться и обсудить\пообщаться в Telegram. Я несомненно ищу пути улучшения подхода, который использую и статья — один из способов его улучшить в какой-то мере
                                +1
                                Если у Вас будет время, можете рассказать, как бы Вы изменили архитектуру выше?

                                1. Добавил бы observables (можно и MobX) и постарался бы сделать декларативным почти всё что получается с пользой для дела сделать декларативным
                                2. Как можно больше бы всё унифицировал и уделил большое внимание тому, чтобы не писать так много копипасты
                                3. Вынес бы VM в React компоненты (он для этого и создан). Ну или хотя бы в MobX слой. Да это создаёт большую привязку к фреймворкам\либам, зато сильно упрощает разработку (ура!).
                                4. Продумал бы возможность частичной подписки на данные, а не сразу на всё разом

                                Затем попробовал бы это в деле с годик. По ходу пьесы наткнулся бы на 100500 неудобных мелочей и не только. Сел бы и подумал как правильно решить эти проблемы. Уже зная в мелочах в чём эти проблемы. И снова по кругу. Придумал идею, попробовал на мелком модуле. Если зашло — используем. Если нет — ищем дальше...


                                Но всегда держал бы в голове, что clean code это не догмы. Это советы, руководство к действию. В первую очередь код должен решать поставленные перед ним задачи. Если какой-то pattern или правило создаёт много проблем, то надо сесть и подумать что не так. И решить — это правило не для нас\мы делает что-то не так.


                                Руководствуясь "правильными принципами" легко загнать даже мелкую кодовую базу в такой кромешный кровавый ынтерпрайз, что в нём дыхнуть сложно будет без уведомления 5 инстанции, изменения 5 интерфейсов, и 25 тестов. Любой инструмент, будь то какая норма, или правило, или библиотека, должен приносить больше пользы, чем страданий.

                                  0

                                  Как observables сочетается с декларативностью. observables — это её часть или ещё нет?


                                  Совет №4 тоже сомнителен, хотя в текущих реалиях ($mol пока не в них) безальтернативен.

                                    0

                                    MobX по идее и обеспечивает частичную подписку на данные.

                                      0
                                      Как observables сочетается с декларативностью. observables — это её часть или ещё нет?

                                      Ну кодовая база же не состоит только из observable примитивов. Где-то есть какие-нибудь конфиги, схемы и пр… Сделать некую структуру\схему, которая, путём запуска её в неком интерпретаторе, избавляет от рутины и всё делает однообразным образом. В первую очередь я имел ввиду это.


                                      Во вторую, в моём понимании, есть что-то промежуточное между декларативным и императивным подходом. Не знаю как это правильнее назвать. Ну скажем декларирование зависимостей в observables или автоматическое их выявление мне кажется более декларативным, нежели руками через if-ы. Тот же useEffect мне кажется более декларативным, нежели груда if-ов в componentWillReceiveProps. Как это по уму обозвать я не знаю.


                                      Совет №4 тоже сомнителен, хотя в текущих реалиях ($mol пока не в них) безальтернативен

                                      Если в приложении есть места под нагрузкой, то без точечных зависимостей будет сложно обеспечить должную производительность. А ещё когда всё rerender-ится на любой чих целиком — бывает сложно дебажить. Не знаю почему это "сомнительно".

                                +1
                                Спасибо за статью!

                                Я сам стремлюсь к балансу простоты, масштабируемости и уменьшению бойлерплейта. Но, использую Flux-подобный подход. Возможно, вам будет интересен мой пример:
                                github.com/sergeysibara/mobx-react-4-tier-architecture
                                codesandbox.io/s/mobx-react-lite-4-tier-architecture-ue2v1

                                Файловая структура по большей части by features.
                                Код разделен на 4 основных слоя:
                                • view — компоненты
                                • store — слой глобальных данных. Для каждой feature свой стор
                                • actions — middleware слой для побочных эффектов.
                                • api — слой взаимодействия с сервером

                                view взаимодействуют только со сторами и actions. Компоненты могут подписываться на сторы, но не могут их изменять. Изменять стор могут только actions.

                                стор не знает ни о каком слое, ни о других сторах. К нему за данными могут обращаться компоненты и actions. Ну и подписаться на его изменения.
                                В сторе хранятся только глобальные данные и getter-ы/setter-ы для них. Плюс логика для преобразования этих данных (например, фильтрация). Никакой сторонней бизнес-логики, не относящейся к работе с этими данными, здесь быть не должно.

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

                                api ничего не знает о других слоях. Единственные назначения – взаимодействие с сервером и преобразование данных в нужный формат перед отправкой и после получения.

                                В моем примере классы сторов, api, actions можно наследовать и переопределять или использовать свои, если базовые не подходят. В базовые классы я выношу стандартный общий функционал для REST (получить отфильтрованный и отсортированный список, получить один элемент, создать новый элемент, изменить существующий, удалить элемент).
                                При добавлении новой страницы или группы страниц, относящиеся к одной feature, в основном изменения будут в пределах одной папки, как в моем примере: src/pages/todos. В ней находятся унаследованные стор, api, actions и компоненты, относящиеся только к функционалу todos.
                                  0
                                  Спасибо за комментарий!
                                  В ближайшие недели хочу разобраться во всех подходах, которые мне посоветовали\дополнили и дополнить свою архитектуру (на очереди — MobX).

                                  Обязательно посмотрю Ваш репозиторий, не удаляйте его в ближайшее время, пожалуйста :)
                                    0
                                    Не планирую пока-что удалять) К тому-же в другой ветке в нем немного другой вариант пробую. Да и был бы рад отзывам, замечаниям.

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

                                Самое читаемое