Как делать11 октября 2026 г.8 мин чтения
Frontend-разработкаШаг 18 из 18

Архитектура фронтенда: структура проекта, слои и границы

Коротко

Архитектура фронтенда — это договорённость о том, где лежит код и кто кого может импортировать. Рабочая основа для растущего проекта: группировать код по фичам, внутри фичи разделять интерфейс, логику и доступ к данным, открывать фичу наружу только через публичный API (index.ts) и запрещать импорт «вверх» — из общих модулей в фичи. Эти правила стоит проверять линтером, а не держать в голове: React и Next.js структуру не навязывают, поэтому её поддерживает только команда.

Глубокий разборУровень: ПродвинутыйАрхитектура frontend

После материала вы сможете

  • предложить структуру папок по фичам с разделением ui / model / api и обосновать её против группировки по типам
  • описать слои app → features → shared и объяснить, почему импорт «вверх» создаёт связанность и циклы
  • оформить публичный API фичи через index.ts и назвать риски индексных файлов
  • закрепить правила импорта через no-restricted-imports в flat config ESLint и проверить, что они срабатывают на нарушениях

Проверено на

Next.js 16.4 (App Router, create-next-app)React 19.3TypeScript 5.9ESLint 9.39 (flat config) + eslint-config-next 16.4Node.js 24 LTS (24.18)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Симптомы: когда структура перестала работать
  2. 02По типам или по фичам
  3. 03Слои: интерфейс, состояние, данные
  4. 04Границы модулей и публичный API
  5. 05Правила импорта: закрепляем линтером
  6. 06Где живёт работа с API
  7. 07Монорепозиторий и общий UI-кит: когда пора

Симптомы: когда структура перестала работать

Начнём с фактов. React не задаёт структуру папок: в старой документации React прямо сказано, что у него нет мнения о том, как раскладывать файлы. Документация Next.js называет фреймворк «unopinionated» в вопросе организации файлов проекта и советует выбрать стратегию, которая подходит команде, и последовательно её придерживаться. Значит, архитектура — решение команды, и проверять его будет не фреймворк, а вы.

Дальше — наблюдения, по которым видно, что решение пора пересмотреть. Это не метрики, а признаки из практики:

  • Правка в одной фиче ломает другую, и это обнаруживается не на ревью, а у пользователя
  • Чтобы добавить поле в форму, нужно открыть пять папок: components, hooks, services, types, utils
  • В utils и common лежит всё подряд, и никто не решается там ничего удалить
  • Импорты вида ../../../../components/... и циклические зависимости между модулями
  • Компонент интерфейса сам делает fetch, разбирает ответ сервера и хранит глобальное состояние
  • Новый разработчик не может ответить, куда положить новый файл, без вопроса в чат

По типам или по фичам

Старая документация React описывает два распространённых подхода: группировку по фичам или маршрутам и группировку по типу файлов. Next.js в разделе об организации проекта перечисляет три стратегии: держать код вне app, держать его в общих папках внутри app или разделять по фичам и маршрутам, оставляя в корне только общее. Next.js безопасно позволяет класть файлы рядом с маршрутами: папка в app становится публичным адресом, только когда в ней есть page или route, а папки с префиксом _ полностью исключаются из маршрутизации.

По типам файлов

  • Папки components/, hooks/, services/, types/, utils/
  • Понятно с первого дня, удобно для маленького проекта
  • Одна фича размазана по всем папкам — правка затрагивает много мест
  • Границы между фичами не видны: любой файл может импортировать любой

По фичам

  • Папка features/cart/ содержит свой UI, логику и работу с API
  • Всё, что меняется вместе, лежит вместе (колокация)
  • Фичу можно удалить или переписать целиком
  • Нужна договорённость о границах — иначе фичи срастаются через глубокие импорты

По нашему опыту, работает гибрид: верхний уровень делится по ответственности (app — маршруты, features — бизнес-возможности, shared — код без бизнес-смысла), а внутри фичи файлы раскладываются по назначению. Вот дерево проверенного примера — это реальный Next.js-проект, созданный create-next-app с опцией --src-dir:

terminal
$ find src -type f | sort
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/features/cart/api/cart-api.ts
src/features/cart/index.ts
src/features/cart/model/cart.ts
src/features/cart/ui/CartSummary.tsx
src/features/menu/index.ts
src/features/menu/model/menu.ts
src/features/menu/ui/MenuList.tsx
src/shared/api/http.ts
src/shared/lib/format-price.ts
src/shared/ui/Button.tsx
Реальная структура примера: маршруты, две фичи и общий слой

app/

