Главная / Статьи

Разделение слоев домена, данных и пользовательского интерфейса в кодбазе Next.js App Router

Пример из практики Pokédex, демонстрирующий, как разделить приложение Next.js App Router на слои домена, данных и представления с использованием Prisma, Zod, аутентификации через куки и кэширования.

Издатель: REACTAPP.TOP9 мин чтения2172 слов

Источники: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study

App Router в Next.js превращает ранее существовавшие одностраничные приложения на React в полноценную среду выполнения: серверная отрисовка, обработчики маршрутов, кэширование и прямой доступ к базе данных находятся в одном и том же репозитории. Если рассматривать эту базу кода исключительно как «компоненты», её качество быстро снижается — появляются страницы, взаимодействующие с базой данных, виджеты, вызывающие API сторонних сервисов, и бизнес-правила, скрытые в условиях JSX. В этом обзоре принципы чистой архитектуры применяются к проекту с использованием App Router на примере небольшого приложения Pokédex, показывая, куда относится каждый элемент системы и какие подводные камни Next.js выявляет эта структура.

Примерное приложение и его ограничения

Pokédex преднамеренно прост в реализации, но охватывает все виды зависимостей, характерных для реальных продуктов:

  • Удаленные данные: публичный сервис PokeAPI, для работы с которым не требуется API-ключ.
  • Локальное хранение данных: PostgreSQL, к которому осуществляется доступ через Prisma.
  • Хранение состояния: фавориты каждого пользователя, а также кэшированные данные о Покемонах, которые обновляются через семь дней.
  • Аутентификация: электронная почта и пароль, сессия, хранящаяся в httpOnly-куки, и провайдер сессий на стороне клиента.
  • Интерфейс загрузки: использование механизма React Suspense с компонентами-скелетами.
  • Безопасность типов: TypeScript в строгом режиме в сочетании с проверкой на времени выполнения с помощью Zod.
  • Изменения данных: отсутствие серверных операций; любые записи проходят через обработчик маршрута в разделе /app/api/*.
  • Что происходит при отсутствии границ

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

    • файлы страниц, которые напрямую импортируют клиент Prisma;
    • компоненты, которые сами вызывают PokeAPI;
    • правила домена, выраженные в виде специальных условий в интерфейсе пользователя;
    • логика, которую невозможно протестировать без запуска фреймворка, поскольку всё связано с ним.

    Чистая архитектура противодействует этому: правила домена больше не зависят от фреймворков, источники данных можно заменять без изменений интерфейса, сценарии использования можно тестировать без базы данных или среды выполнения Next.js, а механизмы сохранения данных и экраны могут меняться без переписывания кода.

    Три слоя и одно направление зависимостей

    Проект разделен на три слоя, причем зависимости направлены только внутрь, к домену:

    • Домен (ядро): сущности, интерфейсы репозиториев и сценарии использования. В нем отсутствуют импорты Next.js, Prisma и конкретные реализации.
  • Данные: реализации хранилищ, доступ к Prisma, клиент удаленного API, DTO, мапперы и кэширование. Вся инфраструктура находится здесь.
  • Представление: страницы, виджеты, скелеты интерфейсов, механизмы управления сессиями и вспомогательные функции загрузки данных. Это чисто пользовательский интерфейс, который получает данные только через стабильные API или через функции, предоставляемые адаптером.
  • Полезный тест: удалите папки с данными и представлением — приложение должно по-прежнему проходить проверку типов.

    Сохранение лаконичности папки App Router

    Четкая модель мышления начинается с чистой структуры каталогов. Каталог /app считается границей фреймворка и содержит только то, что необходимо Next.js для обнаружения:

    • маршруты и группы маршрутов;
    • layout.tsx;
    • обрабатчики маршрутов в папке app/api/**.

    Бизнес-логика не должна находиться там. В Pokédex папка app/ содержит корневую структуру, группу маршрутов (public) (вход, регистрация, детали Pokémon), группу (app) (список и любимые элементы) и обработчики api/. Все важные компоненты расположены в папках libs/domain/**, libs/data/** и libs/presentation/**, а также в папке prisma/, где хранятся схема и миграции.

    Слой доменной логики: только правила и контракты

    Домен содержит три вида элементов:

    • Энтитеты, такие как Pokemon и User.
    • Интерфейсы репозиториев, такие как PokemonRepo, FavoritesRepo и UserRepo.
  • Сценарии использования, такие как GetPokemonDetails, ListPokemon, AddFavorite, RemoveFavorite, GetFavorites, RegisterUser и LoginUser.
  • Два правила обеспечивают соблюдение принципов. Во-первых, ничто в домене не должно импортировать Next.js, Prisma или любые модули, специфичные для конкретной среды выполнения. Во-вторых, домен возвращает объекты домена, а не модели сохранения данных, поэтому типы строк Prisma не могут попасть в интерфейс пользователя через сценарии использования.

    Сценарии использования зависят только от интерфейсов репозиториев, а не от их реализаций. AddFavorite получает объект FavoritesRepo через конструктор или фабрику и не может отличить базу данных Postgres от виртуальной копии для тестов в памяти. Именно такая инверсия является сутью данного паттерна; правило проверки ограничений импортов обеспечивает его соблюдение.

    Слой данных: место, где находится инфраструктура

    Все, что связано с внешним миром, собирается в слой данных:

    • клиент Prisma и его схема;
    • реализации хранилищ с суффиксом Impl;
    • удаленный сервис, оборачивающий PokeAPI;
    • локальный сервис для базы данных (любимые элементы и кэш);
    • DTO и мапперы, переводящие данные между различными форматами и сущностями;
    • политика кэширования с сроком жизни в семь дней.

    Небольшой контейнер для внедрения зависимостей в папке libs/data/di связывает реализации с интерфейсами, благодаря чему остальная часть приложения запрашивает у контейнера готовые решения вместо того, чтобы самостоятельно создавать хранилища.

    Сочетание удаленного и локального источника

    Данные из удаленного источника поступают через сервис PokeAPI; локально существуют две таблицы: Favorites, связанная с авторизованными пользователями, и PokemonCache, в которой хранятся сырые данные вместе с временными метками.

    PokemonRepoImpl определяет, в каком источнике искать информацию, поэтому ни сценарии использования, ни интерфейс не знают о наличии кэширования.

    Политика кэширования на семь дней

    Данные о Pokémon кэшируются локально, чтобы страницы с информацией загружались быстрее и не происходило чрезмерного использования бесплатного публичного API. Логика принятия решений проста: сначала проверяется кэшированный запись; если она существует и ее временная метка не старше семи дней, она возвращается; в противном случае данные запрашиваются у PokeAPI, проверяется содержимое, затем записываются в PokemonCache с новой временной меткой, и возвращается соответствующий объект.

    Сохраните функцию проверки старости в виде небольшой чистой функции (например, в libs/data/utils/cache.ts), чтобы её было легко протестировать в режиме юнит-тестов. Если PokeAPI недоступен в момент, когда данные устарели, рассмотрите возможность предоставления старой копии вместо того, чтобы допустить сбой; именно здесь следует реализовать такую политику.

    Обработчики маршрутов как стабильные контракты

    Server Components могли бы напрямую вызывать соответствующие функции, но в данном проекте доступ к данным всё равно осуществляется через обработчики, расположенные по пути /app/api/**, что выполняет роль прокси «бэкенд для фронтенда». Для этого есть три причины.

    1. Один и тот же интерфейс обслуживает серверные представления, клиентские компоненты и любых будущих внешних пользователей.
    2. Это обеспечивает целостность системы, поскольку весь доступ к данным проходит через одинаковые контракты.
    3. Это естественно соответствует принципам Clean Architecture: HTTP-API представляет собой ещё один адаптер на границе взаимодействия с ядром системы.

    Чтобы узнать больше об этом паттерне, ознакомьтесь с преобразованием обработчиков маршрутов Next.js в специальный слой BFF.

    Здесь важна одна деталь, связанная с версией. В новых релизах Next.js (начиная с версии 15) параметр params в динамических сегментах представляет собой объект типа Promise. Обработчики маршрутов и страницы должны выполнить операцию await params перед чтением значения name; проверьте документацию для вашей версии на точную структуру параметров.

    Аутентификация на основе куки

    Аутентификация реализована через три точки входа:

    • POST /api/auth/login проверяет учетные данные и устанавливает куку с атрибутом httpOnly, например pokedex_token.
    • GET /api/auth/session считывает эту куку, проверяет токен и возвращает текущее состояние сессии и информацию о пользователе.
  • POST /api/auth/logout удаляет куки и возвращает ответ { ok: true }.
  • Почему использовать куки httpOnly

    Куки типа httpOnly невозможно прочитать с помощью JavaScript на стороне клиента, поэтому внедренные скрипты не могут вывезти данные JWT, а код клиента никогда не хранит и не привязывает токены. Поскольку браузер автоматически отправляет такие куки, их следует использовать вместе с атрибутами SameSite и Secure, а также рассмотреть возможность защиты от CSRF при записи данных.

    Провайдер сессий для интерфейса

    Интерфейс всё ещё должен знать, вошёл ли кто-то в систему, кто это такой, и когда необходимо обновить данные после входа или выхода. Небольшой компонент с клиентской стороны SessionProvider отвечает на эти вопросы. Он загружает сессию из /api/auth/session, хранит значения status, isLoggedIn и user в состоянии React, а также предоставляет методы syncAfterLogin() и logout(). Компоненты используют этот контекст и никогда не взаимодействуют с токенами.

    Компоненты сервера, Suspense и скелетные компоненты

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

    Передача куки при внутренних запросах

    В подходе BFF существует тонкая ловушка. Когда серверный компонент вызывает один из внутренних обработчиков маршрутов приложения по абсолютному URL, операция fetch с серверной стороны представляет собой совершенно новый выходящий запрос, поэтому кошки пользователя автоматически не передаются. Решение заключается в чтении заголовка cookie, поступающего с запросом, с помощью метода headers(), и явном передаче его в качестве заголовка Cookie при вызове адреса /api/favorites. Эта проблема возникает чаще всего, когда URL формируется на основе значений host и протокола запроса.

    Альтернатива — полностью исключить внутренний HTTP и заставить серверные представления напрямую вызывать функции использования через контейнер. HTTP-маршрут обеспечивает единый интерфейс для всего, но это сопряжено с дополнительным сетевым переходом при каждой отрисовке и зависимостью от доступности сервера (что также исключает его использование во время статической сборки). Прямые вызовы функций использования быстрее и проще, но означают, что серверные представления и клиентские виджеты используют разные точки входа. В этом проекте сохраняются стабильные контракты API, а куки пересылаются дальше.

    Любимые элементы от начала до конца

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

    • FavoritesView — серверный компонент, который запрашивает данные по адресу /api/favorites с пересланными куками и отображает список.
  • RemoveFavoriteButton — это клиентский компонент, который отправляет запрос DELETE /api/favorites/:name, а затем вызывает router.refresh(), чтобы повторно загрузить список, сгенерированный на сервере.
  • router.refresh() пересоздает серверские компоненты маршрута, сохраняя при этом состояние клиента.

    Проверка каждой границы с помощью Zod

    Zod проверяет данные на каждой точке их пересечения границами:

    • ответы от PokeAPI, которые проверяются как удаленные DTO перед маппингом;
    • тела запросов для точек аутентификации;
    • JSON-данные, хранящиеся в базе данных, такие как кэшированные сведения.

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

    Минимальная, но полезная база тестов

    Для демонстрации архитектуры не требуется большой набор инструментов. Две группы тестов уже дают хороший результат:

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

    Возможность тестировать сценарии использования без запуска Next.js — это то, что делает иерархическую структуру по-настоящему полезной.

    Запуск кода локально

    Репозиторий можно склонировать через HTTPS:

    git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
    cd pokemon-clean-arch
    npm install
    

    или через SSH, если у вас настроен ключ в GitHub:

    git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
    cd pokemon-clean-arch
    npm install
    

    Далее создайте файл с настройками окружения по предоставленной шаблону и укажите URL базы данных и секреты, указанные в нем:

    cp .env.example .env
    

    Запустите контейнер PostgreSQL 16. Имя базы данных, пользователь и пароль здесь предназначены только для локальной разработки и должны совпадать с теми, что указаны в файле .env; названный том сохраняет данные между перезапусками контейнера.

    docker run -d \
      --name pokedex-postgres \
      -p 5432:5432 \
      -e POSTGRES_DB=pokedex_db \
      -e POSTGRES_USER=pokedex_user \
      -e POSTGRES_PASSWORD=pokedex_password \
      -v pokedex_pg_data:/var/lib/postgresql/data \
      postgres:16
    

    Примените миграции Prisma для создания таблиц:

    npx prisma migrate dev
    

    Наконец, запустите сервер разработки:

    npm run dev
    

    Развернутая версия доступна в виде живого демо.

    Заключение

    Чистая архитектура в Next.js в основном сводится к определению того, где находится каждый тип знаний:

    • Сохраняйте папку /app в качестве тонкого слоя адаптера и перемещайте логику в папку libs.
    • Пусть сфера бизнес-логики определяет интерфейсы и сущности, а только слой данных будет знать о Prisma, HTTP и кэшировании.

  • Проводите проверку на каждой границе, чтобы контракты соблюдались во время выполнения кода, а не только при определении типов.
  • Сознательно выбирайте между внутренними обработчиками маршрутов (один контракт, требуется пересылка куки) и прямыми вызовами по сценариям использования (быстрее, меньше компонентов).
  • Начните тестирование с отдельных элементов: политики кэширования и сценариев использования.
  • Для прототипа на выходные это может показаться избыточной структурой; однако для приложения, которое будет продолжать развиваться, такие границы позволяют ограничивать влияние каждых изменений.