Лучший опыт

Чистая архитектура фронтенда: 7 советов для достижения успеха.

Что такое чистая архитектура фронтенда? Каждый разработчик должен точно знать, как сделать кодовую базу проекта легко расширяемой. При таком подходе новые функции легко добавляются, а исправление ошибок не приводит к другим ошибкам. Иными словами, проекты должны быть легко поддерживаемыми, а функции  —  добавляться в приемлемые сроки. Есть несколько принципов, которые помогают достичь этой цели: SOLID (пиши код с соблюдением едино?
Чистая архитектура фронтенда: 7 советов для достижения успеха...

Что такое чистая архитектура фронтенда?

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

Есть несколько принципов, которые помогают достичь этой цели:

  • SOLID (пиши код с соблюдением единой ответственности, открытости/закрытости, подстановки Лисков, разделения интерфейса и инверсии зависимостей);
  • KISS (keep it short and simple  —  ничего не усложняй);
  • DRY (don’t repeat yourself  —  не повторяйся);
  • DDD (domain-driven-design  —  придерживайся предметно-ориентированного подхода).

Однако, на мой взгляд, наиболее важным фактором являются архитектурные паттерны и правила, доменные и технические, которые соответствуют этим паттернам.

Совет 1-й: определите технические и доменные правила

Возможно, вам приходилось слышать следующее утверждение:

“Мы начали с чистого кода и чистой архитектуры. Однако теперь у нас есть нечто, что не так легко поддерживать, как раньше”.

Чаще всего так говорят в команде, в которой либо появились новички, либо одни участники сменились другими, незнакомыми с реализованной архитектурой. В обоих случаях разработчики склонны нарушать неявно определенные правила архитектуры.

На следующей диаграмме показан пример архитектуры. Она включает в себя API, содержащий сервисы и DTO (data transfer object  —  объект переноса данных), хранилище с действиями и запросами и различные домены/модули с компонентами и утилитами.

Пример архитектуры

Приведенная выше архитектура подчиняется нескольким неявно определенным правилам:

  • Преобразователь данных (mapper): использование фронтенд-моделей вместо DTO.
  • Хранилище (store): использование хранилища для связи с сервисным слоем (API).
  • Компоненты (smart и dumb): разделение логики компонентов на “интеллектуальные” (smart) и “примитивные” (dumb) компоненты.
  • Домен (domain): классификация компонентов по доменам/модулям.

Что же может пойти не так в этой архитектуре?

Существует множество неявных правил, которые не определены в явном виде. Поэтому разработчики могут нарушать эти правила. Наиболее очевидными нарушениями могут быть следующие:

  • Компоненты напрямую взаимодействуют с сервисным слоем (API).
  • Компоненты используют DTO вместо предоставляемых фронтенд-моделей.
  • Компоненты включают слишком много логики  —  нет разделения на “примитивные” и “интеллектуальные” компоненты.

Возможны и другие нарушения, не столь очевидные:

  • Общий домен/модуль включает слишком много логики.
  • Устанавливается прямая связь между доменами/модулями. Компоненты в домене заказа (order-domain) могут быть использованы в домене предложения (offer-domain).

Это означает, что часто определяются правила домена, которые предотвращают прямое соединение между доменами.

Как можно решить эти проблемы?

Установление границ модулей, стратегическое проектирование (метод, обусловленный предметно-ориентированным программированием) и принципы написания чистого кода помогут решить вышеуказанные проблемы.

На следующей схеме показан пример архитектуры с разделением доменов. Здесь есть следующие слои:

  • Feature (функциональный);
  • UI (интерфейсный);
  • Domain (доменный);
  • Util (утилитный).

Однако, как и в любой многоуровневой архитектуре, нижние слои не могут получить доступ к верхним.

Совет 2-й: используйте границы модулей

Предложенная выше архитектура может быть реализована с помощью Nx и определения границ модулей (@nrwl/nx/enforce-module-boundaries).

С помощью Nx проект делится на несколько библиотек. Каждый слой будет представлен библиотекой, внутри которой можно определять теги. Это помогает уточнить техническую и доменную принадлежность каждой библиотеки. В приведенных ниже фрагментах кода показан project.json таких библиотек.

"name": "order-feature",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/order/feature-order/src",
"prefix": "lib",
"tags": ["domain:order", "type:feature"],
"projectType": "library",
...
"name": "shared-ui-common",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/ui-common/src",
"prefix": "lib",
"tags": ["domain:shared", "type:ui"],
"projectType": "library",
"name": "order-domain",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/order/domain-order/src",
"prefix": "lib",
"tags": ["domain:order", "type:domain-logic"],
"projectType": "library",

С помощью тегов, определенных в каждой библиотеке, можно указать границы модулей, которые обеспечивают соблюдение технических и доменных правил. Эти правила могут быть объявлены в файле eslintrc.js и выглядеть следующим образом:

'@nrwl/nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": [
"type:api",
"type:feature",
"type:ui",
"type:domain-logic",
"type:util"
]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": [
"type:ui",
"type:domain-logic",
"type:util",
"type:api"
]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": [
"type:domain-logic",
"type:util",
"type:ui"
]
},
{
"sourceTag": "type:api",
"onlyDependOnLibsWithTags": [
"type:ui",
"type:domain-logic",
"type:util",
"type:api"
]
},
{
"sourceTag": "type:domain-logic",
"onlyDependOnLibsWithTags": [
"type:util",
"type:domain-logic",
"type:api"
]
},
{
"sourceTag": "domain:offer",
"onlyDependOnLibsWithTags": [
"domain:shared",
"domain:offer"
]
},
{
"sourceTag": "domain:order",
"onlyDependOnLibsWithTags": [
"domain:shared",
"domain:offer"
]
},
{
"sourceTag": "domain:shared",
"onlyDependOnLibsWithTags": ["domain:shared"]
}
],
},
],

