Перейти к содержимому

Быстрый старт

В этом разделе мы рассмотрим все основные паттерны ED.

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

Дальнейшие статьи в разделе гайды будут раскрывать отдельные аспекты работы с ED.

Какие проекты можно делать на ED

ED предназначен для работы на основных web фреймворках: vue, react, svelte, solid.

В простых модификациях он будет полезен даже в самых базовых проектах.

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

Модификации ED

Архитектура представлена в 4-х модификациях

  1. ED small - для маленьких проектов (до 12 человеко/месяцев разработки)
  2. ED medium - для средних проектов (команда до 5-6 человек фронтов)
  3. ED enterprise - для крупных проектов (больше одной команды)
  4. ED monorepo - для команды, которая работает над несколькими проектами

В текущем гайде будут описаны первые 2 модификации. 3-4 в будущем будут описаны в других статьях

Из чего состоит ED.

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

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

Первое, с чего начинается ED, это с выделения слоёв.

Выделение основных слоёв

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

Всего есть 4 слоя верхнего уровня.

  • Директорияsrc/
    • Директорияapp/
    • Директорияfeatures/
    • Директорияservices/
    • Директорияshared/
Отличия от FSD
  • В ED shared в отличие от fsd shared может содержать бизнес-логику.
  • services очень похожи на entities, но тут нет ограничений на семантику “объекта из бизнеса”
  • features это крупные куски функциональности приложения. По сути это слои pages widgets features entities в одном.
  • Отдельного слоя pages нет. Код страниц находится в features. Если страницы содержат несколько фич, то композиция находится в app
  • Слайсы у нас называются модулями, а сегменты группами. А ещё у нас разрешены подмодули.

App


Слой точка входа. Здесь происходит запуск проекта, глобальная конфигурация, связь фич в единое приложение

Внутренняя структура app не стандартизирована и сильно зависит от приложения.

Слой, в котором должна лежать самая часто меняющаяся логика

Примеры кода, который тут может быть:
  • app.tsx корневой компонент приложения
  • root-layout.tsx общий лейаут всего приложения
  • root-header.tsx заголовок всего приложения. Может делегировать задачи компонентам из features
  • global.css глобальные стили
  • router.tsx инициализация react-router
  • providers.tsx компонент, который использует глобальные react провайдеры
export function Providers({ children }: { children: React.ReactNode }) {
return (
<StoreProvider store={store}>
<QueryProvider client={queryClient}>{children}</QueryProvider>
</StoreProvider>
);
}
  • В next.js app остаётся самим собой, только старайтесь большую часть логики уносить в features

Features


Основной слой. Здесь должна находиться большая часть кода приложения.

Каждая папка слоя features это реализация крупного независимого куска функциональности

Пример features для планировщика задач

  • Директорияsrc/
    • Директорияapp/
    • Директорияfeatures/
      • Директорияauth/
      • Директорияtask-list/
      • Директорияsidebar/
      • Директорияsettings/

Ограничение взаимодействия с app

Это крайне важное ограничение даёт нам следующее:

  • В app находятся часто меняющиеся штуки. Зависимость на часто меняющийся код делает фичи ненадёжными sdp
  • Импорт из app почти всегда - циклическая зависимость. Циклические зависимости всегда плохо, и для компилятора, и для мозга. adp

Структура фичи

Каждая фича по своей сути это модуль!

Так как модули в ED бывают большие их структура подробно описана ниже.

Сейчас важно понимать, что в фиче может быть всё: И компоненты отображения, и логика, и инфраструктура, и страница.

Главный критерий, все эти вещи должны иметь высокую смысловую связность

Взаимодействие фич

Тут есть различие между “маленькой” модификацией и “средней”

Средняя модификация
  • Директорияsrc/
    • Директорияapp/
    • Директорияfeatures/
      • Директорияauth/
      • Директорияtask-list/ // из task-list нельзя импортировать auth

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

  • Таким образом сильно возрастает понятность. Так как можно рассматривать фичу в отрыве от других
  • Увеличивается надёжность. Так как изменение в одной фиче с меньшей вероятностью сломает другую
  • Защита от циклических зависимостей.

В реальности, полностью изолированные фичи бывают редко. Поэтому для взаимодействия фич, с запретом на прямой импорт используются паттерны слабой связанности или по-другому dependency inversion (Инверсия зависимостей)

Звучит сложно, но по факту это: связь через общий стейт, слоты, рендер-пропсы, события, контекст, и dependency injection

Подробнее о DI

Это самая сложная часть ED. По этой причине в “маленькой” модификации это ограничение накладывать нецелесообразно

Маленькая модификация

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

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

Services

Слой переиспользуемых бизнес модулей. Могут хранить не только логику, но и представление.

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

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

