Entitäten und deren Identifier
Gedanken
Identifier helfen dabei, manche Entitäten – eindeutig – zu benennen. Diesmal starte ich unten im Code-Model und mit einem technischen Detail: Wie gestalte ich die Tabellen am besten?
CREATE TABLE schema.table
(
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY
meistens findet man sowas vor. Die erste Frage, welche sich stellt: Sollte man diese id nach außen hin zur Verfügung stellen? E.g. in einer API wie /persons/{id}? Meine Meinung dazu: nein. Einige Gründe:
- Refactoring von einer Tabelle zu Table inheritance: Hier haben die Tabellen eigene Id Spalten.
- Wechseln von einem einfachen Primary-Key zu einem Composite-Primary-Key (PK über mehrere Spalten) - braucht man oft bei Constraints
- Normalization -/Denormalization (z.B. Order und OrderItem Table wird zu CustomerOrders - man müsste hier den PK der Order als Feld von CustomerOrders machen, welches aber alleine inkrementiert)
- Importieren von Daten aus u.a. mehreren Quellen, die bereits einen Identifier haben oder Database-Merge
Aber es gibt auch technische Gründe, wie folgende Artikel zeigen:
- https://www.cybertec-postgresql.com/en/uuid-serial-or-identity-columns-for-postgresql-auto-generated-primary-keys/
- https://www.cybertec-postgresql.com/en/unexpected-downsides-of-uuid-keys-in-postgresql/
Daher die Empfehlung:
CREATE TABLE schema.table
(
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
external_id uuid NOT NULL UNIQUE
Ganz allgemein spricht man von Surrogatschlüssel - ein Datenbankschlüssel, der nicht aus den Daten in der Tabelle abgeleitet wird. Neben dem Schlüssel kann natürlich auch ein BusinessKey existieren.
Schauen wir uns mal ein Beispiel in SQL Server an:
DROP TABLE IF EXISTS [dbo].[Test];
CREATE TABLE [dbo].[Test] (
[Id] [int] IDENTITY(1, 1) PRIMARY KEY CLUSTERED,
[Year] [int] NOT NULL,
[BusinessKeyId] [int]
)
GO
INSERT INTO [dbo].[Test]([Year], [BusinessKeyId]) VALUES
(2024, 1),
(2024, 2),
(2024, 3),
(2024, NULL),
(2024, NULL),
(2025, 1),
(2025, 2),
(2025, NULL);
-- Make a hole (gap) in the id column
DELETE FROM [dbo].[Test] WHERE [Id] = 2
GO
SELECT *, CONCAT('MYKEY-', [Year], '-', COALESCE(
[BusinessKeyId],
COUNT(CASE WHEN [BusinessKeyId] IS NULL THEN 1 END) OVER (PARTITION BY year ORDER BY [BusinessKeyId], [Id]) +
COALESCE(MAX([BusinessKeyId]) OVER (PARTITION BY YEAR), 0)
)) AS [BusinessKey]
FROM t
So kann man pro Jahr einen fortlaufenden Schlüssel mit Prefix generieren. Wichtig ist, dass das ganze in einer SERIALIZABLE
Transaktion läuft. Das Ergebnis: MYKEY-2024-12
- ein guter BusinessKey.
UUID – aber welche?
Einer der kompliziertesten OpenAPI Specs die ich je gesehen haben, sind jene aus dem TM-Forum. Arbeitet man mit diesen, kommt man oft in folgende Situation:
Entwickler A: Kannst du dir bitte mal Id e3c5c1df-f871-4c24-b7f0-6f882f5f0a01 anschauen?
Entwickler B: Was ist das für eine Id?
Entwickler A: Ich glaube es ist irgendein Preis – aber genau weiß ich es auch nicht
Wie könnte man hier entgegenwirken? Eine Möglichkeit ist natürlich Uniform Resource Name (URN).
urn:ISBN:3-8273-7019-1
lässt niemanden mehr rätseln, was 3-8273-7019-1
ist.
Andere Beispiele
Azure
Eine Id einer Ressource schaut wie folgt aus:
/subscriptions/dd26faa9-ad5a-45fd-aa12-685cb5285f0f/resourceGroups/rg-website-prod-001/providers/Microsoft.Web/staticSites/vodepat
GCP
Schema-less Version von https://datatracker.ietf.org/doc/html/rfc3986
//library.googleapis.com/shelves/shelf1/books/book2
Ein URI-Pfad (path-noscheme) ohne das führende “/”: https://datatracker.ietf.org/doc/html/rfc3986#appendix-A
shelves/shelf1/books/book2
AWS
Hier wurde ein eigener Format definiert: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html
arn:partition:service:region:account-id:resource-type/resource-id
Examples:
arn:aws:iam::123456789012:user/johndoe
arn:aws:ec2:us-east-1:123456789012:vpc/vpc-0e9801d129EXAMPLE
Oracle
https://docs.oracle.com/en-us/iaas/Content/General/Concepts/identifiers.htm
ocid1.<RESOURCE TYPE>.<REALM>.[REGION][.FUTURE USE].<UNIQUE ID>
ocid1: The literal string indicating the version of the OCID.
IBM
https://cloud.ibm.com/docs/account?topic=account-crn
Cloud Resource Name (CRN):
crn:version:cname:ctype:service-name:location:scope:service-instance:resource-type:resource
Identifier korrekt im Code
Auch im Code gibt es oft die Aufgabe
Entwickler A: Such mir bitte alle Stellen raus, wo wir mit der Product Id hantieren – wir müssen den Typ ändern und einige Well-Known-Ids unterbinden
Entwickler B: Das kann dauern … die Variable “productId” ist oft nicht immer das Gleiche … Manchmal heißt es auch nur Id und manchmal wurde die UUID zu einem String gemapped …
Auf den Variablen Namen “productId” kann man sich kaum verlassen. Daher verwende ich immer gerne ValueObjects. Gott sei Dank kennt C# inzwischen Records, da diese automatisch immutable sind.
ProductId mit dem passenden Namespace findet man daher sehr leicht.
using System;
using System.Text.RegularExpressions;
record ProductId
{
public string URN { get; }
private static readonly string URNPattern = @"^urn:ISBN:\d{1,5}-\d{1,7}-\d{1,7}-\d{1}$";
public ProductId(string urn)
{
if (!IsValidURN(urn))
{
throw new ArgumentException("Invalid URN: ISBN format is not valid.", nameof(urn));
}
URN = urn;
}
private static bool IsValidURN(string urn)
{
return Regex.IsMatch(urn, URNPattern);
}
}
class Program
{
static void Main()
{
try
{
ProductId book = new ProductId("urn:ISBN:12345-6789101-2345678-9");
Console.WriteLine($"URN: {book.URN}");
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
}
}
URN in der Datenbank
Auch in der Datenbank kann man mit URN hantieren. Immer im Hinterkopf muss man natürlich Indezeses behalten. Dies können natürlich computed sein - wie folgendes Beispiel zeigt:
CREATE INDEX test1_lower_col1_idx ON test1 (lower(col1));
Aber das mit und ohne URN Supported geht, funktioniert nur mit Operatoren. Und selbst da muss man aufpassen, dass er den Operator nimmt - sonst kommt es zu einem Textvergleich und urn:ISBN:3-8273-7019-1
ist natürlich nicht 3-8273-7019-1
.
DROP SCHEMA IF EXISTS playground CASCADE;
CREATE SCHEMA IF NOT EXISTS playground;
CREATE DOMAIN playground.isbn AS text CONSTRAINT valid_isbn CHECK (VALUE ~ '^\d{1,5}-\d{1,7}-\d{1,7}-\d{1}$');
CREATE DOMAIN playground.urn_isbn AS text CONSTRAINT valid_urn_isbn CHECK (VALUE ~ '^urn:ISBN:\d{1,5}-\d{1,7}-\d{1,7}-\d{1}$');
CREATE OR REPLACE FUNCTION playground.extract_isbn(isbn, urn_isbn)
RETURNS BOOL
AS $$
BEGIN
RETURN $1 = substring($2 from length('urn:ISBN:') + 1);
END;
$$
LANGUAGE plpgsql STABLE;
CREATE OPERATOR playground.= (
LEFTARG = isbn,
RIGHTARG = urn_isbn,
PROCEDURE = playground.extract_isbn
);
CREATE TABLE playground.products
(
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
external_id isbn NOT NULL UNIQUE
);
INSERT INTO playground.products (external_id) VALUES('12345-6789101-2345678-9');
SELECT * FROM playground.products WHERE external_id = 'urn:ISBN:12345-6789101-2345678-9'::urn_isbn
Domain-Driven-Design und Identifier
Auch in dem DDD wird dem Identifier gorße Bedeutung zugeschrieben. Folgende Seiten zeigen interessante Ideen:
- https://ddd-practitioners.com/home/glossary/entity-identity/
- Hier wird nochmal mit Nachdruck drauf hingewiesen, dass der Identifier eindeutig und stabil sehen sollte. Die menschliche Lesbarkeit kann man mit URN nochmal erhöhen und trptzdem die Vorteile einer UUID nutzen.
- In DDD ist der Identifier auch ausschlaggebend für ein Entity - im Kontrast zu einem ValueObject
- DDD empfiehlt auch “natural identifiers”. Beispiel ist für mich WhatsApp mit einer Telefonnummer oder Microsoft Online Dienste mit einer Email:
In some cases, the uniqueness of the ID must apply beyond the computer system’s boundaries. For example, if medical records are being exchanged between two hospitals that have separate computer systems, ideally each system will use the same patient ID, but this is difficult if they generate their own symbol. Such systems often use an identifier issued by some other institution, typically a government agency. In the United States, the Social Security number is often used by hospitals as an identifier for a person. Such methods are not foolproof. Not everyone has a Social Security number (children and nonresidents of the United States, especially), and many people object to its use, for privacy reasons.
In less formal situations (say, video rental), telephone numbers are used as identifiers. But a telephone can be shared. The number can change. An old number can even be reassigned to a different person.
For these reasons, specially assigned identifiers are often used (such as frequent flier numbers), and other attributes, such as phone numbers and Social Security numbers, are used to match and verify. In any case, when the application requires an external ID, the users of the system become responsible for supplying IDs that are unique, and the system must give them adequate tools to handle exceptions that arise.
Quelle: Evans, E. (2004). Domain-driven design: tackling complexity in the heart of software. Addison-Wesley Professional.
Slug
Im Web gibt es ähnliche Konzepte.
Some systems define a slug as the part of a URL that identifies a page in human-readable keywords
Ein Beispiel wäre:
https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array
Fazit
Identifier sind extrem wichtig und sollten einen festen Bestandteil im Design haben.