Jede Entität braucht eine Identität, aber nicht jeder eindeutige Wert taugt als Schlüssel. Anhand einer kleinen Anwendung (Customer und TicketPurchase) trennt diese Seite die technische ID (bedeutungsleer, unveränderlich, ein surrogate key) von der fachlichen ID (lesbar und fachlich nützlich, aber änderbar und wiederverwendbar). Aus dem Unterschied folgen zwei Regeln: die fachliche ID gehört nicht als Primärschlüssel in die Datenbank, und sie darf nur dann in einem Contract stehen, wenn sie über die gesamte Lebensdauer eindeutig und stabil ist.
Jede Entität in einem Domänenmodell braucht eine Identität: etwas, woran man “dasselbe Objekt” über die Zeit wiedererkennt. Dafür gibt es zwei grundverschiedene Arten von Schlüsseln, die man sauber auseinanderhalten muss.
UUID. Sie trägt keine fachliche Information, ändert sich nie
und wird nie wiederverwendet.Eine schon vorhandene fachliche ID gleich als Identität zu verwenden, ist ein verständlicher Reflex. Sie ist da, sie ist lesbar, und sie fühlt sich eindeutig an. Aber genau dieser Reflex führt zu zwei klassischen Fehlern. Dieses Beispiel zeigt beide an einer kleinen Anwendung für ein Loyalty-Programm mit (Bahn-)Ticketverkauf.
In dieser Anwendung gibt es zwei Aggregates:
classDiagram
direction LR
class Customer {
CustomerId id
String email
String name
}
class TicketPurchase {
TicketPurchaseId id
CustomerId buyerId
String transactionCode
int pickupNumber
LocalDate purchaseDate
boolean collected
}
TicketPurchase "*" --> "1" Customer : Käufer
style Customer fill:#fff3cd,stroke:#ff9800,stroke-width:2px
style TicketPurchase fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
Customer hat als fachliche ID seine email, und außerdem id als technische ID. TicketPurchase hat
gleich drei Kandidaten für eine ID, an denen sich der Unterschied gut zeigen lässt:
TicketPurchaseId (die getypte Variante einer UUID),transactionCode wie z.B. TXN-2026-000123. Unter diesem Kürzel ist der Ticketverkauf in
den IT-Systemen des Bahn-Anbieters eindeutig auffindbar und erscheint auch so auf der Rechnung. Häufig
sind solche Kürzel durch die Umsysteme und/oder die Prozesse des Unternehmens festgelegt. Diese fachliche
ID scheint stabil und für immer eindeutig … bis das Unternehmen einen Merger mit einem anderen
Unternehmen vollzieht und sich dadurch das Buchhaltungs-System ändert, und damit auch das Format der
Transaktions-Codes.pickupNumber wie 1251. Das ist eine fachliche ID, die kurz und lesbar, aber wiederverwendbar
ist: Sie gilt nur am Tag des Kaufs (dem purchaseDate). Jeden Tag beginnt die Zählung von vorn,
dieselbe Nummer gehört morgen einem anderen Kauf.Im Source Code lässt sich beides anhand von zwei Packages “vorher” und “nachher” nachvollziehen.
| Variante | Zustand | Quelle |
|---|---|---|
| Vorher | fachliche ID als Schlüssel benutzt, unpassende fachliche ID als Parameter in den “Contracts” der Service-Methoden | before_business_id_as_key/ |
| Nachher | technische ID als Schlüssel und als Parameter in den meisten Methoden-Contracts | after_technical_id_as_key/ |
Die Erklärung folgt in drei Schritten:
Eine technische ID hat genau eine Aufgabe: ein Objekt für immer eindeutig zu identifizieren. Sie ist deshalb bewusst bedeutungsleer. Weil sie nichts Fachliches aussagt, gibt es auch nie einen fachlichen Grund, sie zu ändern. Sie wird beim Anlegen einmal vergeben und bleibt dann konstant, das ganze Leben des Objekts lang.
Eine fachliche ID dagegen lebt im Fachbereich. Sie ist dazu da, von Menschen gelesen, genannt und weitergegeben zu werden. Und damit unterliegt sie den Regeln des Fachbereichs, nicht den Regeln der Datenhaltung.
1251 gehört heute diesem Kauf und morgen
einem anderen.Eine technische ID hat keine dieser Eigenschaften. Genau deshalb ist sie als Identität geeignet und die fachliche ID nicht. Die fachliche ID ist trotzdem wertvoll und gehört ins Modell - sie ist nur ein Attribut, kein Schlüssel. Daraus folgen die zwei Regeln dieses Beispiels.
Es ist verlockend, die E-Mail-Adresse direkt zum Primärschlüssel des Customer zu machen. Sie ist
ja eindeutig, jeder Kunde hat genau eine. Im “Vorher”-Code sieht das so aus:
@Entity
public class Customer {
@Id
private String email; // die fachliche ID ist hier der Primärschlüssel
private String name;
}
Das Problem zeigt sich in dem Moment, in dem ein Kunde seine E-Mail-Adresse ändern möchte - ein ganz normaler fachlicher Vorgang. Ein Primärschlüssel ist aber per Definition unveränderlich. Man kann ihn nicht einfach überschreiben.
TicketPurchase die E-Mail des Käufers. Ändert
sich die E-Mail, müsste man sie in allen referenzierenden Käufen ebenfalls ändern.Die Lösung im “Nachher”-Code: Der Kunde bekommt eine technische ID als Primärschlüssel. Die E-Mail bleibt erhalten, aber als gewöhnliches Attribut, das man mit einem Unique-Constraint absichert.
@Entity
public class Customer {
@EmbeddedId
private CustomerId id; // technische ID = Primärschlüssel, unveränderlich
private String email; // fachliche ID = Attribut, darf sich ändern
private String name;
public void changeEmail( String newEmail ) {
this.email = newEmail; // ein Feld-Update, sonst nichts
}
}
classDiagram
direction LR
class Customer_vorher["Customer (vorher)"] {
String email «@Id»
String name
}
class Customer_nachher["Customer (nachher)"] {
CustomerId id «@Id»
String email «unique»
String name
}
style Customer_vorher fill:#f8d7da,stroke:#c62828,stroke-width:2px
style Customer_nachher fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
Jetzt ist eine E-Mail-Änderung genau das, was sie fachlich ist: ein Update eines Attributs. Alle
TicketPurchase verweisen über die CustomerId auf den Kunden, und die ändert sich nie. Die
Referenzen bleiben gültig, ganz ohne Nacharbeit.
Derselbe Reflex trifft im “Vorher”-Code auch den TicketPurchase. Dort dient der transactionCode
als Primärschlüssel - eine fachliche ID, die noch dazu besonders vertrauenswürdig aussieht: stabil,
für immer eindeutig, von den Umsystemen vergeben. Genau diese Sicherheit ist trügerisch. Vollzieht
das Unternehmen einen Merger und stellt auf ein anderes Buchhaltungs-System um, ändert sich das
Format der Codes - und ein Primärschlüssel lässt sich nicht im laufenden Betrieb umschreiben.
@Entity
public class TicketPurchase {
@Id
private String transactionCode; // auch hier: eine fachliche ID als Primärschlüssel
private String buyerEmail;
// ...
}
In diesem kleinen Beispiel verweist zwar nichts auf den TicketPurchase, der Schaden bleibt also
latent - anders als bei der E-Mail, deren Bruch der Test sichtbar macht. Aber das Muster ist
dasselbe: Sobald der Fachbereich einen Grund hätte, den Wert zu ändern, ist er als Schlüssel
ungeeignet, ganz gleich wie stabil er heute wirkt.
Faustregel: Der Primärschlüssel gehört dem System, nicht dem Fachbereich. Sobald ein Schlüssel einen fachlichen Grund hätte, sich zu ändern, ist er als Primärschlüssel ungeeignet.
Die erste Regel betraf die Datenhaltung. Die zweite betrifft die Contracts im Code, also die Methodensignaturen, über die ein Aggregat angesprochen wird (insbesondere im Application Service).
Am Schalter ruft das Personal “Ticket 1251 ist abholbereit”. Es liegt also nahe, den
TicketPurchaseService genau darüber anzusprechen - über die Abholnummer:
// "Vorher": der Contract identifiziert einen Kauf über die fachliche ID pickupNumber
public int purchase( String customerEmail ); // gibt dem Aufrufer nur die Abholnummer zurück
public boolean isReadyForPickup( int pickupNumber );
public void markCollected( int pickupNumber );
Solange jede Nummer immer nur zu einem Kauf gehört, geht das gut. Die pickupNumber ist aber eine
Tagesnummer: Sie gilt nur am Tag des Kaufs, am nächsten Morgen beginnt die Zählung wieder von
vorn. Über die Zeit gesehen gehört 1251 damit zu vielen Ticketkäufen, an jedem Tag möglicherweise
zu einem anderen. Problematisch wird das, sobald ein Kauf von gestern nie abgeholt wurde und noch
offen in der Datenbank liegt.
flowchart LR
p1["Kauf A (gestern)\npickupNumber = 1251\nnie abgeholt"]
midnight(("Tageswechsel")):::pool
p2["Kauf B (heute)\npickupNumber = 1251"]
p1 -- "Zählung beginnt neu" --> midnight
midnight -- "1251 erneut vergeben" --> p2
classDef pool fill:#fff3cd,stroke:#ff9800,stroke-width:2px
style p1 fill:#f8d7da,stroke:#c62828,stroke-width:2px
style p2 fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
markCollected( 1251 ) ist damit mehrdeutig: Welcher der beiden Käufe ist gemeint, der von
gestern oder der von heute? Der Contract zwingt den Service, das zu raten - und genau dieses Raten
ist die Fehlerquelle. Eine fachliche ID darf nur dann in einem Contract stehen, wenn sie über die
gesamte Lebensdauer des Objekts eindeutig und stabil ist. Die pickupNumber erfüllt das nicht.
Im “Nachher”-Code identifiziert der Contract den Kauf über seine technische ID. Sie ist eindeutig und stabil, also kann hier nichts mehr mehrdeutig werden:
// "Nachher": der Contract identifiziert einen Kauf über die technische ID
public TicketPurchaseId purchase( CustomerId buyerId );
public boolean isReadyForPickup( TicketPurchaseId id );
public void markCollected( TicketPurchaseId id );
// die fachliche Nummer bleibt als abgeleiteter Anzeigewert verfügbar
public int pickupNumberOf( TicketPurchaseId id );
// fachliche IDs dürfen Einstiegspunkte sein - aufgelöst wird immer zur technischen ID
public TicketPurchaseId todaysPurchaseWithNumber( int pickupNumber );
public TicketPurchaseId purchaseWithTransactionCode( String transactionCode );
Die pickupNumber verschwindet dadurch nicht, im Gegenteil. Sie behält ihre fachliche Rolle als
kurze, lesbare Nummer für den Abholschalter - sie ist nur nicht mehr der Schlüssel, über den der Kauf im
Contract benannt wird. Das Auflösen “welcher Kauf trägt heute die 1251?” bleibt möglich, aber es ist
auf das Zeitfenster beschränkt, in dem die Nummer eindeutig ist: den Kauftag. Der
transactionCode darf sogar uneingeschränkt als Suchschlüssel dienen, er ist ja (voraussichtlich) stabil
und für die absehbare nächste Zeit eindeutig. Beide Finder-Methoden beantworten die Anfrage aber mit derselben
Sache: der technischen ID, mit der ab dort alle weitere Arbeit passiert.
Faustregel: Eine fachliche ID darf nur dann der Schlüssel in einem Contract sein, wenn sie über die gesamte Lebensdauer des Objekts eindeutig und stabil ist. Im Zweifel identifiziert man ein Geschäftobjekt nur über die technische ID und nutzt die fachliche ID nur als “Anzeigewert”.
Technische und fachliche ID haben verschiedene Aufgaben, und beide gehören ins Modell.
Eine fachliche ID wie der transactionCode in diesem Beispiel kann stabil und auf absehbare
Zeit eindeutig sein, taugt also problemlos als Such- und Anzeigewert. Aber selbst dann bleibt die
Identität beim System: Was der Fachbereich vergibt, kann der Fachbereich auch ändern, und genau das
darf eine Identität nie.