Strona główna / Artykuły

Wzorzec repozytorium dla skalowalnych backendów w Node.js

Wzorzec repozytorium w Node.js: ukryj Prisma za zaimportowanymi repozytoriami i fabryką, aby usługi pozostały testowalne, a transakcje były atomowe w TypeScript.

Wydawca: REACTAPP.TOP4 min czytania946 słów

Źródła: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern

Wcześniejsze systemy backendowe często wysyłały zapytania do bazy danych bezpośrednio z metod usługowych. Jest to w porządku dla małych systemów. Gdy jednak potrzebna staje się trwałość w środowisku produkcyjnym, ścisłe powiązanie między regułami biznesowymi a wywołaniami Prismy staje się wąskim gardłem w utrzymaniu systemu. Wzorzec Repository to jeden ze sposobów na zapewnienie skalowalności i możliwości testowania kodu.

Problem: „usługa-bóg”

Usługi mogą zaczynać wyglądać w ten sposób:

await prisma.user.findUnique({ where: { email } });
await prisma.user.create({ data });
// ... 100 more lines of database logic mixed with business rules ...

Początkowo wydaje się to proste. Z upływem czasu pojawiają się konsekwencje:

  • Ucieczki informacji: szczegóły związane z SQL przenikają do reguł biznesowych.
  • Duplikacja: te same wywołania Prismy są kopiowane pomiędzy różnymi usługami.
  • Możliwość testowania: testy logiczne wymagają aktywnej bazy danych.
  • Kruchość: zmiana nazwy tabeli lub pola wpływa na wszystkie usługi.

To jest ścisłe powiązanie: logika rejestracji lub autoryzacji jest przyklejona do Prismy. Testowanie jednostkowe bez rzeczywistego bazy danych staje się niemal niemożliwe. Brakującym elementem jest dedykowana warstwa dostępu do danych.

Rozwiązanie: wzorzec repository

Repository pośredniczy pomiędzy logiką biznesową a źródłem danych. Usługi proszą o dane repository i nie przejmują się tym, czy odpowiedź pochodzi z MySQL, PostgreSQL czy innego backendu. Usługi przestają komunikować się bezpośrednio z Prismą; komunikują się z repository.

Główna idea: usługi powinny dbać o to, co otrzymują, a nie o to, w jaki sposób zostało to pobraane.

Definicja uniwersalnego kontraktu

Wspólna interfejs zapewnia spójność repository:

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 (tylko logika bazy danych)

Zadaniem repozytorium jest zapewnienie trwałości danych. Nie powinno ono tworzyć instancji Prisma; powinno otrzymywać klienta poprzez konstruktora.

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 },
    });
  }
}

Repozytorium nie jest odpowiedzialne za tworzenie Prisma — przyjmuje jedynie jego instancję.

Dlaczego konieczne jest przekazywanie PrismaClient (injekcja zależności)

new PrismaClient(); // ❌ inside repository

Zamiast tego użyj iniekcji:

constructor(private prisma: PrismaClient) {}

Dlaczego to ma znaczenie

  • Wspólne połączenia: zapobiega przypadkowemu tworzeniu zbiorów klientów.
  • Transakcje atomowe: kilka repozytoriów może dzielić jedną sesję transakcyjną.
  • Możliwość testowania: klient (lub repozytorium) można symulować w testach jednostkowych.

Taki wzorzec to iniekcja zależności — standardowa praktyka projektowania backendu.

Fabryka singletonów: jedno źródło prawdy dla repozytoriów

Fabryka repozytoriów z instancją singleton i tworzeniem opóźnionym utrzymuje porządek w połączeniach:

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

Następnie usługi wywołują czyste metody bez użycia SQL:

this.userRepository.findByEmail(email);

Dlaczego singleton? Jedna fabryka dla całego procesu oznacza jeden wspólny PrismaClient, brak dublowanych połączeń oraz spójne instancje repozytoriów.

Największa zaleta: transakcje atomowe

Procesy autoryzacji często wymagają wieloetapowej integralności danych. Gdy użytkownik potwierdza adres e-mail, system może zaktualizować jego dane i usunąć OTP. Obie operacje muszą się powieść albo obie muszą zawieść.

Ponieważ repozytoria dzielą się wstrzykiwanym klientem, transakcja może je objąć:

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);
});

Jeśli cokolwiek pójdzie nie tak w trakcie, Prisma cofnie całą operację — bez częściowo zastosowanego stanu weryfikacji.

Dlaczego to ważne:

  • Weryfikacja i usuwanie OTP odbywają się razem pomyślnie
  • Awaria powoduje cofnięcie wszystkiego
  • Dane pozostają spójne
  • Rozdzielenie komponentów

    Wcześniej: usługi polegały bezpośrednio na Prisma.

    Później: usługi polegają na repozytoriach; repozytoria z kolei polegają na PrismaClient.

    AuthService → UserRepository → Prisma
    

    To oznacza, że Prisma można później zastąpić bez przepisywania usług, logika biznesowa pozostaje czytelna, a kod bazy danych może ewoluować samodzielnie.

    Tajna supermoc: możliwość testowania (mockowanie)

    Gdy zapytania są ukryte w usługach, testy wymagają aktywnej bazy danych i stają się powolne lub niestabilne. Dzięki repozytoriom testy jednostkowe mogą używać mocków.

    Zamiast MySQL, przekazuj fałszywy UserRepository do 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();
    

    Dlaczego to ma znaczenie w praktyce

    • Szybkość: tysiące testów może być wykonywanych bez obciążeń sieciowych lub bazy danych.
    • Niezawodność: trudne przypadki, takie jak przekroczenie czasu oczekiwania, można łatwo symulować poprzez wywołanie błędu w mocku.
    • Pewność: po refaktoryzacji otrzymuje się natychmiastową informację, gdy logika ulega zmianie.

    Czego uczy ta implementacja

    1. Rozdzielenie zadań: usługi odpowiadają za reguły biznesowe; repozytoria za zapytania.
    2. Skalowalność: nowe domeny (rezerwacje, wypożyczenia itp.) stosują tę samą strukturę.
    3. Struktura produkcyjna: układ odpowiada wzorcom powszechnym w większych systemach.

    Ostateczne uwagi

    Przeniesienie Prismy za repozytoria to coś więcej niż tylko przetasowanie plików. Usługi stają się jaśniejsze, testy są tańsze, a backend lepiej przygotowany na rozwój. Jeśli wywołania Prismy przekształcają usługi w obiekty o nadmiernych uprawnieniach, wzorzec repozytorium jest praktycznym następnym krokiem.

    Pozycje pokrewne