En la arquitectura de aplicaciones fullstack modernas en Next.js 15, prescindir de capas intermedias de API REST o GraphQL para permitir la comunicación directa de los React Server Components con la base de datos introdujo el riesgo de difuminar responsabilidades: las consultas SQL, las verificaciones de autorización y las políticas de caché pueden terminar dispersas caóticamente entre páginas y componentes.
La solución arquitectónica a este problema es el patrón Data Access Layer (DAL). El DAL consolida todas las interacciones con la base de datos (Prisma, Drizzle, Redis) y actúa como único punto de entrada para la obtención de datos.
Un elemento crítico del DAL en Next.js es su sistema de caché de dos niveles:
React.cache): Proporciona deduplicación de invocaciones a funciones dentro de un mismo ciclo de renderizado del servidor (una única solicitud HTTP entrante). Si la cabecera, la barra lateral y la vista principal solicitan el mismo registro de usuario mediante getUser(id), la consulta física a la base de datos se ejecuta exactamente una vez.unstable_cache): Persiste los resultados serializados de cómputo en la Next.js Data Cache entre peticiones de distintos usuarios, admitiendo invalidación bajo demanda mediante etiquetas (revalidateTag).Las pruebas del DAL garantizan la integridad de los datos, previenen degradaciones de rendimiento causadas por consultas SQL redundantes y suprimen fugas de información confidencial entre inquilinos.
En el modelo mental para probar el DAL y los mecanismos de caché, las consultas atraviesan un riguroso filtro de dos niveles antes de consultar la base de datos física:
Invocación entrante desde RSC
│
▼
+-------------------------------------------------------------------------------+
| Nivel 1: React.cache() (Per-Request Memoization) |
| Ciclo de vida: 1 solicitud HTTP. Deduplicación por identidad referencial de args|
+-------------------------------------------------------------------------------+
│ (Cache Miss) │ (Cache Hit: misma petición)
▼ ▼
+---------------------------------------------------+ Retorno desde memoria
| Nivel 2: unstable_cache() (Next.js Data Cache) | sin consultar el nivel 2
| Ciclo de vida: Entre solicitudes, tags, TTL |
+---------------------------------------------------+
│ (Data Cache Miss) │ (Cache Hit: por tags/claves)
▼ ▼
+---------------------------------------------------+ Retorno desde caché
| Data Access Layer Físico | sin ejecutar SQL
| - Ejecución SQL / Drizzle / Prisma |
| - Verificación de permisos de sesión actual |
+---------------------------------------------------+
│
▼
[Base de Datos Real: PostgreSQL / MySQL]Al escribir pruebas del DAL, se debe verificar:
React.cache() evite llamadas físicas reiteradas al suministrar idénticos argumentos;unstable_cache() genere una clave de caché (keyParts) única que incorpore identificadores de inquilino o usuario para prevenir intoxicación de caché entre usuarios (Cache Poisoning);revalidateTag() purgue la caché de las entidades correspondientes.userId en keyParts.revalidateTag() con los identificadores de tag esperados.Implementamos un servicio para obtener datos de organizaciones empleando React.cache() para deduplicación por solicitud y unstable_cache() para almacenamiento compartido entre peticiones.
import 'server-only';
import { cache } from 'react';
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
import { organizations } from '@/lib/schema';
import { eq } from 'drizzle-orm';
export interface OrganizationDTO {
id: string;
name: string;
plan: 'free' | 'pro' | 'enterprise';
createdAt: Date;
}
// Extractor de bajo nivel contra la base de datos
async function fetchOrganizationFromDb(orgId: string): Promise<OrganizationDTO | null> {
const [record] = await db
.select({
id: organizations.id,
name: organizations.name,
plan: organizations.plan,
createdAt: organizations.createdAt,
})
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return record ?? null;
}
// Caché entre solicitudes con tags de invalidación
export const getCachedOrganization = (orgId: string) =>
unstable_cache(
async () => fetchOrganizationFromDb(orgId),
['organization-cache-key', orgId],
{
revalidate: 3600, // 1 hora
tags: [`org:${orgId}`, 'organizations'],
}
)();
// Método consolidado del DAL con deduplicación por petición
export const getOrganization = cache(async (orgId: string): Promise<OrganizationDTO | null> => {
if (!orgId || typeof orgId !== 'string') {
throw new Error('Identificador de organización inválido');
}
return getCachedOrganization(orgId);
});Para verificar React.cache() en Vitest, invocamos la función memoizada múltiples veces concurrentemente y comprobamos el contador de llamadas del espía subyacente.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cache } from 'react';
describe('React.cache (Per-Request Deduplication)', () => {
it('deduplica invocaciones concurrentes con argumentos idénticos', async () => {
const rawFetcherMock = vi.fn().mockImplementation(async (id: string) => {
return { id, name: `Organización ${id}`, plan: 'enterprise' as const };
});
// Envolvemos el mock en React.cache nativo
const memoizedFetcher = cache(rawFetcherMock);
// Simulamos llamadas concurrentes desde distintos componentes de servidor en un único ciclo de render
const [result1, result2, result3] = await Promise.all([
memoizedFetcher('org-123'),
memoizedFetcher('org-123'),
memoizedFetcher('org-123'),
]);
// Comprobamos la igualdad de los resultados
expect(result1).toEqual(result2);
expect(result2).toEqual(result3);
expect(result1.name).toBe('Organización org-123');
// ASERCIÓN CLAVE: la consulta física se ejecutó exactamente una vez
expect(rawFetcherMock).toHaveBeenCalledTimes(1);
expect(rawFetcherMock).toHaveBeenCalledWith('org-123');
});
it('ejecuta consultas separadas ante argumentos distintos', async () => {
const rawFetcherMock = vi.fn().mockImplementation(async (id: string) => ({ id }));
const memoizedFetcher = cache(rawFetcherMock);
await Promise.all([
memoizedFetcher('org-A'),
memoizedFetcher('org-B'),
]);
expect(rawFetcherMock).toHaveBeenCalledTimes(2);
expect(rawFetcherMock).toHaveBeenNthCalledWith(1, 'org-A');
expect(rawFetcherMock).toHaveBeenNthCalledWith(2, 'org-B');
});
});Dado que unstable_cache es coordinado por el runtime interno de Next.js, en la prueba de integración del DAL interceptamos y verificamos los parámetros de registro, garantizando el aislamiento de claves y la ausencia de colisiones.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { unstable_cache } from 'next/cache';
import * as dbModule from '@/lib/db';
// Mockeamos unstable_cache para interceptar metadatos de configuración
vi.mock('next/cache', () => ({
unstable_cache: vi.fn((fn: () => Promise<unknown>, keyParts?: string[], options?: { revalidate?: number; tags?: string[] }) => {
// Retornamos una función envoltorio que expone los metadatos para inspección
return Object.assign(fn, {
__keyParts: keyParts,
__options: options,
});
}),
revalidateTag: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
db: {
select: vi.fn(),
},
}));
describe('DAL: unstable_cache Configuration & Security', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('registra la caché con clave compuesta única y tags correctos', async () => {
const { getCachedOrganization } = await import('./organization');
getCachedOrganization('org-prod-42');
// Verificamos que unstable_cache fue invocado con los parámetros previstos
expect(unstable_cache).toHaveBeenCalledWith(
expect.any(Function),
['organization-cache-key', 'org-prod-42'],
{
revalidate: 3600,
tags: ['org:org-prod-42', 'organizations'],
}
);
});
it('garantiza que la clave previene la mezcla de datos entre organizaciones', async () => {
const { getCachedOrganization } = await import('./organization');
getCachedOrganization('tenant-alpha');
getCachedOrganization('tenant-beta');
const calls = vi.mocked(unstable_cache).mock.calls;
const keyAlpha = calls[0][1];
const keyBeta = calls[1][1];
expect(keyAlpha).toEqual(['organization-cache-key', 'tenant-alpha']);
expect(keyBeta).toEqual(['organization-cache-key', 'tenant-beta']);
expect(keyAlpha).not.toEqual(keyBeta);
});
});Probamos el servicio de mutación que actualiza los registros en base de datos e invalida selectivamente la caché de una organización concreta.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { updateOrganizationPlan } from './organization-mutations';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
vi.mock('next/cache', () => ({
revalidateTag: vi.fn(),
revalidatePath: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
db: {
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ id: 'org-99' }]),
},
}));
describe('updateOrganizationPlan Mutation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('invalida el tag de la organización específica tras actualizarla con éxito', async () => {
await updateOrganizationPlan('org-99', 'enterprise');
// Verificamos la ejecución del update en base de datos
expect(db.update).toHaveBeenCalled();
// Verificamos la invalidación puntual del tag en Data Cache
expect(revalidateTag).toHaveBeenCalledWith('org:org-99');
// Aseguramos que no se purgue toda la colección ante una modificación individual
expect(revalidateTag).not.toHaveBeenCalledWith('all-data');
});
});Si una función envuelta en unstable_cache retorna datos privados de un usuario pero omite userId dentro del array keyParts, la respuesta del primer usuario quedará guardada de forma global y será entregada a los usuarios posteriores.
// ❌ VULNERABILIDAD CRÍTICA: Clave compartida para datos privados
export const getMyPrivateData = (userId: string) =>
unstable_cache(
async () => fetchPrivateProfile(userId),
['private-user-data'] // ¡Falta userId en la clave!
)();
// ✅ SEGURO: Clave compuesta única por usuario/inquilino
export const getMyPrivateData = (userId: string) =>
unstable_cache(
async () => fetchPrivateProfile(userId),
['private-user-data', userId]
)();React.cache() está confinado exclusivamente a la deduplicación dentro del ciclo de vida de una única petición HTTP entrante (almacenado en la memoria efímera de ese render y destruido al concluir). unstable_cache() almacena cargas serializadas en disco o almacenes externos entre múltiples peticiones y usuarios. Ambos se complementan, pero no son intercambiables.
Si un módulo DAL se importa accidentalmente dentro de un componente de cliente ('use client'), las credenciales de base de datos, variables privadas y consultas ORM se filtrarán en el bundle JavaScript público. Colocar import 'server-only'; en la primera línea genera un error inmediato de compilación si el archivo es importado en el cliente.
Puesto que React.cache() se vincula al contexto global del empaquetador en pruebas, invocar una función memoizada en diferentes bloques it() sin reiniciar mocks puede devolver datos de la prueba anterior. Use siempre beforeEach(() => { vi.clearAllMocks(); }) y cree instancias aisladas de las funciones.
Confirmar que el módulo incluye 'import server-only' y no expone detalles internos de la base de datos.
Invocar la función de 3 a 5 veces con parámetros idénticos y verificar exactamente 1 consulta a la base de datos.
Comprobar que keyParts contenga todos los identificadores dinámicos requeridos (userId, tenantId).
Asegurar que el array tags incluya identificadores granulares de entidad (ej. 'org:id').
Ejecutar la mutación y validar la llamada a revalidateTag con el nombre de tag exacto.
Reiniciar espías y cachés en beforeEach para evitar contaminación entre pruebas en los workers.