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

Слои и архитектура

Где разместить код?

Процесс принятия решений при размещении кода:

Типы ответственности и соответствие слоям

Каждый тип ответственности имеет четко определенное место в архитектуре проекта:

1. Инициализация и конфигурация → app/

  • Запуск приложения
  • Подключение провайдеров
  • Глобальные настройки и окружение

2. Точка входа → pages/

  • Код, связанный с маршрутом или экраном
  • Композиция модулей под конкретный контекст отображения

3. Композиция без бизнес-логики → widgets/

  • Объединение независимых модулей
  • Формирование UI-блоков
  • Визуальная структура без принятия решений

4. Пользовательский сценарий → features/

  • Последовательность действий пользователя
  • Управление состоянием сценария
  • Обработка ошибок и переходов между состояниями

5. Предметная область (домен) → entities/

  • Бизнес-сущности
  • Инварианты и правила
  • Доменные ограничения

6. Техническая инфраструктура → shared/

  • Переиспользуемые утилиты
  • Generic-хуки
  • Клиенты и адаптеры
  • Код, не зависящий от предметной области

Правило единого владельца

Каждая логика должна иметь единственного владельца ответственности:

  • Логика сценариев принадлежит features/
  • Бизнес-правила принадлежат entities/
  • Композиция не принимает решений - pages/, widgets/
  • Техническая инфраструктура - shared/

Слои и зависимости

Направление зависимостей

⬇️ Импорт возможен только вниз по иерархии

Три основных правила зависимостей

Правило 1: Импорт возможен только вниз по иерархии

  • Нижележащие слои не могут импортировать из вышележащих
  • Например: entities/ может использовать shared/, но не features/

Правило 2: Импорты между слайсами одного слоя запрещены

  • features/auth не может импортировать из features/article
  • entities/user не может импортировать из entities/article

Правило 3: Циклические зависимости - архитектурная ошибка

  • Любая циклическая зависимость считается маркером ошибки проектирования
  • Должна быть устранена немедленно

Public API

  • Каждый модуль имеет публичный контракт (обычно index.ts)
  • Внешний код импортирует только из корня модуля
  • Внутренние файлы и утилиты не экспортируются

LIFT Принципы

Внутреннее устройство любого модуля должно обеспечивать удобство навигации и минимизировать когнитивную нагрузку. LIFT - это набор принципов для организации кода внутри модулей.

L - Locate (Находимость)

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

Правило: Не разбрасывайте код по папкам только на основании его типа (хук, тип, стиль). Группируйте по функциональности.

Примеры:

❌ Плохо - группировка по типам файлов
features/article-create/
├── hooks/
│ └── use-article-form.ts
├── types/
│ └── article-form.types.ts
├── components/
│ └── article-form.tsx
└── styles/
└── article-form.module.css

✅ Хорошо - файлы рядом с использованием
features/article-create/
├── model/
│ ├── use-article-form.ts
│ └── article-form.types.ts
└── ui/
├── article-form.tsx
└── article-form.module.css

I - Identify (Понятность)

Принцип: Имя файла должно отражать его суть и назначение.

Правило: Избегайте «анонимных» файлов (styles.ts, index.module.scss, index.tsx). index.ts используется только для Public API.

Примеры:

❌ Плохо - неясные имена
features/article-create/ui/
├── index.tsx // Что это?
├── form.tsx // Какая форма?
└── styles.module.css // Стили чего?

✅ Хорошо - говорящие имена
features/article-create/ui/
├── article-form.tsx
├── article-form.module.css
└── form-field.tsx

F - Flat (Плоскость)

Принцип: Структура должна быть максимально плоской.

Правило: Вложенность более 3 уровней внутри модуля - явный architectural smell.

Примеры:

❌ Плохо - избыточная вложенность
features/article-create/
└── src/
└── components/
└── forms/
└── article/
└── create/
└── article-form.tsx // 6 уровней!

✅ Хорошо - плоская структура
features/article-create/
├── model/
│ └── use-article-form.ts
└── ui/
└── article-form.tsx // 3 уровня

T-DRY (Try to stay DRY)

Принцип: Необходимо соблюдать баланс между повторением кода и сложностью абстракций.

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

Примеры:

// ❌ Плохо - преждевременная абстракция
// shared/lib/form-helpers.ts
export function getArticleFormInitialValues() {
return { title: '', body: '' }; // Используется только в одном месте
}

// ✅ Хорошо - локальная логика
// features/article-create/model/initial-values.ts
export const INITIAL_VALUES = {
title: '',
body: '',
tags: []
}; // Живет рядом с использованием

