Zum Inhalt
wannacode

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 Stadt
class Warehouse {
final Map<String, Integer> stock = new HashMap<>();
final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) {
if (free(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int free(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// Bestellungen: greifen direkt in die Tabelle des Lagers
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
int left = warehouse.stock.get(sku);
if (left < qty) return "nicht auf Lager";
warehouse.stock.put(sku, left - qty);
return "angenommen";
}
}
var warehouse = new Warehouse();
warehouse.stock.put("KTL-100", 2);
boolean held = warehouse.reserve("KTL-100", 2);
System.out.println("Reservierung für den Laden: " + (held ? "ja" : "nein"));
var orders = new Orders(warehouse);
System.out.println("Online-Bestellung: " + orders.place("KTL-100", 1));
System.out.println("Frei im Lager: " + warehouse.free("KTL-100"));
Ausgabe
Reservierung für den Laden: ja
Online-Bestellung: angenommen
Frei im Lager: -1

Der 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 API
class Warehouse {
private final Map<String, Integer> stock = new HashMap<>();
private final Map<String, Integer> reserved = new HashMap<>();
void receive(String sku, int qty) {
stock.merge(sku, qty, Integer::sum);
}
boolean reserve(String sku, int qty) {
if (available(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int available(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// Das Modul „Bestellungen“: Es ruft nur die API des Lagers auf
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
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);
boolean held = warehouse.reserve("KTL-100", 2);
System.out.println("Reservierung für den Laden: " + (held ? "ja" : "nein"));
var orders = new Orders(warehouse);
System.out.println("Online-Bestellung: " + orders.place("KTL-100", 1));
System.out.println("Frei im Lager: " + warehouse.available("KTL-100"));
Ausgabe
Reservierung für den Laden: ja
Online-Bestellung: nicht auf Lager
Frei im Lager: 0

// Lager: Bestand und die Reservierungen des Ladens in der Stadt
class Warehouse {
final Map<String, Integer> stock = new HashMap<>();
final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) {
if (free(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int free(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// Bestellungen: greifen direkt in die Tabelle des Lagers
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
int left = warehouse.stock.get(sku);
if (left < qty) return "nicht auf Lager";
warehouse.stock.put(sku, left - qty);
return "angenommen";
}
}
Schritt für Schritt, in Worten
  1. Ein Prozess, eine Datenbank. Das Lager führt Bestand und Reservierungen, und seine Tabellen sind für alle offen.
  2. Der Laden reserviert beide Wasserkocher. Die Online-Bestellung greift direkt in die Tabelle, übersieht die Reservierung, und der freie Bestand wird -1.
  3. Ein modularer Monolith: Die Daten des Lagers gehören ihm. Nur die API bleibt nach außen: receive, reserve, available.
  4. 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.
  5. Das Lager geht als eigener Service raus: eigener Prozess, eigenes Deploy, eigenes Team. Ein Aufruf wird zu einer Nachricht über das Netzwerk.
  6. Drei Bestellungen, drei Nachrichten: ok, Schweigen, no. Ein Aufruf, der im Monolithen nicht fehlschlagen konnte, schlägt jetzt fehl.
  7. 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.

In Java schützt private die Felder, und Pakete und module-info.java (JPMS) schützen ganze Gruppen von Klassen: Was ein Modul nicht mit exports freigibt, ist von außen nicht zu sehen. Spring Modulith prüft die Grenzen zwischen Modulen in einer Spring-Anwendung.

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.

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) {}
class Boundaries {
static List<String> check(Map<String, Set<String>> api,
Map<String, Set<String>> allowed, List<Use> uses) {
List<String> problems = new ArrayList<>();
for (Use u : uses) {
String edge = u.from() + " → " + u.to();
if (!allowed.get(u.from()).contains(u.to()))
problems.add("✗ " + edge + ": diese Abhängigkeit ist nicht erlaubt");
else if (!api.get(u.to()).contains(u.name()))
problems.add("✗ " + edge + "." + u.name() + ": nicht in der API");
}
return problems;
}
}
// Was jedes Modul nach außen anbietet
Map<String, Set<String>> api = Map.of(
"orders", Set.of("place"),
"warehouse", Set.of("receive", "reserve", "available"),
"payments", Set.of("charge"),
"invoices", Set.of("issue"));
// Welches Modul von welchen abhängen darf
Map<String, Set<String>> allowed = Map.of(
"orders", Set.of("warehouse", "payments"),
"warehouse", Set.of(),
"payments", Set.of(),
"invoices", Set.of("orders"));
// Was der Code wirklich benutzt (aus den Imports)
var uses = List.of(
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(System.out::println);
System.out.println("Verstöße: " + problems.size());
Ausgabe
✗ orders → warehouse.stock: nicht in der API
✗ invoices → orders.rows: nicht in der API
✗ warehouse → orders: diese Abhängigkeit ist nicht erlaubt
Verstöße: 3

Drei 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:

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 WarehouseApi und wissen nicht, dass dahinter ein Netzwerk liegt:

interface WarehouseApi {
boolean reserve(String sku, int qty);
}
// Das Netzwerk ist ein aufgezeichnetes Szenario: "ok", "no" oder "timeout"
class Network {
private final Deque<String> script;
int sent = 0;
Network(String... script) {
this.script = new ArrayDeque<>(List.of(script));
}
String send(String message) {
sent++;
return script.removeFirst();
}
}
// Das Lager als eigener Service: Jeder Aufruf ist eine Nachricht
class RemoteWarehouse implements WarehouseApi {
private final Network network;
RemoteWarehouse(Network network) {
this.network = network;
}
public boolean reserve(String sku, int qty) {
String answer = network.send("reserve " + sku + " " + qty);
if (answer.equals("timeout"))
throw new IllegalStateException("das Lager antwortet nicht");
return answer.equals("ok");
}
}
// Bestellungen wissen nicht, ob das Lager ein Modul oder ein Service ist
class Orders {
private final WarehouseApi warehouse;
Orders(WarehouseApi warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
try {
if (!warehouse.reserve(sku, qty)) return "nicht auf Lager";
return "angenommen";
} catch (IllegalStateException e) {
return "wartet: " + e.getMessage();
}
}
}
var network = new Network("ok", "timeout", "no");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
System.out.println("Bestellung " + i + ": " + orders.place("KTL-100", 1));
System.out.println("Nachrichten über das Netzwerk: " + network.sent);
Ausgabe
Bestellung 1: angenommen
Bestellung 2: wartet: das Lager antwortet nicht
Bestellung 3: nicht auf Lager
Nachrichten über das Netzwerk: 3

Die 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++)
System.out.println("Bestellung " + i + ": " + orders.place("KTL-100", 1));
System.out.println("Nachrichten über das Netzwerk: " + network.sent);
Antwort

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.

Ausgabe
✗ orders ↔ warehouse
Verstöße: 1

Weitere Aufgaben

  • Ein Zyklus über drei Module. orders → warehouse → invoices → orders ist 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 (invoices liest orders.rows), und gib jede Tabelle aus, die mehr als ein Modul benutzt.

Test

Teste dich

1. Was macht ein Modul zu einem Modul und nicht nur zu einem Ordner?
2. Bestellungen schreiben direkt in die Bestandstabelle. Was ist das Problem?
3. Was ist ein guter Grund, das Lager zu einem eigenen Service zu machen?
4. Das Lager ist ein Service und antwortet nicht. Welches davon ist ein neues Problem, das der Monolith nicht hatte?
5. Die Grenzprüfung erlaubt sowohl orders → warehouse als auch warehouse → orders. Warum ist das schlecht?

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