Software-Muster jenseits von GoF

Datenzugriff

Software-Muster jenseits von GoF · Gruppe 3 von 7

Lesestand

Fast jede Anwendung muss Daten dauerhaft speichern, und fast immer in einer relationalen Datenbank, deren Tabellen nicht zu den Objekten im Programm passen. Martin Fowler hat 2002 in Patterns of Enterprise Application Architecture (PoEAA) beschrieben, wie man diese Lücke überbrückt. Die Muster dieser Gruppe stecken heute in jedem ORM-Framework wie JPA und Hibernate. Wer sie kennt, versteht, was diese Frameworks im Hintergrund tun und wo ihre Fallen liegen.

Die Codebeispiele verwenden keine echte Datenbank, sondern Listen und Maps im Speicher. Die ausgegebenen SQL-Befehle zeigen, was eine echte Implementierung an die Datenbank schicken würde.

  • Anwendung / Objekt
  • Muster-Baustein
  • Speicher / Ressource

14. Repository · Ablage

GeschäftslogikBestellRepositoryspeichere(b)mitId(4711)offeneVon("Sven")summeOffen()InMemoryfür TestsSQL / JPAfür den Betriebfachliche Fragen statt SQL„Gib mir alle offenenBestellungen von Sven.“
Die Geschäftslogik stellt fachliche Fragen an das Repository. Ob dahinter eine Liste im Speicher oder eine Datenbank steckt, weiß sie nicht.

Die Idee

Ein Repository verhält sich nach außen wie eine Sammlung von Domänenobjekten im Speicher: Man legt Objekte hinein, holt sie heraus und sucht nach fachlichen Kriterien. Wie und wo die Objekte tatsächlich gespeichert werden, in einer Datenbank, einer Datei oder einem fremden Dienst, verbirgt es vollständig. Die Methoden sprechen die Sprache der Fachabteilung: offeneVon(kunde) statt SELECT ... WHERE.

Das Problem

Streuen sich SQL-Abfragen über die ganze Geschäftslogik, ist diese untrennbar mit der Datenbank verbunden. Tests brauchen eine echte Datenbank, und jede Änderung am Tabellenschema zieht sich durch den ganzen Code. Gleiche Abfragen werden an mehreren Stellen leicht unterschiedlich geschrieben.

Im Code

Das Interface BestellRepository hat zwei Implementierungen: eine im Speicher und eine, die SQL erzeugt (hier nur simuliert und protokolliert). Dieselbe Methode geschaeftsablauf läuft mit beiden und liefert dasselbe Ergebnis.

Java-21-Code: Repository.java

Direkt ausführbar mit java Repository.java

// Repository
// Eine sammlungsartige Schnittstelle zu Domänenobjekten, die verbirgt, wo und wie sie gespeichert sind.
// Start: java Repository.java

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class Repository {

    enum Status { OFFEN, VERSANDT }
    record Bestellung(Long id, String kunde, long cent, Status status) {
        Bestellung mitId(long neueId) { return new Bestellung(neueId, kunde, cent, status); }
    }

    // --- Das Repository: spricht die Sprache der Domäne, nicht die der Datenbank ---
    interface BestellRepository {
        Bestellung speichere(Bestellung b);
        Optional<Bestellung> mitId(long id);
        List<Bestellung> offeneVon(String kunde);
        long summeOffen();
    }

    // --- Implementierung 1: im Speicher, z. B. für Tests ---
    static final class InMemoryBestellungen implements BestellRepository {
        private final Map<Long, Bestellung> daten = new ConcurrentHashMap<>();
        private final AtomicLong naechsteId = new AtomicLong(1);

        public Bestellung speichere(Bestellung b) {
            Bestellung mitId = b.id() == null ? b.mitId(naechsteId.getAndIncrement()) : b;
            daten.put(mitId.id(), mitId);
            return mitId;
        }
        public Optional<Bestellung> mitId(long id) { return Optional.ofNullable(daten.get(id)); }
        public List<Bestellung> offeneVon(String kunde) {
            return daten.values().stream().filter(b -> b.kunde().equals(kunde) && b.status() == Status.OFFEN).toList();
        }
        public long summeOffen() {
            return daten.values().stream().filter(b -> b.status() == Status.OFFEN).mapToLong(Bestellung::cent).sum();
        }
    }

    // --- Implementierung 2: "Datenbank", hier nur simuliert, zeigt die erzeugten SQL-Befehle ---
    static final class SqlBestellungen implements BestellRepository {
        private final InMemoryBestellungen tabelle = new InMemoryBestellungen();
        final List<String> log = new ArrayList<>();

        public Bestellung speichere(Bestellung b) {
            log.add(b.id() == null ? "INSERT INTO bestellung ..." : "UPDATE bestellung ... WHERE id = " + b.id());
            return tabelle.speichere(b);
        }
        public Optional<Bestellung> mitId(long id) {
            log.add("SELECT * FROM bestellung WHERE id = " + id);
            return tabelle.mitId(id);
        }
        public List<Bestellung> offeneVon(String kunde) {
            log.add("SELECT * FROM bestellung WHERE kunde = '" + kunde + "' AND status = 'OFFEN'");
            return tabelle.offeneVon(kunde);
        }
        public long summeOffen() {
            log.add("SELECT SUM(cent) FROM bestellung WHERE status = 'OFFEN'");
            return tabelle.summeOffen();
        }
    }

    // --- Geschäftslogik: kennt nur die Schnittstelle ---
    static void geschaeftsablauf(BestellRepository repo) {
        Bestellung a = repo.speichere(new Bestellung(null, "Sven", 4_990, Status.OFFEN));
        repo.speichere(new Bestellung(null, "Sven", 1_200, Status.OFFEN));
        repo.speichere(new Bestellung(null, "Anna", 8_000, Status.OFFEN));
        repo.speichere(new Bestellung(a.id(), a.kunde(), a.cent(), Status.VERSANDT));

        System.out.println("  offen für Sven: " + repo.offeneVon("Sven"));
        System.out.println("  Bestellung 1:   " + repo.mitId(1).map(Bestellung::status).orElseThrow());
        System.out.printf(java.util.Locale.GERMANY, "  Summe offen:    %.2f €%n", repo.summeOffen() / 100.0);
    }

    public static void main(String[] args) {
        System.out.println("Mit In-Memory-Repository:");
        geschaeftsablauf(new InMemoryBestellungen());

        System.out.println("\nMit SQL-Repository (simuliert):");
        SqlBestellungen sql = new SqlBestellungen();
        geschaeftsablauf(sql);
        System.out.println("  erzeugte Befehle:");
        sql.log.forEach(s -> System.out.println("    " + s));
    }
}

