Kommt drauf an: Domänenmodelle von C bis Java

Die Antwort auf alle Fragen in der Softwareentwicklung ist „it depends“. Diese Weisheit ist hinlänglich bekannt, aber genauso bekannt ist, dass diese Antwort ziemlich langweilig ist. Viel interessanter ist die Antwort auf die unweigerlich folgende Frage: Auf was kommt es denn nun an?

Mein Freund und langjähriger Mentor Rüdiger hat einen Blogartikel geschrieben — quasi als Antwort oder Reaktion auf einen Blog-Artikel von Martin Fowler. Und ich bin mal so frei und antworte gleich auch mit einem Blog-Artikel. Wer Rüdigers Text noch nicht gelesen hat: Jetzt ist die Gelegenheit, das zu tun. Ich warte hier so lang.

Sind „Anemic Domain Models“ also nun schlecht oder nicht? Sollte man seinen Model-Objekten Verhalten geben oder nicht? Meine Antwort sollte klar sein: Kommt drauf an. Aber auf was kommt es denn nun an?

Zurück zu C

Um uns dieser Frage zu nähern, machen wir einen kleinen Abstecher in die prozedurale Programmierung. Damals als die Gummistiefel noch aus Holz waren und man mit der Pointer-Kanone auf einzelne Speicherbereiche schoss, gab es keine Objekte. Daten und Funktionen waren strikt getrennt. Aber auch damals musste man gewisse Konsistenzbedingungen sicher stellen. Die Startzeit liegt immer vor der Endezeit, das Geburtsdatum einer Person liegt immer in der Vergangenheit, Email-Adressen folgen einem bestimmten Format, das Konto darf nur bis zu einem gewissen Kreditrahmen überzogen werden, etc.

Nehmen wir doch einfach mal das Kontobeispiel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <stdio.h>
#include <stdlib.h>

#define OK 0
#define ERROR_CREDIT_LIMIT_REACHED 1

typedef struct Account {
    char* holder;
    int balance;
    int creditLimit;
} Account;

Account* createAccount(char* holder) {
    Account* result = malloc(sizeof(Account));
    result->holder = holder;
    result->balance = 0;
    result->creditLimit = -5;
    return result;
}

void deposit(Account* account, unsigned int amount) {
    account->balance = account->balance + amount;
}

int withdraw(Account* account, unsigned int amount) {
    int newBalance = account->balance - amount;
    if (newBalance < account->creditLimit) {
        return ERROR_CREDIT_LIMIT_REACHED;
    }

    account->balance = newBalance;
    return OK;
}

void show(Account* a) {
    printf("account balance: %d\n", a->balance);
}

int main() {
    Account* a = createAccount("Hans Wurst");
    show(a);

    deposit(a, 10);
    show(a);

    if (withdraw(a, 12) != OK) {
        printf("credit limit reached!\n");
    }
    show(a);

    if (withdraw(a, 7) != OK) {
        printf("credit limit reached!\n");
    }
    show(a);

    a->balance = -100;
    show(a); // oops!

    free(a);
    return 0;
}

Man möge mir grobe Schnitzer verzeihen, ich hab schon Ewigkeiten kein C mehr programmiert und konnte das auch nie gut. Jedenfalls sieht man, dass es hier an Kapselung fehlt. Man kann das Kreditlimit überschreiten, weil man Zugriff auf die Innereien des Accounts hat.

Was ist daran nun schlimm? Erstmal noch nicht so viel. Man könnte einfach eine Konvention einführen, dass das so niemand tun darf. Irgendwann wird natürlich Murphy’s Law zuschlagen und irgend jemand von den Kollegen hat die Konvention nicht mitgekriegt.

Aber es gibt noch ein weiteres Problem: Bei unserem Bankkonto gilt eine gewisse Konsistenzbedingung. Diese muss beim Abheben geprüft werden, aber auch bei allen anderen Operationen. In einem halben Jahr werden Bankeinzüge, Überweisungen und Rückbuchungen implementiert. Jedes Mal muss die genannte Prüfung beachtet werden und wenn wir uns jetzt vorstellen, dass das nicht alles in einer Datei passiert, sondern über diverse Module verteilt ist, kann es leicht passieren, dass dann doch mal jemand einfach so balance setzt, ohne das Kreditlimit vorher zu prüfen.

