Das Repository-Pattern: Vor- und Nachteile und wann man es weglässt
Das Repository-Pattern wird in vielen Backend-Tutorials standardmäßig empfohlen, direkt neben Dependency Injection, als wäre es offensichtlich falsch, es wegzulassen. Das ist es nicht. Es ist ein wirklich nützliches Muster für eine bestimmte Gruppe von Problemen und wirklich unnötiger Overhead für viele Anwendungen, die es trotzdem aus Gewohnheit einsetzen.
Was es eigentlich ist
Ein Repository ist eine Abstraktion zwischen deiner Domänen-/Geschäftslogik und deinem Datenzugriffscode. Statt dass deine Service-Schicht ein ORM aufruft oder direkt SQL schreibt, ruft sie ein Repository-Interface auf, und das Repository ist das Einzige, das weiß, wie die Daten tatsächlich gespeichert werden.
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
Eine konkrete Implementierung könnte ein ORM verwenden:
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 von Pool oder direkt von 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;
}
}
Die Argumente dafür
Testbarkeit ohne echte Datenbank. Eine Fake-Implementierung von UserRepository ist trivial zu schreiben und braucht keine Test-Container, keine Migrationen, kein 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 gegen RegisterUserService laufen in Millisekunden und brauchen keinerlei Infrastruktur.
Eine echte Nahtstelle, wenn sich der Speicher tatsächlich ändert. Migriert man von Postgres zu DynamoDB oder setzt einen Cache vor die Lesezugriffe, ist das Repository-Interface die eine Stelle, die sich ändert – alle Aufrufer sind unberührt, weil sie nie vom Speichermechanismus abhingen.
Ein zentraler Ort, um Abfragekonventionen durchzusetzen. Soft-Delete-Filterung, Mandantentrennung in einem Multi-Tenant-System, einheitliches Spalten-Mapping – diese im Repository zu zentralisieren heißt, dass jeder Aufrufer sie automatisch bekommt, statt dass jede Aufrufstelle daran denken muss.
Die Argumente dagegen
Es ist eine Abstraktion für eine Änderung, die meist nicht eintritt. In der Praxis wechseln sehr wenige Anwendungen tatsächlich ihre Datenbank-Engine. Das größte Verkaufsargument des Repository-Patterns – „man kann die Persistenz ändern, ohne die Geschäftslogik anzufassen" – ist eine Versicherung gegen ein Ereignis, das in den meisten Projekten nie eintritt. Man zahlt die Kosten (ein zusätzliches Interface, eine zusätzliche Implementierung, eine zusätzliche Schicht, durch die man sich beim Lesen des Codes bewegen muss) jeden Tag für einen Nutzen, den man vielleicht nie einlöst.
Moderne ORMs isolieren dich bereits von rohem SQL. Ein Teil der ursprünglichen Motivation für das Repository-Pattern war, SQL aus der Geschäftslogik herauszuhalten. Der Query Builder eines ORMs tut das bereits. Einen ORM-Aufruf in eine Repository-Methode zu wickeln, die nichts tut, als den Aufruf weiterzureichen, fügt eine Schicht hinzu, ohne Isolation zu schaffen, die man nicht ohnehin hatte:
// 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 ausgeführte Abfrage hinter einem Namen verstecken, der nichts über ihre Kosten verrät. orderRepository.findActiveWithLineItems() sieht an der Aufrufstelle harmlos aus. Ob es eine indizierte Abfrage ist oder eine N+1-Schleife über Positionen, ist unsichtbar, bis man die Implementierung liest – die Abstraktion, die den Code leichter nachvollziehbar machen sollte, macht am Ende Performance-Eigenschaften schwerer sichtbar.
Die meisten Testsuites müssen die Datenbank gar nicht austauschen. Test-Container mit einem echten Postgres in Docker sind schnell und zuverlässig genug geworden, dass „ohne echte Datenbank testen" ein schwächeres Argument ist als früher – und das Testen gegen das Echte findet Probleme (ein schlechter Index, eine Constraint-Verletzung, ein realer Abfrageplan), die ein In-Memory-Fake nicht findet.
Ein Mittelweg
Für eine kleine bis mittlere Anwendung, die mit einer Datenbank spricht, die sie realistisch nicht austauschen wird, bringt eine dünne Datenzugriffsschicht direkt auf dem Query Builder deines ORMs – ohne Repository-Interface dahinter – den Großteil des organisatorischen Nutzens (Abfragen zentralisiert, nicht ü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 können weiterhin eine Testdatenbank einsetzen oder das Transaktions-Rollback-pro-Test-Muster deines ORMs nutzen, ohne ein Interface zu brauchen, das eigens zum Austauschen gebaut wurde.
Wann man tatsächlich dazu greifen sollte
- Du baust eine Bibliothek oder ein Modul, das tatsächlich mehrere Backends unterstützen muss (etwa eine selbst gehostete App, die sowohl gegen Postgres als auch gegen SQLite laufen muss).
- Die Domänenlogik ist komplex genug, dass du Unit-Tests ganz ohne I/O willst, und eine echte Testdatenbank ist zu langsam oder in deiner CI-Umgebung nicht verfügbar.
- Du betreibst bewusst Domain-Driven Design mit einem reichen Domänenmodell, das gar nicht wissen soll, dass Persistenz existiert.
Außerhalb davon ist ein Repository-Interface mit genau einer Implementierung, die du je schreiben wirst, Komplexität, die auf Kredit gegen eine Zukunft bezahlt wird, die in den meisten Projekten nicht eintrifft.