Domain-Driven-Design

Bei Domain-Driven-Design gibt es zwei Flughöhen:

  • Strategic: Hier befasst man sich mit dem „schneiden“ der Domänen
  • Tactical: Hier geht es bis in die Implementierung

Ich möchte meine Gedanken zu Tactical zu Blatt bringen.

Analyse von Aggregaten

Aggregate umschließen jene Entitäten, welche eine atomare Einheit bilden sollen. Das hört sich auf den ersten Blick nutzlos an – aber in fast allen Applikationen muss man von parallelem arbeiten an einem Datensatz durch mehrere Personen ausgehen. Und hier ist das Thema Konsistenz dann ein großes Thema.

Nehmen wir als Beispiel eine Entität „Angebot“, welche mehrere „Positionen“ hat. Hier würde das Aggregate vermutlich das „Angebot“ und die „Positionen“ umschließen, weil das das konsistent halten will.

Dem „Angebot“ kommt dabei eine ganz besondere Rolle zu: Die Aggregate-Root – also der Einstieg ins Aggregate. Es wird folgende essentiellen Eigenschaften haben:

  • Eine „Version“, welche in weiterer Folge in der Persistenz auf @Version in Hibernate und [Timestamp] in Entity Framework gemapped werden. Diese ermöglich die optimistic concurrency.
  • Es ist das Top-Level Objekt, welche alle Kinder beinhaltet

Keine POJOs / POCOs

Domain Objekte sollten keine „dummen“ Objekte sein. Stattdessen beinhalten sie die Domain-Logik. So sollte man e.g. die „Positionen“ nicht einfach in die Liste hinzufügen, sondern durch eine „Logik“ laufen lassen:

  • eine „addPosition“ Methode im „Angebot“
  • diese Methode prüft, ob das hinzufügen des Angebots erlaubt ist.

Die „Position“ selber muss sich natürlich auf Korrektheit prüfen (schauen, ob sie in sich gesehen konsistent ist).

Naming ist alles

Hier muss unbedingt ein Klassendiagramm vorhanden sein, damit jeder das Naming und die Kardinalitäten einsehen kann. Mit einem Objektdiagramm kann man dann Beispiele von unterschiedlichen „Angeboten“ zeigen.

DDD

Stammdaten

Stammdaten sind nicht Teil des Aggregates. Allerdings können natürlich Stammdaten Teil der Entscheidung sein. E.g. „es dürfen nur 2 Positionen mit dem Artikel mit der Eigenschaft ‚Sonderangebot‘ hinzugefügt werden“.

In diesen Fall muss man natürlich versuchen, dass man Stammdaten in einen „immutable“ Zustand bekommt – und das geht nur mit Versionierung (e.g. wird in der Position auf Artikel Id=123 Version=3 verwiesen – die Version 3 ist mit Sicherheit readonly).

Ändern sich die Stammdaten so gut wie nie oder spielt es keine Rolle, so kann man auf die Versionierung auch verzichten.

Identifier

Aggregate zeichnen sich durch Identifier aus. Hier empfiehlt es sich, menschenlesbare zu machen. Ein Beispiel für Rechnungen für „Franzis Motorradteile Webshop“ wäre „FMW-2024-45“. Die 45 Rechnung im Jahr 2024 – und die Id lässt sich auch gut dem Service zuordnen.

Ein kleiner Exkurs in die Softwarearchitektur: Id-Vergabe ist meiner Meinung nach Aufgabe der Datenbank. Ein Beispiel wäre hierfür wäre in SQL Server (in PostgreSQL muss man tatsächlich ein Table-Lock machen, weil Serializable anders implementiert ist, als in SQL Server – kein Predicate-Locking):

CREATE OR ALTER TRIGGER [Webshop].[TR_GenerateBusinessKey]
ON [Webshop].[Offers]
AFTER INSERT
AS
WITH records AS
(
    SELECT
        [Id],
        [YearId],
        COALESCE([YearId],
            COUNT(CASE WHEN [YearId] IS NULL THEN 1 END) OVER (PARTITION BY [Year] ORDER BY [Id]) +
            COALESCE(MAX([YearId]) OVER (PARTITION BY [Year]), 0)
        ) AS [GeneratedYearId]
    FROM [Webshop].[Offers]
    WITH(TABLOCKX, HOLDLOCK)
)
UPDATE records
SET records.[YearId] = records.[GeneratedYearId]
FROM records INNER JOIN inserted
ON records.[Id] = inserted.[Id]
GO

Es funktioniert auch mit niedrigeren Isolation-Leveln – aber da Bedarf es an Hirnschmalz, alle Edgecases auszuschließen. Folgender Wikipedia Artikel ist lesenswert: https://en.wikipedia.org/wiki/Surrogate_key (daher auch der name BusinessKey).

Value Objets

Value Objets haben selbst keinen Identifier, aber helfen domainspezifische Namesgebung und keine korrupten Daten im Code zu vermeiden. So könnte es e.g. eine Value-Object „OfferId“ geben, welche immutable ist und eine Selbstvalidierung mit ^FMW-\d{4}-\d+$. Beim ersten Refactoring wird man dankbar sein, wenn man sie hat und man nicht mit Strings herumhantieren muss. Speziell für Ids noch eine Anmerkung: ich finde in großen Systemen kann es auch Sinn machen, die Domains mitzugeben: urn:warehouse: FMW-2024-235

record AngeboteId
{
    public string Id  { get; }

    private static readonly string IdPattern = @"^FMW-\d{4}-\d+$";

