Des Repository-Muster: Vor- und Nachteile, und wann ma's weglassn soit
Des Repository-Muster wird in vüle Backend-Tutorials standardmäßig empfohlen, gleich neben Dependency Injection, ois wär's offensichtlich falsch, es wegzulassn. Is es ned. Es is a wirklich nützliches Muster für a bestimmte Menge an Problemen, und wirklich unnötiger Overhead für vüle Anwendungen, de's trotzdem aus Gewohnheit übernehmen.
Wos es tatsächlich is
A Repository is a Abstraktion zwischn deiner Domänen-/Geschäftslogik und deim Datenzugriffs-Code. Statt dass deine Service-Schicht an ORM aufruaft oda direkt SQL schreibt, ruaft sie a Repository-Interface auf, und das Repository is das Einzige, des woaß, wia die Daten tatsächlich gspeichert wern.
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
A konkrete Implementierung kunnt an ORM nutzn:
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findById(id: string): Promise<User | null> {
const { rows } = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0] ? mapRowToUser(rows[0]) : null;
}
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.db.query('SELECT * FROM users WHERE email = $1', [email]);
return rows[0] ? mapRowToUser(rows[0]) : null;
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, name = $3',
[user.id, user.email, user.name],
);
}
}
Dein Service-Code hängt nur von UserRepository ab, nie direkt von Pool oda SQL:
class RegisterUserService {
constructor(private users: UserRepository) {}
async register(email: string, name: string): Promise<User> {
const existing = await this.users.findByEmail(email);
if (existing) throw new Error('email already registered');
const user = new User(crypto.randomUUID(), email, name);
await this.users.save(user);
return user;
}
}
Das Plädoyer dafür
Testbarkeit ohne echte Datenbank. A Fake-Implementierung von UserRepository is trivial zum Schreibn und braucht koane Test-Container, koane Migrationen, ka Netzwerk:
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string) {
return this.users.get(id) ?? null;
}
async findByEmail(email: string) {
return [...this.users.values()].find((u) => u.email === email) ?? null;
}
async save(user: User) {
this.users.set(user.id, user);
}
}
Tests gegn RegisterUserService laufn in Millisekunden und brauchn koa Infrastruktur.
A echte Nahtstelle, wenn si der Speicher wirklich ändert. Wennst von Postgres auf DynamoDB migrierst oda a Caching-Schicht vor die Lesezugriffe schaltest, is das Repository-Interface der oane Ort, der si ändert - jeder Aufrufer bleibt unberührt, weil er nie vom Speichermechanismus abhängig woar.
Oa zentraler Platz, um Query-Konventionen durchzusetzen. Soft-Delete-Filterung, Mandanten-Scoping in am Multi-Tenant-System, konsistentes Spalten-Mapping - dass im Repository zu zentralisiern heißt, jeder Aufrufer kriagt's automatisch, statt dass sich jede Aufrufstelle drum merken muass.
Das Plädoyer dagegen
Es is a Abstraktion für a Änderung, de meist ned passiert. In da Praxis wechseln sehr wenige Anwendungen tatsächlich ihre Datenbank-Engine. Das größte Verkaufsargument vom Repository-Muster - "du kannst die Persistenz ändern, ohne die Geschäftslogik anzufassn" - is a Versicherung gegn a Ereignis, des bei de meisten Projekten nie eintritt. Du zoist die Kosten (a zusätzliches Interface, a zusätzliche Implementierung, a zusätzliche Schicht zum Durchwandern beim Codelesen) jeden Tag für an Nutzen, den du vielleicht nie einkassierst.
Moderne ORMs isolieren di scho von rohem SQL. A Teil der ursprünglichen Motivation fürs Repository-Muster woar, SQL aus da Geschäftslogik rauszuhoitn. Der Query Builder von an ORM mocht des scho. An ORM-Aufruf in a Repository-Methode zu wickeln, de nix ois den Aufruf weiterreicht, fügt a Schicht dazu, ohne Isolation, die du ned eh scho hättest:
// This "repository" method isn't abstracting anything the ORM wasn't already abstracting.
async findById(id: string) {
return this.db.user.findUnique({ where: { id } });
}
Es kann die tatsächlich laufende Query hinter am Nama verstecken, der dir seine Kosten ned verrät. orderRepository.findActiveWithLineItems() schaut an der Aufrufstelle harmlos aus. Ob es oane indizierte Query oda a N+1-Schleife über Bestellpositionen is, bleibt unsichtbar, bis du die Implementierung liest - die Abstraktion, die Code leichter durchschaubar machn soit, endet dabei, Performance-Eigenschaften schwerer sichtbar zu machn.
De meisten Test-Suites brauchn gor ned wirklich a ausgetauschte Datenbank. Test-Container, de a echtes Postgres in Docker laufn lassn, sand schnell und zuverlässig gnua gworn, dass "ohne echte Datenbank testn" a schwächeres Argument is ois früher - und gegn das echte Ding zu testn fangt Probleme ab (a schlechter Index, a Constraint-Verletzung, a echter Query-Plan), de a In-Memory-Fake ned fangen kann.
A Mittelweg
Für a klane bis mittlere Anwendung, de mit oaner Datenbank redt, die sie realistischerweise ned austauscht, bringt a dünne Datenzugriffsschicht, direkt auf dem Query Builder von deim ORM aufgebaut - ohne Repository-Interface dahinter -, den Großteil vom organisatorischen Nutzen (Queries zentralisiert, ned über die Geschäftslogik verstreut) ohne die zusätzliche Indirektion:
// db/users.ts — plain functions, no interface, no DI required
export async function findUserByEmail(db: PrismaClient, email: string) {
return db.user.findUnique({ where: { email } });
}
export async function saveUser(db: PrismaClient, user: User) {
return db.user.upsert({
where: { id: user.id },
update: user,
create: user,
});
}
Tests kinnan trotzdem a Testdatenbank einsetzen oda das Transaktions-Rollback-pro-Test-Muster von deim ORM nutzn, ohne an Interface, das eigens zum Austauschen gebaut wurde.
Wann ma wirklich dazu greift
- Du baust a Bibliothek oda a Modul, des wirklich mehrere Backends unterstützen muass (a selbst gehostete App, de sowohl gegn Postgres ois a gegn SQLite laufn muass, zum Beispiel).
- Die Domänenlogik is komplex gnua, dass du Unit-Tests ohne jeden I/O willst, und a echte Testdatenbank in deiner CI-Umgebung zu langsam oda ned verfügbar is.
- Du machst bewusst Domain-Driven Design mit am reichen Domänenmodell, des gor ned wissn soit, dass Persistenz existiert.
Außerhalb davon is a Repository-Interface mit genau oaner Implementierung, die du je schreibst, auf Kredit bezahlte Komplexität gegn a Zukunft, de bei de meisten Projekten ned kummt.