Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

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.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/spring-good-practices/-/tree/main/aggregate-references

Zwei Möglichkeiten für Aggregat-Referenzen

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.

DomainModel.png

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.

Vorher — Objektreferenzen (problematisch)

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.

Nachher — Typisierte ID-Referenzen (sauber)

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.


Warum typisierte IDs?

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.

Abstrakte Oberklassen

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.

Wie man sie in jedes Aggregat einbindet

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.
  • Der public-Konstruktor 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() );
        // ...
    }
}