Всем привет! Меня зовут Анатолий, я представляю команду Front-End разработки компании DD Planet.
В этой статье расскажу о том, как наш проект завершил этап разработки и трансформировался в стабильный рабочий продукт.
Рождение

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

Из-за быстрой разработки сразу появились сложности:
Хаотичная структура проекта — файлы и компоненты разбросаны случайным образом, отсутствует единый подход к организации кода, сложно добавлять фичи, так как непонятно, где их правильно разместить, отсутствие единого стиля написания;
Высокая связность — правка в одном модуле неожиданно ломает другой, компоненты тесно переплетены между собой и не могут функционировать изолированно;
Проблемы с переиспользованием кода — похожие компоненты дублируются, а не выносятся в общие модули, одни и те же кнопки, формы и стили ведут себя по‑разному в разных частях приложения;
Трудности масштабирования — внедрение нового функционала занимает больше времени, так как разработчик вынужден разбираться в имеющемся хаотичном коде;
Сложности при интеграции новых сотрудников — отсутствие четких правил, каждый пишет код так, как считает нужным, новичкам нужно долгое время, чтобы начать эффективно работать.
Подготовка

В первую очередь было решено актуализировать основные библиотеки. Так как в процессе разработки проекта они успели обзавестись новыми версиями. В качестве библиотеки компонентов мы использовали Mantine, обновление которой до 7 версии потребовало много усилий по поддержанию несовместимых изменений. В качестве фреймворка используется Next.js, в процессе обновления которого мы поддержали новую систему маршрутизации - App Router. После реализации этих обновлений пришло время определиться с архитектурой проекта.
Выбор архитектуры

Мы определили для себя ряд критериев, которым должна соответствовать новая архитектура:
Простота — понятные и однозначные правила организации кода;
Масштабируемость — возможность быстро добавлять новый функционал;
Командная работа — возможность параллельной разработки разными специалистами, быстрая интеграция нового сотрудника;
Навигация — удобная система поиска компонентов и функциональности.
Мы стали рассматривать популярные подходы к организации архитектуры проекта
Components/Containers
Проект делится на презентационные компоненты и контейнеры. Компоненты служат только для отображения данных, а контейнеры для хранения состояний и управления логикой работы приложения.

Минусы подхода:
Сложность масштабирования — компоненты часто становятся монолитами, которые сложно комбинировать и переиспользовать. Вместо композиции небольших, компонентов, разработчики создают крупные, многофункциональные контейнеры;
Дублирование логики между контейнерами — поскольку каждый контейнер инкапсулирует свою логику, часто возникает ситуация, когда одинаковый или очень похожий код появляется в нескольких контейнерах.
Atomic Design
Основан на аналогии с химией и представляет интерфейс как иерархию компонентов. Методология включает пять уровней иерархии:
Атомы — базовые строительные блоки интерфейса (кнопки, шрифты, цвета, иконки, поля ввода). Это неделимые элементы, которые формируют основу системы;
Молекулы — комбинации атомов, работающие как единое целое (форма поиска, поле ввода с кнопкой);
Организмы — сложные блоки, состоящие из молекул и атомов (хедер, футер, карточки товаров);
Шаблоны — каркасы страниц с определенным расположением организмов;
Страницы — финальные версии с реальным контентом.

Минусы подхода:
Несоответствие реальным бизнес-требованиям - бизнес-логика часто не укладывается в чистую иерархию;
Проблемы с переиспользованием - компоненты становятся слишком абстрактными и обрастают десятками параметров для всех случаев.
Feature Sliced Design (FSD)
Feature‑Sliced Design — это архитектурная методология для фронтенд‑приложений, созданная сообществом разработчиков. Она фокусируется на бизнес‑логике и обеспечивает масштабируемость, поддерживаемость. Ссылка на проект здесь.
Слои стандартизированы во всех проектах FSD:
App — все, благодаря чему приложение запускается — роутинг, точки входа, глобальные стили, провайдеры и так далее;
Pages (страницы) — полные страницы или большие части страницы при вложенном роутинге;
Widgets (виджеты) — большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий;
Features (фичи) — повторно используемые реализации целых фич продукта, то есть действий, приносящих бизнес‑ценность пользователю;
Entities (сущности) — бизнес‑сущности, с которыми работает проект, например user или product;
Shared — переиспользуемый код, отделенный от специфики проекта/бизнеса, хотя это не обязательно.

Минусы подхода:
Высокий порог входа и сложность обучения — FSD требует значительного времени на освоение всеми членами команды;
Субъективность и споры о принадлежности — разные интерпретации правил разными членами командами, споры о принадлежности компонента к тому или иному слою.
Внедрение

Ни одна из описанных архитектур в полной мере не удовлетворяла всем критериям, поэтому нам пришлось пойти своим путем. В качестве основы была выбрана методология Feature-Sliced Design (FSD) с ее идеей разбиения компонентов на слои и наличием подробной документации. При этом мы отказались от жесткой структуры чтобы получить возможность более гибкой настройки импортов.

Каждый из этих слоев, кроме shared, может в свою очередь тоже делиться на слои по необходимости. Здесь нами использовалась уже привычная структура из FSD, однако мы заменили features и entities на common, так как они и порождали больше всего споров, стали отталкиваться в первую очередь от структуры самих страниц. Фактически Feature-Sliced Design (FSD) превратился в Page-Sliced Design (PSD).

Слой domain был определен для бизнес логики, которая тоже может делиться на крупные блоки. На этом уровне уже появляются страницы pages.

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


Данное решение хорошо подойдет для крупных проектов, реализующих бизнес-логику по различным направлениям.
Контроль
В качестве автоматического контроля соблюдения структуры мы использовали ESLint и настроили правила для подсветки некорректных импортов.
module.exports = {
extends: 'next/core-web-vitals',
plugins: ['import'],
ignorePatterns: ['**/.next/**'],
rules: {
'react/jsx-curly-brace-presence': 'error',
'react-hooks/exhaustive-deps': 'off',
'import/no-restricted-paths': [
'error',
{
basePath,
zones,
},
],
},
};
Зоны вычисляются на основе иерархии
const HIERARCHY = [
{ layer: 'app', level: 1 },
{ layer: 'domain', level: 2 },
{ layer: 'modules', level: 3 },
{ layer: 'common', level: 4 },
{ layer: 'core', level: 5 },
{ layer: 'shared', level: 6 },
];
const zones = LAYER_PATHS.map((targetPath) => {
const layer = HIERARCHY.find((w) => w.layer === targetPath);
if (layer) {
const restricted = HIERARCHY.filter((w) => w.level < layer.level);
const restrictedRules = restricted.map((r) => ({
target: targetPath,
from: r.layer,
message:
${targetPath} layer cannot import from ${r.layer} layer,
}));
const modulesPaths = targetPath !== 'shared'
? fs.readdirSync(${basePath}/${targetPath})
: [];
const modulesRules = modulesPaths.map((w) =>
modulesPaths
.filter((m) => m !== w)
.map((m) => ({
target: ${targetPath}/${w},
from: ${targetPath}/${m},
message: import between /${targetPath} are restricted,
}))
);
return [...restrictedRules, ...modulesRules.flat()];
}
})
.flat()
.filter((w) => !!w);
Результаты

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