Шаблон репозиторію для масштабованих бекендів Node.js
Паттерн репозиторію в Node.js: приховуйте Prisma за допомогою вставлених репозиторіїв та фабрики, щоб сервіси залишалися піддаваними тестуванню, а транзакції — атомарними у TypeScript.
Джерела: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern
У ранніх версіях бекендів дані часто запитувалися безпосередньо з методів сервісу. Це підходило для невеликих проектів. Однак коли системам потрібна надійність у продакшені, тісний зв’язок між бізнес-правилами та викликами Prisma стає перешкодою у технічному обслуговуванні. Паттерн Repository — це один із способів зберегти кодову базу масштабованою та піддається тестуванню.
Проблема: „бог-сервіс“
Сервіси можуть починати виглядати ось так:
await prisma.user.findUnique({ where: { email } });
await prisma.user.create({ data });
// ... 100 more lines of database logic mixed with business rules ...
Спочатку це здається простим. З часом з’являються наслідки:
- Витоки: деталі у форматі SQL проникають у бізнес-правила.
- Дуплікація: однакові виклики Prisma копіюються між різними сервісами.
- Піддаємість тестуванню: логічні тести вимагають доступу до живої бази даних.
- Крихкість: зміна назви таблиці чи поля впливає на всі сервіси.
Це є проявом тісного зв’язку: логіка реєстрації чи автентифікації прив’язана безпосередньо до Prisma. Проведення модульних тестів без справжньої бази даних стає майже неможливим. Відсутньою є окрема шар для доступу до даних.
Рішення: паттерн репозиторію
Репозиторій виступає посередником між бізнес-логікою та джерелом даних. Сервіси запитують репозиторії дані та не цікавляться тим, чи надходить відповідь з MySQL, PostgreSQL чи іншого бекенду. Сервіси перестають спілкуватися безпосередньо з Prisma; вони спілкуються з репозиторіями.
Основна ідея: сервіси повинні зосереджуватися на тому, що вони отримують, а не на тому, як це було отримано.
Визначення універсального контракту
Спільний інтерфейс забезпечує послідовність репозиторіїв:
export interface IRepository<T> {
findById(id: string): Promise<T | null>;
create(data: any): Promise<T>;
update(id: string, data: any): Promise<T>;
delete(id: string): Promise<T>;
}
UserRepository (лише логіка бази даних)
Завдання репозиторію — забезпечення сталості даних. Він не повинен створювати Prisma; він має отримувати клієнта через конструктор.
export class UserRepository {
constructor(private prisma: PrismaClient) {}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
});
}
async markEmailAsVerified(id: string) {
return this.prisma.user.update({
where: { id },
data: { isEmailVerified: true },
});
}
}
Репозиторій не відповідає за створення Prisma — він приймає готову інстанцію.
Чому передача PrismaClient є обов’язковою (введення залежностей)
Уникайте створення клієнтів всередині репозиторіїв:
new PrismaClient(); // ❌ inside repository
Натомість використовуйте ін’єкцію:
constructor(private prisma: PrismaClient) {}
Чому це важливо
- Спільні з’єднання: запобігають випадковому створенню кількох пулів клієнтів.
- Атомарні транзакції: кілька репозиторіїв можуть користуватися однією транзакційною сесією.
- Можливість тестування: клієнт (або репозиторій) можна імітувати під час юніт-тестів.
Цей підхід називається введенням залежностей — це стандартна практика проектування бекенду.
Фабрика синглтонів: єдине джерело інформації для репозиторіїв
Фабрика репозиторіїв із синглтонним екземпляром та затриманою створенням допомагає підтримувати порядок у підключеннях:
export class RepositoryFactory {
private static instance: RepositoryFactory;
private _userRepository?: UserRepository;
private constructor(private prisma: PrismaClient) {}
static getInstance(prisma: PrismaClient) {
if (!this.instance) {
this.instance = new RepositoryFactory(prisma);
}
return this.instance;
}
// Lazy Initialization: Only creates the repo if it's called
public get userRepository(): UserRepository {
if (!this._userRepository) {
this._userRepository = new UserRepository(this.prisma);
}
return this._userRepository;
}
}t
Потім сервіси викликають методи очищення без використання SQL:
this.userRepository.findByEmail(email);
Чому синглтон? Одна фабрика для всього процесу означає один спільний PrismaClient, відсутність дублікатів підключень та послідовність екземплярів репозиторіїв.
Головна перевага: атомарні транзакції
Процеси автентифікації часто вимагають багатокрокової цілісності. Коли користувач підтверджує електронну пошту, система може оновити дані користувача та видалити OTP. Обидві операції мають виконатися успішно або обидві — зазнати невдачі.
Оскільки репозиторії використовують один і той самий клієнт, транзакція може їх обгорнути:
await prisma.$transaction(async (tx) => {
// We pass the transaction 'tx' into our factory
const repos = RepositoryFactory.getInstance(tx);
await repos.userRepository.markEmailAsVerified(userId);
await repos.otpRepository.delete(otpId);
});
Якщо щось зазнає невдачі на півдорозі, Prisma скасовує всю операцію — не залишається напівзавершеного стану верифікації.
Чому це важливо:
- верифікація та очищення OTP виконуються разом успішно
- невдача призводить до скасування всього
Розділення компонентів
Раніше: сервіси безпосередньо залежали від Prisma.
Після цього: сервіси залежать від репозиторіїв; репозиторії залежать від PrismaClient.
AuthService → UserRepository → Prisma
Це означає, що пізніше Prisma можна замінити без переписування сервісів, бізнес-логіка залишається зрозумілою, а код бази даних може розвиватися самостійно.
Таємний суперпотенціал: можливість тестування (мокування)
Коли запити знаходяться всередині сервісів, тести потребують живої бази даних, що робить їх повільними або ненадійними. За допомогою репозиторіїв у одиничних тестах можна використовувати моки.
Замість MySQL передавайте фальшивий UserRepository у AuthService:
// Example: Testing the Register logic without a database
const mockUserRepository = {
findByEmail: jest.fn().mockResolvedValue(null), // Pretend user doesn't exist
create: jest.fn().mockResolvedValue({ id: '123', email: 'test@test.com' })
};
// Now I can test the Service logic in isolation
const authService = new AuthService(mockUserRepository as any);
const result = await authService.register(userData);
expect(result.user.email).toBe('test@test.com');
expect(mockUserRepository.create).toHaveBeenCalled();
Чому це важливо з операційної точки зору
- Швидкість: тисячі тестів можна виконати без додаткового навантаження на мережу чи базу даних.
- Надійність: крайні випадки, такі як таймаути, легко імітувати шляхом виклику методу mock.
- Впевненість: під час рефакторингу негайно надходить зворотний зв’язок, якщо логіка відхиляється.
Що дає ця реалізація
- Розділення обов’язків: сервіси відповідають за бізнес-правила; репозиторії — за запити.
- Масштабованість: нові домени (бронювання, оренда тощо) будуються за тим самим принципом.
- Структура продакшну: схема відповідає стандартам, поширеним у більших системах.
Заключні міркування
Переміщення Prisma за межі репозиторіїв — це більше, ніж просто перестановка файлів. Сервіси стають зрозумілішими, тести — дешевшими, а бекенд краще підготовлений до розвитку. Якщо виклики Prisma перетворюють сервіси на «об’єкти-богів», то паттерн репозиторіїв є практичним наступним кроком.