Datenzugriff hinter einem Interface verstecken
Das Repository Pattern trennt Domänenlogik von der konkreten Datenzugriffstechnologie, indem eine Sammlung von Domänenobjekten hinter einem einfachen Interface verborgen wird. Wer das Repository Pattern in reinem PHP versteht, kann Geschäftslogik testen, ohne eine echte Datenbank zu berühren, und die zugrunde liegende Speichertechnologie später austauschen, ohne den restlichen Code anzufassen.
Inhaltsverzeichnis
- 1. Welches Problem das Repository Pattern löst
- 2. Das Repository Interface: Vertrag statt Implementierung
- 3. Eine konkrete PDO-Implementierung des Repositorys
- 4. Domänenobjekte statt Arrays zurückgeben
- 5. Spezifikationen und Filter jenseits von findById
- 6. In-Memory-Repository als Testdouble
- 7. Zusammenspiel mit Unit of Work und Transaktionen
- 8. Repositories per Dependency Injection einbinden
- 9. Repository Pattern im Vergleich zu Active Record
- 10. Zusammenfassung
- 11. FAQ
1. Welches Problem das Repository Pattern löst
Das Repository Pattern ist ein Entwurfsmuster, das den Datenzugriff hinter einer Sammlungs-Metapher verbirgt: Aus Sicht der Geschäftslogik verhält sich ein Repository wie eine In-Memory-Collection von Objekten, obwohl im Hintergrund SQL-Abfragen gegen eine Datenbank laufen. Ohne Repository Pattern verteilt sich SQL-Code typischerweise über die gesamte Anwendung, in Controllern, Services und sogar in Templates, was jede Änderung an der Datenzugriffslogik zu einer Suche über zig Dateien macht.
Der zentrale Vorteil des Repository Patterns ist die Trennung von Domänenlogik und Persistenz. Ein Service, der eine Bestellung storniert, muss nicht wissen, ob die Bestellung aus MySQL, einer REST-API oder einer Datei geladen wurde. Er ruft schlicht $orderRepository->findById($id) auf und arbeitet mit dem zurückgegebenen Domänenobjekt weiter. Diese Entkopplung macht Geschäftslogik unabhängig von der konkreten Speichertechnologie testbar.
Wichtig ist die Abgrenzung zum Query Builder: Ein Repository arbeitet mit Domänenobjekten und fachlichen Methodennamen wie findActiveCustomers(), während ein Query Builder auf der Ebene von SQL-Konstrukten wie SELECT und WHERE bleibt. Ein Repository nutzt intern häufig einen Query Builder oder rohes PDO, verbirgt diese Details aber vollständig vor dem Aufrufer.
2. Das Repository Interface: Vertrag statt Implementierung
Der Ausgangspunkt jedes sauberen Repository Patterns ist ein Interface, das ausschließlich fachliche Methoden definiert, ohne jeden Hinweis auf die konkrete Speichertechnologie. Die Domänenlogik hängt nur von diesem Interface ab, niemals von einer konkreten Implementierung. Das entspricht dem Dependency-Inversion-Prinzip aus SOLID: High-Level-Code hängt von einer Abstraktion ab, nicht von Low-Level-Details wie PDO oder einem bestimmten SQL-Dialekt.
Diese Trennung zahlt sich vor allem beim Testen aus. Ein Service, der gegen das Repository Interface programmiert ist, lässt sich in Unit-Tests mit einem Test-Double des Interfaces ausstatten, ganz ohne Datenbankverbindung. Das Repository Pattern macht diese Art von Test überhaupt erst praktikabel, weil die Domänenlogik nirgends direkt PDO oder eine SQL-Bibliothek referenziert.
<?php
declare(strict_types=1);
interface CustomerRepositoryInterface
{
public function findById(int $id): ?Customer;
/** @return Customer[] */
public function findActive(): array;
public function save(Customer $customer): void;
public function delete(int $id): void;
}
final class Customer
{
public function __construct(
public readonly ?int $id,
public readonly string $name,
public readonly string $email,
public readonly bool $active,
) {
}
}
3. Eine konkrete PDO-Implementierung des Repositorys
Die produktive Implementierung des Repository Patterns übersetzt jede Interface-Methode in konkrete SQL-Abfragen über PDO und wandelt die rohen Datenbankzeilen in Domänenobjekte um. Diese Umwandlung, oft Hydration genannt, ist ein zentraler Bestandteil jeder Repository-Implementierung, weil sie verhindert, dass assoziative Arrays mit Datenbankspalten in die Geschäftslogik durchsickern.
Fehler bei der Umwandlung, etwa vergessene Typumwandlungen bei booleschen Spalten, gehören ausschließlich in die Repository-Implementierung, niemals in den aufrufenden Code. Das Repository Pattern zieht damit eine klare Grenze: Alles, was mit der Datenbank zu tun hat, bleibt innerhalb der Implementierung, alles, was mit Geschäftsregeln zu tun hat, bleibt außerhalb.
<?php
declare(strict_types=1);
final class PdoCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(private readonly PDO $pdo)
{
}
public function findById(int $id): ?Customer
{
$statement = $this->pdo->prepare(
'SELECT id, name, email, active FROM customers WHERE id = ?'
);
$statement->execute([$id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $this->hydrate($row);
}
public function findActive(): array
{
$statement = $this->pdo->query(
'SELECT id, name, email, active FROM customers WHERE active = 1'
);
return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
}
public function save(Customer $customer): void
{
if ($customer->id === null) {
$statement = $this->pdo->prepare(
'INSERT INTO customers (name, email, active) VALUES (?, ?, ?)'
);
$statement->execute([$customer->name, $customer->email, (int) $customer->active]);
return;
}
$statement = $this->pdo->prepare(
'UPDATE customers SET name = ?, email = ?, active = ? WHERE id = ?'
);
$statement->execute([$customer->name, $customer->email, (int) $customer->active, $customer->id]);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM customers WHERE id = ?');
$statement->execute([$id]);
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): Customer
{
return new Customer(
id: (int) $row['id'],
name: (string) $row['name'],
email: (string) $row['email'],
active: (bool) $row['active'],
);
}
}
4. Domänenobjekte statt Arrays zurückgeben
Ein häufiger Fehler bei einer nachträglichen Einführung des Repository Patterns ist, weiterhin assoziative Arrays statt echter Domänenobjekte zurückzugeben. Damit verschwindet zwar SQL aus der Geschäftslogik, aber die Typsicherheit bleibt auf der Strecke: Ein Tippfehler in einem Array-Schlüssel wird erst zur Laufzeit sichtbar, nicht bereits bei der statischen Analyse mit PHPStan. Echte Domänenobjekte mit typisierten, teils readonly Properties machen solche Fehler bereits beim Kompilieren des Codes sichtbar.
Domänenobjekte innerhalb eines Repository Patterns sollten zudem selbst keine Persistenzlogik enthalten. Eine Customer-Klasse kennt ihre eigenen Geschäftsregeln, etwa eine deactivate()-Methode, aber sie weiß nichts von SQL oder der Tabelle, aus der sie geladen wurde. Diese strikte Trennung unterscheidet das Repository Pattern grundlegend vom Active-Record-Ansatz, bei dem das Domänenobjekt sich selbst speichert.
5. Spezifikationen und Filter jenseits von findById
Reale Anwendungen brauchen oft mehr als einfache Methoden wie findById() oder findActive(). Für komplexere, dynamische Suchanfragen kombiniert man das Repository Pattern häufig mit dem Specification Pattern: Eine Specification-Klasse kapselt eine einzelne Filterbedingung, und das Repository nimmt eine oder mehrere Specifications entgegen, um daraus eine WHERE-Klausel zusammenzusetzen.
Diese Kombination verhindert, dass das Repository Interface mit dutzenden spezialisierten findByNameAndActiveAndRegion()-Methoden überladen wird. Stattdessen bleibt eine generische matching(Specification $spec)-Methode im Repository Pattern bestehen, die intern einen Query Builder mit den Bedingungen aus der Specification füttert.
<?php
declare(strict_types=1);
interface CustomerSpecification
{
public function toSqlCondition(): string;
/** @return array<int, mixed> */
public function getBindings(): array;
}
final class ActiveInRegion implements CustomerSpecification
{
public function __construct(private readonly string $region)
{
}
public function toSqlCondition(): string
{
return 'active = 1 AND region = ?';
}
public function getBindings(): array
{
return [$this->region];
}
}
// Extended repository method
public function matching(CustomerSpecification $spec): array
{
$sql = 'SELECT id, name, email, active FROM customers WHERE ' . $spec->toSqlCondition();
$statement = $this->pdo->prepare($sql);
$statement->execute($spec->getBindings());
return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
}
6. In-Memory-Repository als Testdouble
Der größte praktische Vorteil des Repository Patterns zeigt sich beim Testen von Geschäftslogik. Eine In-Memory-Implementierung des Repository Interfaces, die Objekte lediglich in einem Array hält, verhält sich aus Sicht der Geschäftslogik identisch zur PDO-Implementierung, läuft aber ohne Datenbankverbindung und damit deutlich schneller. Unit-Tests für Services, die von einem Repository abhängen, können diese In-Memory-Variante injizieren und exakt kontrollieren, welche Daten zurückgegeben werden.
Diese Testbarkeit ist der eigentliche Business-Grund, das Repository Pattern überhaupt einzuführen. Ohne dieses Muster müssten Tests entweder gegen eine echte Testdatenbank laufen, was Testläufe deutlich verlangsamt, oder komplexe Mocking-Frameworks für PDO-Aufrufe bemühen, was die Tests unnötig fragil macht.
<?php
declare(strict_types=1);
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** @var array<int, Customer> */
private array $customers = [];
private int $nextId = 1;
public function findById(int $id): ?Customer
{
return $this->customers[$id] ?? null;
}
public function findActive(): array
{
return array_values(array_filter($this->customers, fn (Customer $c) => $c->active));
}
public function save(Customer $customer): void
{
$id = $customer->id ?? $this->nextId++;
$this->customers[$id] = new Customer($id, $customer->name, $customer->email, $customer->active);
}
public function delete(int $id): void
{
unset($this->customers[$id]);
}
}
7. Zusammenspiel mit Unit of Work und Transaktionen
Sobald mehrere Repositories innerhalb einer einzigen Geschäftsoperation zusammenarbeiten, etwa beim Anlegen einer Bestellung samt zugehöriger Rechnungsposten, wird eine übergeordnete Transaktionsgrenze notwendig. Das Repository Pattern allein löst dieses Problem nicht, da jedes Repository unabhängig persistiert. Häufig kombiniert man es deshalb mit einer Unit-of-Work-Instanz, die eine PDO-Transaktion umschließt und alle beteiligten Repositories zum richtigen Zeitpunkt committen oder zurückrollen lässt.
In einer schlanken Eigenimplementierung reicht dafür oft schon, dieselbe PDO-Instanz an mehrere Repositories weiterzugeben und die Transaktionssteuerung an einer zentralen Stelle im Anwendungscode zu belassen, statt sie in jedes einzelne Repository zu verlagern. So bleibt das Repository Pattern auf seine eigentliche Aufgabe fokussiert: den Zugriff auf eine Sammlung von Domänenobjekten zu kapseln, nicht die Transaktionslogik zu verwalten.
8. Repositories per Dependency Injection einbinden
Damit Services tatsächlich gegen das Interface statt gegen eine konkrete Implementierung programmieren, muss ein DI-Container das Interface an die passende Implementierung binden. In Frameworks wie Symfony geschieht das über Service-Konfiguration, in einer framework-unabhängigen Anwendung reicht ein simpler Container, der eine Zuordnung von Interface zu konkreter Klasse verwaltet und beim Auflösen automatisch die richtige Repository-Implementierung injiziert.
Für Tests wird dieselbe Bindung einfach umgeschaltet: Statt PdoCustomerRepository wird InMemoryCustomerRepository registriert, ohne dass der Service-Code selbst angefasst werden muss. Diese Austauschbarkeit ist der praktische Kern des Repository Patterns und zeigt, warum sich die zusätzliche Abstraktionsebene gegenüber direktem PDO-Zugriff in den meisten mittelgroßen bis großen Projekten lohnt.
9. Repository Pattern im Vergleich zu Active Record
Die Alternative zum Repository Pattern ist der Active-Record-Ansatz, bei dem ein Domänenobjekt selbst Methoden wie save() oder delete() trägt und damit direkt für seine eigene Persistenz verantwortlich ist. Frameworks wie Laravel mit Eloquent setzen bewusst auf Active Record, weil es für viele CRUD-lastige Anwendungen weniger Boilerplate-Code erfordert.
| Kriterium | Repository Pattern | Active Record |
|---|---|---|
| Testbarkeit ohne Datenbank | Sehr gut, per Testdouble | Schwierig, Objekt trägt Persistenz |
| Boilerplate-Code | Mehr durch Interface und Implementierung | Weniger, direkt am Objekt |
| Trennung von Verantwortlichkeiten | Klar getrennt | Objekt kennt sich selbst und die DB |
| Austausch der Speichertechnologie | Nur Implementierung tauschen | Eng an ORM gekoppelt |
| Passend für | Komplexe Domänenlogik, DDD-Ansätze | Schnelle CRUD-lastige Anwendungen |
Für Projekte mit komplexer Geschäftslogik und dem Anspruch, diese Logik unabhängig von der Datenbank zu testen, ist das Repository Pattern die deutlich robustere Wahl. Für einfache CRUD-Anwendungen mit wenig Domänenlogik kann der geringere Boilerplate-Aufwand von Active Record überwiegen.
10. Zusammenfassung
Das Repository Pattern kapselt Datenzugriff hinter einem fachlichen Interface und trennt Domänenlogik konsequent von der konkreten Speichertechnologie. Die wichtigsten Bausteine sind ein Interface mit ausschließlich fachlichen Methodennamen, eine konkrete PDO-Implementierung mit sauberer Hydration zu Domänenobjekten, sowie eine In-Memory-Variante als schnelles Testdouble für Unit-Tests.
Erweiterungen wie das Specification Pattern für dynamische Filter und eine Unit of Work für übergreifende Transaktionen ergänzen das Repository Pattern in größeren Anwendungen sinnvoll. Im Vergleich zu Active Record erfordert das Repository Pattern etwas mehr Code, zahlt sich aber in Projekten mit komplexer, testbarer Geschäftslogik durch die klare Trennung der Verantwortlichkeiten deutlich aus.
Repository Pattern ohne Framework — Das Wichtigste auf einen Blick
Interface zuerst
Domänenlogik hängt nur vom Interface ab, niemals von PDO oder SQL direkt.
Hydration
Datenbankzeilen werden in typisierte Domänenobjekte umgewandelt, nie als Array durchgereicht.
In-Memory-Testdouble
Schnelle Unit-Tests für Geschäftslogik, komplett ohne Datenbankverbindung.
Abgrenzung
Kein Active Record: Domänenobjekte kennen keine Persistenzlogik.