Ausgabe

Mit In-Memory-Repository:
  offen für Sven: [Bestellung[id=2, kunde=Sven, cent=1200, status=OFFEN]]
  Bestellung 1:   VERSANDT
  Summe offen:    92,00 €

Mit SQL-Repository (simuliert):
  offen für Sven: [Bestellung[id=2, kunde=Sven, cent=1200, status=OFFEN]]
  Bestellung 1:   VERSANDT
  Summe offen:    92,00 €
  erzeugte Befehle:
    INSERT INTO bestellung ...
    INSERT INTO bestellung ...
    INSERT INTO bestellung ...
    UPDATE bestellung ... WHERE id = 1
    SELECT * FROM bestellung WHERE kunde = 'Sven' AND status = 'OFFEN'
    SELECT * FROM bestellung WHERE id = 1
    SELECT SUM(cent) FROM bestellung WHERE status = 'OFFEN'

Worauf achten

Ein Repository gibt es typischerweise pro Aggregat (siehe Gruppe 4), nicht pro Tabelle. Für eine Bestellung mit Positionen gibt es ein BestellRepository, aber kein eigenes für Positionen. Die Abgrenzung zum DAO ist in der Praxis fließend. Als Faustregel gilt: Das Repository denkt in Domänenobjekten und fachlichen Fragen, das DAO in Tabellen und Datenbankoperationen. Ein häufiger Fehler sind „generische“ Repositories mit Hunderten Abfragemethoden. Dann hilft die Specification aus Gruppe 2.

In der Praxis

Spring Data mit Interfaces wie JpaRepository, aus deren Methodennamen (findByKundeAndStatus) die Abfrage automatisch erzeugt wird, Jakarta Data, Repositories in Micronaut Data. Das Muster stammt aus Fowlers PoEAA und wurde durch Eric Evans' Domain-Driven Design bekannt.

15. Data Access Object (DAO) · Datenzugriffsobjekt

AnwendungKundenDaoeinfuegen(...)lese(id)aendere(...)loesche(id)alle()Tabelle kundeidnameort1Sven BergBremen2Anna LenzKöln………SQLeine Methode pro Datenbankoperation
Das DAO bietet für die Tabelle kunde je eine Methode zum Anlegen, Lesen, Ändern und Löschen.

Die Idee

Ein Data Access Object kapselt den Zugriff auf eine Datenquelle, meist eine Tabelle, hinter einer Schnittstelle mit den Grundoperationen Anlegen, Lesen, Ändern und Löschen, kurz CRUD. Die Anwendung ruft Methoden auf, das DAO kümmert sich um Verbindungen, SQL und die Umwandlung der Ergebnisse.

Das Problem

Ohne DAO steht JDBC-Code mit Verbindungen, PreparedStatement, ResultSet und Fehlerbehandlung mitten in der Anwendung. Er wird kopiert, Fehler schleichen sich ein, und ein Wechsel der Datenbank oder der Zugriffstechnik betrifft den ganzen Code.

Im Code

Das Interface KundenDao definiert die fünf Operationen, JdbcKundenDao setzt sie um (die Datenbank ist hier eine TreeMap, die ausgegebenen SQL-Befehle zeigen, was eine echte Implementierung schicken würde). Die Zeilen werden als Record KundeZeile transportiert.

Java-21-Code: DataAccessObject.java

Direkt ausführbar mit java DataAccessObject.java

// Data Access Object (DAO)
// Kapselt den Zugriff auf eine Datenquelle hinter einer Schnittstelle mit Lese- und Schreiboperationen.
// Start: java DataAccessObject.java

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;

public class DataAccessObject {

    // --- Transportobjekt für genau eine Tabellenzeile ---
    record KundeZeile(long id, String name, String ort) {}

    // --- Das DAO: Operationen auf einer Tabelle (CRUD) ---
    interface KundenDao {
        void einfuegen(KundeZeile k);
        Optional<KundeZeile> lese(long id);
        List<KundeZeile> alle();
        void aendere(KundeZeile k);
        void loesche(long id);
    }

    // --- Implementierung für eine (hier simulierte) relationale Datenbank ---
    static final class JdbcKundenDao implements KundenDao {
        private final Map<Long, KundeZeile> tabelle = new TreeMap<>();       // steht für die echte Tabelle

        private void sql(String s) { System.out.println("    SQL: " + s); }

        public void einfuegen(KundeZeile k) {
            sql("INSERT INTO kunde (id, name, ort) VALUES (" + k.id() + ", '" + k.name() + "', '" + k.ort() + "')");
            tabelle.put(k.id(), k);
        }
        public Optional<KundeZeile> lese(long id) {
            sql("SELECT id, name, ort FROM kunde WHERE id = " + id);
            return Optional.ofNullable(tabelle.get(id));
        }
        public List<KundeZeile> alle() {
            sql("SELECT id, name, ort FROM kunde ORDER BY id");
            return new ArrayList<>(tabelle.values());
        }
        public void aendere(KundeZeile k) {
            sql("UPDATE kunde SET name = '" + k.name() + "', ort = '" + k.ort() + "' WHERE id = " + k.id());
            tabelle.put(k.id(), k);
        }
        public void loesche(long id) {
            sql("DELETE FROM kunde WHERE id = " + id);
            tabelle.remove(id);
        }
    }

    public static void main(String[] args) {
        KundenDao dao = new JdbcKundenDao();

        System.out.println("Anlegen:");
        dao.einfuegen(new KundeZeile(1, "Sven Berg", "Hamburg"));
        dao.einfuegen(new KundeZeile(2, "Anna Lenz", "Köln"));

        System.out.println("Umzug von Kunde 1:");
        dao.lese(1).ifPresent(k -> dao.aendere(new KundeZeile(k.id(), k.name(), "Bremen")));

        System.out.println("Löschen von Kunde 2:");
        dao.loesche(2);

        System.out.println("Alle Kunden:");
        dao.alle().forEach(k -> System.out.println("    " + k));
    }
}

Ausgabe

