Separar las capas de dominio, datos y interfaz de usuario en un código base de Next.js App Router
Un estudio de caso de Pokédex que muestra cómo dividir una aplicación Next.js App Router en capas de dominio, datos y presentación utilizando Prisma, Zod, autenticación por cookies y caché.
Fuentes: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study
El App Router de Next.js convierte lo que antes era una aplicación de página única de React en un entorno de ejecución full-stack: renderizado en servidor, controladores de rutas, caché y acceso directo a la base de datos se encuentran todos en el mismo repositorio. Si tratas esa base de código como “solo componentes”, su rendimiento empeora rápidamente, con páginas que se comunican con la base de datos, widgets que llaman a APIs de terceros y reglas de negocio ocultas en condicionales JSX. Esta guía aplica la Arquitectura Limpia a un proyecto de App Router utilizando una pequeña aplicación Pokédex como ejemplo práctico, mostrando dónde pertenece cada aspecto del sistema y qué problemas de Next.js revela esta estructura.
La aplicación de ejemplo y sus limitaciones
Pokédex es deliberadamente sencilla, pero abarca todo tipo de dependencias que tiene un producto real:
- Datos remotos: la API pública PokeAPI, que no requiere clave API.
/app/api/*.Qué sucede si no se usan límites
Next.js permite que cualquier archivo acceda a cualquier elemento, y así es como se enredan los conjuntos de código. Síntomas típicos:
- archivos de página que importan directamente el cliente Prisma;
- componentes que llaman a PokeAPI por sí mismos;
- reglas de dominio expresadas como condicionales ad hoc en la interfaz de usuario;
- lógica que no se puede probar sin iniciar el framework, ya que todo está conectado a él.
La Arquitectura Limpia contrarresta esto: las reglas de dominio dejan de depender de los frameworks, se pueden cambiar las fuentes de datos sin editar la interfaz de usuario, se pueden probar los casos de uso sin una base de datos ni el entorno de ejecución de Next.js, y la persistencia o las pantallas pueden modificarse sin tener que reescribir el código.
Tres capas y una dirección de dependencia
El proyecto se divide en tres capas, y las dependencias apuntan únicamente hacia adentro, hacia el dominio:
- Dominio (el núcleo): entidades, interfaces de repositorio y casos de uso. No contiene importaciones de Next.js, ni Prisma, ni implementaciones concretas.
Una prueba útil: elimine las carpetas de datos y presentación; el dominio aún debería pasar las comprobaciones de tipo.
Mantener la carpeta App Router ligera
Un modelo mental claro comienza con un árbol de directorios ordenado. La carpeta /app se considera el límite del framework y solo contiene lo que Next.js necesita para detectarlo:
- rutas y grupos de rutas;
layout.tsx;- controladores de rutas en
app/api/**.
La lógica de negocio no pertenece allí. En el Pokédex, app/ alberga el diseño raíz, un grupo de rutas (public) (iniciar sesión, registrarse, detalles de Pokémon), un grupo (app) (lista y favoritos) y los controladores api/. Todo lo importante se encuentra en libs/domain/**, libs/data/** y libs/presentation/**, junto con una carpeta prisma/ que contiene el esquema y las migraciones.
La capa de dominio: solo reglas y contratos
El dominio contiene tres tipos de elementos:
- Entidades como
PokemonyUser. - Interfases de repositorio como
PokemonRepo,FavoritesRepoyUserRepo.
GetPokemonDetails, ListPokemon, AddFavorite, RemoveFavorite, GetFavorites, RegisterUser y LoginUser.Existen dos reglas que garantizan la integridad del sistema. Primero, nada en el dominio puede importar Next.js, Prisma ni ningún módulo específico del entorno de ejecución. Segundo, el dominio devuelve entidades propias del dominio, nunca modelos de persistencia; por lo tanto, un tipo de fila de Prisma no puede filtrarse a la interfaz de usuario a través de algún caso de uso.
Los casos de uso dependen únicamente de las interfaces del repositorio, no de sus implementaciones. AddFavorite recibe un objeto FavoritesRepo a través de su constructor o una función de creación, y no puede distinguir entre Postgres y un modelo de prueba en memoria. Esa inversión es el núcleo del patrón; una regla de validación que restringe las importaciones la hace aplicable.
La capa de datos: donde reside la infraestructura
Todo lo que tiene contacto con el mundo exterior se recopila en la capa de datos:
- el cliente y esquema de Prisma;
- las implementaciones de repositorio, denominadas con el sufijo
Impl; - un servicio remoto que encapsula a PokeAPI;
- un servicio local para la base de datos (favoritos y caché);
- DTOs y mapeadores que traducen entre formatos de transmisión y entidades;
- la política de caché con su TTL de siete días.
Un pequeño contenedor de inyección de dependencias en libs/data/di conecta las implementaciones con las interfaces, de modo que el resto de la aplicación solicita al contenedor un caso de uso ya preparado en lugar de construir los repositorios por sí misma.
Combinar una fuente remota y una local
Los datos remotos provienen de PokeAPI a través del servicio remoto; localmente existen dos tablas: Favorites, vinculada a los usuarios autenticados, y PokemonCache, que almacena los detalles brutos junto con las marcas de tiempo.
PokemonRepoImpl decide qué fuente consultar, por lo que ni los casos de uso ni la interfaz de usuario saben que existe el caché.
Política de caché de siete días
Los detalles de Pokémon se almacenan en caché localmente para que las páginas de detalles carguen más rápido y no se recurra repetidamente a una API pública gratuita. El flujo de decisión es sencillo: buscar el registro en caché; si existe y su marca de tiempo tiene menos de siete días, mapearlo y devolverlo; de lo contrario, obtenerlo de PokeAPI, validar el contenido, insertarlo en PokemonCache con una marca de tiempo actualizada y devolver la entidad mapeada.
Mantenga la verificación de obsolescencia como una función pura y sencilla (por ejemplo en libs/data/utils/cache.ts) para que sea fácil de probar unitariamente. Si PokeAPI está inoperativo cuando un registro está obsoleto, considere servir la copia antigua en lugar de fallar; esta capa es el lugar adecuado para tal política.
Manejadores de rutas como contratos estables
Los componentes del servidor podrían llamar directamente a los casos de uso, pero este proyecto dirige el acceso a los datos a través de manejadores bajo /app/api/** de todos modos, funcionando como un proxy backend-for-frontend. Hay tres razones para ello.
- Una única interfaz sirve a las vistas del servidor, los widgets del cliente y cualquier consumidor externo futuro.
- Mantiene el sistema coherente, ya que todo el acceso a los datos fluye a través de los mismos contratos.
- Se ajusta naturalmente a la Arquitectura Limpia: la API HTTP es simplemente otro adaptador periférico alrededor del núcleo.
Para obtener más información sobre este patrón, consulte cómo convertir los controladores de ruta de Next.js en una capa BFF deliberada.
Hay un detalle específico de la versión que es importante aquí. En las versiones recientes de Next.js (a partir de Next.js 15), params en los segmentos dinámicos es un Promise. Los controladores de ruta y las páginas deben await params antes de leer name; consulte la documentación para conocer la firma exacta de su versión.
Autenticación basada en cookies
La autenticación se expone a través de tres puntos de extremo:
POST /api/auth/loginvalida las credenciales y establece una cookie httpOnly, por ejemplopokedex_token.GET /api/auth/sessionlee esa cookie, verifica el token y devuelve el estado actual de la sesión y al usuario.
POST /api/auth/logout elimina la cookie y responde con { ok: true }.Por qué una cookie httpOnly
Una cookie httpOnly nunca puede ser leída desde JavaScript del cliente, por lo que los scripts inyectados no pueden extraer el JWT, y el código del cliente nunca almacena ni adjunta tokens. Dado que el navegador envía la cookie automáticamente, úsela junto con los atributos SameSite y Secure, y considere la protección CSRF para las operaciones de escritura.
Un proveedor de sesiones para la interfaz de usuario
La interfaz de usuario aún necesita saber si alguien está conectado, quién es y cuándo actualizarla después de iniciar o cerrar sesión. Un pequeño SessionProvider del lado del cliente responde a eso. Carga la sesión desde /api/auth/session, mantiene status, isLoggedIn y user en el estado de React, y expone syncAfterLogin() y logout(). Los componentes utilizan este contexto y nunca manipulan los tokens.
Componentes del servidor, Suspense y esqueletos
Allí donde sea útil, las vistas se renderizan en el servidor y se envuelven en Suspense. Esto permite una carga inicial más rápida, páginas indexables y un flujo de datos fácil de seguir. Una página típica combina dos elementos: un límite Suspense cuyo fallback es un componente esqueleto, y una vista del servidor que carga los datos y renderiza la interfaz final.
Reenvío de cookies en solicitudes internas
Existe una trampa sutil en el enfoque BFF. Cuando un componente del servidor llama a uno de los manejadores de rutas de la aplicación mediante una URL absoluta, la solicitud fetch del lado servidor es una nueva petición saliente, por lo que las cookies del usuario no se incluyen automáticamente. La solución es leer el encabezado cookie entrante con headers() y pasarlo explícitamente como un encabezado Cookie en la llamada a /api/favorites. Esto ocurre con mayor frecuencia cuando la URL se construye a partir del host y protocolo de la solicitud.
La alternativa es omitir por completo el HTTP interno y hacer que las vistas del servidor llamen directamente a los casos de uso a través del contenedor. La ruta HTTP mantiene un único contrato para todo, pero con el costo de un salto adicional en la red por cada renderizado y una dependencia de que el servidor esté accesible (lo cual también lo descarta durante las compilaciones estáticas). Las llamadas directas a los casos de uso son más rápidas y sencillas, pero implican que las vistas del servidor y los widgets del cliente utilicen puntos de entrada diferentes. Este proyecto mantiene los contratos API estables y reenvía las cookies.
Favoritos de extremo a extremo
Los favoritos requieren un usuario autenticado, lecturas de la base de datos y mutaciones en el lado del cliente. Dos partes se encargan de esto:
- FavoritesView, un componente del servidor que obtiene datos de
/api/favoritescon las cookies reenviadas y muestra la lista.
DELETE /api/favorites/:name y luego llama a router.refresh() para que se vuelva a obtener la lista generada por el servidor.router.refresh() vuelve a renderizar los componentes del servidor de la ruta mientras mantiene el estado del cliente.
Validación de todos los límites con Zod
Zod verifica los datos en cada punto donde cruzan un límite:
- Las respuestas de PokeAPI, validadas como DTO remotos antes del mapeo;
- Los cuerpos de las solicitudes para los puntos finales de autenticación;
- Los payloads JSON almacenados en la base de datos, como los detalles en caché.
El análisis en los bordes convierte cambios silenciosos en la estructura de los datos, como un campo que recibe otro nombre en una etapa anterior, en errores explícitos desde el principio y documenta cada contrato en el código.
Una línea de base de pruebas mínima pero útil
No se necesita un conjunto amplio de pruebas para demostrar la arquitectura. Dos grupos de pruebas son suficientes:
- El ayudante para verificar la antigüedad de la caché: dado un timestamp, comprobar si es anterior a siete días, incluyendo el caso límite.
- Comportamiento de los casos de uso: reemplazar las interfaces del repositorio por simulaciones y asegurarse de que cada caso de uso las llame correctamente. Mantener a Prisma completamente fuera de las pruebas de dominio.
Poder probar los casos de uso sin que Next.js esté en ejecución es donde la estructura en capas realmente se demuestra útil.
Ejecutar el código localmente
El repositorio puede clonarse mediante HTTPS:
git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
o por SSH, si se tiene una clave configurada en GitHub:
git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
A continuación, cree su archivo de entorno a partir del plantilla proporcionado e introduzca la URL de la base de datos y los secretos que se indican:
cp .env.example .env
Inicie un contenedor de PostgreSQL 16. El nombre de la base de datos, el usuario y la contraseña aquí son valores únicamente para desarrollo local y deben coincidir con lo que haya puesto en .env; el volumen nombrado mantiene los datos entre reinicios del contenedor.
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
Aplique las migraciones de Prisma para crear las tablas:
npx prisma migrate dev
Finalmente, inicie el servidor de desarrollo:
npm run dev
Existe una versión desplegada disponible como demostración en vivo.
Conclusión
La Arquitectura Limpia en Next.js se trata principalmente de decidir dónde reside cada tipo de conocimiento:
- Mantenga
/appcomo una capa de adaptador sencilla y mueva la lógica alibs. - Deje que el dominio defina las interfaces y entidades, y permita que solo la capa de datos conozca sobre Prisma, HTTP y caché.
Para un prototipo de fin de semana, esto podría representar más estructura de la necesaria; para una aplicación que seguirá creciendo, los límites mantienen cada cambio localizado.
Lecturas relacionadas
- Construyendo una página detallada de películas resiliente con Next.js App Router — Aprenda cómo obtener y almacenar en caché los datos de la API OMDB correctamente en Next.js App Router utilizando componentes de servidor asíncronos, parámetros esperados y un manejo adecuado de errores 404.
- Configuración de Prisma 7 en un proyecto de Next.js 16 con TypeScript — Aprenda cómo instalar, configurar y migrar Prisma 7 con PostgreSQL en una aplicación de Next.js 16, y luego crear un cliente singleton para evitar fugas de conexiones.
- Evite la fuga de campos de base de datos: una capa de acceso a data para aplicaciones Next.js — Aprenda cómo una capa de acceso a data centraliza las verificaciones de autenticación y el filtrado de campos en los componentes del servidor de Next.js, de modo que las columnas sensibles nunca lleguen accidentalmente al navegador.