Séparer les couches de domaine, de données et d’interface utilisateur dans un codebase Next.js App Router
Une étude de cas Pokédex montrant comment diviser une application Next.js App Router en couches de domaine, de données et de présentation à l’aide de Prisma, Zod, d’authentification par cookies et du cache.
Sources: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study
Le Next.js App Router transforme ce qui était auparavant une application single-page React en un environnement de développement full-stack : le rendu serveur, les gestionnaires de route, le cache et l’accès direct à la base de données se trouvent tous dans le même répertoire. Si l’on considère cette base de code comme « uniquement des composants », sa qualité diminue rapidement, avec des pages qui communiquent avec la base de données, des widgets qui appellent des API tierces et des règles métier cachées dans des conditions JSX. Cette présentation applique l’Architecture Propre à un projet App Router en utilisant une petite application Pokédex comme exemple concret, montrant où chaque élément doit être placé et quels pièges de Next.js cette structure met en évidence.
L’application d’exemple et ses contraintes
Le Pokédex est délibérément simple, mais il couvre tous les types de dépendances que possède un produit réel :
- Données distantes : la plateforme publique PokeAPI, qui ne nécessite pas de clé API.
/app/api/*.Quelles sont les conséquences en l’absence de limites
Next.js permet à n’importe quel fichier d’accéder à n’importe quoi, ce qui est précisément la cause des enchevêtrements dans les bases de code. Symptômes typiques :
- Fichiers de page qui importent directement le client Prisma ;
- Composants qui appellent eux-mêmes PokeAPI ;
- Règles de domaine exprimées sous forme de conditions ad hoc dans l’interface utilisateur ;
- Logique que l’on ne peut pas tester sans démarrer le framework, car tout y est relié.
L’Architecture Propre remédie à cela : les règles de domaine cessent de dépendre des frameworks, les sources de données peuvent être remplacées sans modification de l’interface utilisateur, les cas d’utilisation peuvent être testés sans base de données ni environnement de runtime Next.js, et la persistance ou les écrans peuvent être modifiés sans réécriture du code.
Trois couches et une seule direction de dépendance
Le projet est divisé en trois couches, et les dépendances pointent uniquement vers l’intérieur, vers le domaine :
- Domaine (le cœur) : entités, interfaces de repository et cas d’utilisation. Il ne contient aucune importation Next.js, ni Prisma, ni de implementations concrètes.
Un test utile : supprimez les dossiers des données et de la présentation ; le domaine doit toujours passer les vérifications de type.
Rendre le dossier App Router léger
/app est considéré comme la frontière du framework et ne contient que ce dont Next.js a besoin pour fonctionner :
- routes et groupes de routes ;
layout.tsx;- gérants de routes sous
app/api/**.
La logique métier ne doit pas se trouver là. Dans le Pokédex, app/ contient le layout racine, un groupe de routes (public) (connexion, inscription, détails des Pokémon), un groupe (app) (liste et favoris) ainsi que les gestionnaires api/. Tout ce qui est essentiel se trouve sous libs/domain/**, libs/data/** et libs/presentation/**, à côté d’un dossier prisma/ contenant le schéma et les migrations.
La couche de domaine : uniquement des règles et des contrats
Le domaine contient trois types d’éléments :
- Entités telles que
PokemonetUser. - Interfaces de repository telles que
PokemonRepo,FavoritesRepoetUserRepo.
GetPokemonDetails, ListPokemon, AddFavorite, RemoveFavorite, GetFavorites, RegisterUser et LoginUser.Deux règles garantissent la transparence. Premièrement, rien dans le domaine ne doit importer Next.js, Prisma ou tout module spécifique à un environnement de exécution. Deuxièmement, le domaine renvoie des entités propres au domaine, jamais des modèles de persistance ; ainsi, un type de ligne Prisma ne peut pas s’infiltrer dans l’interface utilisateur via un cas d’utilisation.
Les cas d’utilisation ne dépendent que des interfaces du repository, et non de leurs implémentations. AddFavorite reçoit un FavoritesRepo via son constructeur ou une factory, et ne peut pas distinguer Postgres d’un double de test en mémoire. Cette inversion constitue l’essence du pattern ; une règle de lintage sur les imports restreints permet de la faire respecter.
La couche de données : où réside l’infrastructure
Tout ce qui entre en contact avec le monde extérieur est regroupé dans la couche de données :
- le client et le schéma Prisma ;
- les implémentations de repository, nommées avec un suffixe
Impl; - un service distant qui encapsule PokeAPI ;
- un service local pour la base de données (favoris et cache) ;
- des DTO et des mappers qui traduisent entre les formats de transmission et les entités ;
- la politique de cache avec son TTL de sept jours.
Un petit conteneur d’injection de dépendances situé dans libs/data/di relie les implémentations aux interfaces, de sorte que le reste de l’application demande au conteneur un cas d’utilisation prêt à l’emploi plutôt que de construire des repositories elle-même.
Combinaison d’une source distante et d’une source locale
Les données distantes proviennent de PokeAPI via le service à distance ; localement, on trouve deux tables : Favorites, liée aux utilisateurs authentifiés, et PokemonCache, qui stocke les détails bruts ainsi que des timestamps.
PokemonRepoImpl décide quelle source consulter, de sorte que ni les cas d’utilisation ni l’interface utilisateur ne sont au courant de l’existence du cache.
Politique de cache sur sept jours
Les détails des Pokémon sont stockés en cache localement afin que les pages de détail chargent plus rapidement et pour éviter d’appeler répétitivement une API publique gratuite. Le processus de décision est simple : rechercher l’enregistrement en cache ; s’il existe et que son timestamp n’est pas ancien de plus de sept jours, le transformer et le retourner ; sinon, récupérer les données depuis PokeAPI, valider le payload, l’insérer dans PokemonCache avec un nouveau timestamp, puis retourner l’entité transformée.
Conservez la vérification de l’obsolescence sous forme de petite fonction pure (par exemple dans libs/data/utils/cache.ts) afin qu’il soit facile de la tester en unité. Si PokeAPI est indisponible lorsque les données sont obsolètes, envisagez de servir la version ancienne au lieu d’échouer ; cette couche est l’endroit idéal pour mettre en œuvre une telle politique.
Gestionnaires de routes en tant que contrats stables
Les composants serveur pourraient appeler directement les cas d’usage, mais ce projet dirige tout de même l’accès aux données via des gestionnaires situés sous /app/api/**, agissant comme un proxy backend-for-frontend. Il y a trois raisons à cela.
- Une seule interface sert les vues serveur, les widgets client et tout futur consommateur externe.
- Cela maintient la cohérence du système, puisque tout accès aux données passe par les mêmes contrats.
- Cela s’intègre naturellement à l’Architecture Propre : l’API HTTP n’est rien de plus qu’un autre adaptateur d’interface autour du noyau.
Pour en savoir plus sur ce schéma, consultez transformer les gestionneurs de route de Next.js en une couche BFF délibérée.
Un détail spécifique à la version est important ici. Dans les dernières versions de Next.js (à partir de Next.js 15), params dans les segments dynamiques est une Promise. Les gestionneurs de route et les pages doivent await params avant de lire name ; consultez la documentation pour connaître la signature exacte de votre version.
Authentification basée sur des cookies
L’authentification s’effectue via trois points de terminaison :
POST /api/auth/loginvalide les identifiants et définit un cookie httpOnly, par exemplepokedex_token.GET /api/auth/sessionlit ce cookie, vérifie le jeton et renvoie l’état actuel de la session ainsi que l’utilisateur.
POST /api/auth/logout supprime le cookie et répond par { ok: true }.Pourquoi un cookie httpOnly
SameSite et Secure, ainsi que d’envisager une protection CSRF pour les écritures.
Un fournisseur de session pour l’interface utilisateur
L’interface utilisateur doit encore savoir si quelqu’un est connecté, qui c’est, ainsi que quand actualiser les données après connexion ou déconnexion. Un petit SessionProvider côté client répond à ces questions : il charge la session depuis /api/auth/session, conserve les valeurs de status, isLoggedIn et user dans l’état de React, et expose les fonctions syncAfterLogin() et logout(). Les composants utilisent ce contexte sans jamais toucher aux tokens.
Composants serveur, Suspense et squelettes
Lorsque c’est utile, les vues sont rendues sur le serveur et enveloppées dans Suspense. Cela permet une première affichage plus rapide, des pages indexables et un flux de données facile à suivre. Une page typique combine deux éléments : une frontière Suspense dont le fallback est un composant squelette, et une vue serveur qui charge les données et affiche l’interface finale.
Réenvoi des cookies lors des requêtes internes
Il existe un piège subtil dans l’approche BFF. Lorsqu’un composant serveur appelle l’un des gestionnaires de route de l’application via une URL absolue, la requête fetch côté serveur constitue une nouvelle demande externe, de sorte que les cookies de l’utilisateur ne sont pas automatiquement inclus. La solution consiste à lire l’en-tête cookie entrant avec headers() et à le transmettre explicitement en tant qu’en-tête Cookie lors de l’appel à /api/favorites. Ce problème se produit le plus souvent lorsque l’URL est générée à partir du host et du protocole de la requête.
L’alternative consiste à éviter complètement l’HTTP interne et à faire en sorte que les vues serveur appellent directement les cas d’usage via le conteneur. La route HTTP maintient un seul contrat pour tout, au prix d’un saut réseau supplémentaire à chaque rendu et d’une dépendance vis-à-vis de la disponibilité du serveur (ce qui l’exclut également lors des builds statiques). Les appels directs aux cas d’usage sont plus rapides et plus simples, mais cela signifie que les vues serveur et les widgets clients utilisent des points d’entrée différents. Ce projet conserve les contrats API stables et redirige les cookies.
Favoris de bout en bout
Les favoris nécessitent un utilisateur authentifié, des lectures dans la base de données et des modifications côté client. Deux composants couvrent cela :
- FavoritesView, un composant serveur qui récupère
/api/favoritesavec les cookies redirigés et affiche la liste.
DELETE /api/favorites/:name puis appelle router.refresh() afin que la liste générée par le serveur soit récupérée à nouveau.router.refresh() ré-génère les composants serveur de la route tout en préservant l’état du client.
Vérification de chaque limite avec Zod
Zod vérifie les données à chaque point où elles franchissent une limite :
- Les réponses de PokeAPI, validées en tant que DTO distants avant le mappage ;
- Les corps des requêtes pour les points d’entrée d’authentification ;
- Les charges JSON stockées dans la base de données, comme les détails en mémoire tampon.
La vérification aux extrémités transforme les écarts discrets de structure, tels qu’un champ rebaptisé en amont, en erreurs explicites précoces et documente chaque contrat dans le code.
Une base de tests minimale mais utile
Vous n’avez pas besoin d’une suite complète pour valider l’architecture. Deux groupes de tests suffisent amplement :
- L’outil de vérification de la date d’expiration du cache : à partir d’une date et heure, vérifier si elle est antérieure de sept jours, y compris le cas limite.
- Comportement des scénarios d’utilisation : remplacer les interfaces du répertoire par des mocks et s’assurer que chaque scénario d’utilisation les appelle correctement. Éviter complètement l’usage de Prisma dans les tests de domaine.
Le fait de pouvoir tester les scénarios d’utilisation sans que Next.js ne soit en cours d’exécution montre clairement l’avantage de cette approche par couches.
Faire fonctionner le code localement
Le répertoire peut être cloné via HTTPS :
git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
ou via SSH, si vous avez une clé configurée avec GitHub :
git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
Ensuite, créez votre fichier d’environnement à partir du modèle fourni et remplissez l’URL de la base de données ainsi que les secrets qu’il indique :
cp .env.example .env
Démarrer un conteneur PostgreSQL 16. Le nom de la base de données, l’utilisateur et le mot de passe indiqués ici ne sont valables que pour le développement local et doivent correspondre à ceux que vous avez saisis dans .env ; le volume nommé permet de conserver les données entre les redémarrages du conteneur.
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
Appliquer les migrations Prisma pour créer les tables :
npx prisma migrate dev
Finalement, démarrer le serveur de développement :
npm run dev
Une version déployée est disponible en tant que démonstration en direct.
En résumé
L’Architecture propre dans Next.js consiste principalement à décider où se trouve chaque type de connaissance :
- Garder
/appen tant que couche d’adaptation légère et déplacer la logique danslibs. - Laisser le domaine définir les interfaces et les entités, et ne permettre à la couche de données que d’être au courant de Prisma, d’HTTP et du cache.
Pour un prototype de week-end, cela peut représenter plus de structure que nécessaire ; pour une application qui continuera à évoluer, ces limites permettent de garder chaque modification locale.
Lectures complémentaires
- Contrats types et mécanismes de vérification Zod pour un tableau de bord analytique en temps réel via WebSocket — Comment créer un tableau de bord fiable en temps réel avec TypeScript : définir des contrats pour les données transmises, valider les messages WebSocket à l’aide de Zod et éviter les connexions dupliquées.