Anlegen:
    SQL: INSERT INTO kunde (id, name, ort) VALUES (1, 'Sven Berg', 'Hamburg')
    SQL: INSERT INTO kunde (id, name, ort) VALUES (2, 'Anna Lenz', 'Köln')
Umzug von Kunde 1:
    SQL: SELECT id, name, ort FROM kunde WHERE id = 1
    SQL: UPDATE kunde SET name = 'Sven Berg', ort = 'Bremen' WHERE id = 1
Löschen von Kunde 2:
    SQL: DELETE FROM kunde WHERE id = 2
Alle Kunden:
    SQL: SELECT id, name, ort FROM kunde ORDER BY id
    KundeZeile[id=1, name=Sven Berg, ort=Bremen]

Worauf achten

Das DAO ist das ältere und technischere Muster: Es stammt aus den Core J2EE Patterns von Sun (2001) und denkt in Tabellen. Das Repository (Muster 14) denkt in Domänenobjekten. In kleineren Anwendungen ohne ausgeprägtes Domänenmodell ist ein DAO völlig ausreichend. In größeren steht das DAO oft unter dem Repository: Das Repository nutzt DAOs, um ein Aggregat aus mehreren Tabellen zusammenzusetzen. Wichtig: SQL immer mit Platzhaltern (PreparedStatement) erzeugen, nie per String-Verkettung wie in dieser vereinfachten Ausgabe, sonst droht SQL-Injection.

In der Praxis

Klassische JDBC-DAOs, JdbcTemplate in Spring, MyBatis-Mapper, die DAO-Schnittstellen von JDBI. In vielen Codebasen heißen Klassen auch dann „DAO“, wenn sie eigentlich Repositories sind, und umgekehrt.

16. Active Record · Aktiver Datensatz

Kundeid · name · ort · umsatzbucheUmsatz() · istPremium()speichere() · loesche() · finde()DatenFachlogikDatenbankTabelle kundeidnameortumsatz1SvenHamburg12002AnnaKöln0ein Objekt = eine Zeile
Die Klasse Kunde vereint Daten, Fachlogik und Datenbankzugriff. Jedes Objekt entspricht einer Zeile der Tabelle.

Die Idee

Beim Active Record entspricht ein Objekt genau einer Zeile einer Tabelle, und es weiß selbst, wie es sich lädt, speichert und löscht. Daten, Fachlogik und Datenbankzugriff stecken in derselben Klasse: kunde.speichere(), Kunde.finde(1), kunde.loesche().

Das Problem, das es löst

Für einfache Anwendungen, in denen die Klassen weitgehend den Tabellen entsprechen, sind getrennte Schichten aus Repository, Mapper und Domänenobjekt viel Aufwand für wenig Nutzen. Active Record ist der direkteste Weg: kein zusätzlicher Code, keine Übersetzung, sofort verständlich.

Im Code

Die Klasse Kunde hat Felder für jede Spalte, Methoden für die Fachlogik (bucheUmsatz, istPremium) und für die Datenbank (speichere, loesche sowie die statischen finde und alle). Beim ersten speichere() wird eingefügt, danach aktualisiert.

Java-21-Code: ActiveRecord.java

Direkt ausführbar mit java ActiveRecord.java

// Active Record
// Ein Objekt entspricht einer Tabellenzeile und weiß selbst, wie es sich lädt, speichert und löscht.
// Start: java ActiveRecord.java

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;

public class ActiveRecord {

    // Simulierte Tabelle "kunde"
    static final Map<Long, Object[]> TABELLE = new TreeMap<>();
    static long naechsteId = 1;
    static void sql(String s) { System.out.println("    SQL: " + s); }

    // --- Der Active Record: Daten + Geschäftslogik + Datenbankzugriff in einer Klasse ---
    static final class Kunde {
        Long id;
        String name;
        String ort;
        long umsatzCent;

        Kunde(String name, String ort) { this.name = name; this.ort = ort; }

        // Datenbankzugriff, direkt am Objekt
        void speichere() {
            if (id == null) {
                id = naechsteId++;
                sql("INSERT INTO kunde VALUES (" + id + ", '" + name + "', '" + ort + "', " + umsatzCent + ")");
            } else {
                sql("UPDATE kunde SET name='" + name + "', ort='" + ort + "', umsatz=" + umsatzCent + " WHERE id=" + id);
            }
            TABELLE.put(id, new Object[] { name, ort, umsatzCent });
        }

        void loesche() {
            sql("DELETE FROM kunde WHERE id = " + id);
            TABELLE.remove(id);
        }

        static Optional<Kunde> finde(long id) {
            sql("SELECT * FROM kunde WHERE id = " + id);
            Object[] z = TABELLE.get(id);
            if (z == null) return Optional.empty();
            Kunde k = new Kunde((String) z[0], (String) z[1]);
            k.id = id;
            k.umsatzCent = (long) z[2];
            return Optional.of(k);
        }

        static List<Kunde> alle() {
            sql("SELECT * FROM kunde");
            List<Kunde> l = new ArrayList<>();
            for (long id : TABELLE.keySet()) {
                Object[] z = TABELLE.get(id);
                Kunde k = new Kunde((String) z[0], (String) z[1]);
                k.id = id;
                k.umsatzCent = (long) z[2];
                l.add(k);
            }
            return l;
        }

        // Geschäftslogik, ebenfalls am Objekt
        void bucheUmsatz(long cent) { umsatzCent += cent; }
        boolean istPremium()        { return umsatzCent >= 100_000; }

        public String toString() { return "Kunde#" + id + " " + name + ", " + ort + ", " + umsatzCent / 100 + " €"; }
    }

    public static void main(String[] args) {
        System.out.println("Neue Kunden speichern:");
        Kunde sven = new Kunde("Sven Berg", "Hamburg");
        sven.speichere();
        new Kunde("Anna Lenz", "Köln").speichere();

        System.out.println("Laden, ändern, speichern:");
        Kunde k = Kunde.finde(1).orElseThrow();
        k.bucheUmsatz(120_000);
        k.speichere();
        System.out.println("    Premium? " + k.istPremium());

        System.out.println("Alle Kunden:");
        Kunde.alle().forEach(x -> System.out.println("    " + x));
    }
}

Ausgabe

Neue Kunden speichern:
    SQL: INSERT INTO kunde VALUES (1, 'Sven Berg', 'Hamburg', 0)
    SQL: INSERT INTO kunde VALUES (2, 'Anna Lenz', 'Köln', 0)
