Aggregate müssen als „Transaktionsgrenzen” fungieren, um voneinander entkoppelt zu bleiben. Eine wesentliche Regel lautet, andere Aggregate nur per ID-Referenz zu referenzieren, nicht per Objektreferenz. Das verhindert die Versuchung, andere Aggregate in derselben Transaktion zu verändern, und hält Datenbankabfragen schlank.
Aggregate sollten Transaktionsgrenzen sein. Eine der vier Aggregat-Design-Regeln von Vaughn Vernon lautet: Andere Aggregate dürfen nur per ID referenziert werden, nicht per Objektreferenz. Das verhindert die Versuchung, mehrere Aggregate in einer einzigen Transaktion zu modifizieren, und es hält Queries klein.

Dieses Modul zeigt dieselbe Customer-/Order-/Product-Domäne in zwei Implementierungen nebeneinander, sodass der Unterschied sichtbar wird.
| Variante | Stil | Quelle | Demonstrationstest |
|---|---|---|---|
| Vorher | Objektreferenzen (@ManyToOne, @OneToMany) |
ref_by_object/ |
RefByObjectNavigationTest |
| Nachher | Typisierte ID-Referenzen (CustomerId, ProductId) |
ref_by_id/ |
RefByIdNavigationTest |
Die beiden Tests teilen sich die identische given / when / then-Struktur und identische Assertions. Lediglich das aggregatübergreifende Setup und die Navigation unterscheiden sich – und um genau diesen Unterschied geht es hier.
Das Order-Aggregat hält die zugehörigen Aggregate als Objektreferenzen - der
“JPA-traditionelle” Weg
(→ Order.java):
@ManyToOne
private Customer customer;
@OneToMany
@JoinColumn(name = "order_id")
private List<Product> products;
Die Navigation zwischen Aggregaten erfolgt dann durch einen direkten Methodenaufruf:
order.getCustomer().getName();
order.getProducts();
Das ist bequem. Es macht es aber auch zu einfach, Code zu schreiben, der Aggregat-Grenzen in
einer einzigen Transaktion überschreitet. OrderService.createOrderFor(...) auf dieser
Seite tut genau das: einen Customer per find-or-create anlegen, ein Product per
find-or-create anlegen und dann eine neue Order speichern — alles innerhalb einer einzigen
@Transactional-Methode
(→ OrderService.java):
@Transactional
public Order createOrderFor( String customerName, String... productNames ) {
Customer customer = customerRepository.findByName( customerName )
.orElseGet( () -> customerRepository.save( new Customer( customerName ) ) );
Order order = new Order( customer, "Order for " + customerName );
for ( String productName : productNames ) {
Product product = productRepository.findByName( productName )
.orElseGet( () -> productRepository.save( new Product( productName ) ) );
order.addProduct( product );
}
return orderRepository.save( order );
}
Bis zu drei Aggregate (Customer, Product, Order) werden in einer Transaktion erzeugt. Vernons Regel wird damit strukturell verletzt.
Hinzu kommt: die resultierende Query für die Order kann ziemlich groß werden. Das kann
problematisch werden, wenn die referenzierte Entität ihrerseits eine Reihe weiterer
Entitäten referenziert, insbesondere in einer @...ToMany-Beziehung. Es entstehen so
verschachtelte Queries, die am Ende bis zu Hunderte von Objekten selektieren.
Jedes aggregatübergreifende Feld am Order-Aggregat ist eine typisierte ID, niemals
eine Objektreferenz
(→ Order.java):
private CustomerId customerId; // ersetzt @ManyToOne Customer
@ElementCollection
private List<ProductId> productIds; // ersetzt @OneToMany List<Product>
Man kann jetzt keinen Customer mehr an die Order-Erzeugungsmethode übergeben, nur
noch seine ID. Die Erzeugung von Customer und Product lebt in jeweils eigenen
Application Services
(→ CustomerService.java und
→ ProductService.java),
jeder mit seiner eigenen Transaktion. Der Order-Service tut nur noch eine einzige Sache
(→ OrderService.java):
public Order createOrderFor( CustomerId customerId, ProductId... productIds ) {
Order order = new Order( customerId, "Order" );
for ( ProductId productId : productIds ) {
order.addProduct( productId );
}
return orderRepository.save( order );
}
Auch die Navigation wird explizit: jeder aggregatübergreifende Schritt wird zu einem Service-Aufruf (→ RefByIdNavigationTest.java):
Customer orderCustomer = customerService.findById( order.getCustomerId() );
List<Product> orderProducts = order.getProductIds().stream()
.map( productService::findById )
.toList();
Das ist mehr Code als oben, aber genau das ist der Punkt. Die Aggregat-Grenze wird sichtbar, und es ist jetzt strukturell unnatürlich, mehrere Aggregate in einer einzigen Transaktion zu modifizieren.
Die ref_by_object-Seite verwendet eine schlichte UUID als Identifier der Entitäten.
Die ref_by_id-Seite führt getypte IDs ein (CustomerId, OrderId, ProductId),
die jeweils einen typisierten Wrapper um UUID darstellen.
Warum der Aufwand? Mit generischen UUID-IDs (oder einem anderen passenden Typ) ist eine
versehentliche Vertauschung in Klassenattributen oder Methodenparametern leicht
möglich. Der Compiler kann nicht zwischen UUID productId und UUID customerId
unterscheiden. Sie sind austauschbar, und der falsche Wert kann unbemerkt
durchgereicht werden. (Man denke nur an eine Methode wie
createOrderFor( UUID customerId, UUID... productIds ) – es ist leicht, die
Parameter versehentlich zu vertauschen, und der Compiler beschwert sich nicht.)
Typisierte IDs (CustomerId und ProductId) machen aus diesem Fehler einen
Kompilierfehler.
Es ist sinnvoll, eine abstrakte ID-Klasse bereitzustellen (→ GenericId.java):
@MappedSuperclass
@Getter
@EqualsAndHashCode( onlyExplicitlyIncluded = true )
public abstract class GenericId {
@EqualsAndHashCode.Include
@Column( nullable = false, updatable = false )
private final UUID id;
protected GenericId() {
this( UUID.randomUUID() );
}
protected GenericId( UUID id ) {
this.id = id;
}
}
Die drei Annotationen verdienen eine Erklärung:
@EqualsAndHashCode( onlyExplicitlyIncluded = true ) kehrt Lomboks Default von
“alle Felder einbeziehen” um zu “nichts einbeziehen, sofern nicht explizit markiert”.
Ohne diese Einstellung würde jedes Feld, das künftig in einer Subklasse hinzukommt, die
Equality-Semantik unbemerkt brechen. Zwei IDs, die dieselbe UUID umschließen, würden
plötzlich als ungleich gelten.@EqualsAndHashCode.Include ist der Opt-in-Marker. Nur das Feld id nimmt an
equals() und hashCode() teil. Das ist die korrekte Semantik für ein Value Object:
die Identität wird ausschließlich durch die UUID bestimmt, sonst nichts.@Column( nullable = false, updatable = false ):
nullable = false fügt dem generierten DDL (dem SQL-Statement, das die
zugrundeliegende DB-Tabelle anlegt) eine NOT NULL-Constraint hinzu. Das ist ein
Sicherheitsnetz für alles, was die Java-Schicht umgeht (rohes SQL,
Migrationsskripte, andere Anwendungen auf demselben Schema).updatable = false ist die wichtigere Absicherung: sie weist JPA an, diese Spalte
niemals in einem UPDATE-Statement aufzunehmen. Da eine ID per Definition
unveränderlich ist, verhindert das, dass eine unachtsame Zuweisung oder ein
Framework-Bug einen Primärschlüssel in der Datenbank stillschweigend überschreibt.Für jede Aggregat-Wurzel leitet man eine dedizierte ID-Klasse von GenericId ab
(→ CustomerId.java):
@Embeddable
@NoArgsConstructor( access = AccessLevel.PROTECTED )
@AttributeOverride( name = "id", column = @Column(name = "customer_id") )
public class CustomerId extends GenericId {
public CustomerId( UUID id ) {
super( id );
}
}
Was die einzelnen Annotationen beitragen:
@Embeddable markiert die Klasse als JPA-Embeddable, sodass sie über @EmbeddedId in
der Entität als Primärschlüssel dienen kann.@NoArgsConstructor(access = PROTECTED) generiert den protected No-Args-Konstruktor,
den JPA benötigt, um die ID-Klasse beim Laden einer Entität zu instanziieren. Er ist
protected statt public, damit Anwendungscode aus anderen Paketen nicht versehentlich
new CustomerId() aufrufen kann — neue IDs werden immer explizit mit
new CustomerId( UUID.randomUUID() ) erzeugt.@AttributeOverride( name = "id", column = @Column(name = "customer_id") ) benennt die
geerbte id-Spalte in customer_id um. Ohne dies würde jedes Aggregat versuchen, in
eine Spalte zu schreiben, die schlicht id heißt, was zu Konflikten führen würde.CustomerId( UUID id ) delegiert an super( id ) und wird immer
dann verwendet, wenn man eine ID aus einer bereits existierenden UUID rekonstruieren
muss — z.B. beim Lesen aus einem DTO, einem Event-Payload oder einem Query-Parameter.Schließlich verwendet man die ID in der Entität. Sie müssen die ID in jedem Konstruktor initialisieren (→ Customer.java):
@Entity
public class Customer {
@EmbeddedId
private CustomerId id;
public Customer( String name ) {
this.id = new CustomerId( UUID.randomUUID() );
// ...
}
}