Handles

Mit ein paar Tricks kommt man um dieses Problem drum herum: Wir ändern unseren Code so, dass createAccount keinen direkten Pointer auf den Account mehr zurück liefert, sondern wir führen ein AccountHandle ein:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef int AccountHandle;

int numberOfAccounts = 0;
Account* accounts[10];

AccountHandle createAccount(char* holder) {
    Account* result = malloc(sizeof(Account));
    ...

    if (numberOfAccounts >= MAX_NUMBER_OF_ACCOUNTS)
        ...

    int index = numberOfAccounts;
    accounts[numberOfAccounts++] = result;
    return index;
}

void deposit(AccountHandle handle, unsigned int amount) {
    Account* account = accounts[handle];
    account->balance = account->balance + amount;
}

...

Alle Funktionen benutzen nur noch das AccountHandle. Wenn man keinen Pointer auf das accounts-Array kriegt, hat man keine Möglichkeit, zu schummeln. Wir haben Kapselung, ganz ähnlich wie in der Objektorientierung und das in C! Wer sich schon immer gefragt hat, was diese komischen Handles sind, mit denen diverse Frameworks und Betriebssystem-APIs funktionieren: Das ist quasi das selbe Prinzip.

Heißt das, dass man damals in C quasi mit prozeduralen Mitteln objektorientiert programmiert hat? Ja, im Grunde genommen heißt das genau das.

Von C nach Java

Jetzt gucken wir uns mal eine typische Account-Klasse in Java an:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Account {
    private String holder;
    private int balance;
    private int creditLimit = 10;

    public Account(String holder) {
        this.holder = holder;
    }

    public int getBalance() {
        return balance;
    }

    public void setBalance(int value) {
        balance = value;
    }

    ...
}

Hm…. warte mal. Das sieht doch aus, wie… Genau: Wie die prozedurale Variante oben. Wir programmieren mit prozeduralen Sprachen objektorientiert und mit objektorientierten Sprachen prozedural. Schön, nicht?

Durch die Getter und Setter, die wir wie selbstverständlich an alle Klassen dran kleben, hebeln wir die Kapselung der Klasse quasi aus. Das ist der Schmerz, den Martin Fowler mit Anemic Domain Models hat. Wir nutzen nicht das Potenzial, das die Datenstrukturen hätten, sondern wir treiben unheimlich viel Zeremonie mit den ganzen Gettern und Settern (die in 99% aller Fälle, wenn nicht häufiger, rein gar nichts tun) nur um am Ende so zu coden, wie vor 40 Jahren.

Für DTOs, die nur zum Serialisieren und Deserialisieren da sind, sind Getter und Setter vollkommen OK. Ein Domänenmodell ist das aber nicht.

Vielleicht wird der Unterschied noch etwas klarer, wenn wir uns ein allseits bekanntes Beispiel ansehen. Angenommen wir wollen ein Domänenmodell für Zeit entwickeln. Begriffe aus der Domäne wären also Tag, Datum, Dauer, Zeitzone, etc. Ein blutleeres Domänenmodell sähe in etwa so aus, wie java.util.Date. Konstruktoren, Getter, Setter, nicht viel mehr. Ein echtes Domänenmodell sieht aus wie java.time.

Was ist die Domäne?

Zurück zur Frage, auf was es denn nun bei Domänenmodellen ankommt. Die Frage, die wir uns hierzu stellen müssen, ist „Was ist die Domäne meines Systems?“ Vielleicht sind ja Bankkonten tatsächlich meine Domäne und dann habe ich ein Domänenmodell, das die dort geltenden Konsistenzbedingungen sicher stellt. In einer Microservice-Landschaft gibt es aber mitunter Services in unterschiedlichen Schichten. Habe ich beispielsweise ein Backend-For-Frontend für eine Smartphone-App, dann sind Bankkonten nicht mehr direkt meine Domäne, selbst, wenn es inhaltlich um ebensolche geht. Mein System ist dann eine Fassade, deren Aufgabe es ist, eine einheitliche Schnittstelle für die App zur Verfügung zu stellen und dann selbst Services aufzurufen. Die Konsistenzprüfungen, das Domänenwissen, hat nicht die Fassade, sondern der Domänenservice eine oder mehrere Ebenen darunter.

