Zum Inhalt
wannacode

Schichten

Kurz gesagt: Die Schichtenarchitektur teilt die Anwendung in Darstellung, Anwendung, Domäne und Daten, und Abhängigkeiten zeigen nur nach unten, damit eine Geschäftsregel an einer Stelle lebt.

Problem

Der Rabatt an der Kasse und im Bericht

Der Shop gibt 10 % Rabatt auf einen Warenkorb ab 100 €. Die Kasse berechnet den Rabatt im Controller, der die Anfrage bearbeitet. Der Bericht für die Buchhaltung ist eine SQL-Abfrage auf dieselbe Tabelle, mit einer eigenen Bedingung in WHERE:

// Die Kasse: Der Controller liest den Warenkorb und berechnet den Rabatt selbst
class CheckoutController {
constructor(carts) {
this.carts = carts;
}
post(customer) {
let total = this.carts.get(customer);
if (total >= 100) total = total - Math.floor(total / 10);
return `${customer} zahlt ${total} €`;
}
}
// Der Bericht ist eine SQL-Abfrage auf dieselbe Tabelle:
// SELECT customer FROM carts WHERE subtotal > 100
// Hier ist die Tabelle ein Dictionary und WHERE ein if.
function discounted(carts) {
return [...carts].filter(([, subtotal]) => subtotal > 100).map(([customer]) => customer);
}
const carts = new Map([["Maria", 100], ["Jonas", 150], ["Petra", 80]]);
const checkout = new CheckoutController(carts);
for (const customer of carts.keys())
console.log("Kasse: " + checkout.post(customer));
console.log("Bericht: mit Rabatt sind " + discounted(carts).join(", "));
Ausgabe
Kasse: Maria zahlt 90 €
Kasse: Jonas zahlt 135 €
Kasse: Petra zahlt 80 €
Bericht: mit Rabatt sind Jonas

Maria zahlt 90 €, und der Bericht sagt, sie hatte keinen Rabatt. Die Kasse hat >= 100, die Abfrage hat > 100. Die Buchhaltung sieht 10 € Unterschied, und niemand weiß, welche Version stimmt.

Es gibt einen zweiten Schmerz. Die Regel ist an den Controller geklebt: Um zu prüfen, ob >= 100 stimmt, brauchst du eine Anfrage, einen Warenkorb in einer Tabelle und eine Antwort zum Lesen. Die Mobile-App wird eine dritte Version wollen.

Die Idee

Vier Schichten und eine Richtung

In der Lektion Repository hast du einen Controller, einen Service und ein Repository untereinander gesehen. Hier ordnen wir eine ganze Anwendung so an und geben ihr eine Regel. Die Schichtenarchitektur teilt den Code in vier Schichten:

  • Darstellung: der Controller. Er liest die Anfrage und zeigt die Antwort. Er rechnet nicht;
  • Anwendung: der Service. Er kennt die Schritte einer Aufgabe („hole den Warenkorb, berechne den Preis“), aber nicht die Regeln;
  • Domäne: die Regeln des Shops. Pricing.total kennt „ab 100 € gibt es 10 % Rabatt“ und sonst nichts: keine Anfragen, keine Tabellen;
  • Daten: die Repositories. Woher der Warenkorb kommt.

Schichten haben eine Regel: Abhängigkeiten zeigen nach unten. Die obere Schicht ruft die untere auf, und die untere weiß nicht, dass es die obere gibt. Deshalb können Kasse, Bericht und Mobile-App die Domäne benutzen, ohne sie anzufassen.

Eine Schicht ist nicht unbedingt eine Klasse. In einem großen Projekt ist eine Schicht ein Paket oder ein Projekt mit vielen Klassen darin.

Sieh es dir an

Die Anfrage durch die Schichten

Die vier Schichten der Kasse, von oben nach unten:

// Darstellung: Die Anfrage kommt herein, die Antwort geht hinaus
class CheckoutController {
constructor(service) {
this.service = service;
}
post(customer) {
const total = this.service.checkout(customer);
return `${customer} zahlt ${total} €`;
}
}
// Anwendung: die Schritte einer Aufgabe
class CheckoutService {
constructor(carts) {
this.carts = carts;
}
checkout(customer) {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal);
}
}
// Domäne: die Regel des Shops und sonst nichts
const Pricing = {
total(subtotal) {
if (subtotal < 100) return subtotal;
return subtotal - Math.floor(subtotal / 10);
},
};
// Daten: Ein Warenkorb-Repository hat subtotalOf(customer)
JavaScript hat keine Interfaces. Der Vertrag des Repositorys ist der Kommentar ganz unten: Jedes Objekt mit subtotalOf(customer) genügt.

Das Repository im Speicher und der Bericht, der jetzt die Domäne fragt, statt eine eigene Bedingung zu schreiben:

class InMemoryCarts {
#subtotals = new Map();
put(customer, subtotal) {
this.#subtotals.set(customer, subtotal);
}
subtotalOf(customer) {
return this.#subtotals.get(customer) ?? 0;
}
}
// Anwendung: Der Bericht fragt die Domäne und hat keine eigene Regel
class DiscountReport {
constructor(carts) {
this.carts = carts;
}
discounted(customers) {
return customers.filter((customer) => {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal) < subtotal;
});
}
}

Zusammengesetzt: die Kasse und der Bericht über denselben Daten.

const carts = new InMemoryCarts();
carts.put("Maria", 100);
carts.put("Jonas", 150);
carts.put("Petra", 80);
const customers = ["Maria", "Jonas", "Petra"];
const web = new CheckoutController(new CheckoutService(carts));
for (const customer of customers)
console.log(web.post(customer));
const report = new DiscountReport(carts);
console.log("Mit Rabatt: " + report.discounted(customers).join(", "));
Ausgabe
Maria zahlt 90 €
Jonas zahlt 135 €
Petra zahlt 80 €
Mit Rabatt: Maria, Jonas

// Darstellung: Die Anfrage kommt herein, die Antwort geht hinaus
class CheckoutController {
constructor(service) {
this.service = service;
}
post(customer) {
const total = this.service.checkout(customer);
return `${customer} zahlt ${total} €`;
}
}
// Anwendung: die Schritte einer Aufgabe
class CheckoutService {
constructor(carts) {
this.carts = carts;
}
checkout(customer) {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal);
}
}
// Domäne: die Regel des Shops und sonst nichts
const Pricing = {
total(subtotal) {
if (subtotal < 100) return subtotal;
return subtotal - Math.floor(subtotal / 10);
},
};
// Daten: Ein Warenkorb-Repository hat subtotalOf(customer)
Schritt für Schritt, in Worten
  1. Vier Schichten: Darstellung, Anwendung, Domäne, Daten. Jede ruft nur die Schichten unter sich auf: Alle Pfeile zeigen nach unten.
  2. Die Kassenanfrage für Maria kommt im Controller an. Er liest nur die Anfrage und zeigt die Antwort.
  3. Der Controller gibt die Aufgabe an die Anwendungsschicht: Der Service kennt die Schritte der Kasse.
  4. Der Service fragt die Datenschicht nach dem Warenkorb: 100 €. Das Repository liefert Daten und hält keine Regeln.
  5. Dann fragt er die Domäne: Pricing kennt die Regel „ab 100 € gibt es 10 % Rabatt“. 100 € werden zu 90 €.
  6. Die Antwort steigt zurück zum Controller, und er druckt die Zeile. Der Bericht fragt dasselbe Pricing.
  7. Jetzt lässt jemand das Repository die Summe nach dem Rabatt zurückgeben. Die Datenschicht ruft Pricing auf: ein Pfeil nach oben.
  8. Der Warenkorb von Jonas: Das Repository liefert 135 €, und der Service zieht noch einmal 10 % ab. Er zahlt 122 € statt 135 €.
  9. Nach unten ist erlaubt, nach oben ist der Fehler: Die Regel ist wieder in die Daten gesickert, wie in der SQL-Abfrage des Berichts.