Пример services для планировщика задач

  • Директорияsrc/
    • Директорияapp/
    • Директорияfeatures/
      • Директорияauth/
      • Директорияtask-list/
      • Директорияmanage-settings/
    • Директорияservices/
      • Директорияsession/
      • Директорияsettings/

В данном случае в session скорее всего находится хранилище сессии, которое используется в большей части других фич. А в settings хранятся настройки, которые редактируются в фиче manage-settings и используются в task-list

Ограничение взаимодействия с app и features

Как фичи не могут импортировать app, так и сервисы не могут импортировать features и app

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

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

Взаимодействие сервисов

Тут нет однозначного решения. Базово я разрешаю взаимодействие сервисов друг с другом.

Так как чаще всего dependency inversion на таком уровне вызывает очень много сложностей.

Но если появляются проблемы, вводится такое же ограничение как и для фич.

Shared

Слой ядро приложения. Здесь расположены вещи, которые используются в приложении повсеместно

Старайтесь располагать в shared только следующее:

  • Глобальные бизнес типы. Типы редко меняются и не вызывают багов
  • Глобальную инфраструктуру. Инстансы сторов, нотификации, интернационализацию, тему Обычно такая инфраструктура используется широко, а меняется редко
  • Глобальные константы, связывающие приложение. Например, константы роутинга и результат чтения .env
Важные исключения:

По своей сути uikit и api не должны находиться в shared

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

Поэтому знайте, что uikit и api вечные источники багов. И относитесь к ним соответственно

Структура shared жёстко не стандартизирована, но всегда похожа

Пример стандартного shared

всё ниже перечисленное опционально и может не понадобиться в вашем случае

  • Директорияsrc/
    • Директорияshared/
      • Директорияdomain/ // глобальные бизнес типы. Нужные для работы инфраструктуры и всего приложения
        • ids.ts
        • events.ts
        • session.ts
        • user.ts
      • Директорияui/ // uikit приложения
        • Директорияkit/
          • button.tsx
        • Директорияtable/ // переиспользуемый компонент таблицы
      • Директорияapi/ // обычно автоматически генерируемый api instance
        • api-instance.ts // инстанс axios
        • generated.ts
      • Директорияmodel/ // работа с глобальными данными
        • routes.ts // константы для роутинга
        • config.ts // получение доступа к .env
        • store.ts // инстанс redux
      • Директорияlib/ // Глобальная инфраструктура и хелперы
        • Директорияnotifications/
        • Директорияi18n/
        • Директорияreact/
          • use-mutation-observer.ts
        • date.ts

domain, model, ui, lib

Весь код shared разделён на стандартные группы:

  • domain: Самые важные бизнес типы и правила. Эта группа должна быть изолированной и ни на кого не зависеть
  • model: Работа с глобальным состоянием
  • ui: Глобальные компоненты
  • lib: модули глобальной инфраструктуры

Ограничение взаимодействия с app, features и services

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

При этом его могут и будут импортировать все другие слои.

Это правило крайне важно и не должно нарушаться. shared - опасный слой. С ним всегда нужно быть на чеку)

Схема слоёв

”Маленькая” модификация

Small schema

“Средняя” модификация

Medium schema

Структура модуля


features и services - в ED содержат модули. Но по вашему желанию вы можете создавать модули и в app и shared

Также ED поддерживает концепцию sub-modules, таким образом Модули в ED могут быть очень большими.

По сути модули в ED можно рассматривать как мини-приложения, у которых есть своя архитектура

Этапы эволюции модулей

Мы против оверхеда, поэтому у модулей есть этапы эволюции, от простого к сложному.

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

По нашему опыту это самый эффективный подход! Излишняя архитектура хуже, чем её недостаток

Этап 1: Single file module

Да-да, самые простые модули могут состоять только из одного файла!

Вот пример фичи todo-list, на самых первых этапах

import { useState } from "react";
export function TodoListPage() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState("");
const addTodo = (e) => {
e.preventDefault();
if (!input.trim()) return;
setTodos([...todos, { id: Date.now(), text: input, done: false }]);
setInput("");
};
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div className="max-w-md mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Todo</h1>
<form onSubmit={addTodo} className="flex mb-4">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 border p-2 rounded-l"
placeholder="Add todo..."
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 rounded-r hover:bg-blue-600"
>
Add
</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id} className="flex items-center mb-2">
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
className="mr-2"
/>
<span
className={`flex-1 ${
todo.done ? "line-through text-gray-400" : ""
}`}
>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
className="text-red-500 hover:text-red-700"
>
×
</button>
</li>
))}
</ul>
{todos.length > 0 && (
<div className="mt-4 text-sm text-gray-500">
{todos.filter((t) => !t.done).length} items left
</div>
)}
</div>
);
}

