В архітектурі повнофункціональних застосунків 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() скидає кеш відповідних сутностей.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');
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 повертає приватні дані користувача, але в масив 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(); }) та ізолюйте інстанси функцій.
Переконатися, що модуль містить декларацію 'server-only' і не експортує приватні структури бази даних.
Викликати функцію 3-5 разів з однаковими аргументами та зафіксувати рівно 1 звернення до бази даних.
Перевірити, що keyParts включає всі динамічні ідентифікатори аргументів (userId, tenantId).
Упевнитися, що масив tags містить гранулярні ідентифікатори сутностей (наприклад, 'entity:id').
Викликати відповідний метод мутації та підтвердити виклик revalidateTag з точним ім'ям тегу.
Скинути виклики шпигунів і внутрішній стан кеш-сховища в блоці beforeEach.