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 Kundenstatic void showDiscount(Connection conn, String customer) throws SQLException { try (PreparedStatement st = conn.prepareStatement("SELECT COUNT(*) FROM orders WHERE customer = ?")) { st.setString(1, customer); try (ResultSet rs = st.executeQuery()) { rs.next(); int count = rs.getInt(1); String text = count > 3 ? "10 % Rabatt" : "kein Rabatt"; System.out.println(customer + ": " + text); } }}
// Die Kassestatic double checkout(Connection conn, String customer, double total) throws SQLException { try (PreparedStatement st = conn.prepareStatement("SELECT id FROM orders WHERE customer = ?")) { st.setString(1, customer); int count = 0; try (ResultSet rs = st.executeQuery()) { while (rs.next()) { count++; } } return count >= 3 ? total * 0.9 : total; }}
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { setup(conn); showDiscount(conn, "Maria"); System.out.printf("Maria zahlt %.2f €%n", checkout(conn, "Maria", 50.00));}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, double total) {}
interface OrderRepository { 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 implements OrderRepository { private final Map<Integer, Order> orders = new LinkedHashMap<>();
public Order findById(int id) { return orders.get(id); }
public List<Order> findByCustomer(String customer) { List<Order> result = new ArrayList<>(); for (Order order : orders.values()) { if (order.customer().equals(customer)) { result.add(order); } } return result; }
public void save(Order order) { orders.put(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(OrderRepository orders) { orders.save(new Order(1, "Maria", 80.00)); orders.save(new Order(2, "Jonas", 25.50)); orders.save(new Order(3, "Maria", 40.00)); orders.save(new 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 { private final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }}
class OrderController { private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
void show(String customer) { int discount = loyalty.discountFor(customer); String text = discount > 0 ? "" + discount + " % Rabatt" : "kein Rabatt"; System.out.println(customer + ": " + text); }}Derselbe Service und derselbe Controller, erst auf dem Speicher, dann auf SQLite:
OrderRepository memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Maria");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { OrderRepository database = new SqliteOrderRepository(conn); fill(database); new OrderController(new LoyaltyService(database)).show("Maria");}Maria: 10 % RabattMaria: 10 % Rabattclass LoyaltyService { private final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }}
class OrderController { private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
void show(String customer) { int discount = loyalty.discountFor(customer); String text = discount > 0 ? "" + discount + " % Rabatt" : "kein Rabatt"; System.out.println(customer + ": " + text); }}
OrderRepository memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Maria");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { OrderRepository 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 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.
- 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 (?), nie Text, der in das SQL geklebt wird. Warum das wichtig ist, steht in der Lektion SQL-Injection und Parameter.
class SqliteOrderRepository implements OrderRepository { private final Connection conn;
SqliteOrderRepository(Connection conn) throws SQLException { this.conn = conn; try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)"); } }
public Order findById(int id) { try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE id = ?")) { st.setInt(1, id); try (ResultSet rs = st.executeQuery()) { return rs.next() ? read(rs) : null; } } catch (SQLException e) { throw new IllegalStateException(e); } }
public List<Order> findByCustomer(String customer) { try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id")) { st.setString(1, customer); List<Order> result = new ArrayList<>(); try (ResultSet rs = st.executeQuery()) { while (rs.next()) { result.add(read(rs)); } } return result; } catch (SQLException e) { throw new IllegalStateException(e); } }
public void save(Order order) { try (PreparedStatement st = conn.prepareStatement( "INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total")) { st.setInt(1, order.id()); st.setString(2, order.customer()); st.setDouble(3, order.total()); st.executeUpdate(); } catch (SQLException e) { throw new IllegalStateException(e); } }
private static Order read(ResultSet rs) throws SQLException { return new Order(rs.getInt("id"), rs.getString("customer"), rs.getDouble("total")); }}SQLException, also wird der Datenbankfehler in eine IllegalStateException verpackt. So hängt der Service nicht von java.sql ab, und der Fehler erreicht trotzdem den, der ihn anzeigen kann.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.
InMemoryOrderRepository orders = new InMemoryOrderRepository();LoyaltyService loyalty = new LoyaltyService(orders);orders.save(new Order(1, "Maria", 80.00));orders.save(new Order(2, "Maria", 40.00));System.out.println("2 Bestellungen: " + loyalty.discountFor("Maria") + "%");orders.save(new Order(3, "Maria", 12.90));System.out.println("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?
OrderRepository orders = new InMemoryOrderRepository();orders.save(new Order(1, "Maria", 80.00));orders.save(new Order(1, "Maria", 50.00));System.out.println(orders.findByCustomer("Maria").size());System.out.printf("%.2f €%n", orders.findById(1).total());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 implements OrderRepository { private final Map<String, List<Order>> byCustomer = new HashMap<>();
public Order findById(int id) { for (List<Order> orders : byCustomer.values()) { for (Order order : orders) { if (order.id() == id) { return order; } } } return null; }
public List<Order> findByCustomer(String customer) { return byCustomer.getOrDefault(customer, new ArrayList<>()); }
public void save(Order order) { byCustomer.values().forEach(orders -> orders.removeIf(o -> o.id() == order.id())); byCustomer.computeIfAbsent(order.customer(), c -> new ArrayList<>()).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 final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }
int discountIfBuys(String customer, Order cart) { List<Order> orders = this.orders.findByCustomer(customer); orders.add(cart); return orders.size() >= 3 ? 10 : 0; }}OrderRepository orders = new FastOrderRepository();fill(orders);LoyaltyService loyalty = new LoyaltyService(orders);System.out.println("Wenn Jonas noch eine kauft: " + loyalty.discountIfBuys("Jonas", new Order(5, "Jonas", 30.00)) + "%");System.out.println("Bestellungen von Jonas: " + orders.findByCustomer("Jonas").size());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 ArrayList<>(...). 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 (
Spring Data). 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