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 selbstclass CheckoutController(Dictionary<string, int> carts){ public string Post(string customer) { int total = carts[customer]; if (total >= 100) total = total - 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.static class DiscountReport{ public static List<string> Discounted(Dictionary<string, int> carts) { var names = new List<string>(); foreach (var (customer, subtotal) in carts) if (subtotal > 100) names.Add(customer); return names; }}var carts = new Dictionary<string, int>{ ["Maria"] = 100, ["Jonas"] = 150, ["Petra"] = 80,};var checkout = new CheckoutController(carts);foreach (var customer in carts.Keys) Console.WriteLine("Kasse: " + checkout.Post(customer));var names = DiscountReport.Discounted(carts);Console.WriteLine("Bericht: mit Rabatt sind " + string.Join(", ", names));Kasse: Maria zahlt 90 €Kasse: Jonas zahlt 135 €Kasse: Petra zahlt 80 €Bericht: mit Rabatt sind JonasMaria 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.Totalkennt „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 hinausclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} zahlt {total} €"; }}
// Anwendung: die Schritte einer Aufgabeclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Domäne: die Regel des Shops und sonst nichtsstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Daten: woher der Warenkorb kommtinterface ICartRepository{ int SubtotalOf(string customer);}Das Repository im Speicher und der Bericht, der jetzt die Domäne fragt, statt eine eigene Bedingung zu schreiben:
class InMemoryCarts : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) => subtotals.GetValueOrDefault(customer);}// Anwendung: Der Bericht fragt die Domäne und hat keine eigene Regelclass DiscountReport(ICartRepository carts){ public List<string> Discounted(IEnumerable<string> customers) { var names = new List<string>(); foreach (var customer in customers) { int subtotal = carts.SubtotalOf(customer); if (Pricing.Total(subtotal) < subtotal) names.Add(customer); } return names; }}Zusammengesetzt: die Kasse und der Bericht über denselben Daten.
var carts = new InMemoryCarts();carts.Put("Maria", 100);carts.Put("Jonas", 150);carts.Put("Petra", 80);string[] customers = ["Maria", "Jonas", "Petra"];
var web = new CheckoutController(new CheckoutService(carts));foreach (var customer in customers) Console.WriteLine(web.Post(customer));var report = new DiscountReport(carts);var names = report.Discounted(customers);Console.WriteLine("Mit Rabatt: " + string.Join(", ", names));Maria zahlt 90 €Jonas zahlt 135 €Petra zahlt 80 €Mit Rabatt: Maria, Jonas// Darstellung: Die Anfrage kommt herein, die Antwort geht hinausclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} zahlt {total} €"; }}
// Anwendung: die Schritte einer Aufgabeclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Domäne: die Regel des Shops und sonst nichtsstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Daten: woher der Warenkorb kommtinterface ICartRepository{ int SubtotalOf(string customer);}// Darstellung: Die Anfrage kommt herein, die Antwort geht hinausclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} zahlt {total} €"; }}
// Anwendung: die Schritte einer Aufgabeclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Domäne: die Regel des Shops und sonst nichtsstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Daten: woher der Warenkorb kommtinterface ICartRepository{ int SubtotalOf(string customer);}class InMemoryCarts : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) { int sum = subtotals.GetValueOrDefault(customer); // der Bericht wollte die Summe nach dem Rabatt return Pricing.Total(sum); }}Schritt für Schritt, in Worten
- Vier Schichten: Darstellung, Anwendung, Domäne, Daten. Jede ruft nur die Schichten unter sich auf: Alle Pfeile zeigen nach unten.
- Die Kassenanfrage für Maria kommt im Controller an. Er liest nur die Anfrage und zeigt die Antwort.
- Der Controller gibt die Aufgabe an die Anwendungsschicht: Der Service kennt die Schritte der Kasse.
- Der Service fragt die Datenschicht nach dem Warenkorb: 100 €. Das Repository liefert Daten und hält keine Regeln.
- Dann fragt er die Domäne: Pricing kennt die Regel „ab 100 € gibt es 10 % Rabatt“. 100 € werden zu 90 €.
- Die Antwort steigt zurück zum Controller, und er druckt die Zeile. Der Bericht fragt dasselbe Pricing.
- Jetzt lässt jemand das Repository die Summe nach dem Rabatt zurückgeben. Die Datenschicht ruft Pricing auf: ein Pfeil nach oben.
- Der Warenkorb von Jonas: Das Repository liefert 135 €, und der Service zieht noch einmal 10 % ab. Er zahlt 122 € statt 135 €.
- 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 xUnit.
Was gibt das Programm aus?
foreach (int subtotal in new[] { 99, 100, 101 }) Console.WriteLine($"{subtotal} € → {Pricing.Total(subtotal)} €");Die Grenze ist eingeschlossen: 100 € bekommen schon den Rabatt. Ab 101 € gehen 10 € ab, weil die Division eine ganzzahlige ist und 101 / 10 gleich 10 ergibt. Genau diese Grenzen (99, 100, 101) sind der erste Test jeder Regel über Geld.
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 : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) { int sum = subtotals.GetValueOrDefault(customer); // der Bericht wollte die Summe nach dem Rabatt return Pricing.Total(sum); }}Die Kasse und der Bericht sind dieselben, ohne eine einzige geänderte Zeile:
var carts = new InMemoryCarts();carts.Put("Maria", 100);carts.Put("Jonas", 150);carts.Put("Petra", 80);string[] customers = ["Maria", "Jonas", "Petra"];
var web = new CheckoutController(new CheckoutService(carts));foreach (var customer in customers) Console.WriteLine(web.Post(customer));var report = new DiscountReport(carts);var names = report.Discounted(customers);Console.WriteLine("Mit Rabatt: " + string.Join(", ", names));Maria zahlt 90 €Jonas zahlt 122 €Petra zahlt 80 €Mit Rabatt: JonasJonas zahlt 122 € statt 135 €, und Maria fehlt wieder im Bericht. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Die Datenschicht ruft die Domäne auf: ein Pfeil nach oben. Das Repository kennt jetzt die Regel, und sie wird zweimal angewendet: 150 € → 135 € im Repository, dann 135 € → 122 € im Service. Der Bericht fragt „sind 90 € reduziert?“ und sagt für Maria „nein“. Es ist dasselbe Problem wie WHERE in der SQL-Abfrage am Anfang: eine Regel, die in den Daten lebt. Das Repository soll nur zurückgeben, was es speichert. Die Summe nach dem Rabatt ist Aufgabe des Services, der Pricing fragt.
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.
Shop/ Web/ CheckoutController.cs CatalogController.cs Services/ CheckoutService.cs CatalogService.cs Domain/ Pricing.cs Product.cs Data/ ICartRepository.cs IProductRepository.csDer zweite ist nach Funktion (Vertical Slice): ein Ordner für jeden Teil des Shops, mit den Schichten darin.
Shop/ Checkout/ CheckoutController.cs CheckoutService.cs Pricing.cs ICartRepository.cs Catalog/ CatalogController.cs CatalogService.cs Product.cs IProductRepository.csNach 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 decimal, 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 €.
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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Tests mit xUnit · Singleton und Dependency Injection · Repository · SOLID ohne Dogma
- Geht weiter in: Hexagonale und Clean Architecture · Monolith, modularer Monolith, Services · Caching · ADR: Architekturentscheidungen festhalten
- Aufgaben zum Thema: Softwarearchitektur