Laden, ändern, speichern:
    SQL: SELECT * FROM kunde WHERE id = 1
    SQL: UPDATE kunde SET name='Sven Berg', ort='Hamburg', umsatz=120000 WHERE id=1
    Premium? true
Alle Kunden:
    SQL: SELECT * FROM kunde
    Kunde#1 Sven Berg, Hamburg, 1200 €
    Kunde#2 Anna Lenz, Köln, 0 €

Worauf achten

Active Record ist schnell und bequem, solange Klassen und Tabellen sich decken. Weichen sie voneinander ab, etwa weil ein Kunde aus drei Tabellen zusammengesetzt wird oder die Fachlogik komplex wird, stößt das Muster an Grenzen. Da Fachlogik und Datenbank untrennbar verbunden sind, lässt sich die Logik kaum ohne Datenbank testen. Dann ist der Data Mapper (Muster 17) die bessere Wahl.

In der Praxis

Ruby on Rails hat Active Record berühmt gemacht. In der Java-Welt gibt es es in Panache für Quarkus (PanacheEntity mit persist() und findById()), in ActiveJDBC und in Grails/GORM. Laravel (PHP) und Django (Python) folgen ebenfalls diesem Muster.

17. Data Mapper · Datenabbilder

DomäneKundevorname, nachnameAdresseKundenMapperKD_STAMMKD_NRKD_NAMEKD_ORT815Berg, SvenHamburgkennt keine Datenbankkennt beide Seitenkennt keine Objekte
Der Kunde weiß nichts von der Tabelle KD_STAMM. Der Mapper übersetzt Vor- und Nachname in die Spalte KD_NAME und zurück.

Die Idee

Ein Data Mapper ist eine eigene Schicht zwischen Domänenobjekten und Datenbank. Er überträgt Daten in beide Richtungen und hält beide Seiten vollständig unabhängig voneinander. Die Domänenobjekte enthalten kein SQL und keine Spaltennamen, die Datenbank weiß nichts von Objekten.

Das Problem

Oft passen Objektmodell und Tabellenstruktur nicht zusammen: Eine Altdatenbank speichert den Namen als „Nachname, Vorname“ in einer Spalte mit kryptischem Namen, die Adresse ist ein eigenes Objekt im Modell, aber Spalten in derselben Tabelle. Mit Active Record müsste das Domänenobjekt diese Eigenheiten übernehmen.

Im Code

Die Records Kunde und Adresse sind reine Fachobjekte. Die simulierte Tabelle KD_STAMM hat eine ganz andere Struktur. Nur der KundenMapper kennt beide und übersetzt, inklusive Zerlegung des Namens. Nach Speichern und Laden ist das Objekt gleich dem Original.

Java-21-Code: DataMapper.java

Direkt ausführbar mit java DataMapper.java

// Data Mapper
// Eine eigene Schicht übersetzt zwischen Domänenobjekten und Datenbank. Beide kennen einander nicht.
// Start: java DataMapper.java

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public class DataMapper {

    // --- Domänenmodell: reine Fachlogik, kein SQL, keine Spaltennamen ---
    record Adresse(String strasse, String ort) {}
    record Kunde(long id, String vorname, String nachname, Adresse adresse) {
        String anzeigename() { return vorname + " " + nachname; }
    }

    // --- Datenbank (simuliert): andere Struktur als das Domänenmodell ---
    //     Tabelle KD_STAMM mit Spalten KD_NR, KD_NAME ("Nachname, Vorname"), KD_STR, KD_ORT
    static final Map<Long, Map<String, Object>> KD_STAMM = new HashMap<>();

    // --- Der Data Mapper: der einzige Ort, der beide Welten kennt ---
    static final class KundenMapper {
        void speichere(Kunde k) {
            Map<String, Object> zeile = new LinkedHashMap<>();
            zeile.put("KD_NR", k.id());
            zeile.put("KD_NAME", k.nachname() + ", " + k.vorname());
            zeile.put("KD_STR", k.adresse().strasse());
            zeile.put("KD_ORT", k.adresse().ort());
            System.out.println("    SQL: INSERT INTO KD_STAMM " + zeile);
            KD_STAMM.put(k.id(), zeile);
        }

        Optional<Kunde> lade(long id) {
            System.out.println("    SQL: SELECT * FROM KD_STAMM WHERE KD_NR = " + id);
            Map<String, Object> z = KD_STAMM.get(id);
            if (z == null) return Optional.empty();
            String[] name = ((String) z.get("KD_NAME")).split(", ");
            return Optional.of(new Kunde((long) z.get("KD_NR"), name[1], name[0],
                    new Adresse((String) z.get("KD_STR"), (String) z.get("KD_ORT"))));
        }
    }

    public static void main(String[] args) {
        KundenMapper mapper = new KundenMapper();

        Kunde sven = new Kunde(815, "Sven", "Berg", new Adresse("Hafenstraße 7", "Hamburg"));
        System.out.println("Speichern:");
        mapper.speichere(sven);

        System.out.println("Laden:");
        Kunde geladen = mapper.lade(815).orElseThrow();
        System.out.println("    " + geladen);
        System.out.println("    Anzeigename: " + geladen.anzeigename());
        System.out.println("    gleich dem Original: " + geladen.equals(sven));

        System.out.println("\nDas Domänenobjekt weiß nichts von KD_STAMM oder KD_NAME.");
        System.out.println("Ändert sich die Tabelle, ändert sich nur der Mapper. Dasselbe gilt umgekehrt.");
    }
}

Ausgabe

Speichern:
    SQL: INSERT INTO KD_STAMM {KD_NR=815, KD_NAME=Berg, Sven, KD_STR=Hafenstraße 7, KD_ORT=Hamburg}
Laden:
    SQL: SELECT * FROM KD_STAMM WHERE KD_NR = 815
    Kunde[id=815, vorname=Sven, nachname=Berg, adresse=Adresse[strasse=Hafenstraße 7, ort=Hamburg]]
    Anzeigename: Sven Berg
    gleich dem Original: true

Das Domänenobjekt weiß nichts von KD_STAMM oder KD_NAME.
Ändert sich die Tabelle, ändert sich nur der Mapper. Dasselbe gilt umgekehrt.

Worauf achten