Чаще всего модули очень быстро перерастают этот этап. Но иногда нет!

С файлом до 400 строк работать приемлемо.

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

Не пренебрегайте этим подходом, особенно на этапе прототипирования.

Этап 2: Flat module

Вот ваш файл стал больше 400 строк, и стало неудобно. Что выделяем components/hooks/ui/model?

Нет ещё рано!

Нет ничего хуже папки, в которой один файл.

Второй этап эволюции, это создание плоской структуры разнородных модулей

Вот пример фичи todo-list на втором этапе

  • Директорияtodo-list/
    • todo-list-page.tsx
    • use-todo-list.tsx
    • api.tsx
    • use-intersection-observer.tsx
    • index.ts

Здесь мы просто разделили код на функции. А потом вынесли их в отдельные файлы. Файлы называем в соответствии с содержимым. Избегаем названия hooks components

Это число выведено эмпирически и связано с концепцией кошелёка миллера

Public api

На этом этапе уже не понятно, какой код предназначен для внешнего использования, а какой должен оставаться внутри.

Поэтому мы добавляем public-api.

Это index.ts файл, в котором вы реэкспортируем (иногда со сменой названия) доступные извне элементы.

export { TodoListPage } from "./todo-list-page";
Для тех у кого next.js

Если в index файлах нет сайд эффектов, то в vite tree-shaking работает корректно

С next.js же встречались проблемы. Поэтому там часто используется другой подход с public-api.

Все приватные файлы начинаются с _, а публичные без. Таким образом не обязательно создавать только один public файл

  • Директорияtodo-list/
    • todo-list-page.tsx
    • _use-todo-list.tsx
    • _api.tsx
    • _use-intersection-observer.tsx

Explicit dependencies (экспериментально)

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

Поэтому все зависимости, которые используются внутри, можно реэкспортировать через специальный deps.ts файл.

Особенно это важно для services и features, если разрешены кросс импорты (из фичи фичу, из сервиса сервис)

Пример:

  • Директорияfeatures/
    • Директорияsettings/
    • Директорияauth/
    • Директорияtodo-list/
      • index.ts
      • deps.ts
      • todo-list-page.tsx
      • use-todo-list.tsx
      • create-todo-form.tsx

deps.tsx

export { useSession } from "@/features/auth";
export { useSettings } from "@/features/settings";

todo-list-page.tsx

import { useSession, useSettings } from "./deps";

Sub modules

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

Но они не обязаны быть однофайловыми. Любой дочерний модуль может быть на любом этапе эволюции

  • Директорияtodo-list/
    • index.ts
    • todo-list-page.tsx
    • use-todo-list.tsx
    • Директорияcreate-todo-form/
      • index.ts
      • create-todo-form.tsx
      • use-create-todo.tsx

Опасности вложенности

Благодаря подмодулям, на втором уровне можно оставаться очень долго.

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

Поэтому чаще всего для преодоления ограничения в ~6 элементов подмодулям лучше предпочитать создание групп

Этап 3: Grouped module

Как было сказано выше, если для разнородных папок комфортно < 6 элементов. То для однородных папок это количество резко возрастает (до 20 элементов. Сильно зависиот от однородности)

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

Что такое группа.

Группа это объединение нескольких модулей на основании общего признака:

Примеры групп:

  • components
  • hooks
  • services
  • features
  • ui
  • model
  • lib
  • api

Кстати, все слои это тоже группы.😉

Стандартные группы

Существуют достаточно удачные группы, которые хорошо себя показали:

Группа: ui

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

Примеры:

  • todo-list-page.tsx
  • todo-card.tsx
  • todo-form.tsx
  • use-render-arrows.tsx
Группа: model

Группа, в которой лежит основная работа с данными.

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

Если у вас стейт менеджер, то здесь будет лежать вся логика работы со стейт менеджером

Примеры:

  • use-todo-list.ts
  • todo-list.slice.ts
  • todo-list-store.ts
  • todo-item.ts
Группа: lib

Группа, в которой находится инфраструктурный код. Это код чаще всего предоставляет более удобные обёртки над браузерным api и библиотеками. Или просто упрощает рутинные задачи

Примеры:

  • use-mutation-observer.ts
  • date.ts
Группа: api

Группа для кода работы с api и типами контрактов.

Группа: domain

Если логика в model становится очень сложной.

То код, описывающий самые важные бизнес процессы:

  • Расчёт скидки
  • Вычисление отпуска
  • Получение прогресса
  • Расчёт координат при перемещении элемента по карте

Можно вынести в виде чистых функций в группу domain

Также в domain находятся все типы, над которыми эти чистые функции проводят манипуляции