// ✅ Хорошо - оправданная абстракция (использование в 3+ местах)
// shared/lib/validators/email.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Используется в: features/auth, features/profile, features/settings

📌 Ключевые моменты:

  • MUST: Импорт только вниз по иерархии слоев
  • FORBIDDEN: Импорты между слайсами одного слоя
  • MUST: Каждый модуль имеет Public API (index.ts)
  • SHOULD: Следовать LIFT принципам: Locate, Identify, Flat, Try-DRY
  • FORBIDDEN: Циклические зависимости - архитектурная ошибка

Структура слоев

Интеграция с Next.js

Вариант 1: Pages Router

.
├── pages/ # Next.js роутинг (корень проекта)
│ ├── _app.tsx # Root App с провайдерами
│ ├── index.tsx # Роут "/" → экспортирует из src/pages/home
│ ├── article/
│ │ └── [slug].tsx # Роут "/article/:slug" → экспортирует из src/pages/article-details
│ └── api/ # API routes
└── src/ # FSD слои
├── app/ # Layer 1: Инициализация (FSD)
├── pages/ # Layer 2: Компоненты страниц (FSD)
├── widgets/ # Layer 3: Композиция
├── features/ # Layer 4: Сценарии
├── entities/ # Layer 5: Домен
└── shared/ # Layer 6: Инфраструктура

Пример Pages Router:

// src/pages/home/ui/home-page.tsx
import { ArticleFeed } from '@/widgets/article-feed';

export function HomePage() {
return <ArticleFeed />;
}

// src/pages/home/index.ts
export { HomePage } from './ui/home-page';

// pages/index.tsx (Next.js роутинг, корень проекта)
export { HomePage as default } from '@/pages/home';

// pages/_app.tsx
import { Providers } from '@/app/providers';
import '@/app/styles/globals.css';

export default function App({ Component, pageProps }) {
return (
<Providers>
<Component {...pageProps} />
</Providers>
);
}

Вариант 2: App Router

.
├── app/ # Next.js роутинг (корень проекта)
│ ├── layout.tsx # Root layout с провайдерами
│ ├── page.tsx # Роут "/" → экспортирует из src/pages/home
│ └── article/
│ └── [slug]/
│ └── page.tsx # Роут "/article/:slug" → экспортирует из src/pages/article-details
└── src/ # FSD слои
├── app/ # Layer 1: Инициализация (FSD)
├── pages/ # Layer 2: Компоненты страниц (FSD)
├── widgets/ # Layer 3: Композиция
├── features/ # Layer 4: Сценарии
├── entities/ # Layer 5: Домен
└── shared/ # Layer 6: Инфраструктура

Пример App Router:

// src/pages/home/ui/home-page.tsx
import { ArticleFeed } from '@/widgets/article-feed';

export function HomePage() {
return <ArticleFeed />;
}

// src/pages/home/index.ts
export { HomePage } from './ui/home-page';

// app/page.tsx (Next.js роутинг, корень проекта)
export { HomePage as default } from '@/pages/home';

// app/layout.tsx
import { Providers } from '@/app/providers';
import '@/app/styles/globals.css';

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

Принцип: Next.js роутинг (корень проекта), FSD слои в src/ для организации кода.


Layer 1: src/app/ - Инициализация приложения

Назначение: Глобальные провайдеры, инициализация сервисов, конфигурация.

Структура:

src/app/
├── providers.tsx # Глобальные провайдеры
└── styles/
└── globals.css # Глобальные стили

Пример:

// src/app/providers.tsx
'use client';

import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

// app/layout.tsx (Next.js роутинг, корень проекта)
import { Providers } from '@/app/providers';
import '@/app/styles/globals.css';

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

MUST:

  • Инициализация глобальных провайдеров
  • Подключение стилей и тем
  • Настройка роутинга

FORBIDDEN:

  • Бизнес-логика
  • UI-компоненты (кроме layout)
  • Запросы к API

Layer 2: src/pages/ - Компоненты страниц

Назначение: Композиция модулей под конкретный роут. Компоненты страниц без привязки к роутингу.

Структура:

src/pages/
├── home/
│ ├── ui/
│ │ └── home-page.tsx
│ └── index.ts
├── article-details/
│ ├── ui/
│ │ └── article-details-page.tsx
│ └── index.ts
└── profile/
├── ui/
│ └── profile-page.tsx
└── index.ts

Интеграция с Next.js роутингом:

