Von Golo Roden

Estimated reading time: 11 Minuten

Techblog

Teil 2: DDD, Event-Sourcing und CQRS im Beispiel

Teil 2: DDD, Event-Sourcing und CQRS im Beispiel Golo Roden ist Gründer und CTO von the native web. Wir freuen uns riesig über seinen Gasteitrag in unserem Blog. Er twittert unter @goloroden.  Wie im ersten Teil beschrieben sind fachliche Events das verbindende Element von Domain-Driven Design (DDD), Event-Sourcing und CQRS. Sie beschreiben Veränderungen, die in der Domäne stattgefunden haben. Wenn…

Techblog

Teil 2: DDD, Event-Sourcing und CQRS im Beispiel

Golo Roden ist Gründer und CTO von the native web. Wir freuen uns riesig über seinen Gasteitrag in unserem Blog. Er twittert unter @goloroden


Wie im ersten Teil beschrieben sind fachliche Events das verbindende Element von Domain-Driven Design (DDD), Event-Sourcing und CQRS. Sie beschreiben Veränderungen, die in der Domäne stattgefunden haben. Wenn Sie eine Anwendung auf der Basis und mit dem Nutzen von Events entwickeln möchten, müssen Sie sich daher zunächst überlegen, welche Events in der jeweiligen Domäne enthalten sind. Sie beginnen die Arbeit also mit einer Modellierung.

Als Beispiel soll im Folgenden ein einfacher Instant-Messaging-Dienst im Stil von Slack entwickelt werden, der allerdings auf die wichtigsten Funktionen reduziert ist. Die wichtigste Einschränkung ist, dass die Anwendung nur einen einzigen Channel unterstützt, in dem Sie Nachrichten verfassen und bewerten können. Auch das Authentifizieren der Anwender bleibt Außen vor.

Von der Idee zum Modell

Der Kern der Anwendung ist der Versand von Nachrichten. Daher liegt es Nahe, ein sent-Event einzuführen, das immer dann ausgelöst wird, wenn eine Nachricht von einem Anwender versendet wurde. Außerdem ist es sinnvoll, ein liked-Event einzuführen. Das Event weist darauf hin, dass eine Nachricht positiv bewertet wurde. Auslösen können Sie die Events mit den beiden zugehörigen Commands send und like.

Die gemeinsame Logik befindet sich gemäß DDD in einem Aggregate. Da sich die Commands und Events auf eine einzelne Nachricht beziehen, liegt es nahe, das Aggregate message zu nennen. Bei alldem geht es um die Kommunikation innerhalb eines Teams, weshalb das Aggregate dem Kontext communication zugeordnet wird. Nur innerhalb der sprachlichen Grenze dieses Kontexts gilt die bislang entwickelteTerminologie, die in DDD als Ubiquitous Language bezeichnet wird.

Die gleiche Anwendung kann unter Umständen über weitere Kontexte verfügen, die sich mit anderen Aspekten beschäftigen, beispielsweise der Verwaltung der Anwender oder der Abrechnung. Auch hier könnten die Begriffe messagesent und liked auftreten – gegebenenfalls aber mit anderer Semantik. Alle Kontexte zusammen bilden die Domäne, die Sie in dem Fall beispielsweise chat nennen können.

Vom Modell zum Code

Um das Modell zu implementieren (in diesem Fall als in JavaScript geschriebene Node.js-Anwendung), legen Sie als erstes ein Verzeichnis für die Anwendung an. Da Sie primär das Backend entwickeln werden, empfiehlt es sich, darin direkt ein server-Verzeichnis anzulegen:

$ mkdir -p chat/server

Gemäß CQRS verfügt der Server über eine Schreib- und eine Leseseite. Auch hierfür legen Sie wiederum Verzeichnisse an. Außerdem müssen Sie ein Verzeichnis flows anlegen, in dem Sie langlaufende Workflows ablegen können. Solche Workflows werden in dem vorliegenden Beispiel zwar nicht benötigt, das Verzeichnis muss aber trotzdem zumindest in leerer Form existieren:

$ mkdir -p chat/server/writeModel
$ mkdir -p chat/server/readModel
$ mkdir -p chat/server/flows

Die Schreibseite enthält die zuvor modellierte Domäne. Erzeugen Sie daher ein Verzeichnis mit dem Namen des Kontexts, also communication:

$ mkdir -p chat/server/writeModel/communication

Dem Verzeichnis fügen Sie nun die Datei message.js für das Aggregate hinzu. Initialisieren Sie die Datei mit der folgenden Struktur:

'use strict';

​

const initialState = {};

const commands = {};

const events = {};

​