Что лежит
Маршруты, раскладки, сборка страницы из фич
Что не лежит
Бизнес-логика и запросы к API

features/<name>/ui

Что лежит
Компоненты фичи
Что не лежит
Разбор ответа сервера

features/<name>/model

Что лежит
Типы, чистая логика, состояние фичи
Что не лежит
JSX и fetch

features/<name>/api

Что лежит
Запросы фичи и преобразование данных сервера
Что не лежит
Компоненты

shared/

Что лежит
UI-кит, HTTP-клиент, форматирование — без знания о фичах
Что не лежит
Импорты из features и app

Слои: интерфейс, состояние, данные

Слой — группа модулей с одним уровнем ответственности. Главное правило слоёв: зависимости направлены в одну сторону. Маршрут собирает страницу из фич, фича пользуется общим кодом, общий код не знает ни о фичах, ни о маршрутах. Если shared начинает импортировать из features, общий код перестаёт быть общим, а между слоями появляются циклы.

  1. 01appМаршруты и раскладки: собирают страницу из фич
  2. 02featuresБизнес-возможности: корзина, меню, заявка — со своим UI, логикой и API
  3. 03sharedКод без бизнес-смысла: UI-кит, HTTP-клиент, форматирование
Импорт разрешён только сверху вниз

Внутри фичи полезно отделять три вещи. Интерфейс (ui) показывает данные и сообщает о действиях пользователя. Модель (model) содержит типы, правила предметной области и состояние: например, cartTotal — чистая функция, которую легко протестировать без DOM. Доступ к данным (api) знает адреса и формат сервера. Состояние размещайте по правилу из «Thinking in React»: в ближайшем общем родителе компонентов, которым оно нужно, а глобальное хранилище — только когда такого родителя разумно не найти.

Одна из формализаций этой идеи — методология Feature-Sliced Design. В ней семь стандартных слоёв (app, processes — устаревший, pages, widgets, features, entities, shared), модуль может импортировать только из слоёв строго ниже, а срезы одного слоя не используют друг друга. Сами авторы оговаривают, что методология рассчитана на приложения, а не на библиотеки, и что переходить на неё стоит, если текущая структура реально мешает команде. По нашему опыту, для сайта или небольшого кабинета хватает трёх слоёв из примера; entities и widgets появляются, когда одни и те же сущности и блоки используют несколько фич.

Границы модулей и публичный API

Граница модуля — это ответ на вопрос «что снаружи можно использовать, а что является внутренним устройством». В JavaScript нет модификатора доступа для файлов, поэтому границу задают соглашением: у фичи есть файл index.ts, который явно перечисляет, что она отдаёт наружу. Всё остальное — детали реализации, которые можно переписать без поиска по всему проекту.

src/features/cart/index.ts
export { CartSummary } from "./ui/CartSummary";
export { cartTotal } from "./model/cart";
export type { CartLine } from "./model/cart";
Публичный API корзины: компонент, функция расчёта и тип — и ничего лишнего
src/features/menu/index.ts
export { MenuList } from "./ui/MenuList";
export { getMenu } from "./model/menu";
export type { MenuItem } from "./model/menu";
Публичный API меню
src/app/page.tsx
import { CartSummary, type CartLine } from "@/features/cart";
import { getMenu, MenuList } from "@/features/menu";

export default function HomePage() {
  const menu = getMenu();
  const lines: CartLine[] = [{ ...menu[0], qty: 2 }];

  return (
    <main>
      <h1>Coffee House</h1>
      <MenuList items={menu} />
      <CartSummary lines={lines} />
    </main>
  );
}
Маршрут собирает страницу из фич и знает только их публичные API

Документация Feature-Sliced Design определяет публичный API как контракт между группой модулей и кодом, который ими пользуется, и называет две цели: защитить остальное приложение от структурных изменений внутри модуля и открывать только необходимое. Там же перечислены риски индексных файлов, и их стоит знать:

  • export * from … удобно писать, но по такому файлу уже не видно, что модуль отдаёт наружу. Поэтому в примере экспорты перечислены по именам.
  • Индексный файл облегчает случайный циклический импорт: файл внутри фичи импортирует собственный index.ts, а тот — этот файл. Внутри фичи используйте относительные пути.
  • Некоторым сборщикам тяжело выполнять tree-shaking через большие индексы. Особенно это касается shared/ui и shared/lib: если бандл растёт, общий слой лучше открывать по подпапкам, а не одним файлом.

Правила импорта: закрепляем линтером