Совет 3-й: упростите общий домен

Приведенный выше пример  —  хорошее начало для создания чистой архитектуры. Однако здесь все еще есть некоторые проблемы. Как слою Feature, так и слою UI разрешено зависеть от слоя Domain, что может нарушить разделение “интеллектуальных” и “примитивных” компонентов. Слою Util запрещено зависеть от слоя Domain, и слишком много логики может оказаться в общем (совместно используемом/разделяемом) домене.

В этом разделе рассмотрим проблему с общим доменом и способы ее решения. Одним из простейших способов является удаление общего домена либо некоторых слоев в этом домене.

В предметно-ориентированном программировании существует понятие “разделяемое ядро” (shared kernel), которое включает все совместно используемые модели и логику. Однако лично я не большой поклонник этой концепции. В реальных доменах нет совместно используемых доменов.

Архитектура без общего домена

Я бы предложил перенести общие компоненты в отдельный репозиторий и импортировать эти компоненты в каждый домен. Кроме того, в каждый домен можно перенести общие утилиты. В итоге общего домена больше не будет.

Архитектура с упрощенным общим доменом

Однако если вы не хотите создавать отдельное хранилище для общих компонентов, архитектура может выглядеть следующим образом. В общем домене хранятся только общие dumb-компоненты и утилиты.

Совет 4-й: используйте дублирование кода вместо жестких связей между доменами

DRY (не повторяйся) и KISS (ничего не усложняй)  —  это принципы, которые не стоит применять постоянно. Внутри доменов определенно имеет смысл придерживаться этих принципов. Однако я бы предпочел, чтобы связь между доменами была как можно меньше. Это означает, что я готов дублировать код.

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

Что же должно быть общим для доменов?

Рассмотрим smart-компоненты. Требования/бизнес-логика smart-компонентов могут отличаться в разных доменах. Поэтому я бы не стал делать общими (совместно используемыми) такие компоненты. Более того, то же самое относится и к моделям. Свойства продукта в домене заказа (order-domain) могут отличаться от свойств в домене предложения (offer-domain).

Таким образом, лично я бы делал общими (совместно используемыми) только утилиты и dumb-компоненты. Однако учтите, что поначалу отношения между доменами могут казаться похожими, но со временем требования меняются, а вместе с ними и реализация внутри доменов.

Подумайте об общем компоненте. Если есть запрос на изменение, который касается только домена заказа (order-domain), но не домена предложения (offer-domain), не стоит добавлять *ngIf для проверки домена  —  лучше продублировать компонент, чтобы разделить логику.