Meine Domäne bzw. meine Aufgabe ist dann das Weiterleiten von Requests der App (so man das als Domäne bezeichnen möchte). In einem solchen Fall hab ich natürlich gar nicht viel Domänenlogik, die ich in ein Domänenmodell packen könnte. Prinzipiell könnte mein Domänenmodell die Anfragen an die anderen Services übernehmen. Für eine solche Architektur sind Frameworks i.d.R. nicht ausgelegt, was Komplexität bedeutet, die ich i.d.R. nicht haben will. Für ein BFF hab ich also fast notgedrungen ein Domänenmodell mit eher wenig Verhalten.

Layers

Ein Domänenmodell zu haben, bedeutet, dass man sich Gedanken darüber machen muss, was Teil des Domänenmodells ist und, was Anwendungslogik ist. Natürlich ist das eine nicht immer einfach zu beantwortende Frage. Man kann natürlich das Domänenmodell leer lassen und sich so die Beantwortung leicht machen. Damit verschenkt man aber Potenzial. Es ist die Aufgabe der Architektur, fundamentale Ordnungsprinzipien zu etablieren. Das ist schwer, aber niemand hat behauptet, Architektur wäre einfach. Alles in eine große Grabbelkiste zu stecken geht auch, ist aber ein schwächeres Ordnungsprinzip.

Wie trifft man nun diese nicht-triviale Entscheidung, was in welchen Layer gehört? Ich will mich mal von der anderen Seite nähern: Man könnte sagen, ein Domänenmodell macht, dass man in der Anwendungsschicht so programmieren kann, als wären Kunden und Verträge, Fahrzeuge und Halter, Drehmomente und Kräfte Teil der Programmiersprache bzw. Teil der API — so selbstverständlich wie Strings, Datenströme und Datumswerte es bereits sind.

Diese Vorstellung dient mir als Daumenregel. Wenn ich mir bei einem Domänenmodell vorstelle, dass ich quasi die Sprache erweitere um damit im Application Layer einfacher programmieren zu können, bedeutet das für mich dreierlei: 1. Das Domänenmodell muss nicht alles können. Es bildet vielmehr die Basis. 2. Die eigentliche Anwendungslogik befindet sich im Application Layer (oh Wunder!). 3. Das Domänenmodell sollte unabhängig und in sich geschlossen sein.

Weitere Aspekte

Wenn ich grad schonmal dabei bin, etwas über Domänenmodelle zu sagen…

Operation-Klassen im Domain-Layer

Operation-Klassen mit in den DomainLayer zu packen, ist natürlich möglich. Zwei Dinge würde ich bei diesem Ansatz aber mitbedenken wollen: a) Wenn ich die Operation-Klassen nicht beachte (etwa weil ich nicht dran gedacht habe) und direkt die Domänenobjekte manipuliere, kann ich dann Inkonsistenzen erzeugen? b) Wie schaffe ich es, dass ich die Operation-Klassen nicht aus den Augen verliere, wenn ich an ihnen vorbei die Domänenobjekte manipulieren kann? Anders als die Domänenklassen können deren Namen ja nicht aus der Domäne selbst kommen, sondern müssen künstlich sein. AccountWithdrawer, AccountOperations, … wie auch immer ich diese dann nennen mag.

OR-Mapper

Manche Dinge sind mit heutigen OR-Mappern tatsächlich relativ einfach geworden. Beispielsweise kann ich mit JPA durch entsprechende Annotationen beschreiben, ob eine Objekt-Abhängigkeit „eager“ oder „lazy“ geholt werden soll. Das ermöglicht mir, bestimmte Operationen im Domänenmodell zu machen, ohne, dass das Domänenmodell selbst die Datenbank befragen muss. Durch diese indirekte Möglichkeit der Einflussnahme kann ich tatsächlich mehr Logik in den Domänenklassen umsetzen, für die ich ansonsten Orchestrierungslogik in Operation-Klassen bräuchte. Das Nachladen von Daten geht ganz einfach.