Договорённость, которую не проверяет машина, разрушается за несколько спринтов. Здесь хватает двух инструментов. Алиас пути в tsconfig.json даёт одинаковый абсолютный путь @/… из любого места. create-next-app настраивает его сам, а Next.js по документации поддерживает paths из tsconfig.json без дополнительной настройки. Документация TypeScript подчёркивает, что paths не меняет пути в скомпилированном коде, поэтому ту же схему должен знать сборщик — в Next.js это уже сделано.

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
Фрагмент: алиас @/ на папку src (остальные опции create-next-app не показаны)

Второй инструмент — встроенное правило ESLint no-restricted-imports. Его опция patterns принимает шаблоны в стиле .gitignore (поле group) или регулярные выражения (поле regex) и своё сообщение об ошибке. Правила задаём отдельными объектами flat config для разных папок:

eslint.config.mjs
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

// Rule 1: other modules see a feature only through its public API (index.ts)
const publicApiOnly = {
  group: ["@/features/*/*"],
  message: "Import a feature through its public API: @/features/<name>.",
};

// Rule 2: lower layers do not know about upper ones
const noUpwardImports = (layers) => ({
  group: layers.flatMap((layer) => [`@/${layer}`, `@/${layer}/*`, `**/${layer}/**`]),
  message: `This layer must not import from: ${layers.join(", ")}.`,
});

const eslintConfig = defineConfig([
  ...nextVitals,
  ...nextTs,
  globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
  {
    files: ["src/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": ["error", { patterns: [publicApiOnly] }],
    },
  },
  {
    files: ["src/features/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": ["error", { patterns: [publicApiOnly, noUpwardImports(["app"])] }],
    },
  },
  {
    files: ["src/shared/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": [
        "error",
        { patterns: [publicApiOnly, noUpwardImports(["app", "features"])] },
      ],
    },
  },
]);

export default eslintConfig;
Конфиг create-next-app плюс два правила: импорт фичи только через публичный API и запрет импорта «вверх»

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

TSX
// src/app/checkout/page.tsx
import { cartTotal } from "@/features/cart/model/cart";

export default function CheckoutPage() {
  return <p>{cartTotal([])}</p>;
}

// src/features/menu/model/upsell.ts
import { CartSummary } from "@/features/cart/ui/CartSummary";

export const upsell = CartSummary;

// src/shared/lib/order-total.ts
import { cartTotal, type CartLine } from "../../features/cart";

export const orderTotal = (lines: CartLine[]) => cartTotal(lines);
Три файла-нарушителя (созданы для проверки и затем удалены)
terminal
$ npx eslint src


src/app/checkout/page.tsx
  1:1  error  '@/features/cart/model/cart' import is restricted from being used by a pattern. Import a feature through its public API: @/features/<name>  no-restricted-imports

src/features/menu/model/upsell.ts
  1:1  error  '@/features/cart/ui/CartSummary' import is restricted from being used by a pattern. Import a feature through its public API: @/features/<name>  no-restricted-imports

src/shared/lib/order-total.ts
  1:1  error  '../../features/cart' import is restricted from being used by a pattern. This layer must not import from: app, features  no-restricted-imports

✖ 3 problems (3 errors, 0 warnings)
$ echo $?
1
Реальный вывод ESLint 9.39: три ошибки, код выхода 1 — сборка в CI остановится