app/                                    # Next.js роутинг (корень проекта)
├── page.tsx # → экспортирует HomePage из src/pages/home
├── article/
│ └── [slug]/
│ └── page.tsx # → экспортирует ArticleDetailsPage из src/pages/article-details
└── profile/
└── page.tsx # → экспортирует ProfilePage из src/pages/profile

Пример:

// src/pages/article-details/ui/article-details-page.tsx
import { ArticleDetail } from '@/widgets/article-detail';
import { ArticleComments } from '@/widgets/article-comments';

interface ArticleDetailsPageProps {
slug: string;
}

export function ArticleDetailsPage({ slug }: ArticleDetailsPageProps) {
return (
<div>
<ArticleDetail slug={slug} />
<ArticleComments slug={slug} />
</div>
);
}

// src/pages/article-details/index.ts
export { ArticleDetailsPage } from './ui/article-details-page';

// app/article/[slug]/page.tsx (Next.js роутинг)
import { ArticleDetailsPage } from '@/pages/article-details';

interface PageProps {
params: { slug: string };
}

export default function Page({ params }: PageProps) {
return <ArticleDetailsPage slug={params.slug} />;
}

// src/pages/home/ui/home-page.tsx
import { ArticleFeed } from '@/widgets/article-feed';
import { PopularTags } from '@/widgets/popular-tags';

export function HomePage() {
return (
<main>
<ArticleFeed />
<PopularTags />
</main>
);
}

// src/pages/home/index.ts
export { HomePage } from './ui/home-page';

// app/page.tsx (Next.js роутинг)
export { HomePage as default } from '@/pages/home';

MUST:

  • Композиция widgets и features
  • Обработка параметров роута (в динамических страницах)
  • Минимальная логика (только композиция)

FORBIDDEN:

  • Бизнес-логика
  • Прямые запросы к API
  • Импорты из других pages

Layer 3: widgets/ - Композиционные блоки

Назначение: Самодостаточные UI-блоки, объединяющие features и entities без бизнес-логики.

Структура:

widgets/
├── article-detail/
│ ├── ui/
│ │ ├── article-detail.tsx
│ │ └── article-detail.module.css
│ └── index.ts
├── article-comments/
│ ├── ui/
│ │ ├── article-comments.tsx
│ │ └── comment-list.tsx
│ └── index.ts
└── feed/
├── ui/
│ ├── feed.tsx
│ └── feed-tabs.tsx
└── index.ts

Пример:

// widgets/article-detail/ui/article-detail.tsx
import { ArticleCard, useArticle } from '@/entities/article';
import { FavoriteButton } from '@/features/article-favorite';
import { FollowButton } from '@/features/profile-follow';
import { Spinner } from '@/shared/ui/spinner';
import { ErrorMessage } from '@/shared/ui/error-message';

interface ArticleDetailProps {
slug: string;
}

export function ArticleDetail({ slug }: ArticleDetailProps) {
const { data: article, isLoading, error } = useArticle(slug);
const t = useTranslations('article');

if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!article) return <ErrorMessage message={t('notFound')} />;

return (
<div>
<ArticleCard article={article} />
<div>
<FavoriteButton record={article} />
<FollowButton profile={article.author} />
</div>
</div>
);
}

MUST:

  • Композиция features и entities
  • UI-логика без бизнес-решений
  • Может иметь внутреннее состояние UI

FORBIDDEN:

  • Бизнес-логика
  • Импорты из pages или других widgets
  • Прямые мутации данных

Layer 4: features/ - Пользовательские сценарии

Назначение: Законченные пользовательские действия и сценарии.

Структура:

features/
├── article-create/
│ ├── model/
│ │ ├── use-article-form.ts
│ │ └── validation.ts
│ ├── ui/
│ │ ├── article-form.tsx
│ │ └── article-form.module.css
│ └── index.ts
├── article-favorite/
│ ├── model/
│ │ └── use-toggle-favorite.ts
│ ├── ui/
│ │ └── favorite-button.tsx
│ └── index.ts
└── comment-add/
├── model/
│ └── use-add-comment.ts
├── ui/
│ └── comment-form.tsx
└── index.ts

⚠️ Важно: API-вызовы (CRUD операции) размещаются в shared/api/endpoints/, а не внутри features. Features используют эти API-функции через импорты.

Пример:

// features/article-create/model/use-article-form.ts
'use client';

import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { createArticle } from '@/shared/api/endpoints/article.api';
import { articleSchema } from './validation';

export function useArticleForm() {
const router = useRouter();

return useMutation({
mutationFn: createArticle,
onSuccess: (article) => {
router.push(`/article/${article.slug}`);
}
});
}

