Startseite / Artikel

Trennung der Domänen-, Daten- und UI-Schichten in einer Next.js App Router Codebase

Ein Fallbeispiel aus dem Pokédex, das zeigt, wie eine Next.js App Router-Anwendung mit Prisma, Zod, Cookie-Authentifizierung und Caching in Domänen-, Daten- und Präsentationsschichten aufgeteilt werden kann.

Verlag: REACTAPP.TOP9 Min. Lesezeit2172 Wörter

Quellen: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study

Der Next.js App Router verwandelt das, was früher eine React-einseitige Anwendung war, in einen vollständigen Stack-Runtime: Server-Rendering, Route-Handler, Caching sowie direkter Datenbankzugriff befinden sich alle im selben Repository. Wenn man diese Codebasis als „nur Komponenten“ betrachtet, verschlechtert sich ihre Qualität schnell – mit Seiten, die mit der Datenbank kommunizieren, Widgets, die auf Drittanbieter-APIs zugreifen, sowie Geschäftsregeln, die in JSX-Bedingungen versteckt sind. In dieser Anleitung wird die Clean Architecture auf ein App Router-Projekt angewandt, wobei eine kleine Pokédex-Anwendung als Beispiel dient, um zu zeigen, wo sich jeweils bestimmte Aufgabenbereiche befinden und welche Fallstricke von Next.js durch diese Struktur offengelegt werden.

Die Beispielanwendung und ihre Einschränkungen