Maria zahlt 90 € und steht im Bericht, weil beide Stellen dieselbe Regel fragen. Der Service ruft das Repository auf, ohne über die Domäne zu gehen: Das ist erlaubt, weil das Repository unter ihm liegt. Die Regel betrifft nur die Richtung.

Probier es aus

Die Regel ohne alles drumherum

Die Domäne weiß nichts über Controller und Repositories, deshalb wird sie für sich geprüft: keine Anfrage, kein Warenkorb, keine Datenbank. Ihr Test sieht auch so aus, wie in der Lektion Tests mit node:test.

Was gibt das Programm aus?

for (const subtotal of [99, 100, 101])
console.log(`${subtotal} € → ${Pricing.total(subtotal)} €`);
Antwort

Probier es aus

Finde den Fehler

Der Bericht will die Summe nach dem Rabatt. Jemand beschließt, der einfachste Weg sei, dass das Repository sie fertig liefert:

class InMemoryCarts {
#subtotals = new Map();
put(customer, subtotal) {
this.#subtotals.set(customer, subtotal);
}
subtotalOf(customer) {
const subtotal = this.#subtotals.get(customer) ?? 0;
// der Bericht wollte die Summe nach dem Rabatt
return Pricing.total(subtotal);
}
}

Die Kasse und der Bericht sind dieselben, ohne eine einzige geänderte Zeile:

const carts = new InMemoryCarts();
carts.put("Maria", 100);
carts.put("Jonas", 150);
carts.put("Petra", 80);
const customers = ["Maria", "Jonas", "Petra"];
const web = new CheckoutController(new CheckoutService(carts));
for (const customer of customers)
console.log(web.post(customer));
const report = new DiscountReport(carts);
console.log("Mit Rabatt: " + report.discounted(customers).join(", "));
Ausgabe
Maria zahlt 90 €
Jonas zahlt 122 €
Petra zahlt 80 €
Mit Rabatt: Jonas

Jonas zahlt 122 € statt 135 €, und Maria fehlt wieder im Bericht. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Nach Technik oder nach Funktion

Schichten sagen, wer wen aufruft. Wie die Dateien angeordnet sind, ist eine eigene Frage. Der erste Weg ist nach Technik: ein Ordner für jede Schicht.

src/
web/
checkout-controller.js
catalog-controller.js
services/
checkout-service.js
catalog-service.js
domain/
pricing.js
product.js
data/
cart-repository.js
product-repository.js

Der zweite ist nach Funktion (Vertical Slice): ein Ordner für jeden Teil des Shops, mit den Schichten darin.

src/
checkout/
controller.js
service.js
pricing.js
cart-repository.js
catalog/
controller.js
service.js
product.js
product-repository.js

Nach Technik ist am Anfang leicht, aber jede Änderung an der Kasse berührt vier Ordner, und der Ordner service/ wächst mit allem. Nach Funktion liegt die Änderung an einer Stelle, und jeder Ordner kann eigene Regeln dafür haben, wer ihn von außen aufruft. Das ist der Schritt zu den Modulen in der Lektion Monolith, modularer Monolith, Services. Die Regel über die Richtung gilt in beiden Fällen.

Die Idee

Wenn Schichten schaden

Schichten kosten Dateien und Aufrufe. Sie lohnen sich nicht, wenn:

  • es keine Regeln gibt. Ein Formular, das nur in die Datenbank speichert, geht durch einen Controller, einen Service und eine Domäne, die nur weiterreichen. Das heißt Sinkhole: Die Anfrage fällt durch die Schichten, und niemand fasst sie an;
  • jede Schicht ihr eigenes Objekt für dieselben Daten hat: CartDto, CartModel, CartEntity, und das Umwandeln zwischen ihnen ist der halbe Code;
  • alles nach Technik angeordnet ist und das Projekt groß ist: Der Ordner service/ hat 200 Dateien, die nichts miteinander zu tun haben.

