Rozdzielenie warstw domeny, danych i interfejsu użytkownika w kodzie Next.js App Router
Przykład z Pokédex pokazujący, jak podzielić aplikację Next.js App Router na warstwy domeny, danych i prezentacji przy użyciu Prisma, Zod, autoryzacji przez pliki cookie oraz cache’owania.
Źródła: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study
Next.js App Router przekształca to, co kiedyś było aplikacją jednostronicową w React, w środowisko typu full-stack: renderowanie na serwerze, obsługa tras, cache oraz bezpośredni dostęp do bazy danych znajdują się wszystkie w tym samym repozytorium. Jeśli potraktujemy tę bazę kodu jako „tylko komponenty”, jej jakość szybko się pogarsza – pojawiają się strony komunikujące się z bazą danych, elementy interfejsu wykorzystujące API firm trzecich oraz reguły biznesowe ukryte w warunkach JSX. Ten przewodnik stosuje Clean Architecture do projektu App Router, używając małej aplikacji Pokédex jako przykładu, pokazując, gdzie powinny znajdować się poszczególne elementy oraz jakie pułapki Next.js ujawnia ta struktura.
Aplikacja przykładowa i jej ograniczenia
Pokédex jest celowo mały, ale obejmuje każdy rodzaj zależności charakterystycznych dla prawdziwego produktu:
- Dane zdalne: publiczna platforma PokeAPI, która nie wymaga klucza API.
/app/api/*.Co się dzieje, gdy brakuje takich mechanizmów
Next.js umożliwia każdemu plikowi dostęp do wszystkiego, co właśnie powoduje splątanie kodu. Typowe objawy to:
- pliki strony, które bezpośrednio importują klienta Prisma;
- komponenty, które same wywołują PokeAPI;
- reguły domeny wyrażone jako ad hoc warunki w interfejsie użytkownika;
- logikę, której nie można przetestować bez uruchomienia frameworka, ponieważ wszystko jest z nim połączone.
Clean Architecture przeciwdziała temu: reguły domeny przestają zależeć od frameworków, źródła danych można zamieniać bez edycji interfejsu użytkownika, przypadki użycia można testować bez bazy danych czy środowiska Next.js, a mechanizmy przechowywania lub ekranów mogą ulegać zmianom bez konieczności przepisywania kodu.
Trzy warstwy i jedna kierunek zależności
Projekt jest podzielony na trzy warstwy, a zależności skierowane są wyłącznie do wewnątrz, ku domenie:
- Domena (jądro): entity, interfejsy repozytoriów oraz przypadki użycia. Nie zawiera żadnych importów z Next.js, ani Prisma, ani konkretnych implementacji.
Korzystny test: usuń foldery z danymi i elementami prezentacji – domena powinna nadal przepuszczać sprawdzanie typów.
Zachowanie lekkości folderu App Router
Czysty model mentalny zaczyna się od czystej struktury katalogów. Katalog /app jest traktowany jako granica frameworku i zawiera tylko to, co Next.js musi odkryć:
- ścieżki i grupy ścieżek;
layout.tsx;- przetwarzacze ścieżek w katalogu
app/api/**.
Logika biznesowa nie powinna się tam znajdować. W Pokédexie katalog app/ zawiera podstawową strukturę, grupę tras (public) (logowanie, rejestracja, szczegóły Pokémonów), grupę (app) (lista i ulubione) oraz obsługiwacze z katalogu api/. Wszystko istotne znajduje się w katalogach libs/domain/**, libs/data/** i libs/presentation/**, obok foldera prisma/ z schematem i migracjami.
Szczegół warstwy domeny: tylko reguły i kontrakty
Domena zawiera trzy rodzaje elementów:
- Entity, takie jak
PokemoniUser. - Interfejsy repozytoriów, takie jak
PokemonRepo,FavoritesRepoiUserRepo.
GetPokemonDetails, ListPokemon, AddFavorite, RemoveFavorite, GetFavorites, RegisterUser oraz LoginUser.Dwie zasady zapewniają uczciwość tego podejścia. Po pierwsze, nic w tym domenie nie może importować Next.js, Prisma ani żadnego modułu specyficznego dla środowiska wykonawczego. Po drugie, domena zwraca entityy domeny, a nie modele persistencji, więc typ wiersza Prisma nie może przedostać się do interfejsu użytkownika poprzez żadne zastosowanie.
Zastosowania opierają się wyłącznie na interfejsach repozytoriów, a nie na ich implementacjach. AddFavorite otrzymuje obiekt FavoritesRepo poprzez konstruktor lub fabrykę i nie może odróżnić Postgresa od testowego dublera w pamięci. To odwrócenie jest istotą tego wzorca; reguła sprawdzania ograniczeń importów umożliwia jej egzekucję.
Szczegół danych: gdzie znajduje się infrastruktura
Wszystko, co ma kontakt z światem zewnętrznym, jest gromadzone w warstwie danych:
- klient Prisma i jego schemat;
- wykonania repositoryjne, oznaczone sufiksem
Impl; - służba zdalna, która otacza PokeAPI;
- służba lokalna do bazy danych (ulubione elementy i cache);
- DTO-y oraz mapery, które przekształcają dane w różnych formatach na entity;
- polityka cache z siedmiodniowym TTL.
Niewielki kontener iniekcji zależności w katalogu libs/data/di łączy wykonania z interfejsami, dzięki czemu reszta aplikacji może żądać od kontenera gotowych rozwiązań zamiast sama tworzyć repositoryjne.
Łączenie źródła zdalnego i lokalnego
Dane zdalne pochodzą z PokeAPI za pośrednictwem usługi zdalnej; lokalnie znajdują się dwie tabele: Favorites, powiązana z uwierzytelnionymi użytkownikami, oraz PokemonCache, która przechowuje surowe dane wraz z datami czasowymi.
PokemonRepoImpl decyduje, jakie źródło skonsultować, więc ani przypadki użycia, ani interfejs użytkownika nie wiedzą o istnieniu buforowania.
Polityka buforowania na siedem dni
Dane o Pokémonach są buforowane lokalnie, aby strony z tymi danymi ładowały się szybciej i aby nie korzystano wielokrotnie z bezpłatnego, publicznego API. Proces decyzyjny jest prosty: sprawdza się zapis w buforze; jeśli istnieje i jego data czasowa jest starsza niż siedem dni, przekształca się go i zwraca; w przeciwnym razie pobiera się dane z PokeAPI, waliduje się treść, wprowadza ją do PokemonCache z aktualną datą czasową i zwraca przekształconą instancję.
Zachowaj funkcję sprawdzającą terminowość jako małą, czystą funkcję (na przykład w libs/data/utils/cache.ts), aby jej łatwo było testować jednostkowo. Jeśli PokeAPI jest niedostępny, gdy dane są przestarzałe, rozważ dostarczenie starej wersji zamiast powodowania błędu; to właśnie ten warstwa jest odpowiednim miejscem na taką politykę.
Obsługi tras jako stabilne kontrakty
Komponenty serwerowe mogłyby bezpośrednio wywoływać konkretne przypadki użycia, ale w tym projekcie dostęp do danych jest i tak kierowany przez obsługi znajdujące się pod adresem /app/api/**, pełniąc rolę proxy backend-for-frontend. Istnieją na to trzy powody.
- Jedna interfejs służy zarówno widokom serwerowym, widgetom klienckim, jak i przyszłym zewnętrznym użytkownikom.
- Pozwala to na zachowanie spójności systemu, ponieważ cały dostęp do danych przechodzi przez te same kontrakty.
- Pasuje to naturalnie do architektury Clean Architecture: API HTTP jest po prostu kolejnym adapterem na granicy otoczenia wokół rdzenia systemu.
Aby dowiedzieć się więcej na temat tego wzorca, zobacz przekształcanie obsługujących trasy w Next.js w celową warstwę BFF.
Tutaj istotna jest jedna szczegółowa kwestia związana z wersją. W najnowszych wydaniach Next.js (począwszy od wersji 15) wartość params w dynamicznych segmentach jest obiektem typu Promise. Obsługujące trasy funkcje oraz strony muszą najpierw wykonać await params, zanim przeczytają wartość name; sprawdź dokumentację, aby poznać dokładną specyfikację dla swojej wersji.
Autoryzacja oparta na plikach cookie
Proces autoryzacji odbywa się za pośrednictwem trzech punktów końcowych:
POST /api/auth/loginweryfikuje dane logowania i ustawia plik cookie typu httpOnly, na przykładpokedex_token.GET /api/auth/sessionodczytuje ten plik cookie, sprawdza token i zwraca aktualny stan sesji oraz informacje o użytkowniku.
POST /api/auth/logout usuwa plik cookie i odpowiada wartością { ok: true }.Dlaczego plik cookie httpOnly?
Plik cookie httpOnly nie może być odczytany z JavaScriptu po stronie klienta, więc wstrzykiwane skrypty nie mogą wydostać tokena JWT, a kod po stronie klienta nigdy nie przechowuje ani nie dołącza takich tokenów. Ponieważ przeglądarka wysyła ten plik cookie automatycznie, należy go połączyć z atrybutami SameSite i Secure, a także rozważyć zastosowanie ochrony CSRF przy operacjach zapisu.
Dostawca sesji dla interfejsu użytkownika
Interfejs użytkownika nadal musi wiedzieć, czy ktoś jest zalogowany, kim jest oraz kiedy odświeżyć treść po logowaniu lub wylogowaniu. Mały komponent po stronie klienta SessionProvider odpowiada na te pytania. Ładuje sesję z adresu /api/auth/session, przechowuje wartości status, isLoggedIn oraz user w stanie React i udostępnia metody syncAfterLogin() oraz logout(). Komponenty korzystają z tego kontekstu i w ogóle nie dotykają tokenów.
Komponenty serwerowe, Suspense i szkielety
Gdzie to możliwe, widoki są renderowane na serwerze i otoczone elementem Suspense. Dzięki temu uzyskuje się szybsze pierwsze wyświetlenie, strony nadające się do indeksowania oraz przejrzysty przepływ danych. Typowa strona składa się z dwóch elementów: granicy Suspense, której alternatywą jest komponent szkieletowy, oraz widoku serwerowego, który ładuje dane i renderuje gotowy interfejs użytkownika.
Przekierowywanie plików cookie przy wewnętrznych żądaniach fetch
W podejściu BFF kryje się subtelna pułapka. Gdy komponent serwerowy wywołuje jeden z własnych obsługowników tras aplikacji za pomocą bezwzględnej adresu URL, operacja fetch po stronie serwera stanowi zupełnie nowe żądanie, więc pliki cookie użytkownika nie są automatycznie dołączane. Rozwiązaniem jest odczytanie nagłówka cookie przychodzącego za pomocą metody headers() i jego wyraźne przekazanie jako nagłówek Cookie podczas wywołania adresu /api/favorites. Problem ten występuje najczęściej wtedy, gdy adres URL jest budowany na podstawie wartości host i protokołu żądania.
Opcją alternatywną jest całkowite pominięcie wewnętrznego HTTP i sprawienie, by widoki serwera bezpośrednio wywoływały przypadki użycia przez kontener. Trasa HTTP zapewnia jeden kontrakt dla wszystkiego, ale kosztem dodatkowego skoku sieciowego przy każdym renderowaniu oraz zależności od dostępności serwera (co również wyklucza tę opcję podczas budowy w trybie statycznym). Bezpośrednie wywołania przypadków użycia są szybsze i prostsze, ale oznaczają, że widoki serwera oraz elementy klienta używają różnych punktów wejścia. Ten projekt zachowuje stabilne kontrakty API i przekazuje pliki cookie.
Lubiany element od początku do końca
Aby korzystać z funkcji „Lubiany element”, potrzebny jest zalogowany użytkownik, odczyty z bazy danych oraz modyfikacje po stronie klienta. To obejmują dwie części:
- FavoritesView – komponent serwera, który pobiera dane z
/api/favoritesprzy użyciu przekazanych plików cookie i renderuje listę.
DELETE /api/favorites/:name, a następnie wywołuje router.refresh(), aby ponownie pobrać listę wygenerowaną na serwerze.router.refresh() ponownie renderuje komponenty serwerowe danej trasy, zachowując jednocześnie stan kliencki.
Zod sprawdza dane w każdym momencie, gdy przechodzą one przez granicę:
- Odpowiedzi z PokeAPI, walidowane jako odległe DTO przed mapowaniem;
- Ciała żądań dla punktów końcowych autoryzacji;
- Ładunki JSON przechowywane w bazie danych, takie jak zapisane szczegóły.
Parsowanie na granicach przekształca ukryte zmiany struktury danych, np. zmianę nazwy pola w górnej części łańcucha, w wyraźny błąd wcześniejszy i dokumentuje każdy kontrakt w kodzie.
Nie potrzebujesz dużego zestawu narzędzi, aby udowodnić architekturę. Dwie grupy testów bardzo pomagają:
- Narzędzie do sprawdzania terminu ważności pamięci podręcznej: na podstawie daty czasu sprawdź, czy jest starsza niż siedem dni, włączając przypadek graniczny.
- Zachowanie przypadków użycia: zastąp interfejsy repozytorium mockami i upewnij się, że każdy przypadek użycia korzysta z nich prawidłowo. Unikaj używania Prisma w testach domeny.
Możliwość testowania przypadków użycia bez uruchamiania Next.js pokazuje wyraźnie korzyści z takiego podziału na warstwy.
Uruchomienie kodu lokalnie
Repozytorium można sklonować przez HTTPS:
git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
lub przez SSH, jeśli masz klucz skonfigurowany w GitHubie:
git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
Następnie utwórz plik środowiskowy na podstawie dostarczonego szablonu i uzupełnij w nim adres bazy danych oraz informacje poufne, które są tam wymienione:
cp .env.example .env
Rozpocznij kontener PostgreSQL 16. Nazwa bazy danych, użytkownik oraz hasło są przeznaczone wyłącznie do lokalnego rozwoju i powinny odpowiadać tym, które wpisałeś w pliku .env; nazwany wolumen zapewnia przechowywanie danych między restartami kontenera.
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
Zastosuj migracje Prisma w celu utworzenia tabel:
npx prisma migrate dev
Na koniec uruchom serwer rozwojowy:
npm run dev
Wersja wdrożona jest dostępna jako żywa demonstracja.
Zakończenie
Czysta architektura w Next.js polega głównie na decydowaniu o tym, gdzie znajduje się każdy rodzaj wiedzy:
- Zachowaj
/appjako cienką warstwę adaptera i przenieś logikę do folderulibs. - Pozwól domenie definiować interfejsy i entity, a tylko warstwie danych powinna znać informacje o Prisma, HTTP i cache’ingu.
Dla prototypu na weekend może to być zbyt duża struktura; dla aplikacji, która będzie się rozwijać, granice pozwalają utrzymać każdą zmianę w lokalnym zakresie.