Чем плохи жесткие связи?

Жесткие связи между доменами/модулями негативно влияют на сопровождаемость любого проекта. Почему? Потому что каждое вносимое изменение может затронуть несколько доменов/модулей. Поначалу это может быть не такой уж большой проблемой, но со временем изменения, вносимые в кодовую базу, могут привести к нежелательным побочным эффектам.

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

Совет 5-й: используйте отдельные API-фрагменты

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

В чем проблема такого подхода?

Представьте Offer-API, который предоставляет REST-Service-API и некоторые компоненты. Допустим, есть несколько команд, которые импортируют этот API. Если нет отдельного API-фрагмента для каждой команды, то между этими командами и Offer-API возникает жесткая связь. Это означает, что Команда 1 может запросить функцию, которая не нужна Команде 2. Это приведет не только к большим затратам на управленческие коммуникации, но и к большому недовольству внутри команд. Кроме того, команда, предоставляющая Offer-API, станет “бутылочным горлышком” для других команд, а это не та ситуация, в которой кто-то хотел бы оказаться.

Зависимости Offer-API

Поэтому я бы предложил либо добавить отдельные фрагменты для каждой команды, либо чтобы каждая команда дублировала необходимый код и хранила его в своей кодовой базе.

Совет 6-й: определите правила ESLint

Очень важно, чтобы код имел четкую структуру и определенные правила. ESLint поможет установить эти правила.

Поэтому настоятельно рекомендую добавлять ESLint-правила в любой код на основе TypeScript. Следующий фрагмент кода eslintrc.js показывает несколько примеров правил.

'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'warn',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/unbound-method': 'warn',
'@angular-eslint/no-empty-lifecycle-method': 'warn',
'@angular-eslint/no-forward-ref': 'warn',
'@angular-eslint/no-input-rename': 'warn',
'@angular-eslint/no-output-native': 'warn',
'@angular-eslint/no-output-rename': 'warn',
'@angular-eslint/prefer-on-push-component-change-detection': 'warn',
'@angular-eslint/prefer-output-readonly': 'warn',
'@angular-eslint/relative-url-prefix': 'warn',
'@angular-eslint/use-component-selector': 'warn',
'@angular-eslint/use-component-view-encapsulation': 'warn',
'@angular-eslint/use-injectable-provided-in': 'off',
'@angular-eslint/use-lifecycle-interface': 'warn',
'@typescript-eslint/member-ordering': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'rxjs/no-implicit-any-catch': 'warn',
'rxjs/no-nested-subscribe': 'warn',
'rxjs/throw-error': 'warn',
'rxjs/no-unsafe-subject-next': 'warn'

Однако помните, что определения ESLint-правил недостаточно. Эти правила должны быть интегрированы в конвейер CI/CD. Под интеграцией я подразумеваю, что при возникновении ESLint-ошибки конвейер сборки не будет работать.

Совет 7-й: относитесь серьезно к обзорам кода

Я много внимания уделил связям между доменами/модулями и определению технических и доменных правил. Однако в конечном итоге важно лишь то, чтобы каждый разработчик имел базовое представление об архитектурных паттернах и принципах чистого кода и чтобы эти принципы и паттерны применялись к кодовой базе. Другими словами, нет идеальной архитектуры, которая бы предотвращала проблемы с кодом и проектными решениями. Такие инструменты, как ESLint и границы модулей, помогают разработчикам лучше оценивать изменения кода, но в конечном итоге разработчики должны очень серьезно относиться к обзорам кода, чтобы гарантировать поддерживаемость кодовой базы.

Подведение итогов

Я дал несколько советов о том, как добиться чистой архитектуры фронтенда. Первое и самое главное, что нужно сделать,  —  это определить технические и доменные правила.

Важно также избегать жестких связей между доменами/модулями и разумно подходить к использованию DRY (не повторяйся). Придерживаться этого принципа следует в рамках домена, но за его пределами я бы предпочел дублирование кода жестким связям.

Кроме того, вы узнали, зачем нужно реализовать границы модулей и как решать проблемы общего домена и API.

Последние разделы были посвящены силе правил ESLint и важности обзоров кода для сопровождаемости любого проекта.