В архитектуре полнофункциональных приложений Next.js 15 отказ от промежуточного слоя REST/GraphQL API в пользу прямого взаимодействия React Server Components с базой данных создал риск размытия ответственности: SQL-запросы, проверки прав доступа и логика кэширования могут хаотично распределяться по страницам и компонентам.
Решением этой проблемы является паттерн Data Access Layer (DAL). DAL консолидирует все обратные вызовы к базе данных (Prisma, Drizzle, Redis) и служит единственной точкой входа для выборки данных.
Критически важной частью DAL в Next.js является двухуровневая система кэширования:
React.cache): Обеспечивает дедупликацию вызовов функций внутри одного цикла серверного рендеринга (один входящий HTTP-запрос). Если заголовок, боковое меню и основной контент запрашивают одного и того же пользователя getUser(id), физический запрос к базе выполняется ровно один раз.unstable_cache): Сохраняет сериализованные результаты вычислений в постоянном кэше (Next.js Data Cache) между запросами разных пользователей, поддерживая инвалидацию по строковым тегам (revalidateTag).Тестирование DAL позволяет гарантировать целостность данных, предотвратить деградацию производительности из-за избыточных SQL-запросов и исключить утечки конфиденциальной информации между тенантами.
В ментальной модели тестирования DAL и кэширования запросы проходят через строгий двухуровневый фильтр до момента обращения к реальной базе данных:
Входящий вызов из RSC
│
▼
+-------------------------------------------------------------------------------+
| Уровень 1: React.cache() (Per-Request Memoization) |
| Жизненный цикл: 1 HTTP-запрос. Дедупликация по ссылочной идентичности аргументов|
+-------------------------------------------------------------------------------+
│ (Cache Miss) │ (Cache Hit: тот же реквест)
▼ ▼
+---------------------------------------------------+ Возврат из памяти
| Уровень 2: unstable_cache() (Next.js Data Cache) | без обращения к слою 2
| Жизненный цикл: Кросс-запросный, теги, TTL |
+---------------------------------------------------+
│ (Data Cache Miss) │ (Cache Hit: по тегам/ключам)
▼ ▼
+---------------------------------------------------+ Возврат из кэша
| Физический Data Access Layer | без выполнения SQL
| - Выполнение SQL / Drizzle / Prisma |
| - Проверка прав текущей сессии |
+---------------------------------------------------+
│
▼
[Реальная СУБД: PostgreSQL / MySQL]При тестировании необходимо проверять:
React.cache() не выполняет повторный вызов при повторной передаче тех же аргументов;unstable_cache() формирует корректный ключ кэша (keyParts) с учетом идентификатора тенанта/пользователя во избежание межпользовательской утечки (Cache Poisoning);revalidateTag() сбрасывает кэш соответствующих сущностей.Убедиться, что модуль содержит декларацию 'server-only' и не экспортирует приватные структуры базы.
Вызвать функцию 3-5 раз с одинаковыми аргументами и зафиксировать строго 1 обращение к базе данных.
Проверить, что keyParts включает все динамические идентификаторы аргументов (userId, tenantId).
Удостовериться, что массив tags содержит гранулярные идентификаторы сущностей (например, 'entity:id').
Вызвать соответствующий метод мутации и подтвердить вызов revalidateTag с точным именем тега.
Сбросить вызовы шпионов и внутреннее состояние кэш-хранилища в блоке beforeEach.
В чем заключается принципиальная разница в жизненном цикле и области видимости между React.cache() и unstable_cache() при тестировании DAL?
A отдаются пользователю B из-за отсутствия userId в ключе keyParts кэша.revalidateTag() с ожидаемым именем тега.Реализуем функцию получения данных организации с использованием React.cache() для дедупликации внутри реквеста и unstable_cache() для разделяемого кэширования.
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;
}
// Низкоуровневая выборка из базы данных
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;
}
// Кросс-запросный кэш с тегами инвалидации
export const getCachedOrganization = (orgId: string) =>
unstable_cache(
async () => fetchOrganizationFromDb(orgId),
['organization-cache-key', orgId],
{
revalidate: 3600, // 1 час
tags: [`org:${orgId}`, 'organizations'],
}
)();
// Итоговый метод DAL с дедупликацией внутри одного запроса
export const getOrganization = cache(async (orgId: string): Promise<OrganizationDTO | null> => {
if (!orgId || typeof orgId !== 'string') {
throw new Error('Некорректный идентификатор организации');
}
return getCachedOrganization(orgId);
});Для проверки React.cache() в Vitest вызываем мемоизированную функцию несколько раз подряд и проверяем счетчик вызовов шпиона базового метода.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cache } from 'react';
describe('React.cache (Per-Request Deduplication)', () => {
it('дедуплицирует параллельные вызовы с идентичными аргументами', async () => {
const rawFetcherMock = vi.fn().mockImplementation(async (id: string) => {
return { id, name: `Организация ${id}`, plan: 'enterprise' as const };
});
// Оборачиваем мок в нативный React.cache
const memoizedFetcher = cache(rawFetcherMock);
// Симулируем параллельные запросы из разных серверных компонентов в рамках одного рендера
const [result1, result2, result3] = await Promise.all([
memoizedFetcher('org-123'),
memoizedFetcher('org-123'),
memoizedFetcher('org-123'),
]);
// Проверяем эквивалентность результатов
expect(result1).toEqual(result2);
expect(result2).toEqual(result3);
expect(result1.name).toBe('Организация org-123');
// КЛЮЧЕВОЙ АССЕРТ: физический вызов fetcher произошел ровно 1 раз
expect(rawFetcherMock).toHaveBeenCalledTimes(1);
expect(rawFetcherMock).toHaveBeenCalledWith('org-123');
});
it('выполняет отдельные вызовы для различных аргументов', 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');
});
});Поскольку unstable_cache управляется внутренним рантаймом Next.js, в интеграционном тесте DAL мы верифицируем параметры, с которыми регистрируется кэш-делегат, гарантируя предотвращение коллизий ключей.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { unstable_cache } from 'next/cache';
import * as dbModule from '@/lib/db';
// Мокируем unstable_cache для перехвата метаданных конфигурации
vi.mock('next/cache', () => ({
unstable_cache: vi.fn((fn: () => Promise<unknown>, keyParts?: string[], options?: { revalidate?: number; tags?: string[] }) => {
// Возвращаем функцию-обертку, которая может вызывать оригинальный коллбэк
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('регистрирует кэш с уникальным составным ключом и корректными тегами', async () => {
const { getCachedOrganization } = await import('./organization');
const cachedFn = getCachedOrganization('org-prod-42');
// Проверяем, что unstable_cache был вызван с ожидаемыми параметрами
expect(unstable_cache).toHaveBeenCalledWith(
expect.any(Function),
['organization-cache-key', 'org-prod-42'],
{
revalidate: 3600,
tags: ['org:org-prod-42', 'organizations'],
}
);
});
it('гарантирует, что ключ кэша предотвращает смешение данных разных организаций', 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);
});
});Проверяем сервис мутации, который обновляет данные в БД и сбрасывает кэш для конкретной организации.
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('инвалидирует тег конкретной организации после успешного обновления', async () => {
await updateOrganizationPlan('org-99', 'enterprise');
// Проверяем вызов обновления в БД
expect(db.update).toHaveBeenCalled();
// Проверяем точечный сброс тега в Data Cache
expect(revalidateTag).toHaveBeenCalledWith('org:org-99');
// Не должен сбрасывать весь список, если обновляется только единичный план
expect(revalidateTag).not.toHaveBeenCalledWith('all-data');
});
});Почему при кэшировании приватных пользовательских данных через unstable_cache() критически важно включать userId в массив keyParts?
Если функция внутри unstable_cache возвращает приватные данные пользователя, но в массив keyParts не включен userId, данные первого запросившего пользователя закэшируются глобально и будут отдаваться всем последующим пользователям.
// ❌ КРИТИЧЕСКАЯ УЯЗВИМОСТЬ: Ключ общий, данные приватные
export const getMyPrivateData = (userId: string) =>
unstable_cache(
async () => fetchPrivateProfile(userId),
['private-user-data'] // Нет userId в ключе!
)();
// ✅ БЕЗОПАСНО: Ключ уникален для каждого пользователя
export const getMyPrivateData = (userId: string) =>
unstable_cache(
async () => fetchPrivateProfile(userId),
['private-user-data', userId]
)();React.cache() предназначен исключительно для дедупликации вызовов в рамках одного входящего HTTP-запроса (хранится в оперативной памяти конкретного рендера и уничтожается после его завершения). unstable_cache() сохраняет данные на диск или во внешний Data Cache между разными запросами и пользователями. Их объединение дает максимальный эффект, но заменять один другим нельзя.
Если файл DAL случайно импортируется в клиентский компонент ('use client'), секретные переменные окружения, пароли БД или ORM попадут в публичный JavaScript-бандл. Директива import 'server-only'; в первой строке файла приводит к ошибке сборки, если файл попадет на клиент. В тестах важно проверять наличие этой директивы.
Поскольку React.cache() привязан к глобальному контексту сборщика в тестах, повторный вызов функции в соседнем it() блоке без сброса моков может вернуть значение из предыдущего теста. Используйте beforeEach(() => { vi.clearAllMocks(); }) и изолируйте инстансы функций.