module.exports = { initialState, commands, events };

Der initialState enthält den initialen Zustand des Aggregates. Da eine Nachricht zunächst keinen Text enthält und noch nicht bewertet wurde, setzen Sie folgende Werte:

const initialState = {

  text: undefined,

  likes: 0

};

Nun folgen die beiden Events sent und liked. Sie werden durch zwei Funktionen repräsentiert, die aufgerufen werden, wenn das jeweilige Event ausgelöst wurde. Da Events eine Veränderung darstellen, müssen Sie den Zustand des Aggregates ändern. Dazu dient die Funktion setState, die Sie am message-Aggregate aufrufen:

const events = {

  sent (message, event) {

    message.setState({ text: event.data.text });

  },

​

  liked (message, event) {

    message.setState({ likes: event.data.likes });

  }

};

Um die Events auszulösen, benötigen Sie die beiden Commands send und like. Die Commands müssen ihre Parameter prüfen und entscheiden, welche Events sie am Aggregate auslösen. Dazu dient die events.publish-Funktion, die Sie wiederum am message-Aggregate aufrufen. Markieren Sie das Command darüberhinaus als erfolgreich ausgeführt oder als fehlgeschlagen, indem Sie die Funktion mark.asDone oder mark.asRejected aufrufen:

const commands = {

  send (message, command, mark) {

    if (!command.data.text) {

      return mark.asRejected('Text is missing.');

    }

​

    message.events.publish('sent', { text: command.data.text });

    mark.asDone();

  },

​

  like (message, command, mark) {

    message.events.publish('liked', { likes: message.state.likes + 1 });

    mark.asDone();

  }

};

Da eingangs festgelegt wurde, auf Authentifizierung zu verzichten, müssen Sie die Commands und Events schließlich noch für jedermann zugänglich machen. Dazu erweitern Sie den initialState wie folgt:

const initialState = {

  text: undefined,

  likes: 0,

  isAuthorized: {

    commands: {

      send: { forPublic: true },

      like: { forPublic: true }

    },

    events: {

      sent: { forPublic: true },

      liked: { forPublic: true }

    }

  }

};

Damit ist das fachliche Modell implementiert. Selbstverständlich fehlen für eine ausführbare Anwendung noch zahlreiche Aspekte wie eine HTTPS- oder Websocket-API oder eine Datenbankanbindung. All diese Aspekte sind aber technischer, nicht fachlicher Natur.

Die Leseseite implementieren

Da die UI eine Liste aller Nachrichten anzeigen soll, muss die Leseseite der Anwendung die entsprechenden Daten bereithalten. Dazu ist eine Interpretation der fachlichen Events erforderlich. Die Interpretation muss der Liste bei einem sent-Event einen neuen Eintrag hinzufügen, bei einem liked-Event hingegen einen bereits bestehenden Eintrag aktualisieren.

Zu dem Zweck legen Sie zunächst ein Verzeichnis lists innerhalb der Leseseite an:

$ mkdir -p chat/server/readModel/lists

Anschließend fügen Sie dem Verzeichnis die Datei messages.js hinzu, und initialisieren sie mit folgendem Code:

'use strict';

​

const fields = {};

const when = {};

​

module.exports = { fields, when };

Als Felder muss die Liste nicht nur den Text der einzelnen Nachrichten und die Anzahl der jeweiligen Bewertungen enthalten, sondern auch den Zeitpunkt, wann eine Nachricht gesendet wurde. Da der Zeitpunkt als Sortierkriterium dient, empfiehlt es sich, ihn zu indexieren:

const fields = {

  timestamp: { initialState: 0, fastLookup: true },

  text: { initialState: '' },

  likes: { initialState: 0 }

};

Nun müssen Sie noch die Liste aktualisieren, wenn ein relevantes Event ausgelöst wird: 

const when = {

  'communication.message.sent' (messages, event, mark) {

    messages.add({

      text: event.data.text,

      timestamp: event.metadata.timestamp

    });

    mark.asDone();

  },

​

  'communication.message.liked' (messages, event, mark) {

    messages.update({

      where: { id: event.aggregate.id },

      set: {

        likes: event.data.likes

      }

    });

    mark.asDone();

  }

};

Damit ist aus fachlicher Sicht alles implementiert, was für die Leseseite erforderlich ist.

Die Anwendung starten

Tatsächlich müssen Sie sich um den weiteren technischen Unterbau nicht kümmern, denn die Arbeit kann Ihnen problemlos ein Framework abnehmen, beispielsweise wolkenkit. Die Modellierung mit DDD müssen Sie also selbst vornehmen, aber für die Implementierung auf Basis von Event-Sourcing und CQRS können Sie auf einen generischen Ansatz zurückgreifen.