Der Data Mapper ist mehr Aufwand als Active Record, lohnt sich aber, sobald die Fachlogik komplex wird oder Modell und Datenbank voneinander abweichen. Von Hand geschrieben wird er schnell umfangreich. Deshalb übernehmen das meist ORM-Frameworks. Selbst geschriebene Mapper sind sinnvoll, wenn man volle Kontrolle über das SQL braucht.

In der Praxis

JPA mit Hibernate oder EclipseLink ist ein Data Mapper: Die Abbildung steht in Annotationen oder XML, die Entity selbst hat keine Speicher-Methoden. Ebenso MyBatis, Spring Data JDBC und jOOQ mit eigenen Mappern. Zusammen mit Unit of Work, Identity Map und Lazy Load (Muster 18 bis 20) bildet er den Kern jedes ORM.

18. Unit of Work · Arbeitseinheit

Position 1 neuPosition 2 neuArtikel 1 geändertArtikel 2 geändertUnit of Workneu:Pos. 1, Pos. 2geändert:Artikel 1, Artikel 2gelöscht:–commit()eineTransaktionbis zum commit: kein Datenbankzugriff
Alle Änderungen eines Vorgangs werden gesammelt und beim Commit in einer einzigen Transaktion geschrieben.

Die Idee

Eine Unit of Work merkt sich alle Änderungen während eines Geschäftsvorgangs: welche Objekte neu sind, welche geändert und welche gelöscht wurden. Erst am Ende schreibt sie alles gebündelt in einer Transaktion in die Datenbank. Bricht der Vorgang ab, wird nichts geschrieben.

Das Problem

Würde jede einzelne Änderung sofort gespeichert, entstünden viele kleine Datenbankzugriffe, und bei einem Abbruch mittendrin bliebe ein halbfertiger Zustand zurück: Bestellpositionen ohne Bestandsbuchung, oder umgekehrt. Wird dasselbe Objekt mehrmals geändert, gäbe es unnötig viele Schreibvorgänge.

Im Code

Die Arbeitseinheit hält drei Mengen für neue, geänderte und gelöschte Objekte. Wird ein Objekt mehrfach als geändert gemeldet, zählt es nur einmal. commit() gibt alle Befehle in einer Transaktion aus. Im zweiten Vorgang wird abgebrochen, und verwerfen() schreibt nichts.

Java-21-Code: UnitOfWork.java

Direkt ausführbar mit java UnitOfWork.java

// Unit of Work
// Sammelt alle Änderungen eines Geschäftsvorgangs und schreibt sie am Ende gebündelt in die Datenbank.
// Start: java UnitOfWork.java

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class UnitOfWork {

    static final class Artikel {
        final long id;
        int bestand;
        Artikel(long id, int bestand) { this.id = id; this.bestand = bestand; }
        public String toString() { return "Artikel#" + id; }
    }
    record Bestellposition(long artikelId, int menge) {}

    // --- Unit of Work: merkt sich, was neu, geändert und gelöscht ist ---
    static final class Arbeitseinheit {
        private final Set<Object> neu = new LinkedHashSet<>();
        private final Set<Object> geaendert = new LinkedHashSet<>();
        private final Set<Object> geloescht = new LinkedHashSet<>();

        void registriereNeu(Object o)       { neu.add(o); }
        void registriereGeaendert(Object o) { if (!neu.contains(o)) geaendert.add(o); }   // doppelt zählt nur einmal
        void registriereGeloescht(Object o) { neu.remove(o); geaendert.remove(o); geloescht.add(o); }

        void commit() {
            List<String> sql = new ArrayList<>();
            sql.add("BEGIN");
            neu.forEach(o -> sql.add("INSERT " + o));
            geaendert.forEach(o -> sql.add("UPDATE " + o));
            geloescht.forEach(o -> sql.add("DELETE " + o));
            sql.add("COMMIT");
            sql.forEach(s -> System.out.println("    " + s));
            neu.clear(); geaendert.clear(); geloescht.clear();
        }

        void verwerfen() {
            System.out.println("    nichts geschrieben, " + (neu.size() + geaendert.size() + geloescht.size()) + " Änderungen verworfen");
            neu.clear(); geaendert.clear(); geloescht.clear();
        }
    }

    public static void main(String[] args) {
        Artikel buch = new Artikel(1, 10);
        Artikel lampe = new Artikel(2, 3);

        System.out.println("Vorgang 1: Bestellung mit zwei Artikeln");
        Arbeitseinheit uow = new Arbeitseinheit();
        Bestellposition p1 = new Bestellposition(1, 2);
        Bestellposition p2 = new Bestellposition(2, 1);
        uow.registriereNeu(p1);
        uow.registriereNeu(p2);
        buch.bestand -= 2;  uow.registriereGeaendert(buch);
        lampe.bestand -= 1; uow.registriereGeaendert(lampe);
        uow.registriereGeaendert(buch);                            // dasselbe Objekt noch einmal: kein zweites UPDATE
        System.out.println("  Bis hierhin: kein einziger Datenbankzugriff");
        System.out.println("  commit():");
        uow.commit();

        System.out.println("\nVorgang 2: bricht mittendrin ab");
        uow.registriereNeu(new Bestellposition(2, 5));
        lampe.bestand -= 5; uow.registriereGeaendert(lampe);
        if (lampe.bestand < 0) {
            System.out.println("  Bestand würde negativ, Vorgang wird abgebrochen:");
            lampe.bestand += 5;
            uow.verwerfen();
        }
    }
}

Ausgabe

Vorgang 1: Bestellung mit zwei Artikeln
  Bis hierhin: kein einziger Datenbankzugriff
  commit():
    BEGIN
    INSERT Bestellposition[artikelId=1, menge=2]
    INSERT Bestellposition[artikelId=2, menge=1]
    UPDATE Artikel#1
    UPDATE Artikel#2
    COMMIT

Vorgang 2: bricht mittendrin ab
  Bestand würde negativ, Vorgang wird abgebrochen:
    nichts geschrieben, 2 Änderungen verworfen

Worauf achten

Die Unit of Work muss wissen, welche Objekte sich geändert haben. Entweder melden sich die Objekte selbst (wie im Beispiel), oder die Unit of Work vergleicht beim Commit jedes geladene Objekt mit einer Kopie seines Ursprungszustands (Dirty Checking). Die Reihenfolge der Befehle ist wichtig: Zuerst müssen Einträge entstehen, auf die andere verweisen. Eine Unit of Work sollte nur so lange leben wie ein Geschäftsvorgang, etwa eine Web-Anfrage.