Was allerdings immer noch nicht so einfach geht, ist das Abbilden von Vererbungshierarchien. Nicht, dass es hierzu nicht diverse theoretische Ansätze und praktische Umsetzungen in JPA und Co. gäbe. Dennoch ist die Abbildung nie vollständig natürlich. Ähnliches gilt auch für die Serialisierung von Objekthierarchien in Json oder XML. Das ist immer ein wenig unschön und die entsprechenden Frameworks haben gerade an der Stelle immer wieder Probleme mit Sicherheitslücken, weil man mit Code, der beliebige Klassen deserialisieren kann, mitunter auch beliebigen Code einschleusen kann. Wenn ich es also schaffe, in meinem Domänenmodell auf Vererbung zu verzichten, dann tue ich das i.d.R.

Unpassende Domänenmodelle

Wir haben einen Monitoring-Service, der den Status deployter Artefakte abfragt und grafisch darstellt. Begriffe aus dieser Domäne sind also Service, Deployment, Cluster, Node, etc.

Ursprünglich hatte dieser Service gar kein Domänenmodell, sondern hat einfach Daten gesammelt und daraus direkt HTML produziert. Bis zu einem gewissen Punkt war das vollkommen in Ordnung. Das war ein kleines Tool, mehr nicht. Relativ schnell war dann natürlich doch eines nötig.

Es zeigte sich aber, dass das erste Modell nicht so ganz gepasst hatte. Im Grunde genommen gab es schon die Domäne wieder, aber es war dann doch relativ stark auf die momentane Aufgabe fokussiert. Bestimmte Änderungen waren dann schwieriger, als sie hätten sein müssen. So haben wir einen zweiten Cluster monitoren wollen, eine zusätzliche Stage kam hinzu, der neue Cluster hatte 4 statt 3 Nodes, etc. Unscheinbare Entscheidungen wie „das muss jetzt keine Klasse sein, da reicht auch ein Enum“ hatten dann auf einmal unangenehme Konsequenzen.

Das Domänenmodell passte nicht mehr zu den sich ändernden Anforderungen. Und das ist eigentlich merkwürdig, denn normalerweise sollte ein Domänenmodell ja einigermaßen stabil gegenüber Anforderungsänderungen sein, die aus der Domäne kommen. Die Ursache war, dass wir das Domänenmodell zu stark im Hinblick auf eine einzige Anforderung hin gebaut hatten, statt die Domäne zu modellieren. Ich bin erklärter Feind von Overengineering, will also damit auf keinen Fall sagen, dass man im Domänenmodell mehr umsetzen sollte, als angefordert ist. Aber man kann Struktur eines Domänenmodells sehr wohl geschickt und ungeschickt wählen. Und manchmal macht es schon einen ziemlichen Unterschied, ob etwas eine Klasse oder ein Enum ist oder ob A nun B kennt oder umgekehrt.

Funktionale Ansätze

Bleiben wir bei unserem Monitoring-Tool: Dieses hatten wir zu Java7-Zeiten geschrieben und eine Haupt-Aufgabe des Domänenmodells war es, sich selbst zu beschreiben. Lieber Service, sag mir mal, wo du überall deployed bist! Lieber Cluster, welche Services laufen auf deinen Nodes? Liebes Deployment, bist du die aktuellste Version?

Viel dieser Funktionalität beschränkte sich darauf, eine mehrstufig verschachtelte Objektstruktur zu durchlaufen. Jede Ebene in dieser Objektstruktur machte ihren Teil der Arbeit. Das sah in etwa folgendermaßen aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Cluster {
    List<Deployment> getDeployments() {
        List<Deployment> result = new ArrayList<>();
        for (Node node : getNodes()) {
            result.addAll(node.getDeployments());
        }
        return result;
    }
}

public class Node {
    List<Deployment> getDeployments() {...}
}

Man sieht hier schön, die Anwendung des Prinzips Tell, don’t Ask. Das vermeidet Train Wrecks wie cluster.getNodes().get(i).getDeployments().get(j).getService().get.... Außerdem verteilt es Funktionalität gleichmäßig über die Klassen und vermeidet, dass einzelne Klassen zu groß werden, während andere quasi leer bleiben (ein Symptom eines blutleeren Domänenmodells).

