CQRS-ready applications

Eins vorweg: In diesem Artikel werde ich nicht CQRS erläutern. Wem Konzepte wie Domain Events, Event Sourcing und Trennung von Lese- und Schreibvorgängen nicht sagen, der sollte hier nicht weiterlesen.

Hier geht es darum, wie man eine klassische Businessanwendung mit zentraler (normalisierter) SQL-Datenbank CQRS-ready macht. Das macht Sinn, falls man CQRS noch nicht sofort einsetzen, sich aber die Option für später wahren möchte.

Vor zirka einem Jahr bin ich das erste Mal auf CQRS aufmerksam geworden. Ich fand (und finde) es eine sehr interessante Architekturüberlegung. Unschön fand ich dagegen, dass es eine ziemlich einschneidende Entscheidung für die Architektur ist, die nicht mal einfach so nachträglich hinzugefügt oder entfernt werden kann. Deshalb habe ich es auch schon einmal als fordernde Diva bezeichnet, nach der man sein ganzes Programmierparadigma ausrichten muss.

Vor allem das Paradigma der Domain Events – die semantische Modellierung nach Use Cases und Geschäftsvorfällen anstatt von CRUD, finde ich sehr sinnvoll. Aber – man muss nicht unbedingt die Domain Events benutzen, um die Vorteile der Skalierbarkeit durch die Entkoppelung von Lesen und Schreiben zu nutzen. Deshalb funktionieren auch (die nenne ich jetzt so) CRUD-Events statt Domain Events.

Meine Grundüberlegung ist nun, dass für CQRS mit CRUD-Events sich die API so gestalten lässt, dass die Business Logik (oder von mir aus direkt die GUI) nicht weiß, ob der Schreibvorgang direkt oder entkoppelt erfolgt, also ob das CQRS-Paradigma überhaupt eingesetzt wird. Die API darf also keine Events beinhalten und sollte dem Repository (oder DAL-Pattern) ähneln.

Zu einem klassischen Repository-Pattern muss man dabei eine winzige Änderung vornehmen. Anstatt einem Repository welches Read- und Write-Operationen enthält, gibt es eine Reader und Writer-Klasse. Der Writer muss durch ein Interface austauschbar sein, der Reader dagegen nicht.

Stufe 1 – klassische relationale Datenbank

public interface IPersonWriter
{
 void Insert(Person person);
 void Update(Person person);
 void Delete(Person person);
}

public class PersonDirectWriter : IPersonWriter
{
 public void Insert(Person person)
 {
 // use SQL or ORM here
 }

 public void Update(Person person)
 {
 // use SQL or ORM here
 }

 public void Delete(Person person)
 {
 // use SQL or ORM here
 }
} 

public class PersonWriterFactory
{
 public static IPersonWriter CreateWriter()
 {
 return new PersonDirectWriter(); 
 }
}

Mit diesen Klassen kann man ganz normal programmieren, so wie man es mit einem Repository auch tut:

var personWriter = PersonWriterFactory.CreateWriter();
personWriter.Insert(new Person() { Name = "Testperson 1" });
personWriter.Insert(new Person() { Name = "Testperson 2" });

Einen Reader habe ich hier nicht abgebildet. Bei LINQ-to-SQL und ähnlich aufgebauten ORMs könnten wir den Datacontext direkt benutzen, um LINQ-Queries darauf abzufeuern. Wem dies als Sakrileg erscheint, kann es in eine separate Reader-Klasse auslagern oder sogar mehrere Query-Klassen. Eigentlich gefällt mir der Name Query in dem Zusammenhang sogar besser als Reader.

Damit eins klar ist: Diese Abstraktion bietet keinen Nutzen für die aktuelle Anwendung! Der Nutzen liegt ausschließlich darin, dass die Implementierung PersonWriter auf CRUD-Events in Zukunft ausgetauscht werden kann, ohne dass z.B. die Maske oder der Service Layer, der das „Insert“ aufruft, geändert werden muss. Die oft zitierte Evolvierbarkeit wird also verbessert.

Stufe 2  – Entkoppelt

Nun implementieren wir einen völlig anderen PersonWriter, der einen Multi-Cast an beliebige Handler ausführt, die sich dann darum kümmern sollen, wie wirklich gespeichert wird. In der Praxis sollte vielleicht eine generische Queue zum Einsatz kommen. Zur Demonstration reichen aber simple .NET-Events:

public class PersonCqrsWriter : IPersonWriter
{
 public event Action<Person> Inserted;
 public event Action<Person> Updated;
 public event Action<Person> Deleted;

 public void Insert(Person person)
 {
 var handler = Inserted;
 if (handler != null) handler(person);
 }

 public void Update(Person person)
 {
 var handler = Updated;
 if (handler != null) handler(person);
 }

 public void Delete(Person person)
 {
 var handler = Deleted;
 if (handler != null) handler(person);
 }
}

