Software-Muster jenseits von GoF

Struktur und Verhalten im Kleinen

Software-Muster jenseits von GoF · Gruppe 2 von 7

Lesestand

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 · Nullobjekt

r.anwenden(preis)keine Prüfung auf nullRabattProzentTreuebonusKeinRabattgibt den Preis unverändertzurück, ist aber nie nullnullmüsste überallgeprüft werden
Kunden ohne Rabatt bekommen ein KeinRabatt-Objekt statt null. Der Aufrufer behandelt alle gleich.

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

12,90 EURmal(2)25,80 EURplus(4,90)30,70 EURbleibt unverändertjede Rechnung liefert ein neues Objekt12,90 EUR12,90 EUR=zwei Objekte, gleiche Werte: gleichPLZ "2045"ungültig: entsteht gar nicht
Geldbeträge ändern sich nie. Rechnen erzeugt neue Werte, und zwei Beträge mit gleichem Wert sind gleich.

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 nicht

Worauf 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

Suche.nach("Buch").preisBis(25).mindestensSterne(4).besteZuerst().hoechstens(2).in(katalog)jede Methode gibt this zurück„Suche nach Büchern bis 25 €, mindestens 4 Sterne, die besten zuerst, höchstens 2“
Jeder Aufruf liefert das Suchobjekt zurück, sodass der nächste direkt angehängt werden kann. Der Code liest sich wie die Anforderung.

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
  Roman

Worauf 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

GRATISVERSANDUNDPREMIUMODERDEATUNDODERNICHTumsatzUeber(1000)stammkunde()gesperrtRegeln ausBausteinen,wiederverwendbar
„Gratisversand“ ist „Premium und Land DE oder AT“. „Premium“ ist selbst aus kleineren Regeln zusammengesetzt.

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

mit UnterklassenArtikelBuchElektronikFrischwareneue Kategorie = neue Klassemit Type ObjectKrimiKopfhörerKäseBuch · 7 % · 2 TageElektronik · 19 % · 3 TageFrischware · 7 % · 1 Tagaus Konfigurationstatt
Statt einer Unterklasse pro Kategorie verweist jeder Artikel auf ein Kategorie-Objekt, das aus der Konfiguration geladen wird.

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

DokumentRechnung 4711erweiterung(Typ) → OptionalDruckbarExportierbarSignierbarnicht vorhanden → leerDruckfunktionerweiterung(Druckbar)Plugins hängen neue Fähigkeiten an, ohne die Klasse zu ändern
Die Rechnung kann gedruckt und exportiert werden, weil Erweiterungen dafür angehängt sind. Nach Signierbar gefragt, liefert sie ein leeres Optional.

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ützt

Worauf 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

ZeitgestempeltalterInTagen()MitBetragbetragText()AlsZeilezeile()BestellungGutscheinNotizjede Klasse mischt sich die Fähigkeiten zusammen, die sie brauchtDefault-Methoden
Bestellung nutzt alle drei Mixins, Gutschein nur MitBetrag, Notiz Zeitgestempelt und AlsZeile.

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

MusterLöstIn Java 21 typisch mit
Null ObjectPrüfungen auf null überall im Codeenum mit einer Konstante
Value Objectnackte Zahlen und Strings ohne Bedeutung und Prüfungrecord mit kompaktem Konstruktor
Fluent Interfaceunlesbare ParameterlistenMethoden, die this zurückgeben
Specificationverstreute und fehleranfällige BedingungenPredicate bzw. eigenes funktionales Interface
Type Objecteine Unterklasse pro Art, Änderungen nur per Deploymentrecord als Typ, geladen aus Daten
Extension ObjectKlassen, die für jede neue Fähigkeit wachsenMap<Class, Object> und Optional
Mixin / Traitkopierter Code in Klassen ohne gemeinsame OberklasseInterfaces mit Default-Methoden