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.

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.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/architecture-good-practices/-/tree/main/business-and-technical-id
Keywords
Identität, fachliche ID, technische ID, surrogate key, natural key, Primärschlüssel, UUID, Aggregate, Entity, DDD

Fachliche und technische ID

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.

  • Eine technische ID (in der Literatur surrogate key) ist ein vom System vergebener, bedeutungsloser Wert, meist eine UUID. Sie trägt keine fachliche Information, ändert sich nie und wird nie wiederverwendet.
  • Eine fachliche ID (in der Literatur natural key) ist ein Wert mit fachlicher Bedeutung, den ein Mensch kennt und benutzt: die E-Mail-Adresse eines Kunden, eine Rechnungsnummer, oder die Nummer “1251” auf dem Abholzettel an der Theke.

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:

  • Die technische ID TicketPurchaseId (die getypte Variante einer UUID),
  • Einen 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.
  • Eine 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:

  1. Was unterscheidet die beiden IDs?
  2. Regel 1: Die fachliche ID gehört nicht als Primärschlüssel in die Datenbank
  3. Regel 2: Eine fachliche ID in einem Contract muss eindeutig und stabil sein

1. Was unterscheidet die beiden IDs?

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.

  • Sie wird sich garantiert (in einigen Fällen) ändern. Ein Kunde wechselt seine E-Mail-Adresse.
  • Sie kann sich auch dann ändern, wenn man es nicht erwartet. Das neue Buchhaltungs-System mit anderem Rechnungs-Code-Format.
  • Sie kann wiederverwendet werden. Die Abholnummer 1251 gehört heute diesem Kauf und morgen einem anderen.
  • Sie kann mehrdeutig werden. Über die Zeit gesehen tragen mehrere Käufe nacheinander dieselbe Abholnummer.

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.

2. Regel 1: Die fachliche ID gehört nicht als Primärschlüssel in die Datenbank

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.

  • Jede andere Tabelle, die auf den Kunden verweist, tut das über seine E-Mail-Adresse als Fremdschlüssel. In unserem Beispiel trägt jeder TicketPurchase die E-Mail des Käufers. Ändert sich die E-Mail, müsste man sie in allen referenzierenden Käufen ebenfalls ändern.
  • Eine E-Mail zu ändern wird damit von einem Ein-Feld-Update zu einer Wanderung durch die halbe Datenbank - mit dem Risiko, eine Referenz zu vergessen und die Daten inkonsistent zu hinterlassen.

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.

3. Regel 2: Eine fachliche ID in einem Contract muss eindeutig und stabil sein

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”.

Zusammenfassung

Technische und fachliche ID haben verschiedene Aufgaben, und beide gehören ins Modell.

  • Die technische ID identifiziert. Sie ist bedeutungsleer, unveränderlich, nie wiederverwendet - und damit der Primärschlüssel und der Schlüssel in Contracts.
  • Die fachliche ID beschreibt. Sie ist lesbar und fachlich nützlich, aber sie darf sich ändern, wiederverwendet und mehrdeutig werden - und ist deshalb ein Attribut, kein Schlüssel.

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.