Architektur
Software-Muster jenseits von GoF · Gruppe 5 von 7
Architekturmuster beschreiben die Grobstruktur einer ganzen Anwendung: wie sie in Teile gegliedert ist und wie diese Teile zusammenarbeiten. Viele davon stammen aus dem Buch Pattern-Oriented Software Architecture (POSA, 1996), andere sind später entstanden. Sie lassen sich nur eingeschränkt in einer einzelnen Datei zeigen. Wo ein kleines Codebeispiel die Idee trotzdem verständlich macht, ist eines dabei. Bei Layers, Pipes and Filters und Broker sind Diagramm und Text aussagekräftiger.
- Anwendung / Objekt
- Muster-Baustein
- Speicher / Ressource
- 27. Layers
- 28. Model-View-Controller
- 29. Hexagonal Architecture
- 30. Pipes and Filters
- 31. Broker
- 32. Microkernel / Plugin
- 33. Blackboard
- 34. Event Sourcing
- 35. CQRS
27. Layers · Schichtenarchitektur
Die Idee
Die Anwendung wird in übereinanderliegende Schichten gegliedert, jede mit einer klaren Aufgabe. Eine Schicht darf nur die Dienste der Schicht unter ihr nutzen, nie die einer höheren. So bleibt jede Schicht austauschbar und verständlich, ohne dass man das ganze System kennen muss. Das Muster ist eines der ältesten der Softwarearchitektur und steht auch im POSA-Buch.
Beispiel
Ein Online-Shop hat vier Schichten: Die Präsentation nimmt Anfragen aus dem Browser entgegen. Die Anwendungsschicht setzt Anwendungsfälle wie „Bestellung aufgeben“ um und steuert Transaktionen. Die Domäne enthält die Fachlogik, etwa Preis- und Rabattregeln. Die Infrastruktur spricht mit Datenbank, Mailserver und externen Diensten. Wird die Weboberfläche durch eine App ersetzt, betrifft das nur die oberste Schicht.
Worauf achten
In der strikten Variante darf jede Schicht nur die direkt darunterliegende nutzen, in der gelockerten alle tieferen. Ein bekanntes Problem: In der klassischen Anordnung hängt die Domäne von der Infrastruktur ab, die Fachlogik also von der Datenbanktechnik. Genau das kehren die Hexagonal Architecture (Muster 29) und verwandte Ansätze wie Clean Architecture um. Zu viele Schichten erzeugen außerdem reine Durchreichcode, bei dem jede Schicht nur die nächste aufruft, ohne selbst etwas zu tun.
In der Praxis
Die typische Spring-Anwendung mit @Controller, @Service und @Repository, das OSI-Modell und TCP/IP in Netzwerken, die Schichtung eines Betriebssystems von der Hardware bis zur Anwendung. Werkzeuge wie ArchUnit prüfen in Tests, dass keine verbotenen Abhängigkeiten zwischen Schichten entstehen.
28. Model-View-Controller · MVC, MVP und MVVM
Die Idee
MVC trennt eine Anwendung mit Benutzeroberfläche in drei Teile: Das Model enthält die Daten und Regeln und weiß nichts von ihrer Darstellung. Die View stellt das Model dar. Der Controller nimmt Eingaben des Benutzers entgegen und übersetzt sie in Änderungen am Model. Ändert sich das Model, werden die Views benachrichtigt, meist über das Observer-Muster. Erfunden wurde MVC 1979 von Trygve Reenskaug für Smalltalk.
Im Code
Der Warenkorb ist das Model mit Preisregeln und einer Liste von Beobachtern. Die KopfzeilenView zeigt bei jeder Änderung Anzahl und Summe, die DetailView listet die Positionen auf, beide über dasselbe Model. Der WarenkorbController wandelt Eingaben wie „+ Buch“ in Aufrufe am Model um und fängt Fehler ab.
Java-21-Code: ModelViewController.java
Direkt ausführbar mit java ModelViewController.java
// Model-View-Controller (MVC)
// Trennt Daten und Logik (Model), Darstellung (View) und Eingabeverarbeitung (Controller).
// Start: java ModelViewController.java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
public class ModelViewController {
// ================= Model: Daten und Regeln, kennt keine Darstellung =================
static final class Warenkorb {
private final Map<String, Integer> positionen = new LinkedHashMap<>();
private final Map<String, Long> preise = Map.of("Buch", 1_990L, "Lampe", 3_990L, "Tasse", 890L);
private final List<Consumer<Warenkorb>> beobachter = new ArrayList<>();
void beobachte(Consumer<Warenkorb> b) { beobachter.add(b); }
void lege(String artikel, int menge) {
if (!preise.containsKey(artikel)) throw new IllegalArgumentException("unbekannter Artikel: " + artikel);
positionen.merge(artikel, menge, Integer::sum);
if (positionen.get(artikel) <= 0) positionen.remove(artikel);
beobachter.forEach(b -> b.accept(this)); // Views benachrichtigen (Observer)
}
Map<String, Integer> positionen() { return java.util.Collections.unmodifiableMap(positionen); }
long summe() { return positionen.entrySet().stream().mapToLong(e -> preise.get(e.getKey()) * e.getValue()).sum(); }
int anzahl() { return positionen.values().stream().mapToInt(Integer::intValue).sum(); }
}
// ================= Views: zwei verschiedene Darstellungen desselben Models =================
static final class KopfzeilenView {
void zeige(Warenkorb w) {
System.out.printf(Locale.GERMANY, " [Kopfzeile] Warenkorb (%d) %.2f €%n", w.anzahl(), w.summe() / 100.0);
}
}
static final class DetailView {
void zeige(Warenkorb w) {
w.positionen().forEach((a, m) -> System.out.println(" [Details] " + m + " × " + a));
}
}
// ================= Controller: nimmt Eingaben entgegen und ändert das Model =================
static final class WarenkorbController {
private final Warenkorb model;
WarenkorbController(Warenkorb model) { this.model = model; }
void eingabe(String befehl) { // z. B. aus einem Klick oder einer Anfrage
System.out.println("Eingabe: " + befehl);
String[] t = befehl.split(" ");
try {
switch (t[0]) {
case "+" -> model.lege(t[1], 1);
case "-" -> model.lege(t[1], -1);
default -> System.out.println(" unbekannter Befehl");
}
} catch (IllegalArgumentException e) {
System.out.println(" Fehler: " + e.getMessage());
}
}
}
public static void main(String[] args) {
Warenkorb model = new Warenkorb();
KopfzeilenView kopf = new KopfzeilenView();
model.beobachte(kopf::zeige); // die Kopfzeile zeigt jede Änderung
WarenkorbController controller = new WarenkorbController(model);
controller.eingabe("+ Buch");
controller.eingabe("+ Buch");
controller.eingabe("+ Lampe");
controller.eingabe("+ Fahrrad");
controller.eingabe("- Buch");
System.out.println("Warenkorb-Seite öffnen:");
new DetailView().zeige(model); // zweite View auf dasselbe Model
}
}Ausgabe
Eingabe: + Buch
[Kopfzeile] Warenkorb (1) 19,90 €
Eingabe: + Buch
[Kopfzeile] Warenkorb (2) 39,80 €
Eingabe: + Lampe
[Kopfzeile] Warenkorb (3) 79,70 €
Eingabe: + Fahrrad
Fehler: unbekannter Artikel: Fahrrad
Eingabe: - Buch
[Kopfzeile] Warenkorb (2) 59,80 €
Warenkorb-Seite öffnen:
[Details] 1 × Buch
[Details] 1 × LampeDie Varianten
| MVC | MVP | MVVM | |
|---|---|---|---|
| Vermittler | Controller | Presenter | ViewModel |
| View kennt das Model? | ja, liest direkt | nein | nein |
| Aktualisierung der View | Model benachrichtigt die View | Presenter setzt Werte in der View | automatisch per Datenbindung |
| Typisch für | Web-Frameworks | Android (früher), Desktop | JavaFX, Angular, WPF, Vue |
Worauf achten
„MVC“ bezeichnet heute viele leicht unterschiedliche Dinge. In Web-Frameworks wie Spring MVC verarbeitet der Controller eine HTTP-Anfrage, lädt Daten und gibt sie an ein Template weiter. Die Benachrichtigung des Models an die View fällt dort weg. Der häufigste Fehler ist der „fette Controller“, in den die Geschäftslogik wandert. Sie gehört ins Model bzw. in die darunterliegenden Schichten.
In der Praxis
Spring MVC und Jakarta MVC, Ruby on Rails, ASP.NET MVC, JavaFX mit Properties und Bindings (MVVM), Angular und Vue (MVVM-artig), Swing mit getrennten Models wie TableModel.
29. Hexagonal Architecture · Ports und Adapter
Die Idee
Die Hexagonal Architecture von Alistair Cockburn (2005), auch Ports and Adapters genannt, stellt die Fachlogik in die Mitte. Der Kern definiert Ports, also Schnittstellen in seiner eigenen Sprache: eingehende für das, was er anbietet, und ausgehende für das, was er von außen braucht. Alle Technik, Web, Datenbank, Mail, Messaging, wird über Adapter angeschlossen, die diese Ports bedienen oder implementieren. Die Abhängigkeiten zeigen immer von außen nach innen, nie umgekehrt. Das Sechseck hat keine besondere Bedeutung, es soll nur zeigen, dass es viele gleichberechtigte Seiten gibt, nicht „oben“ und „unten“ wie bei Schichten.
Im Code
Der BestellService implementiert den eingehenden Port BestellenUseCase und nutzt die ausgehenden Ports BestellungenSpeichern und KundeBenachrichtigen. Derselbe Kern wird über einen REST-Adapter und einen Kommandozeilen-Adapter angesprochen und läuft einmal mit SQL und E-Mail, einmal mit Test-Adaptern ganz ohne Infrastruktur.
Java-21-Code: HexagonalArchitecture.java
Direkt ausführbar mit java HexagonalArchitecture.java
// Hexagonal Architecture (Ports & Adapters)
// Der fachliche Kern definiert Ports (Schnittstellen). Technik wird über austauschbare Adapter angeschlossen.
// Start: java HexagonalArchitecture.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class HexagonalArchitecture {
// ======================= KERN: kennt keine Technik =======================
record Bestellung(long nr, String kunde, long cent) {}
// Eingehender Port: was die Anwendung anbietet
interface BestellenUseCase {
long bestelle(String kunde, long cent);
}
// Ausgehende Ports: was die Anwendung von außen braucht
interface BestellungenSpeichern { void speichere(Bestellung b); Optional<Bestellung> lade(long nr); }
interface KundeBenachrichtigen { void bestaetige(Bestellung b); }
// Die Fachlogik: implementiert den eingehenden Port, nutzt die ausgehenden
static final class BestellService implements BestellenUseCase {
private final BestellungenSpeichern speicher;
private final KundeBenachrichtigen benachrichtigung;
private long naechsteNr = 1000;
BestellService(BestellungenSpeichern s, KundeBenachrichtigen b) { this.speicher = s; this.benachrichtigung = b; }
public long bestelle(String kunde, long cent) {
if (cent <= 0) throw new IllegalArgumentException("Betrag muss positiv sein");
Bestellung b = new Bestellung(++naechsteNr, kunde, cent);
speicher.speichere(b);
benachrichtigung.bestaetige(b);
return b.nr();
}
}
// ======================= ADAPTER: Technik außen herum =======================
// Treibender Adapter (links): z. B. REST, hier als einfache "Anfrage"
static final class RestAdapter {
private final BestellenUseCase useCase;
RestAdapter(BestellenUseCase u) { this.useCase = u; }
String post(String json) { // {"kunde":"Sven","cent":4990}
String kunde = json.replaceAll(".*\"kunde\":\"([^\"]+)\".*", "$1");
long cent = Long.parseLong(json.replaceAll(".*\"cent\":(\\d+).*", "$1"));
return "201 Created, Bestellung " + useCase.bestelle(kunde, cent);
}
}
// Treibender Adapter: Kommandozeile
static final class CliAdapter {
private final BestellenUseCase useCase;
CliAdapter(BestellenUseCase u) { this.useCase = u; }
void befehl(String zeile) {
String[] t = zeile.split(" ");
System.out.println(" CLI: Bestellung " + useCase.bestelle(t[1], Long.parseLong(t[2])) + " angelegt");
}
}
// Getriebene Adapter (rechts)
static final class InMemorySpeicher implements BestellungenSpeichern {
final Map<Long, Bestellung> daten = new HashMap<>();
public void speichere(Bestellung b) { daten.put(b.nr(), b); }
public Optional<Bestellung> lade(long nr) { return Optional.ofNullable(daten.get(nr)); }
}
static final class SqlSpeicher implements BestellungenSpeichern {
private final InMemorySpeicher intern = new InMemorySpeicher();
public void speichere(Bestellung b) { System.out.println(" SQL: INSERT INTO bestellung VALUES (" + b.nr() + ", ...)"); intern.speichere(b); }
public Optional<Bestellung> lade(long nr) { return intern.lade(nr); }
}
static final class EmailAdapter implements KundeBenachrichtigen {
public void bestaetige(Bestellung b) { System.out.println(" E-Mail an " + b.kunde() + ": Bestellung " + b.nr() + " eingegangen"); }
}
static final class TestPostfach implements KundeBenachrichtigen {
final List<String> gesendet = new ArrayList<>();
public void bestaetige(Bestellung b) { gesendet.add(b.kunde() + "#" + b.nr()); }
}
public static void main(String[] args) {
System.out.println("Betrieb: REST + SQL + E-Mail");
BestellenUseCase betrieb = new BestellService(new SqlSpeicher(), new EmailAdapter());
System.out.println(" Antwort: " + new RestAdapter(betrieb).post("{\"kunde\":\"Sven\",\"cent\":4990}"));
System.out.println("\nAdministration: Kommandozeile, derselbe Kern");
new CliAdapter(betrieb).befehl("bestelle Anna 1290");
System.out.println("\nTest: ohne Datenbank, ohne Mailserver");
InMemorySpeicher speicher = new InMemorySpeicher();
TestPostfach postfach = new TestPostfach();
long nr = new BestellService(speicher, postfach).bestelle("Testkunde", 100);
System.out.println(" gespeichert: " + speicher.lade(nr).isPresent() + ", benachrichtigt: " + postfach.gesendet);
}
}Ausgabe
Betrieb: REST + SQL + E-Mail
SQL: INSERT INTO bestellung VALUES (1001, ...)
E-Mail an Sven: Bestellung 1001 eingegangen
Antwort: 201 Created, Bestellung 1001
Administration: Kommandozeile, derselbe Kern
SQL: INSERT INTO bestellung VALUES (1002, ...)
E-Mail an Anna: Bestellung 1002 eingegangen
CLI: Bestellung 1002 angelegt
Test: ohne Datenbank, ohne Mailserver
gespeichert: true, benachrichtigt: [Testkunde#1001]Worauf achten
Der Kern darf keine Abhängigkeit zu Frameworks, Datenbanken oder Web-Bibliotheken haben, auch keine Annotationen wie @Entity. Das macht ihn ohne Infrastruktur testbar und überlebt jeden Technologiewechsel. Der Preis sind zusätzliche Schnittstellen und Abbildungen zwischen Kernobjekten und Technikobjekten. Für einfache CRUD-Anwendungen ist das zu viel. Eng verwandt sind die Onion Architecture (Jeffrey Palermo) und die Clean Architecture (Robert C. Martin), die dasselbe Prinzip mit konzentrischen Ringen darstellen.
In der Praxis
Häufig in Microservices nach Domain-Driven Design. Java-Projekte gliedern sich dann oft in Pakete wie domain, application.port.in, application.port.out und adapter.in.web, adapter.out.persistence. Bibliotheken wie jMolecules und ArchUnit helfen, die Regeln einzuhalten.
30. Pipes and Filters · Röhren und Filter
Die Idee
Eine Verarbeitung wird in eine Kette unabhängiger Schritte zerlegt. Jeder Schritt (Filter) hat einen Eingang und einen Ausgang. Verbunden werden sie durch Pipes, die Daten weiterreichen. Kein Filter kennt seine Nachbarn, deshalb lassen sich Filter austauschen, umsortieren und wiederverwenden. Das Muster steht im POSA-Buch als Architekturmuster und bei den Enterprise Integration Patterns als Grundbaustein des Messaging.
In Java
Im Kleinen sind Java Streams ein Pipes-and-Filters-System: liste.stream().filter(...).map(...).distinct().sorted(). Jede Methode ist ein Filter, der Stream die Pipe. Im Großen verbinden Frameworks wie Apache Camel oder Spring Integration Filter über Nachrichtenkanäle.
Eine ausführliche Beschreibung mit Diagramm steht bei den Enterprise Integration Patterns, Muster 7.
31. Broker · Vermittler
Die Idee
Ein Broker ist ein Vermittler zwischen verteilten Komponenten. Statt einander direkt zu kennen, schicken die Komponenten ihre Anfragen oder Nachrichten an den Broker, der sie an den passenden Empfänger weiterleitet und Antworten zurückbringt. Das entkoppelt die Komponenten: Wo ein Dienst läuft, in welcher Sprache er geschrieben ist und wie viele Instanzen es gibt, ist für den Aufrufer unsichtbar.
Varianten
Das POSA-Buch beschreibt vor allem den Broker für entfernte Aufrufe, wie ihn CORBA mit dem Object Request Broker umsetzt. Heute ist der Begriff vor allem für Message Broker gebräuchlich: RabbitMQ, ActiveMQ, Kafka. Auch ein API-Gateway oder ein Service Mesh übernimmt Teile der Broker-Rolle.
Eine ausführliche Beschreibung mit Vergleich zur direkten Verbindung aller Systeme steht bei den Enterprise Integration Patterns, Muster 40.
32. Microkernel / Plugin · Mikrokern
Die Idee
Beim Microkernel-Muster enthält der Kern nur das Nötigste: den Grundablauf und eine klar definierte Schnittstelle für Erweiterungen. Alle weiteren Funktionen werden als Plugins angeschlossen, die unabhängig entwickelt, ausgeliefert und aktiviert werden können. Der Name stammt von Betriebssystemen, deren Kern nur Prozesse und Speicher verwaltet, während Treiber und Dateisysteme außerhalb laufen.
Im Code
Der Kern verarbeitet Bestellungen, kennt aber weder Versandkosten noch Sonderregeln. Ohne Plugins ist jede Bestellung „nicht lieferbar“. Drei Plugins registrieren beim Start Versandkosten und reagieren auf jede Bestellung mit eigenen Notizen. Kein Plugin kennt ein anderes.
Java-21-Code: Microkernel.java
Direkt ausführbar mit java Microkernel.java
// Microkernel / Plugin
// Ein minimaler Kern stellt die Grundfunktionen bereit. Alles Weitere kommt über Plugins hinzu.
// Start: java Microkernel.java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
public class Microkernel {
record Bestellung(String nr, String land, long cent, boolean express) {}
// --- Kontrakt zwischen Kern und Plugins ---
interface Plugin {
String name();
default void beimStart(Kern kern) {}
default void beiBestellung(Bestellung b, Map<String, String> notizen) {}
}
// --- Der Kern: nur das Nötigste ---
static final class Kern {
private final List<Plugin> plugins = new ArrayList<>();
private final Map<String, Long> versandkosten = new TreeMap<>();
void installiere(Plugin p) {
plugins.add(p);
p.beimStart(this);
System.out.println(" Plugin geladen: " + p.name());
}
void registriereVersand(String land, long cent) { versandkosten.put(land, cent); } // Erweiterungspunkt
void verarbeite(Bestellung b) {
Map<String, String> notizen = new TreeMap<>();
Long versand = versandkosten.get(b.land());
notizen.put("versand", versand == null ? "nicht lieferbar" : String.format(Locale.GERMANY, "%.2f €", versand / 100.0));
plugins.forEach(p -> p.beiBestellung(b, notizen));
System.out.println(" " + b.nr() + " " + notizen);
}
}
// --- Plugins: unabhängig entwickelt, einzeln aktivierbar ---
static final class VersandDACH implements Plugin {
public String name() { return "Versand DACH"; }
public void beimStart(Kern k) { k.registriereVersand("DE", 490); k.registriereVersand("AT", 790); k.registriereVersand("CH", 1490); }
}
static final class Expresszuschlag implements Plugin {
public String name() { return "Express"; }
public void beiBestellung(Bestellung b, Map<String, String> n) { if (b.express()) n.put("express", "+9,90 €"); }
}
static final class Betrugspruefung implements Plugin {
public String name() { return "Betrugsprüfung"; }
public void beiBestellung(Bestellung b, Map<String, String> n) { if (b.cent() > 200_000) n.put("prüfung", "manuell freigeben"); }
}
public static void main(String[] args) {
List<Bestellung> bestellungen = List.of(
new Bestellung("B-1", "DE", 4_990, false),
new Bestellung("B-2", "CH", 250_000, true));
System.out.println("Nur der Kern:");
Kern nackt = new Kern();
bestellungen.forEach(nackt::verarbeite);
System.out.println("\nKern mit drei Plugins:");
Kern kern = new Kern();
// In echten Systemen findet der Kern seine Plugins selbst, z. B. per java.util.ServiceLoader
for (Plugin p : List.of(new VersandDACH(), new Expresszuschlag(), new Betrugspruefung())) kern.installiere(p);
bestellungen.forEach(kern::verarbeite);
}
}Ausgabe
Nur der Kern:
B-1 {versand=nicht lieferbar}
B-2 {versand=nicht lieferbar}
Kern mit drei Plugins:
Plugin geladen: Versand DACH
Plugin geladen: Express
Plugin geladen: Betrugsprüfung
B-1 {versand=4,90 €}
B-2 {express=+9,90 €, prüfung=manuell freigeben, versand=14,90 €}Worauf achten
Das Herzstück ist der Plugin-Vertrag: Er muss stabil bleiben, denn jede Änderung betrifft alle Plugins. Man braucht Regeln für Reihenfolge, Abhängigkeiten zwischen Plugins und Fehler: Darf ein abstürzendes Plugin den ganzen Kern mitreißen? Microkernel lohnt sich für Produkte, die bei verschiedenen Kunden unterschiedlich erweitert werden, weniger für eine einzelne, interne Anwendung.
In der Praxis
Die Eclipse-IDE und IntelliJ IDEA, deren gesamte Funktionalität aus Plugins besteht, Browser mit Erweiterungen, Jenkins, Maven und Gradle mit ihren Plugins, OSGi als Plugin-Plattform für Java. Plugins werden in Java oft über java.util.ServiceLoader oder das Modulsystem gefunden.
33. Blackboard · Wandtafel
Die Idee
Beim Blackboard-Muster arbeiten mehrere unabhängige Spezialisten (Wissensquellen) an einem gemeinsamen Datenbestand, der Tafel. Jeder schaut, ob er mit dem, was dort steht, etwas beitragen kann, und schreibt sein Ergebnis dazu. Eine Steuerung entscheidet, wer als Nächstes an der Reihe ist. Wie Experten vor einer Wandtafel, die gemeinsam ein Problem lösen, ohne dass einer allein den ganzen Lösungsweg kennt.
Im Code
Bei jeder Bestellung prüfen drei Spezialisten Betrag, Kundenhistorie und Lieferadresse und schreiben ein Teilrisiko auf die Tafel. Der Entscheider wird erst aktiv, wenn alle drei Teilergebnisse vorliegen. Die Steuerung fragt reihum, wer beitragen kann, bis eine Entscheidung steht. Die Reihenfolge der Spezialisten in der Liste spielt keine Rolle.
Java-21-Code: Blackboard.java
Direkt ausführbar mit java Blackboard.java
// Blackboard
// Mehrere unabhängige Spezialisten arbeiten an einem gemeinsamen Datenbestand, bis eine Lösung entsteht.
// Start: java Blackboard.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class Blackboard {
// --- Die Tafel: gemeinsamer Datenbestand, auf den alle schauen ---
static final class Tafel {
final Map<String, Object> eintraege = new TreeMap<>();
boolean hat(String k) { return eintraege.containsKey(k); }
int zahl(String k) { return (int) eintraege.getOrDefault(k, 0); }
void schreibe(String quelle, String k, Object v) {
eintraege.put(k, v);
System.out.println(" " + quelle + ": " + k + " = " + v);
}
}
// --- Wissensquellen: jede kann nur einen Teil, und meldet sich, wenn sie etwas beitragen kann ---
interface Wissensquelle {
String name();
boolean kannBeitragen(Tafel t);
void trageBei(Tafel t);
}
record AdressPruefung() implements Wissensquelle {
public String name() { return "Adressprüfung"; }
public boolean kannBeitragen(Tafel t) { return t.hat("lieferadresse") && !t.hat("adresse_risiko"); }
public void trageBei(Tafel t) {
boolean packstation = t.eintraege.get("lieferadresse").toString().contains("Packstation");
t.schreibe(name(), "adresse_risiko", packstation ? 30 : 0);
}
}
record KundenHistorie() implements Wissensquelle {
public String name() { return "Kundenhistorie"; }
public boolean kannBeitragen(Tafel t) { return t.hat("kunde") && !t.hat("historie_risiko"); }
public void trageBei(Tafel t) { t.schreibe(name(), "historie_risiko", t.eintraege.get("kunde").equals("neu") ? 25 : 0); }
}
record BetragsPruefung() implements Wissensquelle {
public String name() { return "Betragsprüfung"; }
public boolean kannBeitragen(Tafel t) { return t.hat("betrag") && !t.hat("betrag_risiko"); }
public void trageBei(Tafel t) { t.schreibe(name(), "betrag_risiko", t.zahl("betrag") > 1_000 ? 30 : 5); }
}
record Entscheider() implements Wissensquelle { // wird erst aktiv, wenn alle anderen fertig sind
public String name() { return "Entscheider"; }
public boolean kannBeitragen(Tafel t) {
return t.hat("adresse_risiko") && t.hat("historie_risiko") && t.hat("betrag_risiko") && !t.hat("entscheidung");
}
public void trageBei(Tafel t) {
int summe = t.zahl("adresse_risiko") + t.zahl("historie_risiko") + t.zahl("betrag_risiko");
t.schreibe(name(), "risiko_gesamt", summe);
t.schreibe(name(), "entscheidung", summe >= 60 ? "manuell prüfen" : "freigeben");
}
}
// --- Steuerung: fragt reihum, wer beitragen kann, bis nichts mehr passiert ---
static void loese(Tafel t, List<Wissensquelle> quellen) {
boolean fortschritt = true;
while (fortschritt && !t.hat("entscheidung")) {
fortschritt = false;
for (Wissensquelle q : quellen) {
if (q.kannBeitragen(t)) { q.trageBei(t); fortschritt = true; }
}
}
}
public static void main(String[] args) {
List<Wissensquelle> quellen = new ArrayList<>(List.of(new Entscheider(), new BetragsPruefung(), new KundenHistorie(), new AdressPruefung()));
for (Map<String, Object> bestellung : List.of(
Map.<String, Object>of("kunde", "stamm", "betrag", 80, "lieferadresse", "Hafenstraße 7, Hamburg"),
Map.<String, Object>of("kunde", "neu", "betrag", 2_400, "lieferadresse", "Packstation 112, Berlin"))) {
System.out.println("Bestellung: " + new TreeMap<>(bestellung));
Tafel t = new Tafel();
t.eintraege.putAll(bestellung);
loese(t, quellen);
System.out.println();
}
}
}Ausgabe
Bestellung: {betrag=80, kunde=stamm, lieferadresse=Hafenstraße 7, Hamburg}
Betragsprüfung: betrag_risiko = 5
Kundenhistorie: historie_risiko = 0
Adressprüfung: adresse_risiko = 0
Entscheider: risiko_gesamt = 5
Entscheider: entscheidung = freigeben
Bestellung: {betrag=2400, kunde=neu, lieferadresse=Packstation 112, Berlin}
Betragsprüfung: betrag_risiko = 30
Kundenhistorie: historie_risiko = 25
Adressprüfung: adresse_risiko = 30
Entscheider: risiko_gesamt = 85
Entscheider: entscheidung = manuell prüfenWorauf achten
Blackboard eignet sich für Probleme ohne festen Lösungsweg, bei denen verschiedene Teilergebnisse in unvorhersehbarer Reihenfolge zusammenkommen. Für Abläufe mit fester Reihenfolge ist es zu aufwendig: Dann ist eine Kette (Pipes and Filters) einfacher. Die Steuerung ist der schwierige Teil, besonders wenn mehrere Spezialisten gleichzeitig beitragen können oder sich widersprechen. Und weil alle auf denselben Daten arbeiten, muss der Zugriff bei paralleler Ausführung abgesichert werden.
In der Praxis
Spracherkennung (das ursprüngliche Hearsay-II-System aus den 1970ern), Betrugserkennung und Risikobewertung, Bildanalyse, Expertensysteme, Planungs- und Optimierungsprobleme. Moderne Varianten sind Systeme mit mehreren KI-Agenten, die über einen gemeinsamen Speicher zusammenarbeiten.
34. Event Sourcing · Ereignisspeicherung
Die Idee
Üblicherweise speichert eine Anwendung den aktuellen Zustand: Der Kontostand ist 1.000 €. Bei Event Sourcing speichert sie stattdessen die Folge aller Ereignisse, die zu diesem Zustand geführt haben: eröffnet, 1.000 € eingezahlt, 300 € abgehoben und so weiter. Der Zustand wird bei Bedarf berechnet, indem man die Ereignisse der Reihe nach „abspielt“. Ereignisse werden nur angehängt, nie geändert oder gelöscht. So arbeitet jede Buchhaltung seit Jahrhunderten: Man radiert keine Buchung aus, man bucht eine Korrektur.
Im Code
Die Ereignisse sind Records hinter einem sealed Interface. Der EventStore hängt nur an. Der Kontostand entsteht durch Konto.aus(ereignisse), das jedes Ereignis per Pattern Matching anwendet. Befehle wie hebeAb prüfen gegen den berechneten Zustand und erzeugen nur dann ein neues Ereignis. Zum Schluss zwei Dinge, die mit einem normalen Zustandsspeicher unmöglich wären: der Kontostand an einem beliebigen Tag der Vergangenheit und eine neue Auswertung über alle bisherigen Einzahlungen.
Java-21-Code: EventSourcing.java
Direkt ausführbar mit java EventSourcing.java
// Event Sourcing
// Gespeichert wird nicht der aktuelle Zustand, sondern die Folge aller Ereignisse. Der Zustand wird daraus berechnet.
// Start: java EventSourcing.java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class EventSourcing {
// --- Ereignisse: unveränderlich, in der Vergangenheit benannt ---
sealed interface Ereignis permits KontoEroeffnet, Eingezahlt, Abgehoben { LocalDate datum(); }
record KontoEroeffnet(String inhaber, LocalDate datum) implements Ereignis {}
record Eingezahlt(long cent, LocalDate datum) implements Ereignis {}
record Abgehoben(long cent, LocalDate datum) implements Ereignis {}
// --- Event Store: hängt nur an, ändert und löscht nie ---
static final class EventStore {
private final List<Ereignis> log = new ArrayList<>();
void anhaengen(Ereignis e) { log.add(e); }
List<Ereignis> alle() { return List.copyOf(log); }
}
// --- Zustand: wird aus den Ereignissen berechnet ---
record Konto(String inhaber, long saldoCent, int buchungen) {
static final Konto LEER = new Konto("", 0, 0);
Konto anwenden(Ereignis e) {
return switch (e) {
case KontoEroeffnet(String name, LocalDate d) -> new Konto(name, 0, 0);
case Eingezahlt(long c, LocalDate d) -> new Konto(inhaber, saldoCent + c, buchungen + 1);
case Abgehoben(long c, LocalDate d) -> new Konto(inhaber, saldoCent - c, buchungen + 1);
};
}
static Konto aus(List<Ereignis> ereignisse) {
Konto k = LEER;
for (Ereignis e : ereignisse) k = k.anwenden(e);
return k;
}
public String toString() { return String.format(Locale.GERMANY, "%s: %,.2f € nach %d Buchungen", inhaber, saldoCent / 100.0, buchungen); }
}
// --- Befehl: prüft gegen den aktuellen Zustand und erzeugt ein Ereignis ---
static void hebeAb(EventStore store, long cent, LocalDate datum) {
Konto aktuell = Konto.aus(store.alle());
if (aktuell.saldoCent() < cent) {
System.out.println(" abgelehnt: Abhebung " + cent / 100 + " € am " + datum + ", Saldo zu niedrig");
return;
}
store.anhaengen(new Abgehoben(cent, datum));
}
public static void main(String[] args) {
EventStore store = new EventStore();
store.anhaengen(new KontoEroeffnet("Sven", LocalDate.of(2026, 1, 2)));
store.anhaengen(new Eingezahlt(100_000, LocalDate.of(2026, 1, 15)));
store.anhaengen(new Abgehoben(30_000, LocalDate.of(2026, 3, 1)));
store.anhaengen(new Eingezahlt(50_000, LocalDate.of(2026, 6, 30)));
hebeAb(store, 200_000, LocalDate.of(2026, 9, 1));
hebeAb(store, 20_000, LocalDate.of(2026, 10, 1));
System.out.println("Gespeicherte Ereignisse:");
store.alle().forEach(e -> System.out.println(" " + e));
System.out.println("\nAktueller Zustand, berechnet aus allen Ereignissen:");
System.out.println(" " + Konto.aus(store.alle()));
System.out.println("\nZeitreise: Wie stand das Konto am 31.03.2026?");
List<Ereignis> bisMaerz = store.alle().stream().filter(e -> !e.datum().isAfter(LocalDate.of(2026, 3, 31))).toList();
System.out.println(" " + Konto.aus(bisMaerz));
System.out.println("\nNeue Auswertung aus alten Daten: Summe aller Einzahlungen");
long einzahlungen = store.alle().stream().filter(e -> e instanceof Eingezahlt).mapToLong(e -> ((Eingezahlt) e).cent()).sum();
System.out.printf(Locale.GERMANY, " %,.2f €%n", einzahlungen / 100.0);
}
}Ausgabe
abgelehnt: Abhebung 2000 € am 2026-09-01, Saldo zu niedrig
Gespeicherte Ereignisse:
KontoEroeffnet[inhaber=Sven, datum=2026-01-02]
Eingezahlt[cent=100000, datum=2026-01-15]
Abgehoben[cent=30000, datum=2026-03-01]
Eingezahlt[cent=50000, datum=2026-06-30]
Abgehoben[cent=20000, datum=2026-10-01]
Aktueller Zustand, berechnet aus allen Ereignissen:
Sven: 1.000,00 € nach 4 Buchungen
Zeitreise: Wie stand das Konto am 31.03.2026?
Sven: 700,00 € nach 2 Buchungen
Neue Auswertung aus alten Daten: Summe aller Einzahlungen
1.500,00 €Worauf achten
Die Vorteile sind eine lückenlose Historie (wichtig für Prüfungen und Fehlersuche), Zeitreisen und die Möglichkeit, aus alten Ereignissen jederzeit neue Auswertungen zu berechnen. Die Nachteile sind ebenso deutlich: Der Zustand muss berechnet werden, was bei vielen Ereignissen Snapshots erfordert. Ereignisformate lassen sich kaum noch ändern, weil alte Ereignisse für immer bleiben. Und Abfragen über viele Objekte („alle Konten im Minus“) sind ohne zusätzliche Lesemodelle kaum möglich. Deshalb wird Event Sourcing fast immer mit CQRS (Muster 35) kombiniert. Für die meisten Anwendungen ist es zu aufwendig. Es lohnt sich dort, wo die Historie selbst fachlich wichtig ist.
In der Praxis
Axon Framework und EventStoreDB, Kafka als Ereignisprotokoll, Buchhaltungs- und Bankensysteme. Auch Git ist im Grunde Event Sourcing: Gespeichert werden die Änderungen, der aktuelle Stand der Dateien wird daraus hergestellt.
35. CQRS · Trennung von Befehl und Abfrage
Die Idee
CQRS, Command Query Responsibility Segregation, trennt eine Anwendung in zwei Seiten: Die Schreibseite nimmt Befehle entgegen, prüft Geschäftsregeln und ändert Daten. Die Leseseite beantwortet Abfragen aus eigenen, für genau diesen Zweck aufbereiteten Lesemodellen. Verbunden sind die beiden meist über Ereignisse: Jede Änderung auf der Schreibseite wird an die Lesemodelle weitergereicht, die sich selbst aktualisieren. Der Begriff stammt von Greg Young und baut auf dem älteren Prinzip Command-Query Separation von Bertrand Meyer auf.
Im Code
Befehle wie GibBestellungAuf gehen an die BestellungsBefehle, die den Lagerbestand prüfen und bei Erfolg ein Ereignis veröffentlichen. Zwei Lesemodelle, Umsatz pro Kunde und Bestseller, aktualisieren sich bei jedem Ereignis. Die Abfragen am Ende lesen nur fertige Werte, ohne etwas zu berechnen. Die abgelehnte Bestellung taucht in keinem Lesemodell auf.
Java-21-Code: CQRS.java
Direkt ausführbar mit java CQRS.java
// CQRS (Command Query Responsibility Segregation)
// Schreiben und Lesen bekommen getrennte Modelle: eines für Befehle, eines (oder mehrere) für Abfragen.
// Start: java CQRS.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Consumer;
public class CQRS {
// ===================== Gemeinsam: Ereignisse als Brücke =====================
record BestellungAufgegeben(long nr, String kunde, String artikel, int menge, long cent) {}
// ===================== Schreibseite (Command) =====================
record GibBestellungAuf(String kunde, String artikel, int menge, long einzelpreisCent) {} // ein Befehl
static final class BestellungsBefehle {
private final Map<String, Integer> lager = new HashMap<>(Map.of("Buch", 10, "Lampe", 2));
private final List<Consumer<BestellungAufgegeben>> abonnenten = new ArrayList<>();
private long naechsteNr = 1;
void abonniere(Consumer<BestellungAufgegeben> a) { abonnenten.add(a); }
// Befehle prüfen Regeln, ändern Zustand und liefern nichts außer Erfolg/Fehler
void ausfuehren(GibBestellungAuf b) {
int bestand = lager.getOrDefault(b.artikel(), 0);
if (b.menge() > bestand) throw new IllegalStateException(b.artikel() + ": nur noch " + bestand + " auf Lager");
lager.put(b.artikel(), bestand - b.menge());
var e = new BestellungAufgegeben(naechsteNr++, b.kunde(), b.artikel(), b.menge(), b.menge() * b.einzelpreisCent());
abonnenten.forEach(a -> a.accept(e));
}
}
// ===================== Leseseite (Query): je ein Modell pro Bildschirm =====================
// Lesemodell 1: Umsatz pro Kunde, fertig berechnet
static final class KundenUmsatz {
final Map<String, Long> umsatz = new TreeMap<>();
void aktualisiere(BestellungAufgegeben e) { umsatz.merge(e.kunde(), e.cent(), Long::sum); }
}
// Lesemodell 2: Bestseller-Liste
static final class Bestseller {
final Map<String, Integer> verkauft = new TreeMap<>();
void aktualisiere(BestellungAufgegeben e) { verkauft.merge(e.artikel(), e.menge(), Integer::sum); }
List<String> top() {
return verkauft.entrySet().stream().sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.map(x -> x.getKey() + " (" + x.getValue() + ")").toList();
}
}
public static void main(String[] args) {
BestellungsBefehle befehle = new BestellungsBefehle();
KundenUmsatz umsatz = new KundenUmsatz();
Bestseller bestseller = new Bestseller();
befehle.abonniere(umsatz::aktualisiere); // Lesemodelle werden bei jedem Ereignis nachgeführt
befehle.abonniere(bestseller::aktualisiere);
List<GibBestellungAuf> eingang = List.of(
new GibBestellungAuf("Sven", "Buch", 2, 1_990),
new GibBestellungAuf("Anna", "Lampe", 1, 3_990),
new GibBestellungAuf("Sven", "Buch", 3, 1_990),
new GibBestellungAuf("Ben", "Lampe", 5, 3_990));
System.out.println("Befehle (Schreibseite):");
for (var b : eingang) {
try { befehle.ausfuehren(b); System.out.println(" ok: " + b); }
catch (IllegalStateException e) { System.out.println(" abgelehnt: " + b + " -> " + e.getMessage()); }
}
System.out.println("\nAbfragen (Leseseite), ohne jede Berechnung beim Lesen:");
umsatz.umsatz.forEach((k, c) -> System.out.printf(Locale.GERMANY, " Umsatz %-5s %7.2f €%n", k, c / 100.0));
System.out.println(" Bestseller: " + bestseller.top());
}
}Ausgabe
Befehle (Schreibseite):
ok: GibBestellungAuf[kunde=Sven, artikel=Buch, menge=2, einzelpreisCent=1990]
ok: GibBestellungAuf[kunde=Anna, artikel=Lampe, menge=1, einzelpreisCent=3990]
ok: GibBestellungAuf[kunde=Sven, artikel=Buch, menge=3, einzelpreisCent=1990]
abgelehnt: GibBestellungAuf[kunde=Ben, artikel=Lampe, menge=5, einzelpreisCent=3990] -> Lampe: nur noch 1 auf Lager
Abfragen (Leseseite), ohne jede Berechnung beim Lesen:
Umsatz Anna 39,90 €
Umsatz Sven 99,50 €
Bestseller: [Buch (5), Lampe (1)]Worauf achten
Schreib- und Lesemodell haben oft völlig unterschiedliche Anforderungen. Geschrieben wird selten und mit vielen Regeln, gelesen sehr oft und in vielen verschiedenen Darstellungen. Getrennt lässt sich beides unabhängig optimieren und skalieren. Der Preis ist Komplexität und meist letztendliche Konsistenz: Laufen die Lesemodelle asynchron nach, sieht ein Benutzer seine eigene Änderung womöglich erst eine Sekunde später. Für einfache CRUD-Anwendungen ist CQRS fast immer übertrieben. Greg Young selbst betont, dass es nur für Teile eines Systems gedacht ist, nicht für das ganze.
In der Praxis
Axon Framework, das CQRS und Event Sourcing kombiniert, Lesemodelle in Elasticsearch neben einer relationalen Schreibdatenbank, materialisierte Sichten in Datenbanken, Projektionen in Kafka Streams. Eine einfache Form ist schon die Trennung von Methoden, die etwas ändern, und solchen, die nur lesen.
Im Überblick
| Muster | Strukturiert nach | Typischer Einsatz |
|---|---|---|
| Layers | technischen Ebenen, oben nach unten | klassische Geschäftsanwendungen |
| MVC / MVP / MVVM | Daten, Darstellung, Eingabe | Benutzeroberflächen |
| Hexagonal | Fachkern innen, Technik außen | langlebige Fachanwendungen, Microservices |
| Pipes and Filters | Verarbeitungsschritten in Reihe | Datenverarbeitung, Compiler, Integration |
| Broker | Vermittler zwischen verteilten Teilen | verteilte Systeme, Messaging |
| Microkernel | minimalem Kern und Erweiterungen | erweiterbare Produkte, IDEs |
| Blackboard | gemeinsamem Datenbestand | Probleme ohne festen Lösungsweg |
| Event Sourcing | Ereignissen statt Zuständen | Buchhaltung, Prüfpflichten, Historie |
| CQRS | Schreiben und Lesen getrennt | viele Leser, komplexe Abfragen |
Die Links zu den Enterprise Integration Patterns funktionieren, wenn die Ordner eip und muster auf der Webseite nebeneinander liegen.