Zum Inhalt
wannacode

Repository

Kurz gesagt: Repository versteckt die Datenbank hinter einem Interface mit Methoden wie find und save, sodass die Geschäftsregeln nichts von SQL wissen und der Speicher wechseln kann, ohne dass der Service es merkt.

Problem

Abfragen, verstreut über die Seite und die Kasse

Der Shop gibt einem Kunden mit drei Bestellungen oder mehr 10 % Rabatt. Die Seite des Kunden zeigt, ob es einen Rabatt gibt, und die Kasse wendet ihn beim Bezahlen an. Jede schreibt ihre eigene Abfrage an die Datenbank und ihre eigene Regel:

// Die Seite des Kunden
function showDiscount(db, customer) {
const { count } = db.prepare("SELECT COUNT(*) AS count FROM orders WHERE customer = ?").get(customer);
const text = count > 3 ? "10 % Rabatt" : "kein Rabatt";
console.log(`${customer}: ${text}`);
}
// Die Kasse
function checkout(db, customer, total) {
const rows = db.prepare("SELECT id FROM orders WHERE customer = ?").all(customer);
return rows.length >= 3 ? total * 0.9 : total;
}
const db = new DatabaseSync(":memory:");
setup(db);
showDiscount(db, "Maria");
console.log(`Maria zahlt ${checkout(db, "Maria", 50).toFixed(2)} €`);
db.close();
Ausgabe
Maria: kein Rabatt
Maria zahlt 45.00 €

Maria hat drei Bestellungen. Ihre Seite sagt ihr „kein Rabatt“, weil dort die Regel count > 3 lautet. Die Kasse gibt ihr den Rabatt, weil dort count >= 3 steht. Zwei Stellen, zwei Versionen einer Regel, und niemand merkt es, bis sich ein Kunde beschwert.

Es gibt ein zweites Problem. Die Regel klebt an der Abfrage. Um zu prüfen, ob >= 3 stimmt, brauchst du eine Datenbank mit einer Tabelle und Zeilen darin. Die Tabelle orders und ihre Zeilen füllt die Funktion setup, wie in den Lektionen über die Datenbank im Programm. Hier ist sie versteckt, damit sie nicht stört.

Die Idee

Repository: die Daten hinter einem Interface

Die Abfragen wandern an eine Stelle: ein Repository. Es hat ein paar Methoden, die in der Sprache des Shops benannt sind, nicht in der der Datenbank: findById, findByCustomer, save. Wer Bestellungen braucht, ruft sie auf und weiß nicht, ob dahinter SQL, eine Datei oder ein Dictionary im Speicher steckt.

Das ist das Repository-Muster. Drumherum ist der Code in Schichten geordnet, und jede Schicht spricht nur mit der Schicht unter ihr:

  • der Controller (die Seite, die Kasse) bekommt die Anfrage und zeigt die Antwort an;
  • der Service hält die Regel: „drei Bestellungen oder mehr geben 10 %“;
  • das Repository gibt und hält Daten. Regeln gibt es darin nicht;
  • die Daten: Speicher, SQLite oder eine andere Datenbank.

Der Service bekommt das Repository von außen, in seinem Konstruktor. Das ist Dependency Injection aus der Lektion Singleton und Dependency Injection. Darum kann jedes Repository mit denselben Methoden darunter sitzen.

In JavaScript ist das Interface eher eine Vereinbarung als ein Typ: Der Service nimmt jedes Objekt, das findByCustomer hat. JavaScript hat kein interface, also steht die Vereinbarung als Kommentar über den Klassen. Das Muster verschwindet nicht: Die Schichten und die Regel an einer Stelle bleiben dieselben.

Sieh es dir an

Der Vertrag und das Repository im Speicher

Die Bestellung und der Vertrag des Repositorys: drei Methoden und keine einzige Zeile SQL. In JavaScript ist die Bestellung ein einfaches Objekt { id, customer, total }, und der Vertrag ist ein Kommentar.