// features/article-create/ui/article-form.tsx
import { useTranslations } from 'next-intl';
import { useArticleForm } from '../model/use-article-form';
import { ErrorMessage } from '@/shared/ui/error-message';

export function ArticleForm() {
const { mutate, isLoading, error } = useArticleForm();
const t = useTranslations('article');

const handleSubmit = (data: ArticleFormData) => {
mutate(data);
};

if (error) {
return <ErrorMessage error={error} />;
}

return (
<form onSubmit={handleSubmit}>
{/* Форма создания статьи */}
<button type="submit" disabled={isLoading}>
{isLoading ? t('creating') : t('createArticle')}
</button>
</form>
);
}

MUST:

  • Управление сценарием от начала до конца
  • Обработка состояния и ошибок
  • Изолированные действия пользователя

FORBIDDEN:

  • CRUD операции (размещать в shared/api/endpoints/)
  • Доменные правила (вынести в entities)
  • Импорты из других features
  • Зависимость от pages или widgets

Layer 5: entities/ - Доменная логика

Назначение: Бизнес-сущности, инварианты и правила предметной области.

Структура:

entities/
├── article/
│ ├── model/
│ │ ├── article.types.ts
│ │ └── use-article.ts
│ ├── lib/
│ │ ├── format-date.ts
│ │ └── slugify.ts
│ ├── ui/
│ │ ├── article-card.tsx
│ │ └── article-meta.tsx
│ └── index.ts
├── user/
│ ├── model/
│ │ ├── user.types.ts
│ │ └── use-current-user.ts
│ ├── ui/
│ │ ├── user-avatar.tsx
│ │ └── user-link.tsx
│ └── index.ts
└── comment/
├── model/
│ └── comment.types.ts
├── ui/
│ └── comment-card.tsx
└── index.ts

⚠️ Важно: CRUD операции (get, create, update, delete) размещаются в shared/api/endpoints/, а не в entities/. Entities содержат только доменную логику, типы и UI-компоненты.

Пример:

// entities/article/model/article.types.ts
export interface Article {
slug: string;
title: string;
description: string;
body: string;
tagList: string[];
createdAt: string;
updatedAt: string;
favorited: boolean;
favoritesCount: number;
author: Profile;
}

// entities/article/lib/slugify.ts
export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}

// entities/article/ui/article-card.tsx
import { Article } from '../model/article.types';
import { formatArticleDate } from '../lib/format-date';

export function ArticleCard({ article }: Props) {
return (
<article>
<h2>{article.title}</h2>
<p>{article.description}</p>
<time>{formatArticleDate(article.createdAt)}</time>
</article>
);
}

// entities/article/index.ts
export { ArticleCard } from './ui/article-card';
export { useArticle, useArticles } from './model/use-article';
export type { Article } from './model/article.types';
export { slugify } from './lib/slugify';

MUST:

  • Бизнес-правила и инварианты
  • Типы и интерфейсы сущностей
  • Переиспользуемая доменная логика

FORBIDDEN:

  • CRUD операции (размещать в shared/api/endpoints/)
  • Пользовательские сценарии
  • Импорты из features
  • Зависимости между entities (использовать композицию выше)

Layer 6: shared/ - Техническая инфраструктура

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

Структура:

shared/
├── api/
│ ├── client.ts
│ ├── interceptors.ts
│ ├── types.ts
│ ├── dto/
│ │ ├── article.dto.ts # DTO для articles
│ │ ├── user.dto.ts # DTO для users
│ │ └── comment.dto.ts # DTO для comments
│ └── endpoints/
│ ├── article.api.ts # CRUD для articles
│ ├── user.api.ts # CRUD для users
│ └── comment.api.ts # CRUD для comments
├── ui/
│ ├── button/
│ │ ├── button.tsx
│ │ └── button.module.css
│ ├── input/
│ │ └── input.tsx
│ └── modal/
│ └── modal.tsx
├── lib/
│ ├── validation/
│ │ └── email.ts
│ └── hooks/
│ ├── use-toggle.ts
│ └── use-local-storage.ts
└── config/
└── api.config.ts

Пример:

// shared/api/client.ts
import axios from 'axios';

export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
'Content-Type': 'application/json'
}
});

// shared/api/dto/article.dto.ts
export interface ArticleDTO {
slug: string;
title: string;
description: string;
body: string;
tagList: string[];
createdAt: string;
updatedAt: string;
favorited: boolean;
favoritesCount: number;
author: ProfileDTO;
}

