Struktur und Verhalten im Kleinen
Software-Muster jenseits von GoF · Gruppe 2 von 7
Diese Gruppe sammelt kleinere Muster, die im Alltag ständig vorkommen, aber nicht im GoF-Buch stehen. Sie lösen typische Probleme auf Ebene einzelner Klassen: Wie vermeidet man null? Wie stellt man Werte sicher dar? Wie schreibt man APIs, die sich wie Sätze lesen? Wie bildet man Regeln, Typen und zusätzliche Fähigkeiten ab, ohne die Klassenhierarchie wuchern zu lassen? Mehrere davon spielen ihre Stärken besonders in Java 21 aus, mit Records, sealed Interfaces und Default-Methoden.
- Anwendung / Objekt
- Muster-Baustein
- Speicher / Ressource
- 7. Null Object
- 8. Value Object
- 9. Fluent Interface
- 10. Specification
- 11. Type Object
- 12. Extension Object
- 13. Mixin / Trait
7. Null Object · Nullobjekt
Die Idee
Wo sonst null stünde, wird ein Objekt zurückgegeben, das die Schnittstelle erfüllt, aber nichts tut: ein Rabatt von null Prozent, ein Logger, der nichts schreibt, eine leere Liste. Der Aufrufer muss keinen Sonderfall prüfen, weil es keinen Sonderfall mehr gibt.
Das Problem
Liefert eine Methode für Kunden ohne Rabatt null, muss jede Stelle, die einen Rabatt verwendet, vorher prüfen. Wird das auch nur einmal vergessen, gibt es eine NullPointerException, meist erst im Betrieb. Die Prüfungen verteilen sich über den ganzen Code und verschleiern die eigentliche Logik.
Im Code
KeinRabatt ist als enum mit einer einzigen Konstante umgesetzt, weil ein Null Object keinen Zustand hat und eine Instanz für alle reicht. rabattFuer() liefert mit getOrDefault nie null. Am Ende steht zum Vergleich die Variante mit Prüfung.
Java-21-Code: NullObject.java
Direkt ausführbar mit java NullObject.java
// Null Object
// Statt null wird ein Objekt zurückgegeben, das "nichts tut", aber die erwartete Schnittstelle erfüllt.
// Start: java NullObject.java
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class NullObject {
// --- Schnittstelle ---
interface Rabatt {
long anwenden(long cent);
String beschreibung();
}
// --- Echte Implementierungen ---
record Prozent(int p) implements Rabatt {
public long anwenden(long c) { return c - c * p / 100; }
public String beschreibung() { return p + " % Rabatt"; }
}
record Treuebonus(long abzug) implements Rabatt {
public long anwenden(long c) { return Math.max(0, c - abzug); }
public String beschreibung() { return "Treuebonus " + abzug / 100 + " €"; }
}
// --- Das Null Object: erfüllt die Schnittstelle und tut nichts ---
enum KeinRabatt implements Rabatt {
INSTANZ;
public long anwenden(long c) { return c; }
public String beschreibung() { return "kein Rabatt"; }
}
// --- Ein Verzeichnis, das NIE null liefert ---
static final Map<String, Rabatt> rabatte = Map.of(
"Anna", new Prozent(10),
"Ben", new Treuebonus(500));
static Rabatt rabattFuer(String kunde) {
return rabatte.getOrDefault(kunde, KeinRabatt.INSTANZ);
}
public static void main(String[] args) {
long preis = 8_000;
for (String kunde : List.of("Anna", "Ben", "Cem")) {
Rabatt r = rabattFuer(kunde);
// keine null-Prüfung nötig: jeder Kunde hat einen Rabatt, notfalls den "leeren"
System.out.printf(Locale.GERMANY, "%-5s %-16s %6.2f €%n", kunde, r.beschreibung(), r.anwenden(preis) / 100.0);
}
// Zum Vergleich: ohne Null Object müsste jede Stelle selbst prüfen
Rabatt vielleicht = rabatte.get("Cem"); // null!
long ergebnis = (vielleicht != null) ? vielleicht.anwenden(preis) : preis;
System.out.printf(Locale.GERMANY, "%nOhne Null Object, mit Prüfung: %.2f €%n", ergebnis / 100.0);
}
}Ausgabe
Anna 10 % Rabatt 72,00 €
Ben Treuebonus 5 € 75,00 €
Cem kein Rabatt 80,00 €
Ohne Null Object, mit Prüfung: 80,00 €Worauf achten
Ein Null Object ist nur dann richtig, wenn „nichts tun“ ein sinnvolles Verhalten ist. Fehlt ein Wert, der fachlich zwingend vorhanden sein müsste, etwa die Lieferadresse einer Bestellung, verschleiert ein Null Object den Fehler. Dann ist eine Ausnahme die bessere Antwort. In Java ist Optional die Alternative, wenn der Aufrufer bewusst entscheiden soll, was im leeren Fall passiert. Das Null Object trifft diese Entscheidung für ihn.
In der Praxis
Collections.emptyList() statt null für leere Ergebnisse, OutputStream.nullOutputStream() und Writer.nullWriter() seit Java 11, NOP-Logger in SLF4J, wenn keine Logging-Implementierung vorhanden ist. Das Muster wurde 1996 von Bobby Woolf beschrieben, demselben Woolf wie bei den Enterprise Integration Patterns.
8. Value Object · Wertobjekt
Die Idee
Ein Value Object ist ein kleines Objekt, das allein über seine Werte definiert ist: ein Geldbetrag, eine Adresse, eine Postleitzahl, ein Zeitraum. Zwei Value Objects mit denselben Werten sind gleich, egal ob es dasselbe Objekt im Speicher ist. Value Objects sind unveränderlich: Statt einen Betrag zu ändern, erzeugt man einen neuen. Und sie prüfen sich selbst, sodass ungültige Werte gar nicht erst entstehen.
Das Problem
Wird Geld als nacktes double oder long herumgereicht, weiß niemand, ob ein Wert Euro oder Cent ist, und nichts verhindert, Euro und Franken zu addieren. Ist ein Geldobjekt veränderlich, kann eine Methode versehentlich den Preis eines anderen Artikels ändern, weil beide auf dasselbe Objekt zeigen. Und ohne equals sind zwei gleiche Beträge plötzlich ungleich.
Im Code
In Java 21 sind Records die natürliche Form für Value Objects: unveränderlich, mit automatischem equals, hashCode und toString. Der kompakte Konstruktor prüft die Werte. plus und mal geben neue Objekte zurück. Zum Vergleich zeigt eine klassische Klasse ohne equals, dass zwei gleiche Beträge dort als verschieden gelten. Zwei gleiche Adressen landen im HashSet nur einmal.
Java-21-Code: ValueObject.java
Direkt ausführbar mit java ValueObject.java
// Value Object
// Ein kleines, unveränderliches Objekt, das allein über seine Werte definiert ist.
// Start: java ValueObject.java
import java.util.HashSet;
import java.util.Set;
public class ValueObject {
enum Waehrung { EUR, CHF }
// --- Value Object: unveränderlich, prüft sich selbst, vergleicht über Werte ---
record Geld(long cent, Waehrung waehrung) {
Geld {
if (waehrung == null) throw new IllegalArgumentException("Währung fehlt");
}
static Geld euro(double betrag) { return new Geld(Math.round(betrag * 100), Waehrung.EUR); }
Geld plus(Geld anderes) {
if (waehrung != anderes.waehrung) throw new IllegalArgumentException("Währungen passen nicht");
return new Geld(cent + anderes.cent, waehrung); // neues Objekt statt Änderung
}
Geld mal(int faktor) { return new Geld(cent * faktor, waehrung); }
public String toString() { return "%d,%02d %s".formatted(cent / 100, Math.abs(cent % 100), waehrung); }
}
record Postleitzahl(String wert) {
Postleitzahl {
if (!wert.matches("\\d{5}")) throw new IllegalArgumentException("ungültige PLZ: " + wert);
}
}
record Adresse(String strasse, Postleitzahl plz, String ort) {}
// --- Zum Vergleich: veränderliche Klasse ohne equals ---
static final class GeldAlt {
long cent;
GeldAlt(long cent) { this.cent = cent; }
}
public static void main(String[] args) {
Geld preis = Geld.euro(12.90);
Geld versand = Geld.euro(4.90);
System.out.println("Preis × 2 + Versand = " + preis.mal(2).plus(versand));
System.out.println("Preis unverändert: " + preis);
System.out.println("\nGleichheit über Werte:");
System.out.println(" euro(12.90).equals(euro(12.90)): " + Geld.euro(12.90).equals(Geld.euro(12.90)));
System.out.println(" alte Klasse, gleicher Betrag: " + new GeldAlt(1290).equals(new GeldAlt(1290)));
Set<Adresse> adressen = new HashSet<>();
adressen.add(new Adresse("Hafenstraße 7", new Postleitzahl("20457"), "Hamburg"));
adressen.add(new Adresse("Hafenstraße 7", new Postleitzahl("20457"), "Hamburg"));
System.out.println(" zwei gleiche Adressen im Set: " + adressen.size() + " Eintrag");
System.out.println("\nUngültige Werte entstehen gar nicht erst:");
try { new Postleitzahl("2045"); } catch (IllegalArgumentException e) { System.out.println(" " + e.getMessage()); }
try { preis.plus(new Geld(500, Waehrung.CHF)); } catch (IllegalArgumentException e) { System.out.println(" " + e.getMessage()); }
}
}Ausgabe
Preis × 2 + Versand = 30,70 EUR
Preis unverändert: 12,90 EUR
Gleichheit über Werte:
euro(12.90).equals(euro(12.90)): true
alte Klasse, gleicher Betrag: false
zwei gleiche Adressen im Set: 1 Eintrag
Ungültige Werte entstehen gar nicht erst:
ungültige PLZ: 2045
Währungen passen nichtWorauf achten
Value Objects lohnen sich auch für scheinbar einfache Werte. Eine eigene Postleitzahl statt eines String macht Methodensignaturen lesbarer und verhindert Verwechslungen. Man spricht hier von „Primitive Obsession“ als Gegenmuster. Bei Geld nie double für die Speicherung verwenden, sondern Cent als long oder BigDecimal, weil Gleitkommazahlen Rundungsfehler haben. Value Objects haben keine eigene Identität. Braucht ein Objekt eine, etwa ein Kunde, der umzieht und trotzdem derselbe Kunde bleibt, ist es eine Entity (Gruppe 4).
In der Praxis
Im JDK: String, LocalDate, Duration, BigDecimal, UUID. Die Money-API JSR 354 (Moneta) für Geldbeträge. Im Domain-Driven Design sind Value Objects einer der wichtigsten Bausteine. Mit Project Valhalla sollen künftige Java-Versionen echte Werttypen ohne eigene Objektidentität erhalten.
9. Fluent Interface · Flüssige Schnittstelle
Die Idee
Ein Fluent Interface ist eine API, deren Methoden sich verketten lassen und deren Aufrufe sich wie ein Satz lesen. Jede Methode gibt dafür ein Objekt zurück, auf dem der nächste Aufruf folgen kann, meist das Objekt selbst. Der Begriff stammt von Martin Fowler und Eric Evans.
Das Problem
Eine Suchfunktion mit vielen optionalen Kriterien führt zu Methoden wie suche("Buch", 25.0, 4.0, true, 2), bei denen niemand mehr weiß, was true bedeutet. Alternativ entstehen viele einzelne Setter-Zeilen, die die Absicht zerstückeln.
Im Code
Die Klasse Suche sammelt Bedingungen als Predicate-Objekte, jede Methode gibt this zurück. Die abschließende Methode in(katalog) führt die Suche aus. Am Ende zeigt ein Stream, dass das JDK dasselbe Prinzip nutzt.
Java-21-Code: FluentInterface.java
Direkt ausführbar mit java FluentInterface.java
// Fluent Interface
// Verkettbare Methodenaufrufe, die sich wie ein Satz lesen.
// Start: java FluentInterface.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
public class FluentInterface {
record Artikel(String name, String kategorie, double preis, double bewertung) {}
// --- Fluent Interface: jede Methode gibt das Objekt selbst zurück ---
static final class Suche {
private final List<Predicate<Artikel>> bedingungen = new ArrayList<>();
private Comparator<Artikel> sortierung = Comparator.comparing(Artikel::name);
private int limit = Integer.MAX_VALUE;
static Suche nach(String kategorie) {
Suche s = new Suche();
s.bedingungen.add(a -> a.kategorie().equals(kategorie));
return s;
}
Suche preisBis(double max) { bedingungen.add(a -> a.preis() <= max); return this; }
Suche mindestensSterne(double min) { bedingungen.add(a -> a.bewertung() >= min); return this; }
Suche billigsteZuerst() { sortierung = Comparator.comparingDouble(Artikel::preis); return this; }
Suche besteZuerst() { sortierung = Comparator.comparingDouble(Artikel::bewertung).reversed(); return this; }
Suche hoechstens(int n) { limit = n; return this; }
List<Artikel> in(List<Artikel> katalog) {
return katalog.stream()
.filter(bedingungen.stream().reduce(a -> true, Predicate::and))
.sorted(sortierung)
.limit(limit)
.toList();
}
}
public static void main(String[] args) {
List<Artikel> katalog = List.of(
new Artikel("Krimi", "Buch", 12.99, 4.1),
new Artikel("Kochbuch", "Buch", 29.90, 4.7),
new Artikel("Reiseführer", "Buch", 19.50, 4.4),
new Artikel("Roman", "Buch", 9.99, 3.6),
new Artikel("Lampe", "Wohnen", 39.00, 4.8));
// Liest sich wie ein Satz:
List<Artikel> treffer = Suche.nach("Buch")
.preisBis(25)
.mindestensSterne(4.0)
.besteZuerst()
.hoechstens(2)
.in(katalog);
System.out.println("Bücher bis 25 €, ab 4 Sternen, die besten zwei:");
treffer.forEach(a -> System.out.printf(Locale.GERMANY, " %-12s %6.2f € %.1f ★%n", a.name(), a.preis(), a.bewertung()));
// Dasselbe Prinzip im JDK: Streams und Comparator
System.out.println("\nIm JDK, ebenfalls fluent:");
katalog.stream()
.filter(a -> a.preis() < 20)
.sorted(Comparator.comparing(Artikel::preis).reversed())
.map(Artikel::name)
.forEach(n -> System.out.println(" " + n));
}
}Ausgabe
Bücher bis 25 €, ab 4 Sternen, die besten zwei:
Reiseführer 19,50 € 4,4 ★
Krimi 12,99 € 4,1 ★
Im JDK, ebenfalls fluent:
Reiseführer
Krimi
RomanWorauf achten
Ein gutes Fluent Interface ist für den Leser entworfen, nicht für die Implementierung: Die Methodennamen ergeben zusammen einen verständlichen Satz. Die Fehlersuche ist etwas schwieriger, weil eine lange Kette in einer Zeile steht. Daher hilft eine Zeile pro Aufruf. Man unterscheidet zwei Arten: veränderliche, bei denen jede Methode this ändert und zurückgibt (wie hier und beim Builder), und unveränderliche, bei denen jede Methode ein neues Objekt liefert (wie bei Streams oder LocalDate.plusDays()). Die zweite Art ist sicherer, wenn Zwischenstände weiterverwendet werden.
In der Praxis
Java Streams, Comparator.comparing(...).thenComparing(...), HttpRequest.newBuilder(), StringBuilder, AssertJ (assertThat(x).isNotNull().startsWith("A")), jOOQ für SQL, Mockito (when(...).thenReturn(...)). Ein Builder (GoF) hat fast immer ein Fluent Interface.
10. Specification · Spezifikation
Die Idee
Eine Specification kapselt eine Geschäftsregel als eigenes Objekt mit einer einzigen Frage: Erfüllt dieses Objekt die Regel, ja oder nein? Einfache Regeln lassen sich mit und, oder und nicht zu komplexen kombinieren. Das Muster stammt von Eric Evans und Martin Fowler und ist ein Baustein des Domain-Driven Design.
Das Problem
Was ein „Premiumkunde“ ist, steht in vielen Systemen an fünf Stellen als if-Bedingung: in der Preisberechnung, im Versand, in der Werbung, im Bericht. Ändert sich die Definition, muss man alle Stellen finden. Und eine Bedingung wie k.umsatz() > 100000 || k.bestellungen() >= 10 && !k.gesperrt() enthält einen typischen Fehler: Wegen der Vorrangregeln bindet && stärker als ||.
Im Code
Spezifikation<T> ist ein funktionales Interface mit Default-Methoden für und, oder und nicht. Einfache Regeln heißen wie in der Fachsprache: umsatzUeber, stammkunde, ausLand. Daraus werden PREMIUM und GRATISVERSAND zusammengesetzt. Dieselbe Regel dient zum Prüfen eines einzelnen Kunden und zum Filtern einer Liste.
Java-21-Code: Specification.java
Direkt ausführbar mit java Specification.java
// Specification
// Geschäftsregeln als eigene, kombinierbare Objekte mit and, or und not.
// Start: java Specification.java
import java.util.List;
public class Specification {
record Kunde(String name, String land, long umsatzCent, int bestellungen, boolean gesperrt) {}
// --- Die Spezifikation: eine Regel, die erfüllt ist oder nicht ---
@FunctionalInterface
interface Spezifikation<T> {
boolean erfuelltVon(T t);
default Spezifikation<T> und(Spezifikation<T> andere) { return t -> erfuelltVon(t) && andere.erfuelltVon(t); }
default Spezifikation<T> oder(Spezifikation<T> andere) { return t -> erfuelltVon(t) || andere.erfuelltVon(t); }
default Spezifikation<T> nicht() { return t -> !erfuelltVon(t); }
}
// --- Einzelne, benannte Regeln aus der Fachsprache ---
static Spezifikation<Kunde> ausLand(String land) { return k -> k.land().equals(land); }
static Spezifikation<Kunde> umsatzUeber(long euro) { return k -> k.umsatzCent() > euro * 100; }
static Spezifikation<Kunde> stammkunde() { return k -> k.bestellungen() >= 10; }
static final Spezifikation<Kunde> GESPERRT = Kunde::gesperrt;
// --- Zusammengesetzte Regeln: Bausteine wiederverwenden ---
static final Spezifikation<Kunde> PREMIUM =
umsatzUeber(1_000).oder(stammkunde()).und(GESPERRT.nicht());
static final Spezifikation<Kunde> GRATISVERSAND =
PREMIUM.und(ausLand("DE").oder(ausLand("AT")));
public static void main(String[] args) {
List<Kunde> kunden = List.of(
new Kunde("Anna", "DE", 150_000, 4, false),
new Kunde("Ben", "AT", 30_000, 12, false),
new Kunde("Cem", "CH", 200_000, 20, false),
new Kunde("Dora", "DE", 500_000, 30, true),
new Kunde("Emil", "DE", 8_000, 2, false));
System.out.printf("%-6s %-8s %-14s%n", "Kunde", "Premium", "Gratisversand");
for (Kunde k : kunden) {
System.out.printf("%-6s %-8s %-14s%n", k.name(),
PREMIUM.erfuelltVon(k) ? "ja" : "-",
GRATISVERSAND.erfuelltVon(k) ? "ja" : "-");
}
// Dieselbe Regel zum Filtern verwenden
System.out.println("\nPremiumkunden: " +
kunden.stream().filter(PREMIUM::erfuelltVon).map(Kunde::name).toList());
}
}Ausgabe
Kunde Premium Gratisversand
Anna ja ja
Ben ja ja
Cem ja -
Dora - -
Emil - -
Premiumkunden: [Anna, Ben, Cem]Worauf achten
Specifications haben drei typische Einsatzzwecke: prüfen (erfüllt dieser Kunde die Regel?), auswählen (alle Kunden, die sie erfüllen) und erzeugen (ein Objekt bauen, das sie erfüllt). Beim Auswählen aus einer Datenbank ist eine Prüfung im Speicher zu langsam. Dafür muss die Specification in eine Abfrage übersetzt werden können, was Frameworks wie Spring Data unterstützen. Java selbst bietet mit Predicate und dessen Methoden and, or und negate bereits alles Nötige. Eine eigene Schnittstelle lohnt sich vor allem wegen der fachlichen Namen.
In der Praxis
Specification<T> in Spring Data JPA, die sich in SQL übersetzen lässt, java.util.function.Predicate, Regelwerke für Rabatte, Berechtigungen und Validierungen.
11. Type Object · Typobjekt
Die Idee
Wenn sich verschiedene „Arten“ von Dingen nur in ihren Eigenschaften unterscheiden, nicht in ihrem Verhalten, bildet man die Arten nicht als Unterklassen ab, sondern als Objekte. Jedes Ding verweist auf sein Typobjekt. Neue Arten entstehen dann durch neue Daten, ohne neuen Code.
Das Problem
Ein Shop modelliert seine Produktkategorien als Klassen: Buch, Elektronik, Frischware. Jede legt Mehrwertsteuersatz und Lieferzeit fest. Will der Einkauf eine neue Kategorie „Pflanzen“, muss ein Entwickler eine Klasse schreiben, und die Anwendung muss neu ausgeliefert werden. Bei Hunderten von Kategorien wird das unmöglich.
Im Code
Der Record Kategorie ist das Typobjekt mit Steuersatz, Lieferzeit und Kühlpflicht. Die Kategorien werden aus einem Text geladen, der für eine Datenbank oder Konfigurationsdatei steht. Am Ende kommt zur Laufzeit eine neue Kategorie hinzu, ohne eine Zeile Code.
Java-21-Code: TypeObject.java
Direkt ausführbar mit java TypeObject.java
// Type Object
// Typen werden als Daten (Objekte) abgebildet statt als Unterklassen.
// Start: java TypeObject.java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class TypeObject {
// --- Das Type Object: beschreibt eine Art von Artikel ---
record Kategorie(String name, int mwstProzent, int lieferTage, boolean kuehlpflichtig) {}
// --- Die Instanz verweist auf ihren Typ, statt von einer Unterklasse abzustammen ---
record Artikel(String name, double netto, Kategorie kategorie) {
double brutto() { return netto * (100 + kategorie.mwstProzent()) / 100; }
}
// --- Typen kommen aus Daten, z. B. Datenbank oder Konfigurationsdatei ---
static Map<String, Kategorie> ladeKategorien(String konfiguration) {
Map<String, Kategorie> m = new LinkedHashMap<>();
for (String zeile : konfiguration.strip().split("\n")) {
String[] f = zeile.split(";");
m.put(f[0], new Kategorie(f[0], Integer.parseInt(f[1]), Integer.parseInt(f[2]), Boolean.parseBoolean(f[3])));
}
return m;
}
public static void main(String[] args) {
String konfiguration = """
Buch;7;2;false
Elektronik;19;3;false
Frischware;7;1;true
""";
Map<String, Kategorie> kat = ladeKategorien(konfiguration);
List<Artikel> artikel = List.of(
new Artikel("Krimi", 12.00, kat.get("Buch")),
new Artikel("Kopfhörer", 80.00, kat.get("Elektronik")),
new Artikel("Käse", 4.00, kat.get("Frischware")));
zeige(artikel);
// Neue Kategorie zur Laufzeit: keine neue Klasse, kein neues Deployment
System.out.println("\nNeue Kategorie \"Pflanzen\" aus der Konfiguration ergänzt:");
kat.putAll(ladeKategorien("Pflanzen;7;4;false"));
zeige(List.of(new Artikel("Basilikum", 2.50, kat.get("Pflanzen"))));
}
static void zeige(List<Artikel> liste) {
for (Artikel a : liste) {
Kategorie k = a.kategorie();
System.out.printf(Locale.GERMANY, " %-10s %-11s %6.2f € brutto, Lieferung %d Tag(e)%s%n",
a.name(), k.name(), a.brutto(), k.lieferTage(), k.kuehlpflichtig() ? ", Kühlversand" : "");
}
}
}Ausgabe
Krimi Buch 12,84 € brutto, Lieferung 2 Tag(e)
Kopfhörer Elektronik 95,20 € brutto, Lieferung 3 Tag(e)
Käse Frischware 4,28 € brutto, Lieferung 1 Tag(e), Kühlversand
Neue Kategorie "Pflanzen" aus der Konfiguration ergänzt:
Basilikum Pflanzen 2,68 € brutto, Lieferung 4 Tag(e)Worauf achten
Type Object eignet sich, wenn sich die Arten durch Daten unterscheiden. Unterscheiden sie sich durch Verhalten, etwa weil Frischware eine ganz andere Versandlogik braucht, reicht ein Datenfeld nicht. Dann kann das Typobjekt eine Strategy enthalten, oder Unterklassen bleiben die richtige Wahl. Der Compiler kann bei Typobjekten weniger prüfen: Ein Tippfehler im Kategorienamen fällt erst zur Laufzeit auf. Typobjekte können selbst Typobjekte haben, etwa Unterkategorien, oder Vorgaben von einer übergeordneten Kategorie erben.
In der Praxis
Produktkategorien und Artikeltypen in Shopsystemen, Gegnertypen in Spielen (Lebenspunkte, Geschwindigkeit, Aussehen als Daten), Tarife und Vertragsarten in Versicherungen, Ticketarten in Bugtrackern. Das Muster wurde von Ralph Johnson und Bobby Woolf beschrieben.
12. Extension Object · Erweiterungsobjekt
Die Idee
Ein Objekt soll neue Schnittstellen bekommen, ohne dass seine Klasse geändert wird. Dazu verwaltet es eine Sammlung von Erweiterungsobjekten. Wer eine Fähigkeit braucht, fragt danach: „Kannst du gedruckt werden?“ Gibt es die Erweiterung, bekommt er sie, sonst nicht. Das Muster stammt von Erich Gamma, einem der vier GoF-Autoren.
Das Problem
Eine Dokumentenverwaltung bekommt laufend neue Funktionen: Drucken, Exportieren, Signieren, Archivieren, oft von verschiedenen Teams oder als Plugin. Würde jede Funktion als Schnittstelle in die Klasse Dokument aufgenommen, wüchse sie unbegrenzt, und jedes Plugin müsste den Kern ändern. Außerdem können nicht alle Dokumente alles: Eine Notiz lässt sich nicht als CSV exportieren.
Im Code
Das Dokument hält eine Map von Schnittstellentyp auf Erweiterung. erweiterung(Druckbar.class) liefert ein Optional, das leer ist, wenn die Fähigkeit fehlt. Die Erweiterungen werden als Lambdas zur Laufzeit hinzugefügt. Die Rechnung kann drucken und exportieren, die Notiz nur drucken.
Java-21-Code: ExtensionObject.java
Direkt ausführbar mit java ExtensionObject.java
// Extension Object
// Objekte werden zur Laufzeit um zusätzliche Schnittstellen erweitert, ohne ihre Klasse zu ändern.
// Start: java ExtensionObject.java
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class ExtensionObject {
// --- Mögliche Erweiterungen ---
interface Druckbar { String druckansicht(); }
interface Exportierbar { String alsCsv(); }
interface Signierbar { String signiere(String schluessel); }
// --- Das erweiterbare Objekt: kennt seine Erweiterungen nicht im Voraus ---
static final class Dokument {
final String titel;
final String inhalt;
private final Map<Class<?>, Object> erweiterungen = new HashMap<>();
Dokument(String titel, String inhalt) { this.titel = titel; this.inhalt = inhalt; }
<T> void erweitere(Class<T> typ, T erweiterung) { erweiterungen.put(typ, erweiterung); }
<T> Optional<T> erweiterung(Class<T> typ) {
return Optional.ofNullable(erweiterungen.get(typ)).map(typ::cast);
}
}
public static void main(String[] args) {
Dokument rechnung = new Dokument("Rechnung 4711", "Kopfhörer;1;80,00");
Dokument notiz = new Dokument("Notiz", "Rückruf Herr Berg");
// Erweiterungen zur Laufzeit hinzufügen, z. B. durch Plugins
rechnung.erweitere(Druckbar.class, () -> "=== " + rechnung.titel + " ===\n " + rechnung.inhalt);
rechnung.erweitere(Exportierbar.class, () -> "titel;inhalt\n " + rechnung.titel + ";" + rechnung.inhalt);
notiz.erweitere(Druckbar.class, () -> "[Notiz] " + notiz.inhalt);
for (Dokument d : new Dokument[] { rechnung, notiz }) {
System.out.println(d.titel + ":");
d.erweiterung(Druckbar.class).ifPresent(e -> System.out.println(" drucken: " + e.druckansicht()));
d.erweiterung(Exportierbar.class).ifPresentOrElse(
e -> System.out.println(" CSV: " + e.alsCsv()),
() -> System.out.println(" CSV: nicht unterstützt"));
System.out.println(" signieren: " + (d.erweiterung(Signierbar.class).isPresent() ? "ja" : "nicht unterstützt"));
}
}
}Ausgabe
Rechnung 4711:
drucken: === Rechnung 4711 ===
Kopfhörer;1;80,00
CSV: titel;inhalt
Rechnung 4711;Kopfhörer;1;80,00
signieren: nicht unterstützt
Notiz:
drucken: [Notiz] Rückruf Herr Berg
CSV: nicht unterstützt
signieren: nicht unterstütztWorauf achten
Die Flexibilität wird mit Typsicherheit bezahlt: Ob ein Objekt eine Fähigkeit hat, steht erst zur Laufzeit fest, und jeder Aufrufer muss mit dem Fehlen rechnen. Bei einer festen, bekannten Menge von Fähigkeiten sind normale Interfaces besser. Das Muster spielt seine Stärke in Plugin-Architekturen aus, in denen der Kern die Erweiterungen nicht kennen kann.
In der Praxis
IAdaptable.getAdapter(Class) in der Eclipse-Plattform, die das Muster in großem Stil einsetzt, unwrap(Class) in JDBC und JPA, um herstellerspezifische Funktionen zu erreichen, Lookup in der NetBeans-Plattform.
13. Mixin / Trait · Beimischung
Die Idee
Ein Mixin ist ein Baustein mit fertigem Verhalten, den man in verschiedene Klassen „einmischen“ kann, ohne eine gemeinsame Oberklasse zu brauchen. Eine Klasse kann mehrere Mixins gleichzeitig verwenden. In Java übernehmen Interfaces mit Default-Methoden diese Rolle. Andere Sprachen nennen es Trait (Scala, Rust, PHP) oder Mixin (Ruby, Python).
Das Problem
Bestellungen, Gutscheine und Notizen haben nichts gemeinsam, das eine Oberklasse rechtfertigen würde. Trotzdem brauchen mehrere von ihnen dieselben Hilfsfunktionen: das Alter berechnen, einen Betrag formatieren, eine Zeile für die Ausgabe erzeugen. Da Java nur eine Oberklasse erlaubt, und Records gar keine, würde der Code ohne Mixins in jeder Klasse kopiert.
Im Code
Drei Interfaces verlangen jeweils nur eine einzige Methode, etwa erstelltAm(), und liefern darauf aufbauend fertiges Verhalten als Default-Methoden. Die Records erfüllen die verlangte Methode durch ihre Komponenten ganz automatisch: Ein Record mit der Komponente erstelltAm hat die Methode erstelltAm() bereits. Über den Typ des Mixins lassen sich verschiedene Klassen gemeinsam verarbeiten.
Java-21-Code: Mixin.java
Direkt ausführbar mit java Mixin.java
// Mixin / Trait
// Fertiges Verhalten wird über Interfaces mit Default-Methoden in Klassen "eingemischt".
// Start: java Mixin.java
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
public class Mixin {
// --- Mixin 1: alles mit Zeitstempel bekommt Altersberechnung geschenkt ---
interface Zeitgestempelt {
LocalDateTime erstelltAm(); // das Einzige, was die Klasse liefern muss
default long alterInTagen(LocalDateTime jetzt) { return Duration.between(erstelltAm(), jetzt).toDays(); }
default boolean aelterAls(int tage, LocalDateTime jetzt) { return alterInTagen(jetzt) > tage; }
}
// --- Mixin 2: alles mit Betrag bekommt Formatierung und Vergleiche ---
interface MitBetrag {
long cent();
default String betragText() { return String.format(Locale.GERMANY, "%.2f €", cent() / 100.0); }
default boolean groesserAls(long euro) { return cent() > euro * 100; }
}
// --- Mixin 3: Ausgabe als Zeile ---
interface AlsZeile {
default String zeile() { return getClass().getSimpleName() + " " + this; }
}
// --- Klassen mischen sich zusammen, was sie brauchen ---
record Bestellung(String nr, long cent, LocalDateTime erstelltAm)
implements Zeitgestempelt, MitBetrag, AlsZeile {}
record Gutschein(String code, long cent)
implements MitBetrag {}
record Notiz(String text, LocalDateTime erstelltAm)
implements Zeitgestempelt, AlsZeile {}
public static void main(String[] args) {
LocalDateTime jetzt = LocalDateTime.of(2026, 10, 27, 12, 0);
var b = new Bestellung("4711", 12_990, jetzt.minusDays(45));
var g = new Gutschein("WINTER", 2_000);
var n = new Notiz("Kunde anrufen", jetzt.minusDays(3));
System.out.println("Bestellung: " + b.betragText() + ", " + b.alterInTagen(jetzt) + " Tage alt, über 30 Tage: " + b.aelterAls(30, jetzt));
System.out.println("Gutschein: " + g.betragText() + ", über 50 €: " + g.groesserAls(50));
System.out.println("Notiz: " + n.alterInTagen(jetzt) + " Tage alt");
// Über das Mixin-Interface lassen sich ganz verschiedene Klassen gemeinsam behandeln
List<MitBetrag> mitBetrag = List.of(b, g);
long summe = mitBetrag.stream().mapToLong(MitBetrag::cent).sum();
System.out.printf(Locale.GERMANY, "Summe aller Beträge: %.2f €%n", summe / 100.0);
List<Zeitgestempelt> alt = List.<Zeitgestempelt>of(b, n).stream().filter(z -> z.aelterAls(30, jetzt)).toList();
System.out.println("Älter als 30 Tage: " + alt.size() + " Objekt(e)");
}
}Ausgabe
Bestellung: 129,90 €, 45 Tage alt, über 30 Tage: true
Gutschein: 20,00 €, über 50 €: false
Notiz: 3 Tage alt
Summe aller Beträge: 149,90 €
Älter als 30 Tage: 1 Objekt(e)Worauf achten
Java-Interfaces können keinen Zustand halten. Ein Mixin kann nur auf Daten zugreifen, die die Klasse über abstrakte Methoden bereitstellt. Das unterscheidet sie von Traits in Scala, die eigene Felder haben können. Stellen zwei Mixins dieselbe Default-Methode bereit, muss die Klasse sie überschreiben und entscheiden, welche gilt. Mixins sollten klein und unabhängig sein. Ein Mixin, das von vielen anderen abhängt, ist schwer zu verstehen.
In der Praxis
Im JDK: Comparable und Comparator mit Default-Methoden wie reversed() und thenComparing(), Iterable.forEach(), Collection.removeIf(). Default-Methoden wurden mit Java 8 eingeführt, gerade um solche Erweiterungen zu ermöglichen, ohne bestehende Implementierungen zu brechen.
Im Überblick
| Muster | Löst | In Java 21 typisch mit |
|---|---|---|
| Null Object | Prüfungen auf null überall im Code | enum mit einer Konstante |
| Value Object | nackte Zahlen und Strings ohne Bedeutung und Prüfung | record mit kompaktem Konstruktor |
| Fluent Interface | unlesbare Parameterlisten | Methoden, die this zurückgeben |
| Specification | verstreute und fehleranfällige Bedingungen | Predicate bzw. eigenes funktionales Interface |
| Type Object | eine Unterklasse pro Art, Änderungen nur per Deployment | record als Typ, geladen aus Daten |
| Extension Object | Klassen, die für jede neue Fähigkeit wachsen | Map<Class, Object> und Optional |
| Mixin / Trait | kopierter Code in Klassen ohne gemeinsame Oberklasse | Interfaces mit Default-Methoden |