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.
Ź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
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
- Rozdzielenie zadań: usługi odpowiadają za reguły biznesowe; repozytoria za zapytania.
- Skalowalność: nowe domeny (rezerwacje, wypożyczenia itp.) stosują tę samą strukturę.
- 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
- TypeScript bez Node ani V8: Benchmarking binarnych plików scriptc — Czas rozruchu, RSS, przepustowość oraz kompromisy dotyczące wydajności CPU, gdy ten sam serwer HTTP typu TypeScript działa jako binarny plik scriptc zamiast w środowisku Node.js z V8.
- Przeniesienie backendu NestJS do monorepo pnpm i Turborepo — Dzielenie się kontraktami typu TypeScript pomiędzy API za pomocą przestrzeni roboczych pnpm, grafów zadań Turborepo, plików Dockerfile przyjaznych do cache oraz katalogów pnpm — bez przekształcania standardowych elementów w nieuporządkowane zbiory.