Головна / Статті

Розділення шарів домену, даних та користувацького інтерфейсу в кодбазі Next.js App Router

Кейс-стаді в Pokédex, який демонструє, як розділити додаток Next.js App Router на шари домену, даних та презентації за допомогою Prisma, Zod, автентифікації через cookie та кешування.

Видавець: 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.
  • Збережений стан: улюблені елементи кожного користувача, а також деталі Pokémon у кеші, які оновлюються через сім днів.
  • Аутентифікація: електронна пошта та пароль, сеанс, який зберігається у httpOnly-кукі, та засіб для підтримки сеансу на стороні клієнта.
  • Інтерфейс завантаження: механізм React Suspense із скелетними компонентами.
  • Безпека типів: TypeScript у строгому режимі разом із перевіркою під час виконання за допомогою Zod.
  • Зміни даних: відсутні Server Actions; кожна операція запису проходить через обробник маршруту під адресою /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 із новою часовою міткою, а потім повертається перетворена entитет.

    Зробіть перевірку старості даних невеликою чистою функцією (наприклад, у 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 перевіряє облікові дані та створює cookie з атрибутом httpOnly, наприклад pokedex_token.
    • GET /api/auth/session читає цей cookie, перевіряє токен та повертає поточний стан сеансу та інформацію про користувача.
  • 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 та кешування.
  • Перевіряйте на кожному кордоні, щоб умови контрактів дотримувалися під час виконання, а не лише на рівні типів.
  • Ретельно обирайте між внутрішніми обробниками маршрутів (один контракт, необхідне пересилання куки) та прямими викликами сценаріїв використання (швидше, менше елементів, які потрібно керувати).
  • Почніть тестування з окремих компонентів — політики кешування та сценаріїв використання.
  • Для прототипу на вихідні це може бути занадто багато структури; для додатку, який буде продовжувати розвиватися, такі кордони допомагають тримати кожну зміну локальною.