In der Praxis

Der EntityManager in JPA bzw. die Session in Hibernate ist eine Unit of Work: Geänderte Entities werden automatisch erkannt und beim flush() oder Commit geschrieben. In .NET erfüllt der DbContext von Entity Framework dieselbe Rolle.

19. Identity Map · Identitätstabelle

finde(815)finde(815)1. Aufruf2. AufrufIdentity Map815 → Kunde-Objektschon geladennur beim 1. MalDatenbankbeide Aufrufe bekommen dasselbe Objekt
Der erste Aufruf lädt den Kunden aus der Datenbank, der zweite bekommt dasselbe Objekt aus der Identity Map.

Die Idee

Eine Identity Map ist ein Verzeichnis aller Objekte, die in einem Vorgang bereits geladen wurden, nach ihrer ID. Wird ein Objekt erneut angefordert, liefert sie das vorhandene Objekt, statt es neu zu laden. So gibt es zu jeder Datenbankzeile genau ein Objekt im Speicher.

Das Problem

Lädt man denselben Kunden zweimal, etwa einmal über die Bestellung und einmal über die Rechnung, entstehen zwei getrennte Objekte. Ändert man den Wohnort im einen, zeigt das andere noch den alten Ort. Beim Speichern überschreibt dann womöglich das eine die Änderung des anderen. Und jede Anfrage kostet eine Datenbankabfrage.

Im Code

Ohne Identity Map liefern zwei Aufrufe zwei Objekte und zwei Abfragen. Nach dem Umzug in einem Objekt zeigt das andere noch Hamburg. Mit Identity Map, einer HashMap mit computeIfAbsent, gibt es eine Abfrage und ein Objekt, beide Aufrufer sehen Bremen.

Java-21-Code: IdentityMap.java

Direkt ausführbar mit java IdentityMap.java

// Identity Map
// Jedes Datenbankobjekt wird pro Vorgang nur einmal geladen. Wer es erneut anfordert, bekommt dasselbe Objekt.
// Start: java IdentityMap.java

import java.util.HashMap;
import java.util.Map;

public class IdentityMap {

    static final class Kunde {
        final long id;
        String ort;
        Kunde(long id, String ort) { this.id = id; this.ort = ort; }
    }

    // --- Simulierte Datenbank, zählt die Abfragen ---
    static final class Datenbank {
        int abfragen = 0;
        Kunde lade(long id) {
            abfragen++;
            System.out.println("    SQL: SELECT * FROM kunde WHERE id = " + id);
            return new Kunde(id, "Hamburg");
        }
    }

    // --- Ohne Identity Map ---
    static final class EinfacherMapper {
        final Datenbank db;
        EinfacherMapper(Datenbank db) { this.db = db; }
        Kunde finde(long id) { return db.lade(id); }
    }

    // --- Mit Identity Map: id -> bereits geladenes Objekt ---
    static final class MapperMitIdentityMap {
        final Datenbank db;
        private final Map<Long, Kunde> geladen = new HashMap<>();
        MapperMitIdentityMap(Datenbank db) { this.db = db; }
        Kunde finde(long id) { return geladen.computeIfAbsent(id, db::lade); }
    }

    public static void main(String[] args) {
        System.out.println("Ohne Identity Map:");
        Datenbank db1 = new Datenbank();
        EinfacherMapper ohne = new EinfacherMapper(db1);
        Kunde a = ohne.finde(815);   // z. B. aus der Bestellung
        Kunde b = ohne.finde(815);   // z. B. aus der Rechnung
        a.ort = "Bremen";             // Umzug über Objekt a
        System.out.println("  a == b: " + (a == b) + ", Ort laut b: " + b.ort + "  <- widersprüchlich!");
        System.out.println("  Abfragen: " + db1.abfragen);

        System.out.println("\nMit Identity Map:");
        Datenbank db2 = new Datenbank();
        MapperMitIdentityMap mit = new MapperMitIdentityMap(db2);
        Kunde c = mit.finde(815);
        Kunde d = mit.finde(815);
        c.ort = "Bremen";
        System.out.println("  c == d: " + (c == d) + ", Ort laut d: " + d.ort);
        System.out.println("  Abfragen: " + db2.abfragen);
    }
}

Ausgabe

Ohne Identity Map:
    SQL: SELECT * FROM kunde WHERE id = 815
    SQL: SELECT * FROM kunde WHERE id = 815
  a == b: false, Ort laut b: Hamburg  <- widersprüchlich!
  Abfragen: 2

Mit Identity Map:
    SQL: SELECT * FROM kunde WHERE id = 815
  c == d: true, Ort laut d: Bremen
  Abfragen: 1

Worauf achten

Eine Identity Map gilt für einen begrenzten Rahmen, typischerweise einen Vorgang oder eine Transaktion. Eine anwendungsweite Identity Map würde mit der Zeit veraltete Daten ausliefern und unbegrenzt wachsen. Sie ist kein Cache im üblichen Sinn: Ihr Hauptzweck ist die Konsistenz, die gesparten Abfragen sind ein Nebeneffekt. Abfragen, die nicht über die ID laufen, etwa „alle Kunden aus Hamburg“, gehen trotzdem an die Datenbank. Nur die gefundenen Objekte werden dann mit der Map abgeglichen.

In der Praxis

Der Persistenzkontext in JPA (der „First-Level-Cache“ von Hibernate) ist eine Identity Map: Innerhalb eines EntityManager liefert find(Kunde.class, 815) immer dasselbe Objekt.

20. Lazy Load · Nachladen bei Bedarf

Bestellung 1kunde: Svenpositionen: noch nicht geladen(Platzhalter)erster ZugriffDatenbankBuch × 2Lampe × 1jetzt im SpeicherÜbersicht: nur Kopfdaten, eine AbfrageVorsicht in Schleifen: 1 + N Abfragen
Die Positionen einer Bestellung werden erst geladen, wenn jemand auf sie zugreift.

Die Idee

Beim Laden eines Objekts werden verknüpfte Daten zunächst nicht mitgeladen. An ihrer Stelle steht ein Platzhalter. Erst beim ersten Zugriff holt er die Daten aus der Datenbank. Es ist die Lazy Initialization aus Gruppe 1, angewendet auf Datenbankbeziehungen.

Das Problem