Sie lohnen sich, wenn es Regeln gibt, die an einer Stelle gehalten und ohne Datenbank geprüft werden müssen, und wenn dieselbe Regel von mehreren Stellen aufgerufen wird: von der Kasse, vom Bericht, von der Mobile-App.

Genauer, für Neugierige

Strikte und lockere Schichten. Bei strikten Schichten ruft jede Schicht nur ihre Nachbarin auf. Bei lockeren (wie in dieser Lektion) darf sie nach unten überspringen: Der Service ruft das Repository direkt auf. Die meisten Projekte sind locker. Die strikte Regel bringt mehr Weiterreichen und hilft selten.

In Frameworks. Spring legt die Schichten in Annotationen: @Controller, @Service, @Repository. ASP.NET Core hat Controller, und Services und Repositories werden im DI-Container registriert. Django ist nach Funktion geordnet: Jede „App“ hat ihre eigene views.py und models.py. Nest (Node) fasst einen Controller und seine Services pro Funktion zu einem Modul zusammen, und Express schreibt nichts vor. Kein Framework bewacht die Richtung von selbst: Ein @Repository, das einen @Service aufruft, kompiliert.

Die Richtung bewachen. Die Richtung lässt sich automatisch prüfen: ArchUnit in Java, ArchUnitNET oder NetArchTest in C#, import-linter in Python und dependency-cruiser in JavaScript schlagen an, wenn ein Datenpaket ein Domänenpaket importiert. Eine kleine Prüfung dieser Art für Module schreibst du in der Lektion Monolith, modularer Monolith, Services.

Geld. In der Lektion sind die Summen ganze Euro, damit die Ausgabe in allen vier Sprachen gleich ist. In einem echten Shop steht Geld in Cent (long) oder in ganzen Cent (Number hat keinen Dezimaltyp), nie in double.

Wie es weitergeht. In der Lektion Hexagonale und Clean Architecture wird die Richtung ganz umgedreht: Die Domäne hängt nicht einmal vom Repository ab.

Übung

Die Lieferung in der Domäne

Löse es in deinem Editor.

Die Lieferung kostet 5 € bei einer Bestellung unter 50 € und ist ab 50 € frei (die Summe ist nach dem Rabatt). Lege die Regel in die Domäne, in Delivery.fee(total). Ein neuer Controller zeigt den Preis und die Lieferung, und der Service und das Repository werden nicht angefasst. Prüfe am Ende die Regel für sich, ohne Controller, für 49 € und 50 €.

Ausgabe
Maria zahlt 90 € + Lieferung 0 €
Petra zahlt 80 € + Lieferung 0 €
Georg zahlt 30 € + Lieferung 5 €
49 € → Lieferung 5 €
50 € → Lieferung 0 €

Weitere Aufgaben

  • Die Mobile-App. Ergänze einen zweiten Controller, der dieselbe Summe als JSON-Text {"customer":"Maria","total":90} zurückgibt. Wie viele Zeilen der Regel berührst du?
  • Rabatt pro Kunde. Kunden mit einer Treuekarte bekommen 15 % ab 100 €. Wohin kommt die Karte: ins Repository, in die Domäne oder in den Controller? Warum?
  • Die Richtung prüfen. Schreibe eine Funktion, die eine Liste „welche Schicht ruft welche auf“ (data → domain, application → data) bekommt und jeden Aufruf ausgibt, der nach oben geht.

Test

Teste dich

1. In welcher Schicht lebt die Regel „ab 100 € gibt es 10 % Rabatt“?
2. Der Service ruft das Repository direkt auf, ohne über die Domäne zu gehen. Ist das ein Verstoß?
3. Das Repository fängt an, Pricing aufzurufen, „um die Summe nach dem Rabatt zu liefern“. Was passiert?
4. Ein internes Werkzeug hat drei Formulare, die nur in die Datenbank speichern, ganz ohne Regeln. Lohnen sich vier Schichten?
5. Der Code ist nach Funktion geordnet (checkout/, catalog/). Was gewinnst du gegenüber der Ordnung nach Technik (web/, service/, data/)?

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