Головна / Статті

Шаблон репозиторію для масштабованих бекендів Node.js

Паттерн репозиторію в Node.js: приховуйте Prisma за допомогою вставлених репозиторіїв та фабрики, щоб сервіси залишалися піддаваними тестуванню, а транзакції — атомарними у TypeScript.

Видавець: REACTAPP.TOP4 хв читання946 слів

Джерела: 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.
    • Впевненість: під час рефакторингу негайно надходить зворотний зв’язок, якщо логіка відхиляється.

    Що дає ця реалізація

    1. Розділення обов’язків: сервіси відповідають за бізнес-правила; репозиторії — за запити.
    2. Масштабованість: нові домени (бронювання, оренда тощо) будуються за тим самим принципом.
    3. Структура продакшну: схема відповідає стандартам, поширеним у більших системах.

    Заключні міркування

    Переміщення Prisma за межі репозиторіїв — це більше, ніж просто перестановка файлів. Сервіси стають зрозумілішими, тести — дешевшими, а бекенд краще підготовлений до розвитку. Якщо виклики Prisma перетворюють сервіси на «об’єкти-богів», то паттерн репозиторіїв є практичним наступним кроком.