    public AngebotetId(string id)
    {
        if (!IsValidId(id))
        {
            throw new ArgumentException("Invalid Id: id format is not valid.", nameof(id));
        }

        Id = id;
    }

    private static bool IsValidId(string id)
    {
        return Regex.IsMatch(id, IdPattern);
    }
}

Zeitstempel

In sehr vielen DBMS haben wir den Typ timestamp with time zone. Speichert dieser die Zeitzone? NEIN. Er berücksichtigt nur zum Zeitpunkts die Zeitzeine und wandelt dann in UTC um. Die Daumenregel ist:

  • Wird die Applikation immer am gleichen Ort verwendet, so kann man in UTC speichern und der Client (e.g. UI) rechnet um
  • International (mehrere Zeitzonen), dann muss man die Zeitzone separat speichern.

Aber Achtung: Es gibt einen Unterschied zwischen “Zeitzone” (e.g. Europe\Vienna - also die geografische Lage des Users) und “Offsets” (+01:00) (siehe IANA time zone database). Der Offset ändert sich im Laufe des Jahres (Sommerzeitumstellung).

const requestedAt = new Date(Date.UTC(2024, 9, 5, 7, 0, 0));

console.log(requestedAt.toLocaleString('de-AT', { timeZone: 'Europe/Vienna' }));

Domainservice

Hier befindet sich Logik, welche nicht in das Aggregate gepasst hat.

OpenAPI

Hier stellt sich am Anfang natürlich die Frage der Fragen: Muss die API die Domäne abbilden? Meiner Meinung nach (und wenn man Stackoverflow glaubt): nein. Die klassische REST-API wird meist von einer UI konsumiert und sollte auch auf diese hin getrimmt werden.

So kann es einen POST /offers endpoint geben, welcher nur das „Angebot“ anlegt, aber nichts von „Positionen“ weiß. Bei GET /offers scheiden sich die Geister. Hier empfiehlt sich Gedanken über das Thema „Projektion“ zu machen. In kleinen Projekten kann man den Shortcut nehmen und die Projections benennen:

GET /offers?projection=withPositions

schema:
    oneOf:
        - $ref: '#/components/schemas/OfferWithPositions'
        - $ref: '#/components/schemas/OfferWithPriceSummary'
       discriminator:
           propertyName: projection

Es gibt definitive einen Breakeven-Point (Anzahl der Projections), wo dieses Design kippt und REST an seine Grenzen kommt. Die Syntax von GraphQL Sinn kann dann Sinn machen, welche dem Aufrufer mehr Autonomie erlaubt – und dem Backendentwickler mehr Kopfweh bringt.

Wenn man nicht das Aggregate 1:1 abbildet, wie ändert man es dann? Ich finde PATCH sehr praktisch. Es gibt hier JSON-PATCH RC-6902 – aber json Pointer haben Vor- und Nachteile (e.g. Ansprechen von Array-Elementen mit Nicht-Index Methoden – e.g. queries nach Ids). Auch sagen die Operationen von Json-Patch nichts „domainspezifischen“ aus (add, remove, replace). Daher her auch wieder: pragmatisch sein. Operation „POSITION_COMMENT“ erlaubt es, den Kommentar der Position upzudaten. In der OpenAPI wieder mit „oneOf“ umsetzbar.

Historisierung

Auditing / Historisierung ist oft in den Anforderungen. Hier muss man sich aber die Frage stellen: Was genau soll erreicht werden?

  • Will ich wissen, wie ein Domain-Object vor und nach der Änderung („Transaktion“) ausgeschaut hat? Dann Hibernate Envers oder Audit.NET
  • Will ich wissen, wie es zu meinem Datenstand gekommen ist (e.g. Debugging, Tracing, Security oder ähnliches), dann empfehlen sich temporale Tabellen, da sie auch Manipulationen direkt per DDL erfassen.

Outbox

Ein sehr hilfreiches Pattern, wenn man transaktionell sicher die Änderungen am Domain-Objekt auch der Außenwelt via e.g. Kafka mitteilen will. Solange man keine langläufigen Transaktionen hat, ist das Pattern recht einfach. Unterscheiden muss man zwischen:

CQRS (Command Query Responsibility Segregation)

Wie oben festgehalten, sollte eine API möglichst „Client“ freundlich sein. Das ist meist der Fall, da man in einem klassischen Projekt seine Clients (e.g. Frontend) ja kennt. Twitter, Facebook und Co – anderes Thema. Blase ich jetzt meine Aggregate unendlich auf, nur weil mein Client eine gewisse Repräsentation der Daten haben will? Vermutlich nicht. Muss jede lesende Operation durch den „Domain-Core“ laufen? Vermutlich nicht. Definition von CQRS laut Wikipedia:

  • ein Objektmodell für lesende Zugriffe
  • ein Objektmodell für schreibende Zugriffe

Eine schöne Ableitung findet man in diesem Artikel.

Und ich gehe sogar noch einen Schritt weiter. Inzwischen ist der Blog-Post sicher schon in die Jahre gekommen – und keiner weiß mehr, ob es noch zutrifft. Aber auch schon damals hat es gut funktioniert: die Architektur von stackoverflow.com. Und sie zeichnet sich durch Einfachheit aus.

Wenn ich also eine Tabelle habe, welche Datensätze anzeigt: Dann kann ich ja auch einen View in der Datenbank machen, welche mir die Daten so liefert, wie ich sie brauche. Warum soll ich in Java joinen?! SQL Server hat leider noch immer keine nativen Arrays – aber PostgreSQL schon (z.b. wenn man „Tags“ für einen Datensatz mitliefern will). Als Workaround geht natürlich immer embedded JSON.

DDD Source: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/