Примеры:

  • map-node.ts
  • get-intersections.ts
  • compute-next-lesson.ts
Группа: view-model

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

Обычно это происходит, если реализуется dnd или анимации

В таком случае этот код можно вынести в отдельную группу view-model

Пример:

  • use-dnd.tsx
  • use-animation.tsx

Вложенные группы

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

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

Пример:

  • Директорияui/
    • Директорияfields/
      • file-field.tsx
      • text-field.tsx
      • select-field.tsx
      • date-field.tsx
    • create-user-form.tsx
    • update-user-form.tsx

Пример модуля с группами

  • Директорияtodo-list/
    • index.ts
    • api.ts
    • Директорияmodel/
      • todo-item.ts
      • use-create-todo.ts
      • use-update-todo.ts
    • Директорияui/
      • Директорияpages/
        • todo-list-page.tsx
        • todo-details-page.tsx
      • Директорияfields/
        • file-field.tsx
        • text-field.tsx
        • select-field.tsx
        • date-field.tsx
      • create-todo-form.tsx
      • todo-details-form.tsx
      • todo-item.module.css
      • todo-item.tsx

Группы и подмодули

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

  • Директорияtodo-list/
    • index.ts
    • api.ts
    • Директорияui/
      • file-field.tsx
      • text-field.tsx
      • select-field.tsx
      • date-field.tsx
    • Директорияtodo-list/
      • index.ts
      • todo-list-page.tsx
      • create-todo-form.tsx
      • use-create-todo.ts
      • todo-item.tsx
    • Директорияtodo-details/
      • index.ts
      • todo-details-page.tsx
      • todo-details-form.tsx
      • use-update-todo.ts

Когда переходить на следующий этап?

На этом этапе можно разрабатывать модули любого размера.

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

Если вы столкнулись с такой проблемой, вам поможет следующий этап:

Этап 4: Module with compose

Это сложный, но при этом крайне мощный паттерн борьбы с хаотическими зависимостями.

Его ближайщий аналог это DIP принцип из SOLID.

Основная суть этого паттерна, чтобы убрать зависимости между подмодулями в model и ui

Для этого используются инструменты слабой связанности. (для ui слоты и рендер-пропсы, для model события, DI, или простая связь через параметры)

После того как мы получили набор независимых элементов, всё это объединяется в специальных компонентах медиаторах

Для них я обычно создаю отдельную группу compose Связи между компонентами в compose разрешены

Пример:

  • Директорияtodo-list/
    • index.ts
    • api.ts
    • Директорияcompose/
      • todo-list-page.tsx
      • todo-details-page.tsx
      • create-todo-form.tsx
      • todo-details-form.tsx
    • Директорияdomain/
      • todo-item.ts
    • Директорияmodel/
      • use-todo-list.ts
      • use-delete-todo.ts
      • use-create-todo.ts
      • use-update-todo.ts
    • Директорияui/
      • Директорияfields/
        • file-field.tsx
        • text-field.tsx
        • select-field.tsx
        • date-field.tsx
      • todo-page-layout.tsx
      • todo-item.tsx
      • common-fields.tsx
      • update-button.tsx
      • delete-button.tsx

Тогда todo-list-page.tsx выглядел бы как-то так

export function TodoListPage() {
const todoList = useTodoList();
const deleteTodo = useDeleteTodo(todoList);
const createTodo = useCreateTodo(todoList);
const updateTodo = useUpdateTodo(todoList);
return (
<TodoPageLayout
createForm={<CreateTodoForm createTodo={createTodo} />}
todos={todoList.list.map((item) => (
<TodoItem key={item.id} item={item}>
<UpdateButton onClick={updateTodo.bind(item)} />
<DeleteButton onClick={deleteTodo.bind(item)} />
</TodoItem>
))}
/>
);
}
Где можно почитать подробнее

Документация ещё активно разрабатывается. Позже об этом паттерне появится отдельная статья. Сейчас вы можете изучить следующие материаллы:

Ограничения этапа 4

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

Но в большей части ситуаций, такой подход помогает сильно уменьшить сложность модуля!

Выводы по эволюции модулей

Может быть вы уже запутались во всех вариантах модулей здесь представленных.

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

При более глубоком рассмотрении оказывается, что это всё вариации вокруг трёх понятий: модуль группа и public-api.

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

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

При этом не тратить время на постоянный оверхед от неудобных ритуалов и паттернов.

Что дальше?

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

  • По любым вопросам пишите в чат архитектуры в tg.
  • Смотрите примеры разработки на ED в youtube

Если хотите понять, а как это всё вообще работает. Приходите читать продвинутую часть документации

Сейчас эта часть только начинает развиваться, но со временем там будет появляться всё больше статей, раскрывающих концепции и паттерны