Разделение слоев домена, данных и пользовательского интерфейса в кодбазе Next.js App Router
Пример из практики Pokédex, демонстрирующий, как разделить приложение Next.js App Router на слои домена, данных и представления с использованием Prisma, Zod, аутентификации через куки и кэширования.
Источники: 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-ключ.
/app/api/*.Что происходит при отсутствии границ
Next.js позволяет любому файлу обращаться к чему угодно, и именно поэтому кодовые базы становятся запутанными. Типичные симптомы:
- файлы страниц, которые напрямую импортируют клиент Prisma;
- компоненты, которые сами вызывают PokeAPI;
- правила домена, выраженные в виде специальных условий в интерфейсе пользователя;
- логика, которую невозможно протестировать без запуска фреймворка, поскольку всё связано с ним.
Чистая архитектура противодействует этому: правила домена больше не зависят от фреймворков, источники данных можно заменять без изменений интерфейса, сценарии использования можно тестировать без базы данных или среды выполнения Next.js, а механизмы сохранения данных и экраны могут меняться без переписывания кода.
Три слоя и одно направление зависимостей
Проект разделен на три слоя, причем зависимости направлены только внутрь, к домену:
- Домен (ядро): сущности, интерфейсы репозиториев и сценарии использования. В нем отсутствуют импорты Next.js, Prisma и конкретные реализации.
Полезный тест: удалите папки с данными и представлением — приложение должно по-прежнему проходить проверку типов.
Сохранение лаконичности папки 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/**, что выполняет роль прокси «бэкенд для фронтенда». Для этого есть три причины.
- Один и тот же интерфейс обслуживает серверные представления, клиентские компоненты и любых будущих внешних пользователей.
- Это обеспечивает целостность системы, поскольку весь доступ к данным проходит через одинаковые контракты.
- Это естественно соответствует принципам 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с пересланными куками и отображает список.
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 и кэшировании.
Для прототипа на выходные это может показаться избыточной структурой; однако для приложения, которое будет продолжать развиваться, такие границы позволяют ограничивать влияние каждых изменений.