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:
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);ShowDiscount(conn, "Maria");Console.WriteLine($"Maria zahlt {Checkout(conn, "Maria", 50.00m):F2} €");
// Die Seite des Kundenstatic void ShowDiscount(SqliteConnection conn, string customer){ var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM orders WHERE customer = $customer"; cmd.Parameters.AddWithValue("$customer", customer); var count = Convert.ToInt32(cmd.ExecuteScalar()); var text = count > 3 ? "10 % Rabatt" : "kein Rabatt"; Console.WriteLine($"{customer}: {text}");}
// Die Kassestatic decimal Checkout(SqliteConnection conn, string customer, decimal total){ var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id FROM orders WHERE customer = $customer"; cmd.Parameters.AddWithValue("$customer", customer); using var reader = cmd.ExecuteReader(); var count = 0; while (reader.Read()) { count++; } return count >= 3 ? total * 0.9m : total;}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: 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.
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.
record Order(int Id, string Customer, decimal Total);
interface IOrderRepository{ Order? FindById(int id); List<Order> FindByCustomer(string customer); void Save(Order 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 : IOrderRepository{ private readonly Dictionary<int, Order> orders = new();
public Order? FindById(int id) => orders.GetValueOrDefault(id);
public List<Order> FindByCustomer(string customer) { var result = new List<Order>(); foreach (var order in orders.Values) { if (order.Customer == customer) { result.Add(order); } } return result; }
public void Save(Order order) => orders[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:
static void Fill(IOrderRepository orders){ orders.Save(new Order(1, "Maria", 80.00m)); orders.Save(new Order(2, "Jonas", 25.50m)); orders.Save(new Order(3, "Maria", 40.00m)); orders.Save(new Order(4, "Maria", 12.90m));}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{ private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders) { this.orders = orders; }
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;}
class OrderController{ private readonly LoyaltyService loyalty;
public OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
public void Show(string customer) { var discount = loyalty.DiscountFor(customer); var text = discount > 0 ? $"{discount} % Rabatt" : "kein Rabatt"; Console.WriteLine($"{customer}: {text}"); }}Derselbe Service und derselbe Controller, erst auf dem Speicher, dann auf SQLite:
IOrderRepository memory = new InMemoryOrderRepository();Fill(memory);new OrderController(new LoyaltyService(memory)).Show("Maria");
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();IOrderRepository database = new SqliteOrderRepository(conn);Fill(database);new OrderController(new LoyaltyService(database)).Show("Maria");Maria: 10 % RabattMaria: 10 % Rabattclass LoyaltyService{ private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders) { this.orders = orders; }
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;}
class OrderController{ private readonly LoyaltyService loyalty;
public OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
public void Show(string customer) { var discount = loyalty.DiscountFor(customer); var text = discount > 0 ? $"{discount} % Rabatt" : "kein Rabatt"; Console.WriteLine($"{customer}: {text}"); }}
IOrderRepository memory = new InMemoryOrderRepository();Fill(memory);new OrderController(new LoyaltyService(memory)).Show("Maria");
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();IOrderRepository database = new SqliteOrderRepository(conn);Fill(database);new OrderController(new LoyaltyService(database)).Show("Maria");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.
- FindByCustomer auf IOrderRepository. 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 IOrderRepository, darum werden der Service und der Controller nicht angefasst.
- Derselbe Aufruf. Nur das Objekt unter dem Service ist ein anderes.
- FindByCustomer 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 ($customer), nie Text, der in das SQL geklebt wird. Warum das wichtig ist, steht in der Lektion SQL-Injection und Parameter.
class SqliteOrderRepository : IOrderRepository{ private readonly SqliteConnection conn;
public SqliteOrderRepository(SqliteConnection conn) { this.conn = conn; var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)"; cmd.ExecuteNonQuery(); }
public Order? FindById(int id) { var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, customer, total FROM orders WHERE id = $id"; cmd.Parameters.AddWithValue("$id", id); using var reader = cmd.ExecuteReader(); return reader.Read() ? Read(reader) : null; }
public List<Order> FindByCustomer(string customer) { var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, customer, total FROM orders WHERE customer = $customer ORDER BY id"; cmd.Parameters.AddWithValue("$customer", customer); using var reader = cmd.ExecuteReader(); var result = new List<Order>(); while (reader.Read()) { result.Add(Read(reader)); } return result; }
public void Save(Order order) { var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO orders (id, customer, total) VALUES ($id, $customer, $total) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total"; cmd.Parameters.AddWithValue("$id", order.Id); cmd.Parameters.AddWithValue("$customer", order.Customer); cmd.Parameters.AddWithValue("$total", order.Total); cmd.ExecuteNonQuery(); }
private static Order Read(SqliteDataReader r) => new(r.GetInt32(0), r.GetString(1), r.GetDecimal(2));}IOrderRepository weiß nichts von SqliteConnection. Nur dieses Repository kennt sie, und der Service sieht das Interface.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.
var orders = new InMemoryOrderRepository();var loyalty = new LoyaltyService(orders);orders.Save(new Order(1, "Maria", 80.00m));orders.Save(new Order(2, "Maria", 40.00m));Console.WriteLine($"2 Bestellungen: {loyalty.DiscountFor("Maria")}%");orders.Save(new Order(3, "Maria", 12.90m));Console.WriteLine($"3 Bestellungen: {loyalty.DiscountFor("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?
IOrderRepository orders = new InMemoryOrderRepository();orders.Save(new Order(1, "Maria", 80.00m));orders.Save(new Order(1, "Maria", 50.00m));Console.WriteLine(orders.FindByCustomer("Maria").Count);Console.WriteLine($"{orders.FindById(1)!.Total:F2} €");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 : IOrderRepository{ private readonly Dictionary<string, List<Order>> byCustomer = new();
public Order? FindById(int id) { foreach (var orders in byCustomer.Values) { foreach (var order in orders) { if (order.Id == id) { return order; } } } return null; }
public List<Order> FindByCustomer(string customer) => byCustomer.GetValueOrDefault(customer) ?? new List<Order>();
public void Save(Order order) { foreach (var orders in byCustomer.Values) { orders.RemoveAll(o => o.Id == order.Id); } if (!byCustomer.ContainsKey(order.Customer)) { byCustomer[order.Customer] = new List<Order>(); } byCustomer[order.Customer].Add(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{ private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders) { this.orders = orders; }
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;
public int DiscountIfBuys(string customer, Order cart) { var list = orders.FindByCustomer(customer); list.Add(cart); return list.Count >= 3 ? 10 : 0; }}IOrderRepository orders = new FastOrderRepository();Fill(orders);var loyalty = new LoyaltyService(orders);Console.WriteLine($"Wenn Jonas noch eine kauft: {loyalty.DiscountIfBuys("Jonas", new Order(5, "Jonas", 30.00m))}%");Console.WriteLine($"Bestellungen von Jonas: {orders.FindByCustomer("Jonas").Count}");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: new List<Order>(...). 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.
Ein anderer Weg in C#: Das Interface gibt IReadOnlyList<Order> zurück. Dann lässt sich Add nicht kompilieren, und der Fehler wird gefangen, bevor das Programm läuft.
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 (
DbContext in Entity Framework). 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:
FindByCustomerAndStatusOrderByTotalund 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.
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
DiscountIfBuyshat Jonas weiterhin 1 Bestellung.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: record, switch 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