Ein Kunde hat Bestellungen, die Positionen haben, die auf Artikel verweisen, die Kategorien haben. Würde beim Laden eines Kunden alles Verknüpfte mitgeladen, holte eine einzige Abfrage halbe Datenbank in den Speicher, obwohl die Übersicht nur den Namen anzeigt.

Im Code

Die Klasse Nachladen<T> ist der Platzhalter, ein Virtual Proxy im Sinne der GoF. Die Übersicht braucht nur eine Abfrage. Der Zugriff auf die Positionen einer Bestellung löst genau eine weitere aus, der zweite Zugriff keine. Der letzte Teil zeigt die Kehrseite, das N+1-Problem.

Java-21-Code: LazyLoad.java

Direkt ausführbar mit java LazyLoad.java

// Lazy Load
// Verknüpfte Daten werden erst aus der Datenbank geladen, wenn tatsächlich auf sie zugegriffen wird.
// Start: java LazyLoad.java

import java.util.List;
import java.util.function.Supplier;

public class LazyLoad {

    static int abfragen = 0;
    static <T> T sql(String s, T ergebnis) { abfragen++; System.out.println("    SQL: " + s); return ergebnis; }

    record Position(String artikel, int menge) {}

    // --- Ein Platzhalter, der die Daten beim ersten Zugriff lädt (Virtual Proxy) ---
    static final class Nachladen<T> {
        private final Supplier<T> lader;
        private T wert;
        private boolean geladen;
        Nachladen(Supplier<T> lader) { this.lader = lader; }
        T get() {
            if (!geladen) { wert = lader.get(); geladen = true; }
            return wert;
        }
    }

    // --- Bestellung: Kopfdaten sofort, Positionen erst bei Bedarf ---
    static final class Bestellung {
        final long id;
        final String kunde;
        private final Nachladen<List<Position>> positionen;

        Bestellung(long id, String kunde) {
            this.id = id;
            this.kunde = kunde;
            this.positionen = new Nachladen<>(() -> sql("SELECT * FROM position WHERE bestellung = " + id,
                    List.of(new Position("Buch", 2), new Position("Lampe", 1))));
        }
        List<Position> positionen() { return positionen.get(); }
    }

    static List<Bestellung> ladeBestellungen() {
        return sql("SELECT id, kunde FROM bestellung WHERE kunde = 'Sven'",
                List.of(new Bestellung(1, "Sven"), new Bestellung(2, "Sven"), new Bestellung(3, "Sven")));
    }

    public static void main(String[] args) {
        System.out.println("Übersicht anzeigen (nur Kopfdaten):");
        abfragen = 0;
        ladeBestellungen().forEach(b -> System.out.println("    Bestellung " + b.id));
        System.out.println("  Abfragen: " + abfragen + "  <- Positionen wurden nicht geladen");

        System.out.println("\nDetails einer Bestellung:");
        abfragen = 0;
        Bestellung b = ladeBestellungen().get(0);
        System.out.println("    " + b.positionen());
        System.out.println("    " + b.positionen() + "  (zweiter Zugriff, aus dem Speicher)");
        System.out.println("  Abfragen: " + abfragen);

        System.out.println("\nVorsicht, das N+1-Problem: alle Positionen aller Bestellungen");
        abfragen = 0;
        int summe = 0;
        for (Bestellung x : ladeBestellungen()) {
            summe += x.positionen().stream().mapToInt(Position::menge).sum();
        }
        System.out.println("  Artikel gesamt: " + summe + ", Abfragen: " + abfragen + "  <- 1 + N, eine pro Bestellung");
    }
}

Ausgabe

Übersicht anzeigen (nur Kopfdaten):
    SQL: SELECT id, kunde FROM bestellung WHERE kunde = 'Sven'
    Bestellung 1
    Bestellung 2
    Bestellung 3
  Abfragen: 1  <- Positionen wurden nicht geladen

Details einer Bestellung:
    SQL: SELECT id, kunde FROM bestellung WHERE kunde = 'Sven'
    SQL: SELECT * FROM position WHERE bestellung = 1
    [Position[artikel=Buch, menge=2], Position[artikel=Lampe, menge=1]]
    [Position[artikel=Buch, menge=2], Position[artikel=Lampe, menge=1]]  (zweiter Zugriff, aus dem Speicher)
  Abfragen: 2

Vorsicht, das N+1-Problem: alle Positionen aller Bestellungen
    SQL: SELECT id, kunde FROM bestellung WHERE kunde = 'Sven'
    SQL: SELECT * FROM position WHERE bestellung = 1
    SQL: SELECT * FROM position WHERE bestellung = 2
    SQL: SELECT * FROM position WHERE bestellung = 3
  Artikel gesamt: 9, Abfragen: 4  <- 1 + N, eine pro Bestellung

Worauf achten

Das N+1-Problem ist die bekannteste Falle in der Datenbankprogrammierung: Werden in einer Schleife über N Objekte jeweils deren verknüpfte Daten angefasst, entstehen 1 + N Abfragen, bei 1.000 Bestellungen also 1.001. Abhilfe schafft, die Daten für diesen Fall gezielt in einer Abfrage mitzuladen (Eager Loading per JOIN FETCH oder Entity Graph). Eine zweite Falle: Wird nach dem Schließen der Datenbanksitzung auf einen Platzhalter zugegriffen, kann er nicht mehr nachladen (in Hibernate die LazyInitializationException).

In der Praxis

FetchType.LAZY in JPA, in Hibernate durch Proxy-Objekte umgesetzt, Standard bei @OneToMany- und @ManyToMany-Beziehungen. Fowler beschreibt vier Varianten: Lazy Initialization, Virtual Proxy, Value Holder und Ghost.

21. Data Transfer Object (DTO) · Transferobjekt

Kunde (Domäne)id, vorname, nachnameemailpasswortHashibaninterneNotizbestellungen [ ... ]umsatz()alsDto()KundenDtoidnameanzahloffenumsatz (Text)JSONBrowsersensible Felder bleiben drinnenflach, ohne Verhalten, für einen Zweck
Aus dem vollständigen Kunden wird ein schlankes DTO für die Kundenliste. Passwort, IBAN und Notizen verlassen den Server nicht.

Die Idee

Ein DTO ist ein reiner Datencontainer ohne Verhalten, der genau die Daten bündelt, die über eine Grenze transportiert werden sollen: zwischen Server und Browser, zwischen zwei Diensten oder zwischen Schichten. Ursprünglich ging es Fowler darum, mit einem einzigen Aufruf alle benötigten Daten zu übertragen, statt viele kleine Aufrufe über ein langsames Netz zu machen.

