Главная / Статьи

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

Паттерн репозиториев в Node.js: скрывайте Prisma за внедряемыми репозиториями и фабрикой, чтобы сервисы оставались тестируемыми, а транзакции — атомарными в TypeScript.

Издатель: REACTAPP.TOP4 мин чтения946 слов

Источники: 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();
    

    Почему это важно с операционной точки зрения

    • Скорость: можно запускать тысячи тестов без дополнительной нагрузки сети или базы данных.
    • Надежность: крайние случаи, такие как истечение времени ожидания, легко имитировать путем вызова методов-моков.
    • Уверенность: при изменении логики сразу поступает обратная связь от процессов рефакторинга.

    Чему учит эта реализация

    1. Разделение ответственностей: сервисы отвечают за бизнес-правила; репозитории — за запросы.
    2. Масштабируемость: новые области использования (бронирование, аренда и т. д.) следуют одной и той же структуре.
    3. Структура продакшена: структура соответствует шаблонам, распространенным в крупных системах.

    Перемещение Prisma за пределы репозиториев — это не просто перестановка файлов. Сервисы становятся более понятными, тестирование обходится дешевле, а бэкенд лучше готов к росту. Если вызовы Prisma превращают сервисы в «богов-объекты», то шаблон репозиториев является практичным следующим шагом.

    • TypeScript без Node или V8: сравнение производительности нативных бинарников scriptc — время запуска, среднее количество запросов в секунду, пропускная способность и компромиссы в использовании CPU при работе одного и того же HTTP-сервера на TypeScript в виде нативных бинарников scriptc вместо Node.js с V8.
    • Перенос backend NestJS в monorepo pnpm и Turborepo — совместное использование TypeScript-контрактов в API с помощью рабочих пространств pnpm, графов задач Turborepo, Dockerfiles, оптимизированных для кэширования, и каталогов pnpm — без превращения общих ресурсов в хаотичный набор файлов.