// Jedes Bestell-Repository hat diese drei Methoden:
// findById(id), findByCustomer(customer), save(order)

Das erste Repository hält die Bestellungen im Speicher, in einem Dictionary von der Nummer zur Bestellung. save mit derselben Nummer ersetzt die alte Bestellung.

class InMemoryOrderRepository {
#orders = new Map();
findById(id) {
return this.#orders.get(id) ?? null;
}
findByCustomer(customer) {
return [...this.#orders.values()].filter((order) => order.customer === customer);
}
save(order) {
this.#orders.set(order.id, order);
}
}

Eine Funktion füllt jedes Repository mit den Bestellungen des Shops. Sie kennt nur den Vertrag, also funktioniert sie mit dem Speicher und mit der Datenbank:

function fill(orders) {
orders.save({ id: 1, customer: "Maria", total: 80 });
orders.save({ id: 2, customer: "Jonas", total: 25.5 });
orders.save({ id: 3, customer: "Maria", total: 40 });
orders.save({ id: 4, customer: "Maria", total: 12.9 });
}

Sieh es dir an

Der Service weiß nicht, woher die Daten kommen

Die Regel steht im Service, und der Controller zeigt nur die Antwort an. Keiner von beiden schreibt SQL:

class LoyaltyService {
constructor(orders) {
this.orders = orders;
}
discountFor(customer) {
return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0;
}
}
class OrderController {
constructor(loyalty) {
this.loyalty = loyalty;
}
show(customer) {
const discount = this.loyalty.discountFor(customer);
const text = discount > 0 ? `${discount} % Rabatt` : "kein Rabatt";
console.log(`${customer}: ${text}`);
}
}

Derselbe Service und derselbe Controller, erst auf dem Speicher, dann auf SQLite:

const memory = new InMemoryOrderRepository();
fill(memory);
new OrderController(new LoyaltyService(memory)).show("Maria");
const db = new DatabaseSync(":memory:");
const database = new SqliteOrderRepository(db);
fill(database);
new OrderController(new LoyaltyService(database)).show("Maria");
db.close();
Ausgabe
Maria: 10 % Rabatt
Maria: 10 % Rabatt

class LoyaltyService {
constructor(orders) {
this.orders = orders;
}
discountFor(customer) {
return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0;
}
}
class OrderController {
constructor(loyalty) {
this.loyalty = loyalty;
}
show(customer) {
const discount = this.loyalty.discountFor(customer);
const text = discount > 0 ? `${discount} % Rabatt` : "kein Rabatt";
console.log(`${customer}: ${text}`);
}
}
const memory = new InMemoryOrderRepository();
fill(memory);
new OrderController(new LoyaltyService(memory)).show("Maria");
const db = new DatabaseSync(":memory:");
const database = new SqliteOrderRepository(db);
fill(database);
new OrderController(new LoyaltyService(database)).show("Maria");
db.close();
Schritt für Schritt, in Worten
  1. Ein Repository im Speicher, gefüllt mit den Bestellungen. Der Service bekommt es, ohne zu wissen, was es ist.
  2. Der Controller bekommt die Anfrage für Maria. Er fragt nicht die Datenbank: Er ruft den Service auf.
  3. Der Service braucht die Bestellungen des Kunden und fragt das Repository danach.
  4. findByCustomer auf das Repository. Das Repository im Speicher antwortet: Es geht sein Dictionary durch.
  5. Drei Bestellungen von Maria kommen nach oben. Die Regel steht hier und nur hier: 3 ≥ 3, also 10 %.
  6. Der Controller zeigt nur die Antwort an: „Maria: 10 % Rabatt“.
  7. Der Austausch: SQLite tritt an die Stelle des Speichers. Es hat dieselben Methoden, darum werden der Service und der Controller nicht angefasst.
  8. Derselbe Aufruf. Nur das Objekt unter dem Service ist ein anderes.
  9. findByCustomer schickt jetzt SELECT … WHERE customer = ? an die Tabelle orders.
  10. Wieder drei Zeilen, wieder 10 %. Der Service hat nicht bemerkt, dass die Quelle eine andere ist.
  11. Dieselbe Zeile, diesmal aus einer Datenbank.
  12. Controller → Service → Repository → Daten. Nur die unterste Schicht hat sich geändert, und die Regel blieb an einer Stelle.

Nur die unterste Schicht hat sich geändert. Der Service und der Controller sind dieselben Klassen, ohne eine einzige geänderte Zeile, und Maria bekommt den Rabatt jetzt sowohl auf der Seite als auch an der Kasse, weil es eine einzige Regel gibt.

Sieh es dir an

Die echte Datenbank hinter demselben Interface

Hier ist das Repository in SQLite. Jede Methode ist eine Abfrage mit Parametern (?), nie Text, der in das SQL geklebt wird. Warum das wichtig ist, steht in der Lektion SQL-Injection und Parameter.

class SqliteOrderRepository {
constructor(db) {
this.db = db;
db.exec("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)");
}
findById(id) {
return this.db.prepare("SELECT id, customer, total FROM orders WHERE id = ?").get(id) ?? null;
}
findByCustomer(customer) {
return this.db.prepare("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id").all(customer);
}
save(order) {
this.db
.prepare("INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total")
.run(order.id, order.customer, order.total);
}
}
node:sqlite gibt Zeilen als Objekte mit den Spaltennamen zurück, also sieht eine Bestellung aus der Datenbank aus wie eine aus dem Speicher.

Hier lebt das SQL für Bestellungen. Hier kannst du eine langsame Abfrage reparieren, einen Index hinzufügen oder SQLite gegen PostgreSQL tauschen, und der Service merkt es nicht. Wird das Repository zu einem ORM, achte auf eine Abfrage pro Zeile: Die Lektion ORM und das N+1-Problem zeigt, wie das aussieht.

Sieh es dir an

Die Regel ohne Datenbank

Weil der Service jedes Repository akzeptiert, prüfst du die Regel mit dem Repository im Speicher: keine Datei, kein Server, in Millisekunden.

const orders = new InMemoryOrderRepository();
const loyalty = new LoyaltyService(orders);
orders.save({ id: 1, customer: "Maria", total: 80 });
orders.save({ id: 2, customer: "Maria", total: 40 });
console.log(`2 Bestellungen: ${loyalty.discountFor("Maria")}%`);
orders.save({ id: 3, customer: "Maria", total: 12.9 });
console.log(`3 Bestellungen: ${loyalty.discountFor("Maria")}%`);
Ausgabe
2 Bestellungen: 0%
3 Bestellungen: 10%

Zwei Bestellungen genügen nicht, die dritte gibt 10 %. Genau das ist die Grenze, an der die Seite und die Kasse sich nicht einig waren. So sieht auch ein Test der Regel aus: Du füllst das Repository im Speicher und prüfst, was der Service sagt.

Probier es aus

Was kommt heraus?

Die Bestellung mit der Nummer 1 wird zweimal gespeichert, mit einer anderen Summe. Was gibt das Programm aus?

const orders = new InMemoryOrderRepository();
orders.save({ id: 1, customer: "Maria", total: 80 });
orders.save({ id: 1, customer: "Maria", total: 50 });
console.log(orders.findByCustomer("Maria").length);
console.log(`${orders.findById(1).total.toFixed(2)} €`);
Antwort

Probier es aus

Finde den Fehler

Jemand findet die Suche nach Kunde langsam und schreibt ein Repository, das die Bestellungen gleich nach Kunde hält:

class FastOrderRepository {
#byCustomer = new Map();
findById(id) {
for (const orders of this.#byCustomer.values()) {
const found = orders.find((order) => order.id === id);
if (found) {
return found;
}
}
return null;
}
findByCustomer(customer) {
return this.#byCustomer.get(customer) ?? [];
}
save(order) {
for (const [customer, orders] of this.#byCustomer) {
this.#byCustomer.set(customer, orders.filter((o) => o.id !== order.id));
}
if (!this.#byCustomer.has(order.customer)) {
this.#byCustomer.set(order.customer, []);
}
this.#byCustomer.get(order.customer).push(order);
}
}

Im Service taucht eine neue Methode auf: „Gibt es einen Rabatt, wenn er auch diese kauft?“ Dann fragt die Seite, wie viele Bestellungen Jonas hat:

class LoyaltyService {
constructor(orders) {
this.orders = orders;
}
discountFor(customer) {
return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0;
}
discountIfBuys(customer, cart) {
const orders = this.orders.findByCustomer(customer);
orders.push(cart);
return orders.length >= 3 ? 10 : 0;
}
}
const orders = new FastOrderRepository();
fill(orders);
const loyalty = new LoyaltyService(orders);
console.log(`Wenn Jonas noch eine kauft: ${loyalty.discountIfBuys("Jonas", { id: 5, customer: "Jonas", total: 30 })}%`);
console.log(`Bestellungen von Jonas: ${orders.findByCustomer("Jonas").length}`);
Ausgabe
Wenn Jonas noch eine kauft: 0%
Bestellungen von Jonas: 2

Jonas hat eine Bestellung, und das Repository sagt zwei. Niemand hat save aufgerufen. In welcher Zeile des Repositorys steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Wann du es nicht brauchst

Repository fügt ein Interface und mindestens eine Klasse hinzu. Es lohnt sich nicht, wenn:

  • das Programm ein kleines Skript mit einer Abfrage ist. Die Schichten werden länger als die Arbeit selbst;
  • du ein ORM benutzt, das schon ein Repository liefert (das Modell in Prisma). Dein eigenes Interface, das nur Zeile für Zeile dorthin weiterleitet, ist eine weitere Datei ohne etwas Neues;
  • das Repository für jede Seite eine Methode bekommt: findByCustomerAndStatusOrderByTotal und zwanzig weitere davon. Dann sind die Abfragen wieder verstreut, nur mit längeren Namen.

Es lohnt sich, wenn die Regeln ohne Datenbank geprüft werden müssen, wenn die Quelle wechseln kann (eine Datenbank, eine Datei, ein anderer Dienst) oder wenn dieselben Abfragen an vielen Stellen benutzt werden.

Übung

Aus dem Repository löschen

Löse die Aufgabe in deinem Editor.

Löschen. Füge delete(id) zum Vertrag und zu beiden Repositorys hinzu. In SQLite ist das DELETE FROM orders WHERE id = ?. Zeige Marias Rabatt, lösche die Bestellung 4 und zeige ihn noch einmal, einmal mit dem Speicher und einmal mit der Datenbank.

Ausgabe
Maria: 10 % Rabatt
Maria: kein Rabatt
Maria: 10 % Rabatt
Maria: kein Rabatt

Weitere Aufgaben

  • Eine Regel nach Summe. Der Shop ändert die Regel: 10 % ab 150 € ausgegebenem Geld. Welche Klasse fasst du an? Prüfe es mit dem Repository im Speicher, ohne Datenbank.
  • Ein drittes Repository. Schreibe ein Repository, das die Bestellungen in einer Datei hält, eine pro Zeile. Der Service und der Controller müssen unverändert bleiben.
  • Eine Kopie bei der Suche. Repariere das Repository aus „Finde den Fehler“ und füge eine Prüfung hinzu: Nach discountIfBuys hat Jonas weiterhin 1 Bestellung.

Test

Teste dich

1. Wo lebt die Regel „3 Bestellungen oder mehr geben 10 % Rabatt“?
2. Warum lässt sich die Regel ohne Datenbank prüfen?
3. Das Repository gibt die Liste zurück, die es innen hält, und der Aufrufer fügt etwas hinzu. Was passiert?
4. Das Projekt benutzt ein ORM, das schon für jede Tabelle ein Repository liefert. Lohnt es sich, obendrauf ein eigenes Interface mit denselben Methoden zu setzen?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·