Um die Anwendung zu starten, müssen Sie folglich zunächst wolkenkit installieren, ein CQRS- und Event-Sourcing-Framework für JavaScript und Node.js, das speziell als technischer Unterbau für DDD entwickelt wurde. Das Framework wird als Open-Source entwickelt, daher finden Sie den Quellcode auf GitHub. Die eigentliche Installation erfolgt mit Hilfe von npm:

$ npm install -g wolkenkit

Außerdem müssen Sie lokal Docker installieren. Hinweise zur Installation finden Sie in der Dokumentation von wolkenkit für macOSLinux und Windows. Zu guter letzt müssen Sie Ihrer Anwendung im Verzeichnis chat noch eine Datei namens package.json hinzufügen, in der Sie einen wolkenkit-spezifischen Abschnitt hinterlegen:

{

  "name": "chat",

  "version": "1.0.0",

  "wolkenkit": {

    "application": "chat",

    "runtime": {

      "version": "1.2.0"

    },

    "environments": {

      "default": {

        "api": {

          "address": {

            "host": "local.wolkenkit.io",

            "port": 3000

          },

          "allowAccessFrom": "*"

        },

        "node": {

          "environment": "development"

        }

      }

    }

  }

}

Sobald alle Voraussetzungen erfüllt sind, können Sie die Anwendung starten. Beim ersten Start muss wolkenkit verschiedene Docker-Images herunterladen, weshalb der Vorgang ein paar Minuten dauern kann. Anschließend braucht der Start im Verzeichnis chat aber jeweils nur wenige Sekunden:

$ wolkenkit start

Als Ergebnis haben Sie nun ein lauffähiges Backend, das Sie über HTTPS und Websockets ansprechen können und das sich um die Persistenz der Daten gemäß Event-Sourcing kümmert.

Den Client anbinden

Für den Client müssen Sie zunächst das npm-Modul wolkenkit-client installieren. Anschließend können Sie das Modul in Ihre Webanwendung integrieren und eine Verbindung zum Server herstellen:

const wolkenkit = require('wolkenkit-client');

​

const chat = await wolkenkit.connect({

  host: 'local.wolkenkit.io',

  port: 3000

});

Danach verwenden Sie das chat-Objekt, um auf das Backend zuzugreifen und beispielsweise Nachrichten zu versenden:

chat.communication.message().send({

  text: 'Hallo Welt!'

});

Außerdem können Sie auch die Liste der gesendeten Nachrichten abrufen und anzeigen, wobei Sie sogar anfordern können, dass die Liste im Client live aktualisiert wird, sobald neue Nachrichten gesendet oder bewertet wurden:

chat.lists.messages.readAndObserve({

  orderBy: { timestamp: 'descending' },

  take: 50

}).

  started((messages, cancel) => {

    // ...

  }).

  updated((messages, cancel) => {

    // ...

  });

Wenn Sie nun gerne ein vollständig lauffähiges Beispiel einschließlich einer grafischen Oberfläche haben möchten, können Sie sich die Arbeit einfach machen. Beenden Sie dazu bitte zunächst die laufende Anwendung:

$ wolkenkit stop

Weisen Sie nun das Kommandozeilenwerkzeug wolkenkit in einem leeren Verzeichnis an, eine neue Anwendung zu initialisieren:

$ wolkenkit init

Standardmäßig verwendet wolkenkit für neue Anwendungen eine Vorlage, die eben jenen Chat enthält, den Sie in diesem Blogeintrag von Hand entwickelt haben. Die Details, wie Sie den Chat ausführen, finden Sie im zugehörigen GitHub-Repository:

Abschließend bleibt zu sagen, dass die Entwicklung auf Basis von DDD, Event-Sourcing und CQRS zwar zunächst ungewohnt ist, nach einer kurzen Eingewöhnungszeit aber weitaus effizienter sein kann als das klassische Vorgehen. Insbesondere die Tatsache, dass man auf Grund von DDD gezwungen ist, vor der Implementierung zunächst in einem interdisziplinären Team zu modellieren, trägt viel zum Erreichen einer guten Softwarequalität bei.

Frameworks wie wolkenkit können Sie dabei in hohem Maß unterstützen, indem sie Ihnen ermöglichen, sich auf das Wesentliche zu konzentrieren, nämlich das Lösen der tatsächlich relevanten fachlichen Probleme. Und genau das ist letzten Endes der Grund, warum Sie Software entwickeln: Um das Leben von anderen Menschen einfacher, komfortabler oder sicherer zu gestalten.


Über den Autor

Von Golo Roden