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 Kundendef show_discount(conn, customer): (count,) = conn.execute("SELECT COUNT(*) FROM orders WHERE customer = ?", (customer,)).fetchone() text = "10 % Rabatt" if count > 3 else "kein Rabatt" print(f"{customer}: {text}")
# Die Kassedef checkout(conn, customer, total): rows = conn.execute("SELECT id FROM orders WHERE customer = ?", (customer,)).fetchall() return total * 0.9 if len(rows) >= 3 else total
conn = sqlite3.connect(":memory:")setup(conn)show_discount(conn, "Maria")print(f"Maria zahlt {checkout(conn, 'Maria', 50.00):.2f} €")conn.close()Maria: kein RabattMaria 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: find_by_id, find_by_customer, 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 Python ist das Interface eher eine Vereinbarung als ein Typ: Der Service nimmt jedes Objekt, dasfind_by_customer hat. Die Basisklasse mit ABC schreibt die Vereinbarung nur auf und sagt dir früh, wenn eine Methode fehlt. 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.
@dataclass(frozen=True)class Order: id: int customer: str total: float
class OrderRepository(ABC): @abstractmethod def find_by_id(self, order_id): ...
@abstractmethod def find_by_customer(self, customer): ...
@abstractmethod def save(self, 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(OrderRepository): def __init__(self): self.orders = {}
def find_by_id(self, order_id): return self.orders.get(order_id)
def find_by_customer(self, customer): return [order for order in self.orders.values() if order.customer == customer]
def save(self, order): self.orders[order.id] = orderEine 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:
def fill(orders): orders.save(Order(1, "Maria", 80.00)) orders.save(Order(2, "Jonas", 25.50)) orders.save(Order(3, "Maria", 40.00)) orders.save(Order(4, "Maria", 12.90))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: def __init__(self, orders): self.orders = orders
def discount_for(self, customer): return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
class OrderController: def __init__(self, loyalty): self.loyalty = loyalty
def show(self, customer): discount = self.loyalty.discount_for(customer) text = f"{discount} % Rabatt" if discount > 0 else "kein Rabatt" print(f"{customer}: {text}")Derselbe Service und derselbe Controller, erst auf dem Speicher, dann auf SQLite:
memory = InMemoryOrderRepository()fill(memory)OrderController(LoyaltyService(memory)).show("Maria")
conn = sqlite3.connect(":memory:")database = SqliteOrderRepository(conn)fill(database)OrderController(LoyaltyService(database)).show("Maria")conn.close()Maria: 10 % RabattMaria: 10 % Rabattclass LoyaltyService: def __init__(self, orders): self.orders = orders
def discount_for(self, customer): return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
class OrderController: def __init__(self, loyalty): self.loyalty = loyalty
def show(self, customer): discount = self.loyalty.discount_for(customer) text = f"{discount} % Rabatt" if discount > 0 else "kein Rabatt" print(f"{customer}: {text}")
memory = InMemoryOrderRepository()fill(memory)OrderController(LoyaltyService(memory)).show("Maria")
conn = sqlite3.connect(":memory:")database = SqliteOrderRepository(conn)fill(database)OrderController(LoyaltyService(database)).show("Maria")conn.close()Schritt für Schritt, in Worten
- Ein Repository im Speicher, gefüllt mit den Bestellungen. Der Service bekommt es, ohne zu wissen, was es ist.
- Der Controller bekommt die Anfrage für Maria. Er fragt nicht die Datenbank: Er ruft den Service auf.
- Der Service braucht die Bestellungen des Kunden und fragt das Repository danach.
- find_by_customer auf OrderRepository. Das Repository im Speicher antwortet: Es geht sein Dictionary durch.
- Drei Bestellungen von Maria kommen nach oben. Die Regel steht hier und nur hier: 3 ≥ 3, also 10 %.
- Der Controller zeigt nur die Antwort an: „Maria: 10 % Rabatt“.
- Der Austausch: SQLite tritt an die Stelle des Speichers. Es ist dasselbe OrderRepository, darum werden der Service und der Controller nicht angefasst.
- Derselbe Aufruf. Nur das Objekt unter dem Service ist ein anderes.
- find_by_customer schickt jetzt SELECT … WHERE customer = ? an die Tabelle orders.
- Wieder drei Zeilen, wieder 10 %. Der Service hat nicht bemerkt, dass die Quelle eine andere ist.
- Dieselbe Zeile, diesmal aus einer Datenbank.
- 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(OrderRepository): def __init__(self, conn): self.conn = conn conn.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)")
def find_by_id(self, order_id): row = self.conn.execute("SELECT id, customer, total FROM orders WHERE id = ?", (order_id,)).fetchone() return Order(*row) if row else None
def find_by_customer(self, customer): rows = self.conn.execute("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id", (customer,)) return [Order(*row) for row in rows]
def save(self, order): self.conn.execute( "INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total", (order.id, order.customer, order.total), )Order(*row) macht aus der Zeile eine Bestellung: Die Spalten stehen in derselben Reihenfolge wie die Felder von Order.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.
orders = InMemoryOrderRepository()loyalty = LoyaltyService(orders)orders.save(Order(1, "Maria", 80.00))orders.save(Order(2, "Maria", 40.00))print(f"2 Bestellungen: {loyalty.discount_for('Maria')}%")orders.save(Order(3, "Maria", 12.90))print(f"3 Bestellungen: {loyalty.discount_for('Maria')}%")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?
orders = InMemoryOrderRepository()orders.save(Order(1, "Maria", 80.00))orders.save(Order(1, "Maria", 50.00))print(len(orders.find_by_customer("Maria")))print(f"{orders.find_by_id(1).total:.2f} €")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(OrderRepository): def __init__(self): self.by_customer = {}
def find_by_id(self, order_id): for orders in self.by_customer.values(): for order in orders: if order.id == order_id: return order return None
def find_by_customer(self, customer): return self.by_customer.get(customer, [])
def save(self, order): for orders in self.by_customer.values(): orders[:] = [o for o in orders if o.id != order.id] self.by_customer.setdefault(order.customer, []).append(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: def __init__(self, orders): self.orders = orders
def discount_for(self, customer): return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
def discount_if_buys(self, customer, cart): orders = self.orders.find_by_customer(customer) orders.append(cart) return 10 if len(orders) >= 3 else 0orders = FastOrderRepository()fill(orders)loyalty = LoyaltyService(orders)print(f"Wenn Jonas noch eine kauft: {loyalty.discount_if_buys('Jonas', Order(5, 'Jonas', 30.00))}%")print(f"Bestellungen von Jonas: {len(orders.find_by_customer('Jonas'))}")Wenn Jonas noch eine kauft: 0%Bestellungen von Jonas: 2Jonas 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.
Das Repository gibt seine eigene Liste zurück, keine Kopie. Der Service fügt den Warenkorb hinzu, und der Warenkorb wird unbemerkt zu einer Bestellung von Jonas. Das Repository muss eine Kopie zurückgeben: list(...). Dann kann der Aufrufer mit der Liste machen, was er will, und die Daten ändern sich nur über save. Mit der Kopie lautet die Ausgabe „Bestellungen von Jonas: 1“. Das Repository im Speicher aus dieser Lektion baut bei jeder Suche eine neue Liste, also hat es diesen Fehler nicht.
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 Django). 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:
find_by_customer_and_status_order_by_totalund 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(order_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.
Maria: 10 % RabattMaria: kein RabattMaria: 10 % RabattMaria: kein RabattWeitere 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
discount_if_buyshat Jonas weiterhin 1 Bestellung.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: dataclass, match und Muster · Entwurfsmuster · Strategy · Singleton und Dependency Injection
- Geht weiter in: Der Shop: welches Muster wohin · Schichten · Hexagonale und Clean Architecture · Monolith, modularer Monolith, Services
- Aufgaben zum Thema: Entwurfsmuster