1. Was wir refaktorieren und welchen Nutzen es bringt
Wenn ein Projekt wächst, verhalten sich primitive Felder wie Socken in der Waschmaschine: Man hat sie anscheinend paarweise hineingelegt, aber am Ende leben sie getrennt. Der Preis eines Produkts wird zu priceAmount und priceCurrency in einer Entität, dann zu itemPriceAmount und itemPriceCurrency in einer anderen, und dann noch zu totalAmount und totalCurrency in einer dritten – und plötzlich stellt man fest, dass das halbe Projekt damit beschäftigt ist, „zwei Variablen nebeneinander zu übergeben und zu hoffen, dass sie nicht vertauscht werden“.
In unserem Commerce Persistence Lab ist dies bereits an zwei Feldergruppen zu erkennen: Geld und Adresse. Die Entscheidung für Value Object für sie ist bereits gefallen. Im Folgenden interessiert uns nicht die Wiederholung der allgemeinen Theorie, sondern der eigentliche Refactoring-Pfad: welche Felder geändert werden, wie sich die Entity-API ändert und wann eine Migration erforderlich ist.
Um es ganz anschaulich zu machen, vergleichen wir „wie es war“ und „wie es wird“ in einer kleinen tabellarischen Übersicht:
| Wo | War (Primitive) | Wird (Value Object) |
|---|---|---|
| Product | priceAmount + priceCurrency | Money price |
| OrderItem | priceAtPurchaseAmount + priceAtPurchaseCurrency | Money priceAtPurchase |
| PurchaseOrder | totalAmount + totalCurrency | Money totalAmount |
| CustomerAddress | city + street + house + ... | Address address |
| PurchaseOrder | deliveryCity + deliveryStreet + ... | Address deliveryAddress |
Dieser Pfad hat zwei technische Zweige. Multi-Column-Werte wie Money und Address gehen in @Embeddable. One-Column-Typen wie Status oder SKU leben über @Enumerated und AttributeConverter. Im Folgenden sammeln wir den ersten Zweig detailliert, denn genau dort liegen jetzt Preis und Adresse.
2. Wo Money und Address speichern
Bevor wir Code schreiben, ist es wichtig, eine saubere Geografie zu vereinbaren. Wenn Money im catalog liegt und Address im orders, dann wollen Sie morgen Money im promotion verwenden, und es beginnen traurige Importe, Abhängigkeiten „Feature auf Feature“ und das Gefühl, dass das Projekt nur mit Ach und Krach zusammenbaut.
In unserem Projekt gibt es bereits einen Platz für gemeinsame JPA-Dinge: das Paket com.example.commerce.common.jpa. Es ist logisch, dort Unterpakete für Embeddables und Converter anzulegen. Zum Beispiel so:
com.example.commerce
└── common
└── jpa
├── embeddable
│ ├── Money.java
│ └── Address.java
└── converter
└── ProductStatusConverter.java
Dann verwenden Product (Katalog), PurchaseOrder (Bestellungen) und CustomerAddress (Kunden) denselben Typ Money und dieselbe Address, ohne architektonische „Querverweise“ zwischen Features zu erzeugen.
Und ja, das klingt nach langweiliger organisatorischer Routine – bis Sie in eine Situation geraten, in der Sie „Money an einer Stelle geändert haben, aber an einer anderen plötzlich ein anderes Money mit demselben Namen haben“. Glauben Sie mir, ORM liebt Überraschungen bereits genug; helfen Sie ihm nicht noch dabei.
3. Refactoring von Product: von priceAmount + priceCurrency
Wenn wir ein Modell refaktorieren, ist es besonders wichtig, mit dem „zentralsten“ zu beginnen. Im Commerce Persistence Lab ist ein solcher Mittelpunkt Product. Dies ist die Entität, um die sich viele Szenarien drehen (Fetching, Soft Delete, Repricing, Audit usw.). Wenn wir also lernen, Product sauber zu verbessern, wird es einfacher sein, denselben Ansatz später bei OrderItem und PurchaseOrder zu wiederholen. Hier geht es nicht um die Wiederholung des Aufbaus von Money, sondern um den eigentlichen Übergang von Product zu diesem Typ.
Wie Product „vorher“ aussieht
Unten ein vereinfachtes Beispiel dessen, was normalerweise in Projekten erscheint, wenn der Preis als primitive Werte lebt. Es „funktioniert“ zwar, aber das Modell sagt uns nicht: „das ist Geld“, es sagt: „das sind irgendwelche zwei Felder, vertausch sie nicht“.
package com.example.commerce.catalog.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class Product {
@Id
private Long id;
// Summe und Währung sind in verschiedene Felder auseinandergedriftet:
// sie können leicht in Methoden/DTOs verwechselt werden.
private BigDecimal priceAmount;
// Normalerweise ein ISO-Währungscode (z.B. "USD", "EUR"), aber für den Compiler ist es einfach ein String.
private String priceCurrency;
}
In einem solchen Modell entstehen fast zwangsläufig Methoden und Services, die zwei Parameter nebeneinander akzeptieren. Und früher oder später wird jemand die Währung falsch übergeben. Das ist keine Frage des „ob“, sondern des „wann“ (normalerweise nach einem Release am Freitagabend).
Bereits extrahiertes Money als gemeinsamen Typ verwenden
Money selbst liegt bereits in com.example.commerce.common.jpa.embeddable. Für dieses Refactoring ist ein Fakt wichtig: Product sollte amount und currency nicht mehr als zwei unabhängige Felder speichern.
Money in Product einbetten
Unten nur das Fragment der Entität, das sich tatsächlich ändert. Wir fixieren die Spalten explizit, damit das Schema und das SQL-Log nicht von impliziten Namensregeln abhängen.
package com.example.commerce.catalog.entity;
import com.example.commerce.common.jpa.embeddable.Money;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
public class Product {
// Value Object einbetten: es wird keine separate Tabelle geben,
// die Felder werden in die Product-Tabelle „flachgeklopft“.
@Embedded
@AttributeOverrides({
// Spalten umbenennen, damit im Schema klar ist, dass es "price_..." ist.
@AttributeOverride(name = "amount", column = @Column(name = "price_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "price_currency"))
})
private Money price;
}
Hier ist es wichtig, die Idee zu erfassen: Money erzeugt keine separate Tabelle und lebt nicht separat. Seine Felder werden einfach in die Zeile des Besitzers „flachgeklopft“. Aus SQL-Sicht ist es immer noch dieselbe product, nur dass es in Java jetzt kein zwei Primitive, sondern ein Objekt ist.
Zur Veranschaulichung hier ein kleines Diagramm „im Kopf“:
flowchart LR Product["Product (entity)"] --> Price["price: Money (embeddable)"] Price --> PA["product.price_amount"] Price --> PC["product.price_currency"]
API der Entität ausdrucksstark machen: reprice(Money newPrice)
Ein Refactoring gilt nicht als abgeschlossen, wenn Sie die Annotationen gesetzt haben, sondern wenn Sie aufgehört haben, im Geschäftscode in Primitiven zu denken. Daher ändern wir nicht nur die Felder, sondern auch die Methoden. Statt eines Paares von Settern für Summe und Währung führen wir eine einzige domänenspezifische Methode ein.
package com.example.commerce.catalog.entity;
import com.example.commerce.common.jpa.embeddable.Money;
public class Product {
private Money price;
// Preis als Ganzes ändern, als einen einzelnen domänenspezifischen Wert (whole-value replacement).
public void reprice(Money newPrice) {
this.price = newPrice;
}
}
Jetzt sieht die Preisänderung aus wie die Änderung eines einzelnen Wertes, nicht wie zwei unabhängige Handgriffe. Dirty Checking wird auch „lesbarer“: Wir haben price geändert → beim Flush aktualisiert Hibernate die Spalten price_amount und price_currency.
4. Refactoring von PurchaseOrder: Lieferadresse als Snapshot Address
Bei der Bestellung interessiert uns ein bestimmter Refactor: die Streuung der delivery...-Felder wird zu einem einzigen Wert deliveryAddress. Dies passt gut zum domänenspezifischen Sinn der Bestellung: Die Lieferadresse wird als Snapshot zum Zeitpunkt der Bestellung gespeichert.
Wie sieht die „Adresse mit Primitiven“ aus und warum ist das ärgerlich?
Normalerweise sieht das ungefähr so aus: eine Reihe von Feldern deliveryCity, deliveryStreet, deliveryHouse und noch ein Dutzend ähnliche. Servicemethoden ähneln einem Telefonbuch, und die Wahrscheinlichkeit, die Argumentreihenfolge zu verwechseln, steigt mit der Anzahl der Felder.
package com.example.commerce.orders.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class PurchaseOrder {
@Id
private Long id;
// Adresse ist in Stücke zerlegt: weiter sind Methoden mit 5–10 String-Parametern hintereinander fast unvermeidlich.
private String deliveryCity;
private String deliveryStreet;
private String deliveryHouse;
}
Das Problem ist nicht, dass „drei Felder viel sind“. Das Problem ist, dass dies ein einziger domänenspezifischer Wert (Adresse) ist, aber das Modell zwingt uns, darüber als drei (oder zehn) separate Werte nachzudenken, die synchron übertragen werden müssen.
Bereits extrahiertes Address als Embeddable verwenden
Address selbst liegt bereits in com.example.commerce.common.jpa.embeddable. Unten geht es nicht um seine interne Zusammensetzung, sondern um den Übergang von PurchaseOrder von der Streuung der delivery...-Felder zu einem einzigen Wert.
Adresse in PurchaseOrder einbetten und verständliche Spaltennamen vergeben
In PurchaseOrder heißt das Feld deliveryAddress, und die Spalten nennen wir mit dem Präfix delivery_address_..., damit selbst beim Blick ins SQL-Log klar ist: Dies ist die Lieferadresse der Bestellung, nicht etwas anderes. Unten wieder nur das Refactoring-Fragment der Entität.
package com.example.commerce.orders.entity;
import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
public class PurchaseOrder {
// Adresse – Snapshot in der Bestellung: Wir speichern sie direkt in der Bestellzeile, ohne separate Adresstabelle.
@Embedded
@AttributeOverrides({
// Präfix delivery_address_... hilft, die Lieferadresse von anderen Adressen im Schema zu unterscheiden.
@AttributeOverride(name = "city", column = @Column(name = "delivery_address_city")),
@AttributeOverride(name = "street", column = @Column(name = "delivery_address_street")),
@AttributeOverride(name = "house", column = @Column(name = "delivery_address_house"))
})
private Address deliveryAddress;
}
Ja, das sieht etwas länger aus als drei primitive Felder. Aber das ist „Länge einmal im Mapping“, und dann verwenden Sie im Code ein einziges verständliches Objekt deliveryAddress und nicht drei oder vier oder fünf Strings, die Sie paarweise herumschleppen müssen.
Eine domänenspezifische Methode zum Austausch der gesamten Adresse hinzufügen
Das ist Whole-Value Replacement in der Praxis: Wir mutieren die Adressfelder nicht „stückweise“, sondern ersetzen die Adresse als Wert. Der Code wird einfacher, und die Änderungsstelle ist sofort sichtbar.
package com.example.commerce.orders.entity;
import com.example.commerce.common.jpa.embeddable.Address;
public class PurchaseOrder {
private Address deliveryAddress;
// Adresse als Ganzes ändern: geringere Wahrscheinlichkeit einer „teilweise aktualisierten“ Adresse.
public void changeDeliveryAddress(Address newAddress) {
this.deliveryAddress = newAddress;
}
}
5. CustomerAddress: Entity mit Address im Inneren
Derselbe Typ Address macht CustomerAddress nicht überflüssig. Beim Kunden können Adressen mehrfach vorhanden sein, sie haben einen Typ (z. B. shipping/billing), ein Default-Flag und einen Lebenszyklus (hinzugefügt, als Standard gesetzt, gelöscht). Daher ist CustomerAddress in unserem Projekt eine Entity, und das ist richtig. Aber innerhalb dieser Entität bleiben die konkreten Adressfelder dennoch ein einziger Wert Address.
Warum dies „Entity mit Value Object im Inneren“ eine normale Kombination ist
Manchmal denken Anfänger, dass „wenn wir Address haben, dann wird CustomerAddress nicht benötigt“. Aber Address ist das, was auf dem Umschlag steht. Und CustomerAddress ist der Adressdatensatz des Kunden mit eigener Bedeutung: Adresstyp, Default-Eigenschaft, Verknüpfung mit dem Kunden, Löschregeln. Daher behalten wir beide ruhig: Die Entity definiert den Lifecycle, das Embeddable definiert den Inhalt.
Das sieht aus wie eine Matroschka, aber eine nützliche: Die große Matroschka (Entity) speichert die kleine Matroschka (Value Object), und keiner stört den anderen.
Mapping von CustomerAddress.address über @Embedded
Unten wieder nur das Stück der Entität, das sich ändert.
package com.example.commerce.customer.entity;
import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
public class CustomerAddress {
// Hier speichert Address "was auf dem Umschlag steht", und die Entity CustomerAddress kümmert sich um den Lifecycle des Datensatzes.
@Embedded
@AttributeOverrides({
// In der Tabelle der Kundenadressen liest sich der Präfix address_... natürlich.
@AttributeOverride(name = "city", column = @Column(name = "address_city")),
@AttributeOverride(name = "street", column = @Column(name = "address_street")),
@AttributeOverride(name = "house", column = @Column(name = "address_house"))
})
private Address address;
}
Beachten Sie den „angenehmen Effekt“: Address wird jetzt sowohl in Bestellungen als auch bei Kunden wiederverwendet, aber die Bedeutung wird durch den Feldnamen des Besitzers (deliveryAddress vs. address) und die Spaltennamen bestimmt. Ein Java-Typ, zwei Kontexte – und das ist normal.
6. SQL und Dirty Checking nach dem Refactoring
Embeddable lebt nicht separat, daher wird die Zeile des Besitzers aktualisiert
Nach einem solchen Refactoring führt Hibernate keine separaten INSERT- oder UPDATE-Anweisungen für Money und Address durch. Es aktualisiert weiterhin die Zeile des Besitzers, nur dass diese Spalten jetzt auf Java-Ebene in einem Objekt zusammengefasst sind.
Schematisch sieht das in SQL so aus:
-- Die genauen Tabellennamen hängen von Ihrem Schema ab; hier geht es um den Typ des UPDATEs
update product
set price_amount = ?, price_currency = ?
where id = ?
update purchase_order
set delivery_address_city = ?, delivery_address_street = ?, delivery_address_house = ?
where id = ?
Das heißt, der Refactor ändert vor allem die Semantik und die API des Modells, nicht die Natur der Owner-Row-Updates. Money und Address erhalten keinen eigenen Lebenszyklus und erfordern keine zusätzlichen Joins.
Warum Whole-Value Replacement ein versehentliches Update weniger wahrscheinlich macht
Wenn Preis und Adresse als Primitive leben, können sie leicht zur Hälfte aktualisiert werden: Summe ändern und Währung vergessen, Stadt ändern und den Rest der Adresse unberührt lassen. Wenn sie in Money und Address zusammengefasst sind, drängt der Code Sie zumindest dazu, den Wert als Ganzes zu ändern. Hibernate schaut beim Flush immer noch auf die endgültigen Spalten der Unit of Work, aber für den Menschen wird die Änderungsstelle viel deutlicher.
Genau das wollen wir vom Refactoring: keine neue ORM-Magie, sondern ein ehrlicheres Modell.
7. Flyway: Wann eine Migration nötig ist
Normalerweise klingt die größte Angst bei solchen Refactorings so: „Ok, die Java-Klasse ändern wir, aber wie sieht es mit den Tabellen aus? Jetzt müssen wir Migrationen umschreiben, und dann explodiert alles.“ Die gute Nachricht: Oft muss die Datenbank gar nicht geändert werden. Sie können dieselben Spalten price_amount und price_currency behalten und Hibernate einfach über @AttributeOverride mitteilen, dass dies jetzt Felder eines Embeddables sind.
Szenario „Migration nicht nötig“
Wenn Sie bereits Spalten price_amount und price_currency haben, dann ist der Übergang von Primitiven zu @Embedded Money price ein reines Java-Refactoring. Das SQL-Schema ändert sich nicht, die Daten bleiben an Ort und Stelle. Sie beginnen einfach, sie durch ein ausdrucksstärkeres Modell zu lesen und zu schreiben.
Dies ist eine der angenehmsten Arten von Refactoring: Sie verbessern den Code und verringern das Fehlerrisiko, ohne die Tabellen anzurühren.
Szenario „Migration nötig“ (z. B. Spalten existieren noch nicht)
Wenn Sie nur price_amount hatten und price_currency „im Kopf behalten“ haben (was auch vorkommt), dann ist Flyway nötig. Die Migration sollte minimal und verständlich sein.
-- Vx__add_product_price_currency.sql
alter table product
add column price_currency varchar(3) not null default 'USD';
-- Nach dem Befüllen kann der Default in der nächsten Migration entfernt werden, falls nötig.
Hier bleibt die wichtige Disziplin des Kurses dieselbe: kein ddl-auto=update als „Magie“. Wir möchten, dass das Schema kontrolliert und lesbar ist. Embeddable hebt diese Regel nicht auf, es macht das Mapping in Java nur bequemer.
8. Service-Code nach dem Refactoring
Der letzte Schliff ist nicht Hibernate und nicht die Annotationen. Es geht darum, wie Ihr Code das Modell nutzt. Wenn Services weiterhin BigDecimal amount, String currency akzeptieren, dann geben Sie im Grunde zu: „Ja, Geld sind zwei Primitive, wir haben sie nur in der Entity versteckt.“ Manchmal ist das an der Grenze akzeptabel (z. B. wenn die Eingabe von einem externen DTO kommt), aber innerhalb des Domänen- und Service-Codes möchten wir in der Sprache des Modells sprechen.
Vorher: Service akzeptiert Primitive und „sammelt Geld selbst“
Eine solche Methode kann leicht falsch aufgerufen werden, besonders wenn es mehr Parameter gibt.
package com.example.commerce.catalog.service;
import java.math.BigDecimal;
public class ProductService {
// Auf Service-API-Ebene immer noch "zwei Parameter nebeneinander" – das Risiko der Verwechslung bleibt.
public void reprice(long productId, BigDecimal amount, String currency) {
// ... Product finden und Preis aktualisieren
}
}
Das ist kein „schlechter Code“, aber es hilft uns nicht, in Domänentypen zu denken.
Nachher: Service akzeptiert Money und aktualisiert die Entität über die domänenspezifische Methode
Jetzt spricht die Signatur selbst, was erwartet wird.
package com.example.commerce.catalog.service;
import com.example.commerce.common.jpa.embeddable.Money;
public class ProductService {
// Der Service spricht die Sprache des Modells: Preis ist Money, kein Paar von Primitiven.
public void reprice(long productId, Money newPrice) {
// ... find product
// product.reprice(newPrice);
}
}
Dasselbe funktioniert auch für die Adresse. Statt Methoden wie changeDeliveryAddress(orderId, city, street, house, ...) erhalten Sie changeDeliveryAddress(orderId, Address). Einen solchen Code kann man kaum „mit vertauschter Straße und Hausnummer“ aufrufen, weil Sie nicht zehn String-Argumente hintereinander haben.
Und das ist übrigens einer der am meisten unterschätzten Vorteile von Value Objects: Sie reduzieren die Anzahl der Parameter, die „vom Typ her gleich, aber von der Bedeutung her unterschiedlich“ sind. Und genau solche Parameter führen am häufigsten zu Fehlern, die der Compiler nicht abfängt.
9. Typische Fehler beim Refactoring in Embeddables und Value Objects
Fehler Nr. 1: Alte primitive Felder bleiben neben dem Embeddable erhalten.
Oft wird das Refactoring „vorsichtig“ durchgeführt: Man hat Money price hinzugefügt, aber priceAmount und priceCurrency nicht entfernt, weil „es vielleicht noch nützlich ist“. Am Ende hat das Modell zwei Wahrheitsquellen, die früher oder später auseinanderdriften. Wenn Sie entschieden haben, dass der Preis Money ist, dann sollte der Preis an einer Stelle leben, und die alten Felder sollten aus der Entität verschwinden.
Fehler Nr. 2: Money und Address werden offen mutable gemacht (öffentliche Setter).
Dann kehrt leise das alte Problem zurück: Die Änderungsstelle verteilt sich über Mapper und Hilfsklassen, und Dirty Checking sendet treu UPDATE. Für dieses Refactoring gilt eine einfache Regel: Value Objects werden als Ganzes geändert, nicht über interne Setter.
Fehler Nr. 3: Normale Spaltennamen werden vergessen, und in der Tabelle erscheinen rätselhafte amount und currency.
Technisch wird Hibernate so funktionieren, aber das Lesen von SQL und Schema wird schwerer. Nach ein paar Wochen öffnen Sie product und sehen amount, und Ihr Gehirn stellt die berechtigte Frage: „amount von was genau?“. Präfixe wie price_..., delivery_address_..., address_... sind eine kleine Sache, die die Wartbarkeit enorm verbessert.
Fehler Nr. 4: Versuchen, eine Aufgabe mit dem Converter zu lösen, die eigentlich Multi-Column ist.
Manchmal kommt die Versuchung auf, Money „in einen String zu packen“ und als "99.99|USD" zu speichern. Das sieht nach Spalteneinsparung aus, erschwert aber in der Praxis Abfragen, Indizierung, Sortierungen und die gesamte Arbeit mit den Daten. Converter sind ein gutes Werkzeug für One-Column-Werte, aber Money und Address bleiben Multi-Column-Geschichten.
Fehler Nr. 5: Value Objects verwenden, aber weiterhin eine API mit Primitiven schreiben und die Hälfte des Nutzens verlieren.
Wenn Sie in der Entität Money price haben, aber in den Services immer noch reprice(amount, currency), haben Sie das Mapping verbessert, aber nicht die Lesbarkeit des Codes und den Schutz vor Fehlern. Ein gutes Zeichen für ein abgeschlossenes Refactoring ist, wenn Geschäftsmethoden mit Money und Address operieren und Primitive nur an der Außengrenze bleiben (falls es eine im aktuellen Lernprojekt gibt).
GO TO FULL VERSION