Die Pokédex ist absichtlich klein, berührt aber jede Art von Abhängigkeit, die ein echtes Produkt hat:

  • Ferndaten: die öffentliche PokeAPI, für die kein API-Schlüssel erforderlich ist.
  • Lokale Persistenz: PostgreSQL wird über Prisma aufgerufen.
  • Gespeicherter Zustand: die Favoriten jedes Benutzers sowie in Cache gespeicherte Pokémon-Details, die nach sieben Tagen aktualisiert werden.
  • Authentifizierung: E-Mail und Passwort, eine in einem httpOnly-Cookie gespeicherte Sitzung sowie ein clientseitiger Sitzungsanbieter.
  • Lade-UX: React Suspense-Grenzen mit Skeleton-Komponenten.
  • Typsicherheit: TypeScript im strengen Modus zusammen mit Laufzeitvalidierung mittels Zod.
  • Mutationen: Keine Server Actions; jede Schreiboperation läuft über einen Route-Handler unter /app/api/*.
  • Was passiert schief ohne Grenzen

    Next.js ermöglicht es jedem Datei, auf alles zuzugreifen – genau so verheddern sich Codebasen. Typische Symptome:

    • Seitendateien, die den Prisma-Client direkt importieren;
    • Komponenten, die selbst die PokeAPI aufrufen;
    • Domänenregeln, die in der Benutzeroberfläche als ad hoc Bedingungen dargestellt werden;
    • Logik, die man nicht testen kann, ohne das Framework zu starten, da alles damit verbunden ist.

    Clean Architecture setzt dem entgegen: Domänenregeln sind nicht mehr von Frameworks abhängig, Datenquellen können ohne Änderungen an der Benutzeroberfläche ausgetauscht werden, Anwendungsfalltests sind ohne Datenbank oder Next.js-Laufzeitmöglich, und Persistenzmechanismen sowie Benutzeroberflächen können ohne Neuschreiben geändert werden.

    Drei Schichten und eine Abhängigkeitsrichtung

    Das Projekt ist in drei Schichten unterteilt, und die Abhängigkeiten verlaufen ausschließlich nach innen zur Domäne:

    • Domäne (der Kern): Entitäten, Repository-Schnittstellen und Anwendungsfallbeschreibungen. Sie enthalten keine Next.js-Importe, keinen Prisma und keine konkreten Implementierungen.
  • Daten: Implementierungen von Repositorien, Prisma-Zugriff, der Remote-API-Client, DTOs, Mapper und Caching. Die gesamte Infrastruktur befindet sich hier.
  • Präsentation: Seiten, Ansichten, Widgets, Skelette, der Session-Provider sowie Fetch-Hilfsfunktionen. Es handelt sich dabei ausschließlich um die Benutzeroberfläche, die auf Daten nur über stabile APIs oder über durch einen Adapter bereitgestellte Anwendungsfälle zugreift.
  • Ein nützlicher Test: Löschen Sie die Daten- und Präsentationsordner – das Domain-Modell sollte weiterhin Typüberprüfungen durchführen können.

    Den App Router-Ordner schlank halten

    Ein klares mentales Modell beginnt mit einer sauberen Verzeichnisstruktur. Das /app-Verzeichnis gilt als Rand des Frameworks und enthält nur das, was Next.js zur Erkennung benötigt:

    • Routen und Routengruppen;
    • layout.tsx;
    • Routenhandler unter app/api/**.

    Die Geschäftslogik gehört nicht dorthin. Im Pokédex enthält app/ das Wurzellayout, eine (public)-Route-Gruppe (Anmelden, Registrieren, Pokémon-Details), eine (app)-Gruppe (Liste und Favoriten) sowie die api/-Handler. Alles Wesentliche befindet sich unter libs/domain/**, libs/data/** und libs/presentation/**, zusammen mit einer prisma/-Ordnerstruktur mit dem Schema und den Migrationsdateien.

    Die Domain-Schicht: nur Regeln und Verträge

    Die Domain enthält drei Arten von Elementen:

    • Entities wie Pokemon und User.
    • Repository-Interfaces wie PokemonRepo, FavoritesRepo und UserRepo.
  • Nutzungsfälle wie GetPokemonDetails, ListPokemon, AddFavorite, RemoveFavorite, GetFavorites, RegisterUser und LoginUser.
  • Zwei Regeln sorgen für Transparenz. Erstens darf im Domain-Bereich weder Next.js noch Prisma oder irgendein runtime-spezifisches Modul importiert werden. Zweitens gibt die Domain nur Domänenentitäten zurück, niemals Persistenzmodelle – daher kann ein Prisma-Row-Typ nicht über einen Nutzungsfall in die Benutzeroberfläche gelangen.

    Nutzungsfälle hängen ausschließlich von den Repository-Schnittstellen ab und nicht von deren Implementierungen. AddFavorite erhält über seinen Konstruktor oder eine Factory ein FavoritesRepo und kann nicht zwischen Postgres und einem in-Memory-Test-Doppel unterscheiden. Diese Inversion ist das Herzstück des Musters; eine Regel zur Beschränkung von Imports macht sie durchsetzbar.

    Die Datenschicht: Wo die Infrastruktur liegt

    Alles, was mit der Außenwelt in Berührung kommt, wird in der Daten-Schicht gesammelt:

    • der Prisma-Client und das Schema;
    • Implementierungen von Repositories, die mit dem Suffix Impl benannt sind;
    • ein Remote-Service, der PokeAPI umhüllt;
    • ein lokaler Service für die Datenbank (Lieblinge und Cache);
    • DTOs sowie Mapper, die zwischen den Übertragungsformaten und Entitäten übersetzen;
    • die Cache-Strategie mit ihrer siebentägigen TTL.

    Ein kleiner Abhängigkeits-injektions-Container in libs/data/di verbindet die Implementierungen mit den Schnittstellen, sodass der Rest der Anwendung statt selbst Repositories zu erstellen, den Container um einen fertiggestellten Use-Case bittet.

    Kombination einer Remote- und einer lokalen Quelle

    Die Ferndaten stammen über den Ferndienst von PokeAPI; lokal gibt es zwei Tabellen: Favorites, die mit angemeldeten Benutzern verknüpft sind, sowie PokemonCache, in dem die rohen Datendetails zusammen mit Zeitstempeln gespeichert werden.

    PokemonRepoImpl entscheidet, welche Quelle konsultiert werden soll, sodass weder die Anwendungsfallbeschreibungen noch die Benutzeroberfläche von der Existenz des Cachens wissen.

    Eine siebentägige Cache-Strategie

    Pokémon-Details werden lokal im Cache gespeichert, damit die Detailseiten schneller laden und nicht immer auf eine kostenlose öffentliche API zugegriffen werden muss. Der Entscheidungsablauf ist einfach: Sucht man im Cache nach einem Eintrag; existiert dieser und sein Zeitstempel ist jünger als sieben Tage, wird er abgerufen und zurückgegeben; ansonsten werden die Daten von PokeAPI heruntergeladen, der Datensatz überprüft, mit einem neuen Zeitstempel in PokemonCache eingefügt und die abgerufene Entität zurückgegeben.

    Machen Sie die Überprüfung auf Veraltetheit zu einer kleinen, reinen Funktion (zum Beispiel in libs/data/utils/cache.ts), damit sie leicht unitgetestet werden kann. Falls PokeAPI ausfällt, während ein Datensatz veraltet ist, sollten Sie in Erwägung ziehen, stattdessen die alte Kopie bereitzustellen, anstatt einen Fehler zu verursachen; diese Schicht eignet sich hervorragend für eine solche Strategie.

    Routenhandler als stabile Verträge

    Server Components könnten die jeweiligen Use-Cases direkt aufrufen, doch in diesem Projekt wird der Datenzugriff ohnehin über Handler unter /app/api/** geleitet, wodurch ein Backend-for-Frontend-Proxy entsteht. Dafür gibt es drei Gründe.

    1. Eine einzige Schnittstelle dient sowohl den Serveransichten, den Client-Widgets als auch zukünftigen externen Nutzern.
    2. Sie sorgt für Kohärenz im System, da alle Datenzugriffe über dieselben Verträge ablaufen.
    3. Sie passt sich nahtlos in die Clean Architecture ein: Die HTTP-API ist einfach nur ein weiterer Edge-Adapter um das Kernsystem herum.

    Weitere Informationen zu diesem Muster finden Sie unter Turning Next.js Route Handlers into a Deliberate BFF Layer.

    Ein versionsspezifischer Detail ist hier wichtig. In neueren Next.js-Versionen (ab Next.js 15) ist params in dynamischen Segmenten ein Promise. Route-Handler und Seiten müssen await params aufrufen, bevor sie name lesen; prüfen Sie die Dokumentation für die genaue Signatur Ihrer Version.

    Authentifizierung auf Basis von Cookies

    Die Authentifizierung wird über drei Endpunkte bereitgestellt:

    • POST /api/auth/login überprüft die Anmeldeinformationen und setzt ein httpOnly-Cookie, beispielsweise pokedex_token.
    • GET /api/auth/session liest dieses Cookie, überprüft den Token und gibt den aktuellen Session-Zustand sowie den Benutzer zurück.
  • POST /api/auth/logout löscht die Cookie und antwortet mit { ok: true }.
  • Warum ein httpOnly-Cookie?

    Ein httpOnly-Cookie kann von Client-JavaScript niemals gelesen werden, sodass eingefügte Skripte das JWT nicht entwenden können und der Client-Code niemals Token speichert oder hinzufügt. Da der Browser das Cookie automatisch sendet, sollte es mit den Attributen SameSite und Secure kombiniert werden, und für Schreibvorgänge sollte eine CSRF-Schutzmaßnahme in Betracht gezogen werden.

    Ein Session-Anbieter für die Benutzeroberfläche

    Die Benutzeroberfläche muss weiterhin wissen, ob jemand eingeloggt ist, wer es ist, sowie wann nach einem Login oder Logout aktualisiert werden soll. Ein kleiner Client-seitiger SessionProvider beantwortet diese Fragen. Er lädt die Session von /api/auth/session herunter, speichert status, isLoggedIn und user im React-State und stellt syncAfterLogin() sowie logout() zur Verfügung. Komponenten nutzen diesen Kontext und greifen niemals direkt auf Tokens zu.

    Server-Komponenten, Suspense und Skelette

    Wo es sinnvoll ist, werden Ansichten auf dem Server gerendert und in Suspense eingebettet. Dadurch wird eine schnellere Erstansicht, die Möglichkeit zur Indizierung der Seiten sowie ein leicht verständlicher Datenfluss gewährleistet. Eine typische Seite setzt sich aus zwei Teilen zusammen: einer Suspense-Grenze, deren Ersatz eine Skelett-Komponente ist, sowie einer Server-Ansicht, die Daten lädt und die fertige Benutzeroberfläche darstellt.

    Versand von Cookies bei internen Fetch-Anfragen

    In dem BFF-Ansatz verbirgt sich eine subtile Falle. Wenn ein Server-Komponente einen der eigenen Route-Handler der Anwendung über eine absolute URL aufruft, ist der serverseitige fetch-Aufruf eine völlig neue Ausgangsanfrage, wodurch die Cookies des Benutzers nicht automatisch mitgesendet werden. Die Lösung besteht darin, den eingehenden cookie-Header mit headers() abzurufen und ihn explizit als Cookie-Header bei dem Aufruf von /api/favorites mitzusenden. Dies tritt am häufigsten auf, wenn die URL aus dem host- und Protokollwert der Anfrage gebildet wird.

    Die Alternative besteht darin, interne HTTP-Anfragen vollständig zu umgehen und die Serveransichten dazu zu bringen, die Use-Cases direkt über den Container aufzurufen. Der HTTP-Route bewahrt zwar ein einheitliches Kontrakt für alles, kostet aber einen zusätzlichen Netzwerktransfer pro Darstellung und setzt voraus, dass der Server erreichbar ist – was ihn außerdem bei statischen Builds ausschließt. Direkte Aufrufe der Use-Cases sind schneller und einfacher, bedeuten aber, dass Serveransichten und Client-Widgets unterschiedliche Eingangspunkte verwenden. Dieses Projekt behält die stabilen API-Kontrakte bei und leitet Cookies weiter.

    Favoriten von Anfang bis Ende

    Favoriten erfordern einen authentifizierten Benutzer, Datenbankabfragen sowie Änderungen auf der Client-Seite. Dafür sind zwei Komponenten zuständig:

    • FavoritesView, eine Serverkomponente, die mit den weitergeleiteten Cookies /api/favorites abruft und die Liste darstellt.
  • RemoveFavoriteButton, ein Client-Komponente, die DELETE /api/favorites/:name sendet und anschließend router.refresh() aufruft, damit die auf dem Server gerenderte Liste erneut abgerufen wird.
  • router.refresh() rendert die Server-Komponenten der Route erneut, wobei der Client-Zustand beibehalten wird.

    Validierung jeder Grenze mit Zod

    Zod überprüft die Daten an jedem Punkt, an dem sie eine Grenze überschreiten:

    • Antworten von PokeAPI, die vor der Koppelung als entfernte DTOs validiert werden;
    • Anfragekörper für die Authentifizierungsendpunkte;
    • JSON-Payloads, die in der Datenbank gespeichert sind, wie beispielsweise gehaltene Details.

    Die Parsing-Überprüfung an den Rändern verwandelt unauffällige Veränderungen der Struktur, wie beispielsweise ein oben im System umbenanntes Feld, in einen expliziten Frühfehler und dokumentiert jeden Vertrag im Code.

    Eine minimale, aber nützliche Testbasis

    Man benötigt kein umfangreiches Testframework, um die Architektur zu beweisen. Zwei Gruppen von Tests reichen aus:

    • Hilfsfunktion zur Überprüfung der Cache-Veraltetheit: Unter Verwendung eines Zeitstempels wird überprüft, ob er älter als sieben Tage ist – auch der Grenzfall wird berücksichtigt.
    • Behavior der Anwendungsfall-Szenarien: Die Repository-Interfaces werden durch Mocks ersetzt, und es wird überprüft, dass jedes Anwendungsfall-Szenario sie korrekt aufruft. Prisma sollte in den Domänen-Tests überhaupt nicht verwendet werden.

    Dass Anwendungsfall-Szenarien getestet werden können, ohne dass Next.js läuft, zeigt deutlich die Vorteile dieser Schichtung.

    Den Code lokal ausführen

    Das Repository kann über HTTPS geklont werden:

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

    oder über SSH, falls Sie eine mit GitHub konfigurierte Schlüsselverwaltung haben:

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

    Anschließend erstellen Sie Ihre Umgebungsdatei anhand des bereitgestellten Templates und füllen Sie die dort aufgeführte Datenbank-URL sowie Geheimnisse ein:

    cp .env.example .env
    

    Starten Sie einen PostgreSQL 16-Container. Der Datenbankname, der Benutzer sowie das Passwort dienen ausschließlich für die lokale Entwicklung und sollten mit den Werten in .env übereinstimmen; das benannte Volume bewahrt die Daten über Neustarts des Containers hinweg.

    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
    

    Wenden Sie die Prisma-Migrations an, um die Tabellen zu erstellen:

    npx prisma migrate dev
    

    Schließlich starten Sie den Entwicklungsserver:

    npm run dev
    

    Eine veröffentlichte Version ist als lebende Demo verfügbar.

    Zusammenfassung

    Clean Architecture in Next.js dreht sich hauptsächlich darum, zu entscheiden, wo jede Art von Wissen gespeichert wird:

    • Lassen Sie /app als dünne Adapterschicht und verlagern Sie die Logik in libs.
    • Lassen Sie das Domain-Modul die Schnittstellen und Entitäten definieren, wobei nur die Daten-Schicht von Prisma, HTTP und Caching weiß.
  • Überprüfen Sie an jeder Grenze, damit die Verträge zur Laufzeit eingehalten werden, nicht nur in den Typen.
  • Wählen Sie bewusst zwischen internen Route-Handlern (ein Vertrag, Cookie-Weiterleitung erforderlich) und direkten Aufrufen der Anwendungsfallfunktionen (schneller, weniger Komponenten).
  • Fangen Sie mit den reinen Bestandteilen an – der Caching-Strategie und den Anwendungsfallfunktionen – mit dem Testen.
  • Für ein Prototyp im Wochenendzeitraum mag das mehr Struktur sein, als Sie benötigen; für eine Anwendung, die weiter wächst, halten die Grenzen jede Änderung lokal begrenzt.