Monolith, modularer Monolith, Services
Kurz gesagt: Ein modularer Monolith ist eine Anwendung mit klaren Modulen, jedes mit öffentlicher API und eigenen Daten; Microservices werden herausgelöst, wenn Teams, Skalierung oder unabhängige Deploys die Kosten wert sind.
Problem
Bestellungen in der Lagertabelle
Der Laden in der Stadt reserviert Artikel für Kunden, die persönlich kommen. Das Lager führt den Bestand und die Reservierungen, und für den Wasserkocher KTL-100 beträgt der Bestand 2. Die Online-Bestellungen hat ein anderes Team geschrieben, das „um Zeit zu sparen“ die Lagertabelle direkt liest und schreibt:
// Lager: Bestand und die Reservierungen des Ladens in der Stadtclass Warehouse{ public Dictionary<string, int> Stock { get; } = []; public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty) { if (Free(sku) < qty) return false; Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty; return true; }
public int Free(string sku) => Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);}
// Bestellungen: greifen direkt in die Tabelle des Lagersclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { int left = warehouse.Stock[sku]; if (left < qty) return "nicht auf Lager"; warehouse.Stock[sku] = left - qty; return "angenommen"; }}var warehouse = new Warehouse();warehouse.Stock["KTL-100"] = 2;bool held = warehouse.Reserve("KTL-100", 2);Console.WriteLine("Reservierung für den Laden: " + (held ? "ja" : "nein"));var orders = new Orders(warehouse);Console.WriteLine("Online-Bestellung: " + orders.Place("KTL-100", 1));Console.WriteLine("Frei im Lager: " + warehouse.Free("KTL-100"));Reservierung für den Laden: jaOnline-Bestellung: angenommenFrei im Lager: -1Der Laden hat beide Wasserkocher reserviert, und die Online-Bestellung wird angenommen. Der freie Bestand ist -1: Ein Artikel, den es nicht gibt, wurde verkauft. Die Regel für Reservierungen steckt im Lager, aber die Bestellungen gehen nicht durch es.
Der zweite Schmerz kommt später. Das Lager-Team will stock nach Lagern aufteilen (Sofia, Plovdiv). Es kann nicht: Es weiß nicht, wer sonst seine Tabelle liest und schreibt, und jede Änderung bricht fremden Code.
Die Idee
Module mit einer API und eigenen Daten
Der Code ist ein Programm, das auf einmal ausgeliefert wird: ein Monolith. Das allein ist nicht das Problem. Das Problem ist, dass es innen keine Grenzen gibt.
Ein modularer Monolith ist ein Monolith, der in Module aufgeteilt ist. Jedes Modul:
- hat eine öffentliche API: ein paar Methoden, über die die anderen es aufrufen (
receive,reserve,available); - hat eigene Daten: Die Tabellen und Klassen darin gehören ihm, und niemand sonst liest oder schreibt sie;
- hängt nur von den Modulen ab, von denen es abhängen darf.
Das ist die Aufteilung „nach Funktion“ aus der Lektion „Schichten“, mit einer Regel für die Grenzen. Im Domain-Driven Design heißt so eine Grenze Bounded Context: der Teil des Systems, in dem das Wort „Bestand“ eine Bedeutung hat.
Die Module liegen in einem Prozess und oft in einer Datenbank. Ein Modul aufzurufen heißt, eine Methode aufzurufen: schnell, ohne Netzwerk und bei Bedarf in einer Transaktion.
Sieh es dir an
Das Lager als Modul
// Das Modul „Lager“: Die Daten gehören ihm, nach außen ist nur die APIclass Warehouse{ private readonly Dictionary<string, int> stock = []; private readonly Dictionary<string, int> reserved = [];
public void Receive(string sku, int qty) => stock[sku] = stock.GetValueOrDefault(sku) + qty;
public bool Reserve(string sku, int qty) { if (Available(sku) < qty) return false; reserved[sku] = reserved.GetValueOrDefault(sku) + qty; return true; }
public int Available(string sku) => stock.GetValueOrDefault(sku) - reserved.GetValueOrDefault(sku);}
// Das Modul „Bestellungen“: Es ruft nur die API des Lagers aufclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { if (!warehouse.Reserve(sku, qty)) return "nicht auf Lager"; return "angenommen"; }}var warehouse = new Warehouse();warehouse.Receive("KTL-100", 2);bool held = warehouse.Reserve("KTL-100", 2);Console.WriteLine("Reservierung für den Laden: " + (held ? "ja" : "nein"));var orders = new Orders(warehouse);Console.WriteLine("Online-Bestellung: " + orders.Place("KTL-100", 1));Console.WriteLine("Frei im Lager: " + warehouse.Available("KTL-100"));Reservierung für den Laden: jaOnline-Bestellung: nicht auf LagerFrei im Lager: 0// Lager: Bestand und die Reservierungen des Ladens in der Stadtclass Warehouse{ public Dictionary<string, int> Stock { get; } = []; public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty) { if (Free(sku) < qty) return false; Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty; return true; }
public int Free(string sku) => Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);}
// Bestellungen: greifen direkt in die Tabelle des Lagersclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { int left = warehouse.Stock[sku]; if (left < qty) return "nicht auf Lager"; warehouse.Stock[sku] = left - qty; return "angenommen"; }}// Lager: Bestand und die Reservierungen des Ladens in der Stadtclass Warehouse{ public Dictionary<string, int> Stock { get; } = []; public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty) { if (Free(sku) < qty) return false; Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty; return true; }
public int Free(string sku) => Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);}
// Bestellungen: greifen direkt in die Tabelle des Lagersclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { int left = warehouse.Stock[sku]; if (left < qty) return "nicht auf Lager"; warehouse.Stock[sku] = left - qty; return "angenommen"; }}// Das Modul „Lager“: Die Daten gehören ihm, nach außen ist nur die APIclass Warehouse{ private readonly Dictionary<string, int> stock = []; private readonly Dictionary<string, int> reserved = [];
public void Receive(string sku, int qty) => stock[sku] = stock.GetValueOrDefault(sku) + qty;
public bool Reserve(string sku, int qty) { if (Available(sku) < qty) return false; reserved[sku] = reserved.GetValueOrDefault(sku) + qty; return true; }
public int Available(string sku) => stock.GetValueOrDefault(sku) - reserved.GetValueOrDefault(sku);}
// Das Modul „Bestellungen“: Es ruft nur die API des Lagers aufclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { if (!warehouse.Reserve(sku, qty)) return "nicht auf Lager"; return "angenommen"; }}// Das Netzwerk ist ein aufgezeichnetes Szenario: "ok", "no" oder "timeout"class Network(params string[] script){ private readonly Queue<string> queue = new(script); public int Sent { get; private set; }
public string Send(string message) { Sent++; return queue.Dequeue(); }}
// Das Lager als eigener Service: Jeder Aufruf ist eine Nachrichtclass RemoteWarehouse(Network network) : IWarehouse{ public bool Reserve(string sku, int qty) { var answer = network.Send($"reserve {sku} {qty}"); if (answer == "timeout") throw new TimeoutException("das Lager antwortet nicht"); return answer == "ok"; }}
// Bestellungen wissen nicht, ob das Lager ein Modul oder ein Service istclass Orders(IWarehouse warehouse){ public string Place(string sku, int qty) { try { if (!warehouse.Reserve(sku, qty)) return "nicht auf Lager"; return "angenommen"; } catch (TimeoutException e) { return "wartet: " + e.Message; } }}Schritt für Schritt, in Worten
- Ein Prozess, eine Datenbank. Das Lager führt Bestand und Reservierungen, und seine Tabellen sind für alle offen.
- Der Laden reserviert beide Wasserkocher. Die Online-Bestellung greift direkt in die Tabelle, übersieht die Reservierung, und der freie Bestand wird -1.
- Ein modularer Monolith: Die Daten des Lagers gehören ihm. Nur die API bleibt nach außen: receive, reserve, available.
- Bestellungen laufen über reserve, und das Lager behält seine Regel: „nicht auf Lager“, der freie Bestand bleibt 0. Immer noch ein Prozess, kein Netzwerk.
- Das Lager geht als eigener Service raus: eigener Prozess, eigenes Deploy, eigenes Team. Ein Aufruf wird zu einer Nachricht über das Netzwerk.
- Drei Bestellungen, drei Nachrichten: ok, Schweigen, no. Ein Aufruf, der im Monolithen nicht fehlschlagen konnte, schlägt jetzt fehl.
- Module halten die Grenzen ohne Netzwerk. Ein Service bringt ein unabhängiges Deploy und zahlt dafür mit Netzwerk, getrennten Daten und Monitoring.
Die Bestellung geht über reserve, und das Lager behält seine Regel: Beide Wasserkocher sind reserviert, also bekommt die Online-Bestellung „nicht auf Lager“. Die letzten Schritte der Szene zeigen, was passiert, wenn das Lager als eigener Service rausgeht. Ihr Code steht weiter unten.
private die Felder, und internal versteckt Klassen vor anderen Projekten. Ein Modul ist oft ein eigenes Projekt (.csproj) in derselben Solution: Was internal ist, ist von außen nicht zu sehen.Probier es aus
Finde den Fehler
Der Lagerbericht will die ganze Tabelle zeigen, und jemand fügt eine Methode hinzu:
Wähle die Zeile mit dem Fehler.
Die Methode gibt die Tabelle selbst zurück, nicht eine Kopie. Wer sie bekommt, kann hineinschreiben (stockTable().put(...)), und die Grenze ist wieder offen, nur besser versteckt. Das Modul sollte eine Kopie zurückgeben oder nur das, was der Bericht wirklich braucht, zum Beispiel eine Liste „Produkt und freie Stücke“.
Sieh es dir an
Die Grenzen prüfen
Grenzen hält man nicht mit guten Absichten ein. Hier ist eine kleine Prüfung: was jedes Modul anbietet, welches Modul von welchem abhängen darf und was der Code wirklich benutzt. In einem echten Projekt wird die letzte Liste aus den imports gelesen.
record Use(string From, string To, string Name);
static class Boundaries{ public static List<string> Check(Dictionary<string, HashSet<string>> api, Dictionary<string, HashSet<string>> allowed, List<Use> uses) { var problems = new List<string>(); foreach (var (from, to, name) in uses) { if (!allowed[from].Contains(to)) problems.Add($"✗ {from} → {to}: diese Abhängigkeit ist nicht erlaubt"); else if (!api[to].Contains(name)) problems.Add($"✗ {from} → {to}.{name}: nicht in der API"); } return problems; }}// Was jedes Modul nach außen anbietetvar api = new Dictionary<string, HashSet<string>>{ ["orders"] = ["place"], ["warehouse"] = ["receive", "reserve", "available"], ["payments"] = ["charge"], ["invoices"] = ["issue"],};// Welches Modul von welchen abhängen darfvar allowed = new Dictionary<string, HashSet<string>>{ ["orders"] = ["warehouse", "payments"], ["warehouse"] = [], ["payments"] = [], ["invoices"] = ["orders"],};// Was der Code wirklich benutzt (aus den Imports)List<Use> uses =[ new Use("orders", "warehouse", "reserve"), new Use("orders", "warehouse", "stock"), new Use("orders", "payments", "charge"), new Use("invoices", "orders", "rows"), new Use("warehouse", "orders", "place"),];
var problems = Boundaries.Check(api, allowed, uses);problems.ForEach(Console.WriteLine);Console.WriteLine("Verstöße: " + problems.Count);✗ orders → warehouse.stock: nicht in der API✗ invoices → orders.rows: nicht in der API✗ warehouse → orders: diese Abhängigkeit ist nicht erlaubtVerstöße: 3Drei Verstöße: die Bestellungen in der Lagertabelle, die Rechnungen in den Bestellzeilen und ein Lager, das die Bestellungen aufruft (während die Bestellungen schon das Lager aufrufen). Eine solche Prüfung läuft bei jeder Änderung und schlägt an, sobald jemand durch die Wand geht. Wie sie Teil von CI wird, steht in der Lektion „Architektur-Review“.
Die Idee
Wann ein Modul zum Service wird
Ein Service (Microservice) ist ein Modul, das für sich läuft: eigener Prozess, eigenes Deploy, eigene Datenbank. Die anderen rufen es über das Netzwerk auf. Es lohnt sich, wenn:
- Teams einander im Weg stehen: Das Lager will dreimal am Tag eine neue Version ausliefern, der Shop einmal in der Woche;
- sich die Last unterscheidet: Die Katalogsuche braucht zehn Kopien, die Rechnungen brauchen eine;
- ein Ausfall isoliert werden muss: Fallen die Empfehlungen aus, muss die Zahlung weiterarbeiten.
Und man bezahlt dafür mit:
- dem Netzwerk: Ein Aufruf kann langsam sein, nicht ankommen oder doppelt ankommen;
- Daten: Jeder Service hat seine eigene Datenbank, also gibt es zwischen ihnen kein
JOINund keine gemeinsame Transaktion. Eine Bestellung und eine Reservierung werden nicht mehr „zusammen“ gespeichert. Wie man damit lebt, steht in „Ereignisse und Warteschlangen“, „Transactional Outbox“ und „Eine Transaktion über Services hinweg (Saga)“; - Monitoring: Eine Anfrage läuft durch fünf Prozesse, und der Fehler wird in fünf Logs gesucht.
Server, Container, Kubernetes und Monitoring als Infrastruktur sind Thema des DevOps-Kurses ZeroToOps, nicht dieser Lektion.
Sieh es dir an
Das Lager über das Netzwerk
Das Lager ist ein eigener Service. Im Programm ist das „Netzwerk“ ein Objekt mit einem aufgezeichneten Szenario: Auf jede Nachricht antwortet es ok, no oder schweigt (timeout). Die Bestellungen sehen nur die API IWarehouse und wissen nicht, dass dahinter ein Netzwerk liegt:
interface IWarehouse{ bool Reserve(string sku, int qty);}// Das Netzwerk ist ein aufgezeichnetes Szenario: "ok", "no" oder "timeout"class Network(params string[] script){ private readonly Queue<string> queue = new(script); public int Sent { get; private set; }
public string Send(string message) { Sent++; return queue.Dequeue(); }}
// Das Lager als eigener Service: Jeder Aufruf ist eine Nachrichtclass RemoteWarehouse(Network network) : IWarehouse{ public bool Reserve(string sku, int qty) { var answer = network.Send($"reserve {sku} {qty}"); if (answer == "timeout") throw new TimeoutException("das Lager antwortet nicht"); return answer == "ok"; }}
// Bestellungen wissen nicht, ob das Lager ein Modul oder ein Service istclass Orders(IWarehouse warehouse){ public string Place(string sku, int qty) { try { if (!warehouse.Reserve(sku, qty)) return "nicht auf Lager"; return "angenommen"; } catch (TimeoutException e) { return "wartet: " + e.Message; } }}var network = new Network("ok", "timeout", "no");var orders = new Orders(new RemoteWarehouse(network));for (int i = 1; i <= 3; i++) Console.WriteLine($"Bestellung {i}: {orders.Place("KTL-100", 1)}");Console.WriteLine("Nachrichten über das Netzwerk: " + network.Sent);Bestellung 1: angenommenBestellung 2: wartet: das Lager antwortet nichtBestellung 3: nicht auf LagerNachrichten über das Netzwerk: 3Die zweite Bestellung weiß nicht, ob der Artikel reserviert ist: Das Lager kann die Nachricht bekommen und geantwortet haben, und die Antwort kann verloren gegangen sein. Im Modul gab es diese Frage nicht.
Probier es aus
Sag die Ausgabe voraus
Das Netzwerk schweigt zweimal und antwortet dann ok. Was gibt das Programm aus?
var network = new Network("timeout", "timeout", "ok");var orders = new Orders(new RemoteWarehouse(network));for (int i = 1; i <= 3; i++) Console.WriteLine($"Bestellung {i}: {orders.Place("KTL-100", 1)}");Console.WriteLine("Nachrichten über das Netzwerk: " + network.Sent);Jede Bestellung sendet ihre eigene Nachricht, also sind es drei. Die ersten zwei bekommen keine Antwort und warten, die dritte wird angenommen. Wie oft und wann man es noch einmal versucht, ist ein eigenes Thema: die Lektion „Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker“.
Die Idee
Wann Services weh tun
- Zu früh. Ein neuer Shop mit einem Team und zehn Services zahlt den ganzen Preis des Netzwerks, ohne unabhängige Deploys zu brauchen. Die Grenzen sind noch nicht klar, und eine Grenze zwischen Services zu verschieben kostet weit mehr als zwischen Modulen.
- Ein verteilter Monolith. Services, die zusammen ausgeliefert werden müssen und sich bei jeder Anfrage gegenseitig aufrufen, haben alle Nachteile eines Monolithen und von Services zugleich.
- Eine gemeinsame Datenbank. Zwei Services, die in dieselben Tabellen schreiben, sind das Problem vom Anfang der Lektion, nur über das Netzwerk.
Deshalb ist der übliche Weg: erst der modulare Monolith: klare Module in einem Prozess und eine Grenzprüfung. Ein Modul, das wirklich eigenständig sein muss, geht als Service raus. Das ist leicht, wenn es schon eine API und eigene Daten hat, wie das Lager in der Szene.
Genauer, für Neugierige
Die Frameworks. Ein Service ist eine gewöhnliche Anwendung: Spring Boot, ASP.NET Core, FastAPI oder Django, Express oder Nest. Services reden über HTTP (REST, gRPC) oder über einen Message Broker (Kafka, RabbitMQ). Davor steht oft ein API-Gateway: ein Eingang, der Anfragen an die Services leitet und die Eingabe prüft.
Conways Gesetz. Systeme ähneln der Organisation, die sie baut: Drei Teams bauen ein System aus drei Teilen. Deshalb folgen die Grenzen von Services oft den Teams.
Die Grenzen prüfen. ArchUnit (Java) und ArchUnitNET oder NetArchTest (C#) schreiben die Prüfung als Test. import-linter (Python) und dependency-cruiser (JavaScript) schreiben sie als Regeln in der Konfiguration. Spring Modulith (Java) und Nx (JavaScript) kennen ebenfalls Module.
Wie groß ein Service ist. „Micro“ ist keine Größe in Zeilen. Eine gute Grenze ist eine, durch die wenige Aufrufe gehen und hinter der Daten liegen, die sonst niemand schreibt.
Übung
Ein Zyklus zwischen Modulen
Löse es in deinem Editor.
Jemand hat auch warehouse → orders erlaubt. Jetzt hängen die zwei Module voneinander ab. Schreibe eine Funktion, die die Karte „welches Modul von welchem abhängen darf“ nimmt und jedes Paar von Modulen findet, die voneinander abhängen. Jedes Paar wird einmal ausgegeben, in alphabetischer Reihenfolge.
✗ orders ↔ warehouseVerstöße: 1Weitere Aufgaben
- Ein Zyklus über drei Module.
orders → warehouse → invoices → ordersist auch ein Zyklus. Finde ihn mit Tiefensuche (die Lektion „DFS: Inseln und Zyklen“ findet Zyklen). - Die Tabellen. Ergänze die Prüfung darum, welches Modul wessen Tabelle liest (
invoicesliestorders.rows), und gib jede Tabelle aus, die mehr als ein Modul benutzt.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Repository · SOLID ohne Dogma · Schichten · Hexagonale und Clean Architecture
- Geht weiter in: Ereignisse und Warteschlangen · Transactional Outbox · Skalierung · ADR: Architekturentscheidungen festhalten
- Aufgaben zum Thema: Softwarearchitektur