public class CqrsEngine
{ 
 public static readonly CqrsEngine Instance = new CqrsEngine();

 PersonCqrsWriter personWriter = new PersonCqrsWriter();

 public PersonCqrsWriter Persons
 {
 get { return personWriter; }
 }
}

public class PersonWriterFactory
{
 public static IPersonWriter CreateWriter()
 {
 return CqrsEngine.Instance.Persons;
 }
}

So wird natürlich noch überhaupt nichts irgendwo hin geschrieben, denn es wurden noch keine EventHandler registriert, die die CRUD-Events in die Lesedatenbank schreiben. Hier eine etwas primitive Implementierung, die wieder nur das Grundprinzip verdeutlicht:

public class PersonByFirstLetterDatabase
{
 public PersonByFirstLetterDatabase()
 {
 CqrsEngine.Instance.Persons.Inserted += new Action<Person>(Persons_Inserted);
 }

 void Persons_Inserted(Person obj)
 {
 if(!personsByFirstLetter.ContainsKey(obj.Name.First()))
 personsByFirstLetter.Add(obj.Name.First(), new List<Person>());

 personsByFirstLetter[obj.Name.First()].Add(obj);
 }

 Dictionary<char, List<Person>> personsByFirstLetter = new Dictionary<char, List<Person>>();

 public IEnumerable<Person> GetPersonsByFirstLetter(char letter)
 {
 if (personsByFirstLetter.ContainsKey(letter))
 return personsByFirstLetter[letter].AsReadOnly();

 return null;
 }
}

Nun haben wir es im Prinzip geschafft. Eine GUI-Maske kann irgendwo ein neues Objekt anlegen oder aktualisieren und die Lesedatenbank wird über diese Änderungen über die Event Handler benachrichtigt und kann darauf reagieren. GUI und Services können so die optimierte Lesedatenbank nutzen:

PersonByFirstLetterDatabase optimizedStore = new PersonByFirstLetterDatabase();
var personWriter = PersonWriterFactory.CreateWriter();

personWriter.Insert(new Person() { Name = "Testperson 1" });
personWriter.Insert(new Person() { Name = "Testperson 2" });
personWriter.Insert(new Person() { Name = "Testperson 3" });
personWriter.Insert(new Person() { Name = "Rudolf" });

var persons = optimizedStore.GetPersonsByFirstLetter('T');
var person = optimizedStore.GetPersonsByFirstLetter('R').First();

Stufe 3 – Hybrid

Warum nicht Ansatz 1 und 2 kombinieren? Per Default wird eine zentrale Datenbank verwendet. Zusätzlich kann über Write-Handler die Synchronisation der Replikate erfolgen.

public class PersonWriterMultiCast : IPersonWriter
{
 IPersonWriter[] writers;

 public PersonWriterMultiCast(IPersonWriter[] writers)
 {
 this.writers = writers;
 }

 public void Update(Person person)
 {
 foreach (var writer in writers)
 writer.Update(person);
 }

 public void Insert(Person person)
 {
 foreach (var writer in writers)
 writer.Insert(person);
 }

 public void Delete(Person person)
 {
 foreach (var writer in writers)
 writer.Delete(person);
 }
}

public class PersonWriterFactory
{
 public static IPersonWriter CreateWriter()
 {
 return new PersonWriterMultiCast(new IPersonWriter[] { new PersonDirectWriter(), CqrsEngine.Instance.Persons });
 }
}

Fazit

Die Implementierung ist natürlich nur ein Proof-of-Concept. Ein persistenter (ggf. auch transaktionsfähiger und Remoting-fähiger und damit skalierbarer) EventStore, ein effizienter Mechanismus zum Registrieren der Handler ist für eine produktive Implementierung nötig. Außerdem habe ich viel Plumbing-Code geschrieben, der nach Codegeneratoren schreit.

Auch muss man sich noch einmal klar machen, dass dies nur eine Lite-Variante von CQRS ermöglicht, welche mit CRUD-Events statt semantischen Domain Events arbeitet.

Eigentlich ist das Ganze den Triggern in der Datenbank ziemlich  ähnlich. Aber anders als bei Datenbank-Triggern sind die Verantwortlichkeiten besser verteilt.  Bei einem Trigger müsste dieser wissen, wo die Daten überall repliziert vorliegen. Bei der Enkopplung à la CQRS obliegt es vielmehr den replizierenden Anwendungsteilen, sich auch um die Aktualisierung der Redundanz zu kümmern. Manchmal erfolgt dies direkt nach dem Update, manchmal vielleicht verzögert (lazy) beim ersten Zugriff.

Insgesamt finde ich die Abweichung von einer klassischen (mittlerweile stink-alten) mehrschichten-Architektur mit einem Repository (ich sage meist lieber DAO dazu) absolut minimal, wodurch es sich absolut lohnt, sich die Option auf Entkopplung von Lesen und Schreiben (also vereinfachend gesagt CQRS) zu wahren.