Чистая архитектура фронтенда: 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, станет “бутылочным горлышком” для других команд, а это не та ситуация, в которой кто-то хотел бы оказаться.
Поэтому я бы предложил либо добавить отдельные фрагменты для каждой команды, либо чтобы каждая команда дублировала необходимый код и хранила его в своей кодовой базе.
Совет 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 и важности обзоров кода для сопровождаемости любого проекта.