Шаблон репозитория для масштабируемых бэкендов на Node.js
Паттерн репозиториев в Node.js: скрывайте Prisma за внедряемыми репозиториями и фабрикой, чтобы сервисы оставались тестируемыми, а транзакции — атомарными в TypeScript.
Источники: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern
В ранних версиях бэкендов запросы к базе данных часто формируются непосредственно в методах сервисов. Это подходит для небольших систем. Однако когда требуется высокая надежность в производственных условиях, тесная связь между бизнес-правилами и вызовами Prisma превращается в препятствие для технического обслуживания. Паттерн репозитория — один из способов сделать кодовую базу масштабируемой и поддающейся тестированию.
Проблема: «бог-сервис»
Сервисы могут начинать выглядеть так:
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, отсутствие дублирующихся подключений и единообразие экземпляров репозиториев.
Основное преимущество: атомарные транзакции
Процессы аутентификации часто требуют соблюдения целостности на нескольких этапах. Когда пользователь проверяет свой email, система может обновить его данные и удалить временный код. Оба действия должны выполниться успешно или оба — с ошибкой.
Поскольку репозитории используют один и тот же внедренный клиент, транзакция может охватить их все:
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 откатит всю операцию — не останется неполностью выполненного состояния проверки.
Почему это важно:
- проверка и удаление временного кода выполняются одновременно
- при сбое все возвращается в исходное состояние
Разделение компонентов
Раньше: сервисы напрямую зависели от 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();
Почему это важно с операционной точки зрения
- Скорость: можно запускать тысячи тестов без дополнительной нагрузки сети или базы данных.
- Надежность: крайние случаи, такие как истечение времени ожидания, легко имитировать путем вызова методов-моков.
- Уверенность: при изменении логики сразу поступает обратная связь от процессов рефакторинга.
Чему учит эта реализация
- Разделение ответственностей: сервисы отвечают за бизнес-правила; репозитории — за запросы.
- Масштабируемость: новые области использования (бронирование, аренда и т. д.) следуют одной и той же структуре.
- Структура продакшена: структура соответствует шаблонам, распространенным в крупных системах.
Перемещение Prisma за пределы репозиториев — это не просто перестановка файлов. Сервисы становятся более понятными, тестирование обходится дешевле, а бэкенд лучше готов к росту. Если вызовы Prisma превращают сервисы в «богов-объекты», то шаблон репозиториев является практичным следующим шагом.