Das Problem

Gibt eine REST-Schnittstelle direkt die Domänenobjekte als JSON aus, landen alle Felder im Browser, auch Passwort-Hashes, Bankdaten und interne Notizen. Jede Änderung an der Domänenklasse ändert ungewollt die öffentliche Schnittstelle. Und Objektgeflechte mit Rückverweisen führen bei der Umwandlung in JSON zu Endlosschleifen.

Im Code

Der Record KundenUebersichtDto enthält nur, was die Kundenliste braucht, teils schon berechnet: Anzahl der Bestellungen, offene Bestellungen und formatierter Umsatz. alsDto() bildet den Kunden ab. Ein einziger Aufruf liefert die komplette Liste.

Java-21-Code: DataTransferObject.java

Direkt ausführbar mit java DataTransferObject.java

// Data Transfer Object (DTO)
// Ein reiner Datencontainer, der genau die Daten bündelt, die über eine Grenze transportiert werden sollen.
// Start: java DataTransferObject.java

import java.util.List;
import java.util.stream.Collectors;

public class DataTransferObject {

    // --- Domänenobjekte: viel mehr Daten und Verhalten, als nach außen soll ---
    record Bestellung(long id, long cent, boolean offen) {}
    record Kunde(long id, String vorname, String nachname, String email,
                 String passwortHash, String iban, String interneNotiz, List<Bestellung> bestellungen) {
        long umsatz() { return bestellungen.stream().mapToLong(Bestellung::cent).sum(); }
    }

    // --- DTOs: zugeschnitten auf einen bestimmten Zweck, ohne Verhalten ---
    record KundenUebersichtDto(long id, String name, int anzahlBestellungen, int offeneBestellungen, String umsatz) {}
    record KundenListeDto(List<KundenUebersichtDto> kunden, int gesamt) {}

    // --- Abbildung Domäne -> DTO an der Grenze, z. B. in der REST-Schicht ---
    static KundenUebersichtDto alsDto(Kunde k) {
        return new KundenUebersichtDto(
                k.id(),
                k.vorname() + " " + k.nachname(),
                k.bestellungen().size(),
                (int) k.bestellungen().stream().filter(Bestellung::offen).count(),
                "%d,%02d €".formatted(k.umsatz() / 100, k.umsatz() % 100));
    }

    // --- Einfache JSON-Ausgabe für die Demonstration ---
    static String json(KundenUebersichtDto d) {
        return "{ \"id\": %d, \"name\": \"%s\", \"bestellungen\": %d, \"offen\": %d, \"umsatz\": \"%s\" }"
                .formatted(d.id(), d.name(), d.anzahlBestellungen(), d.offeneBestellungen(), d.umsatz());
    }

    public static void main(String[] args) {
        List<Kunde> kunden = List.of(
                new Kunde(815, "Sven", "Berg", "sven@example.de", "$2a$10$abc...", "DE89 3704 ...", "zahlt pünktlich",
                        List.of(new Bestellung(1, 4_990, false), new Bestellung(2, 12_900, true))),
                new Kunde(816, "Anna", "Lenz", "anna@example.de", "$2a$10$xyz...", "DE12 5001 ...", "",
                        List.of(new Bestellung(3, 2_500, false))));

        // EIN Aufruf liefert alles, was die Kundenliste im Browser braucht
        KundenListeDto antwort = new KundenListeDto(kunden.stream().map(DataTransferObject::alsDto).toList(), kunden.size());

        System.out.println("Antwort der API (" + antwort.gesamt() + " Kunden):");
        System.out.println(antwort.kunden().stream().map(d -> "  " + json(d)).collect(Collectors.joining(",\n", "[\n", "\n]")));
        System.out.println("\nNicht enthalten: E-Mail, Passwort-Hash, IBAN, interne Notiz, einzelne Bestellungen.");
    }
}

Ausgabe

Antwort der API (2 Kunden):
[
  { "id": 815, "name": "Sven Berg", "bestellungen": 2, "offen": 1, "umsatz": "178,90 €" },
  { "id": 816, "name": "Anna Lenz", "bestellungen": 1, "offen": 0, "umsatz": "25,00 €" }
]

Nicht enthalten: E-Mail, Passwort-Hash, IBAN, interne Notiz, einzelne Bestellungen.

Worauf achten

DTOs bedeuten zusätzliche Klassen und Abbildungscode, ein bewusster Preis für eine stabile, sichere Schnittstelle. Records machen DTOs in Java 21 sehr kurz. Für die Abbildung gibt es Bibliotheken wie MapStruct. Ein DTO pro Anwendungsfall ist oft besser als ein großes DTO für alles. DTOs sind kein Ersatz für Domänenobjekte: Geschäftslogik gehört nicht hinein. Das unterscheidet sie vom Value Object, das durchaus Verhalten hat.

In der Praxis

Request- und Response-Klassen in REST-Schnittstellen mit Spring oder Jakarta REST, Projektionen in Spring Data, die direkt DTOs statt Entities laden, Protobuf-Nachrichten bei gRPC, GraphQL-Typen. Auch die Nachrichten aus den Enterprise Integration Patterns sind im Kern DTOs.

Wer spricht mit der Datenbank?

Die ersten vier Muster beantworten dieselbe Frage auf unterschiedliche Weise:

RepositoryDAOActive RecordData Mapper
denkt inDomänenobjektenTabellenZeilen als ObjektenAbbildung zwischen beidem
Datenbankcode steckthinter dem Repositoryim DAOim Objekt selbstim Mapper
Domänenobjekt kennt DBneinneinjanein
passt gut, wennkomplexe Fachlogikeinfacher TabellenzugriffKlassen ≈ TabellenModell ≠ Tabellen
BeispielSpring DataJdbcTemplatePanache, RailsJPA/Hibernate

Die Muster 18 bis 20 arbeiten im Hintergrund jedes ORM zusammen: Die Identity Map sorgt dafür, dass jedes Objekt nur einmal existiert, Lazy Load lädt Beziehungen erst bei Bedarf, und die Unit of Work schreibt alle Änderungen am Ende gemeinsam. In JPA sind alle drei im EntityManager vereint. Das DTO schließlich sorgt dafür, dass von all dem nach außen nur das sichtbar wird, was sichtbar sein soll.