export interface ProfileDTO {
username: string;
bio: string;
image: string;
following: boolean;
}

// shared/api/endpoints/article.api.ts
import { apiClient } from '../client';
import type { ArticleDTO } from '../dto/article.dto';

export async function getArticle(slug: string): Promise<ArticleDTO> {
const { data } = await apiClient.get<{ article: ArticleDTO }>(`/articles/${slug}`);
return data.article;
}

export async function getArticles(): Promise<ArticleDTO[]> {
const { data } = await apiClient.get<{ articles: ArticleDTO[] }>('/articles');
return data.articles;
}

export async function favoriteArticle(slug: string): Promise<ArticleDTO> {
const { data } = await apiClient.post<{ article: ArticleDTO }>(`/articles/${slug}/favorite`);
return data.article;
}

export async function unfavoriteArticle(slug: string): Promise<ArticleDTO> {
const { data } = await apiClient.delete<{ article: ArticleDTO }>(`/articles/${slug}/favorite`);
return data.article;
}

// shared/lib/hooks/use-toggle.ts
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(prev => !prev);
return [value, toggle] as const;
}

// shared/ui/button/button.tsx
export function Button({ children, variant, ...props }: Props) {
return (
<button className={styles[variant]} {...props}>
{children}
</button>
);
}

MUST:

  • Технические утилиты
  • Generic UI-компоненты
  • Инфраструктурные клиенты

FORBIDDEN:

  • Бизнес-логика
  • Доменные правила
  • Зависимости от верхних слоев

DTO → Entity: Правило зависимостей

Важное архитектурное правило: shared/ не может импортировать из entities/, но entities/ могут импортировать и расширять DTO из shared/.

Почему это важно:

  • DTO в shared/ описывают контракт с внешним API (технический слой)
  • Entities в entities/ содержат доменную логику и могут расширять DTO
  • Если shared/ импортирует из entities/, нарушается иерархия зависимостей

Пример правильного использования:

// ✅ shared/api/dto/article.dto.ts
export interface ArticleDTO {
slug: string;
title: string;
favorited: boolean;
favoritesCount: number;
}

// ✅ entities/article/model/article.types.ts
import type { ArticleDTO } from '@/shared/api/dto/article.dto';

// Entity может импортировать и расширять DTO
export interface Article extends ArticleDTO {
// Добавляем доменные методы/свойства
isPublished: boolean;
canEdit: (userId: string) => boolean;
}

// ❌ shared/api/dto/article.dto.ts
import type { Article } from '@/entities/article'; // ЗАПРЕЩЕНО!

📌 Ключевые моменты:

  • MUST: CRUD операции → shared/api/endpoints/
  • MUST: DTO для API → shared/api/dto/
  • MAY: Entities могут импортировать и расширять DTO
  • FORBIDDEN: DTO импортируют из entities
  • MUST: Shared не зависит от предметной области

Критерии квалификации для Shared

MUST: Код может находиться в shared только если он не зависит от предметной области и пригоден для использования в нескольких независимых контекстах.

Структура /shared:

shared/
├── api/ # HTTP-клиент, interceptors, CRUD операции
│ ├── client.ts
│ ├── interceptors.ts
│ ├── types.ts
│ ├── dto/ # DTO для API ответов
│ │ ├── article.dto.ts
│ │ ├── user.dto.ts
│ │ └── comment.dto.ts
│ └── endpoints/ # CRUD операции по ресурсам
│ ├── article.api.ts
│ ├── user.api.ts
│ └── comment.api.ts
├── ui/ # UI-kit, атомарные компоненты
│ ├── button/
│ ├── input/
│ └── modal/
├── lib/ # Технические утилиты
│ ├── format/
│ ├── validation/
│ └── analytics/
├── hooks/ # Generic React-хуки
│ ├── use-toggle.ts
│ └── use-local-storage.ts
├── types/ # Технические TypeScript типы
│ └── utility.ts
└── config/ # Конфигурация
└── api.config.ts

Примеры для shared:

// ✅ Технический helper - не зависит от домена
// shared/lib/format/date.ts
export function formatDate(date: Date | string, locale = 'en-US'): string {
return new Date(date).toLocaleDateString(locale);
}

// ✅ Generic хук - работает с любыми данными
// shared/hooks/use-local-storage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
});
// ...
}

// ✅ Инфраструктурный клиент - не знает о бизнесе
// shared/lib/analytics/analytics.ts
export const analytics = {
track: (event: string, properties?: Record<string, any>) => {
if (window.gtag) {
window.gtag('event', event, properties);
}
}
};