После удаления этих файлов tsc --noEmit, eslint . и next build проходят без ошибок. Ограничения подхода стоит знать. Шаблоны сравниваются со строкой импорта, а не с разрешённым путём к файлу, поэтому относительные пути закрываются отдельными шаблонами вида **/features/**. Внутри фичи нужно импортировать относительно, иначе собственный @/features/cart/model/cart тоже попадёт под запрет. Для сложных схем — много слоёв, запрет связей между фичами, разные правила для типов — есть специализированные плагины, например eslint-plugin-boundaries. Мы его здесь не проверяли, поэтому только упоминаем.

Где живёт работа с API

Запрос к серверу состоит из двух разных знаний: как ходить в сеть (заголовки, обработка ошибок, базовый адрес) и что именно спрашивать у конкретного API (адрес, формат, названия полей). Первое — общий код, второе — часть фичи. Если смешать их в компоненте, формат сервера расползётся по интерфейсу, и переименование одного поля превратится в поиск по всему проекту.

src/shared/api/http.ts
export class HttpError extends Error {
  constructor(public readonly status: number, url: string) {
    super(`HTTP ${status} for ${url}`);
    this.name = "HttpError";
  }
}

export async function postJson<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new HttpError(res.status, url);
  return (await res.json()) as T;
}
Общий HTTP-клиент: не знает ни о корзине, ни о заказах
src/features/cart/api/cart-api.ts
import { postJson } from "@/shared/api/http";
import type { CartLine } from "../model/cart";

type OrderDto = { items: { product_id: string; quantity: number }[] };

function toOrderDto(lines: CartLine[]): OrderDto {
  return { items: lines.map((line) => ({ product_id: line.id, quantity: line.qty })) };
}

export function submitOrder(lines: CartLine[]): Promise<{ orderId: string }> {
  return postJson("/api/orders", toOrderDto(lines));
}
API фичи: адаптер переводит модель корзины в формат сервера (snake_case)
src/features/cart/ui/CartSummary.tsx
"use client";

import { useState } from "react";
import { formatPrice } from "@/shared/lib/format-price";
import { Button } from "@/shared/ui/Button";
import { submitOrder } from "../api/cart-api";
import { cartTotal, type CartLine } from "../model/cart";

export function CartSummary({ lines }: { lines: CartLine[] }) {
  const [status, setStatus] = useState<"idle" | "sent" | "error">("idle");

  async function handleOrder() {
    try {
      await submitOrder(lines);
      setStatus("sent");
    } catch {
      setStatus("error");
    }
  }

  return (
    <section aria-label="Cart">
      <p>Total: {formatPrice(cartTotal(lines))}</p>
      <Button onClick={handleOrder}>Place order</Button>
      <p role="status">{status === "sent" ? "Order sent" : status === "error" ? "Could not send" : ""}</p>
    </section>
  );
}
Интерфейс знает модель и вызывает submitOrder, но не знает формата сервера
  • Адаптер (toOrderDto) — единственное место, где модель приложения встречается с форматом сервера. Если сервер переименует product_id, правка будет в одной функции.
  • Типы ответа сервера (DTO, data transfer object — объект передачи данных) не выходят за пределы api: интерфейс работает с CartLine, а не с полями из JSON.
  • В Next.js данные для страницы часто получают в серверных компонентах, а мутации делают из клиентских или через серверные функции. Разделение ui / model / api от этого не меняется — меняется только то, где вызывается функция из api. Подробнее — в уроке о данных и Server Components.

Монорепозиторий и общий UI-кит: когда пора

Монорепозиторий — несколько пакетов (приложений и библиотек) в одном репозитории с общими зависимостями и инструментами. Это мнение, а не правило, но по нашему опыту выносить код в отдельный пакет имеет смысл, когда появился второй потребитель: второе приложение (кабинет и сайт, веб и мобильное), которое использует тот же UI-кит или те же типы API. Пока приложение одно, папка shared/ui с правилом импорта даёт почти ту же изоляцию без расходов на версионирование, сборку пакетов и настройку CI.

Одно приложение, одна команда

Что обычно достаточно
Папки app / features / shared и правила импорта в ESLint

Появилась дизайн-система с отдельным владельцем

Что обычно достаточно
UI-кит в отдельном пакете монорепозитория, публичный API пакета вместо index.ts папки

Несколько приложений с общими типами и клиентом API

Что обычно достаточно
Монорепозиторий с пакетами ui, api-client, config и общими правилами линтера

Как бы ни была устроена структура, проверка её качества одна: можно ли переписать или удалить фичу, не трогая остальные, и понимает ли новый человек, куда положить файл. Если оба ответа «да», структура работает, даже если она не совпадает ни с одной методологией.

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    Next.js-проект (create-next-app 16.4.0 --ts --eslint --app --src-dir): src/app, features/{cart,menu}, shared/{api,lib,ui}, eslint.config.mjs, tsconfig paths

    tsc --noEmit (TypeScript 5.9.3) — без ошибок; eslint . (ESLint 9.39.5, eslint-config-next 16.4.0) — чисто; next build (Next.js 16.4.0, Turbopack, React 19.3.0) — успешно, / собран статически; Node.js 24.18.0. Google-шрифты из шаблона layout убраны, чтобы сборка не зависела от сети

  • запущено, работает

    три файла-нарушителя (checkout/page.tsx, menu/model/upsell.ts, shared/lib/order-total.ts)

    npx eslint src — 3 errors, exit 1; вывод в статье реальный, абсолютные пути сокращены до src/

  • запущено, работает

    переопределение опций правила в flat config

    блок src/features/** без publicApiOnly — глубокий импорт @/features/cart/ui/CartSummary не ловится (exit 0); с ним — ловится

  • запущено, работает

    решение упражнения (noCrossFeature, eslint.exercise.mjs)

    npx eslint -c eslint.exercise.mjs src: импорт @/features/cart из фичи menu — 1 error; без нарушителя — чисто; базовый конфиг этот импорт пропускает

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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