Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP9 min czytania2172 słów

Ź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.
  • Trwałość lokalna: PostgreSQL dostępny przez Prismę.
  • Stan przechowywany: ulubione elementy każdego użytkownika oraz zapisane w pamięci podręcznej szczegóły Pokémonów, które są odświeżane po siedmiu dniach.
  • Autoryzacja: adres e-mail i hasło, sesja przechowywana w pliku cookie typu httpOnly oraz narzędzie do zarządzania sesją po stronie klienta.
  • UX podczas ładowania: mechanizm React Suspense z komponentami typu skeleton.
  • Bезpieczeństwo typów: TypeScript w trybie ścisłym w połączeniu z walidacją w czasie wykonywania przy użyciu Zod.
  • Mutacje: brak Server Actions; każda operacja zapisu przechodzi przez obsługę ścieżki pod adresem /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.
  • Data: implementacje repozytoriów, dostęp do Prismy, klient API zdalnego, DTO-y, mapery oraz mechanizmy cacheowania. Wszystka infrastruktura znajduje się tutaj.
  • Presentation: strony, widoki, elementy interfejsu, szkielety stron, dostawca sesji oraz narzędzia do pobierania danych. To czysto elementy interfejsu użytkownika, które uzyskują dostęp do danych wyłącznie poprzez stabilne API lub za pośrednictwem przypadków użycia dostarczanych przez adaptera.
  • 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 Pokemon i User.
    • Interfejsy repozytoriów, takie jak PokemonRepo, FavoritesRepo i UserRepo.
  • Zastosowania takie jak 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.

    1. Jedna interfejs służy zarówno widokom serwerowym, widgetom klienckim, jak i przyszłym zewnętrznym użytkownikom.
    2. Pozwala to na zachowanie spójności systemu, ponieważ cały dostęp do danych przechodzi przez te same kontrakty.
    3. 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.

    Proces autoryzacji odbywa się za pośrednictwem trzech punktów końcowych:

    • POST /api/auth/login weryfikuje dane logowania i ustawia plik cookie typu httpOnly, na przykład pokedex_token.
    • GET /api/auth/session odczytuje 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/favorites przy użyciu przekazanych plików cookie i renderuje listę.
  • RemoveFavoriteButton, komponent kliencki, który wysyła żądanie 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 /app jako cienką warstwę adaptera i przenieś logikę do folderu libs.
    • Pozwól domenie definiować interfejsy i entity, a tylko warstwie danych powinna znać informacje o Prisma, HTTP i cache’ingu.

  • Waliduj na każdej granicy, aby umowy były egzekwowane w czasie wykonywania, a nie tylko na poziomie typów.
  • Decyduj świadomie pomiędzy wewnętrznymi obsługami tras (jedna umowa, wymagane przekierowanie plików cookie) a bezpośrednimi wywołaniami przypadków użycia (szybciej, mniej elementów działających wspólnie).
  • Rozpocznij testowanie od prostych elementów, takich jak polityka cache’owania i przypadki użycia.
  • 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.