Repository-Muster für skalierbare Node.js-Backends
Das Repository-Muster in Node.js: Verbergen Sie Prisma hinter injizierten Repositories und einer Factory, damit Services testbar bleiben und Transaktionen in TypeScript atomar sind.
Quellen: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern
Frühe Backend-Lösungen fragen oft direkt über Service-Methoden die Datenbank ab. Das ist für kleine Anwendungsbereiche in Ordnung. Sobald Systeme eine produktive Zuverlässigkeit benötigen, wird die enge Verknüpfung zwischen Geschäftsregeln und Prisma-Aufrufen zu einem Wartungsengpass. Das Repository-Muster ist eine Möglichkeit, die Codebasis skalierbar und testbar zu halten.
Das Problem: der „Gottesdienst“
Services können so aussehen:
await prisma.user.findUnique({ where: { email } });
await prisma.user.create({ data });
// ... 100 more lines of database logic mixed with business rules ...
Zu Beginn wirkt das einfach. Im Laufe der Zeit zeigen sich jedoch die Nachteile:
- Durchsickern von Details: SQL-bezogene Details dringen in die Geschäftsregeln ein.
- Duplizierung: Derselbe Prisma-Aufruf wird in verschiedenen Services wiederholt.
- Testbarkeit: Logiktests erfordern eine aktive Datenbank.
- Brüchigkeit: Das Umbenennen einer Tabelle oder eines Feldes hat Auswirkungen auf alle Services.
Das ist eine enge Kopplung: Die Logik für Registrierung oder Authentifizierung ist direkt mit Prisma verknüpft. Unit-Tests ohne eine echte Datenbank werden nahezu unmöglich. Eine eigene Schicht zur Datenzugriffskontrolle ist das fehlende Element.
Die Lösung: Das Repository-Muster
Ein Repository vermittelt zwischen der Geschäftslogik und der Datenquelle. Dienste beantragen Daten bei Repositories und kümmern sich nicht darum, ob die Antwort von MySQL, PostgreSQL oder einem anderen Backend stammt. Die Dienste kommunizieren nicht mehr direkt mit Prisma, sondern mit Repositories.
Kernidee: Dienste sollten sich darauf konzentrieren, was sie erhalten, und nicht darauf, wie es abgerufen wurde.
Definierung eines generischen Vertrags
Eine gemeinsame Schnittstelle sorgt dafür, dass die Repositories konsistent bleiben:
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 (nur Datenbanklogik)
Die Aufgabe eines Repositoriums ist die Persistenz. Es sollte Prisma nicht konstruieren; es sollte stattdessen über den Konstruktor einen Client empfangen.
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 },
});
}
}
Das Repositorium ist nicht für die Erstellung von Prisma verantwortlich – es erhält lediglich eine Instanz.
Warum die Übermittlung von PrismaClient erforderlich ist (Abhängigkeitsinjektion)
Vermeiden Sie die Konstruktion von Clients innerhalb der Repositorien:
new PrismaClient(); // ❌ inside repository
Injectieren Sie stattdessen:
constructor(private prisma: PrismaClient) {}
Warum das wichtig ist
- Gemeinsame Verbindungen: verhindert versehentliche Ansammlungen von Clients.
- Atomare Transaktionen: mehrere Repositorien können eine Transaktionssession teilen.
- Testbarkeit: der Client (oder das Repositorium) kann in Unit-Tests emuliert werden.
Dieses Muster ist die Abhängigkeitsinjektion – eine gängige Praxis im Backend-Design.
Singleton-Fabrik: eine einzige Quelle der Wahrheit für Repositorien
Eine Repository-Fabrik mit einer Singleton-Instanz und verzögerter Erstellung hält die Verbindungen ordentlich:
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
Dann rufen die Services saubere Methoden ohne SQL auf:
this.userRepository.findByEmail(email);
Warum ein Singleton? Eine Fabrik für den Prozess bedeutet einen gemeinsamen PrismaClient, keine doppelten Verbindungen sowie konsistente Repository-Instanzen.
Der große Vorteil: atomare Transaktionen
Authentifizierungsabläufe benötigen oft mehrstufige Integrität. Wenn ein Benutzer seine E-Mail verifiziert, kann das System den Benutzer und einen OTP löschen. Beides muss erfolgreich sein oder beides muss fehlschlagen.
Weil die Repositories den injizierten Client teilen, kann eine Transaktion sie umfassen:
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);
});
Falls etwas unterwegs fehlschlägt, rollt Prisma die gesamte Transaktion rückgängig – es entsteht kein halb durchgeführter Verifizierungsstatus.
Warum das wichtig ist:
- Verifizierung und Löschung des OTPs erfolgen gemeinsam erfolgreich
- Fehler führen zum vollständigen Rückschritt aller Änderungen
Trennung der Komponenten
Zuvor: Die Services waren direkt von Prisma abhängig.
Nachher: Die Services hängen von Repositorien ab; die Repositorien wiederum vom PrismaClient.
AuthService → UserRepository → Prisma
Das bedeutet, dass Prisma später ausgetauscht werden kann, ohne die Services umschreiben zu müssen. Die Geschäftslogik bleibt lesbar, und der Datenbankcode kann eigenständig weiterentwickelt werden.
Die geheime Superkraft: Testbarkeit (Mocking)
Wenn Abfragen innerhalb der Services versteckt sind, benötigen die Tests eine aktive Datenbank, was zu langsamen oder unzuverlässigen Tests führt. Mit Repositorien können Unit-Tests Mocks einsetzen.
Anstelle von MySQL wird ein falscher UserRepository an AuthService übergeben:
// 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();
Warum das betrieblich wichtig ist
- Geschwindigkeit: Tausende von Tests können ohne Netzwerk- oder Datenbankbelastung ausgeführt werden.
- Toverlässigkeit: Randfälle wie Zeitüberschreitungen lassen sich leicht simulieren, indem der Mock einen Fehler auslöst.
- Sicherheit: Bei Abweichungen in der Logik erhält man umgehend Rückmeldung durch die Refaktorisierung.
Was diese Implementierung lehrt
- Trennung von Aufgaben: Die Services verwalten die Geschäftsregeln; die Repositories kümmern sich um die Abfragen.
- Skalierbarkeit: Neue Bereiche (Buchungen, Mietverträge usw.) folgen demselben Aufbau.
- Produktionsstruktur: Die Gliederung entspricht Mustern, die in größeren Systemen üblich sind.
Zusammenfassende Gedanken
Prisma hinter die Repositories zu verlegen ist mehr als nur ein Umsortieren von Dateien. Dadurch werden die Services klarer, die Tests günstiger und der Backend besser auf Wachstum vorbereitet. Wenn Prisma-Aufrufe aus den Services zu sogenannten God Objects machen, ist das Repository-Muster der praktische nächste Schritt.