Auf der anderen Seite hat es natürlich auch den Nachteil, dass es Funktionalität gleichmäßig über die Klassen verteilt. So ist zwar jede Methode aus sich heraus verständlich, aber das Große Ganze ist schwerer zu sehen.

Eine Sache, die mit funktionaler Programmierung sehr schön geht, ist das Traversieren von Objektstrukturen. Deshalb sind mittlerweile einige dieser „Tell-don’t-Ask-Methoden“ zurückgebaut. Stattdessen verwenden wir Streams (also map und filter). Das sieht in etwa folgendermaßen aus:

1
2
3
4
stage.getClusters().stream()
    .flatMap(Cluster::getNodes)
    .flatMap(Node::getDeployments)
    .collect(toList());

Durch den Einsatz funktionaler Konstrukte, hat das Domänenmodell jetzt also etwas weniger Methoden. Insgesamt gewinnt dadurch die Lesbarkeit.

Zusammenfassung

Nachdem ich jetzt meine Gedanken aufgeschrieben habe, frage ich mich: Bin ich nun anderer Meinung als Rüdiger? Im Großen und Ganzen würde ich sagen, nein. Man kann durchaus auch leere Domänenmodelle machen. Wenn man das aber tut, sollte man wissen, warum und welche Konsequenzen das hat. Und ich denke, das ist genau das, was Rüdiger meint, wenn er sagt „it’s good to have it as a valid tool in your belt“.

2 Kommentare


  1. Sehr coole Diskussion! Man muss zwar nicht meinen oder Martin Fowlers Artikel gelesen haben, um deinen verstehen zu können, aber die Diskussion finde ich wertvoll. Auch wenn wir jetzt in’s Deutsche gewechselt sind.

    Anmerkung 1: Zumindest im klassischen MacOS (< 10) war ein Handle ein indirekter Pointer, um den Speicher besser managen zu können; keine Kapselung.

    Anmerkung 2: Domain-Operation-Klassen können nicht nur aus dem Wortschatz der Domäne kommen, sie müssen es sogar! Ich würde sie nicht unbedingt substantivieren — also bspw. einfach `Withdraw` sagen, auch wenn das manchmal komisch klingt: bspw. `new Withdraw()` müsste eigentlich `new Withdrawal()` heißen.

    Anmerkung 3: Nur weil die Operation (wie `Withdraw`) auf die internen Strukturen (wie `balance`) zugreifen können, heißt nicht, dass sie `public` sein müssen 😉 Verwende einfach die in Java vermutlich am seltensten verwendete Sichtbarkeit: package-private. Offiziell wird das als Default Scope bezeichnet und ist der Scope, wenn man nix dran schreibt; eigentlich sehr schlau gewählt, nur meist missverstanden und ignoriert (in Kotlin ist sogar traurigerweise der default Scope `public`). Ich bin übrigens aus genau diesem Grund mittlerweile dazu übergegangen, meine Tests eben _nicht_ im gleichen Package zu implementieren wie das System Under Test: Ich will nur die öffentliche Schnittstelle meines Codes testen (können).


  2. Hi Rüdiger, Danke für deinen Kommentar!

    Ja, ich geb zu, für das jetzt auf Englisch zu machen, hatte ich schlicht keine Lust. Der Text hat lange genug gedauert.

    Zu 1) Die Sache mit den Handles hab ich aus einen schätzungsweise 20 Jahre alten Artikel, den ich leider nicht mehr gefunden habe. Handles kenne ich noch aus der Zeit, als ich für Windows programmiert hab. Da gabs auch immer File-Handles und Window-Handles (HWND), etc. Wobei ich die Details, ehrlich gesagt, gar nicht kenne. Hatte nur am Rande mit der WinAPI zu tun.

    Zu 2) Klassen mit Verb-Namen fände ich sehr gewöhnungsbedürftig. Insbesondere, da ich das früher immer als Beispiel für schlechten Stil oder falsch verstandene OOP angeführt hab. Und sowas fänd ich tatsächlich sehr… gewöhnungsbedüftig:

    1
    2
    Withdraw withdraw = new Withdraw();
    withdraw.withdraw(account, 10);

    Zu 3) Guter Punkt!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert