Validierung mit dem Command-Query-Separation Muster
In meinem letzten Artikel Eine SOLIDe Architektur habe ich das Command-Query-Separation (CQS) Muster vorgestellt, das wir in einigen unserer Projekte erfolgreich einsetzen. Der heutige Artikel setzt darauf auf und zeigt, wie wir mit dem Muster die Validierung von Commands und Queries umsetzen.
Diese Validierungen können einerseits einfache Regeln wie zum Beispiel das Prüfen einer maximalen Länge von Zeichenketten sein. Andererseits können diese Regeln auch beliebig komplex werden, zum Beispiel zu prüfen, dass bei zwei gesendeten Datumswerten innerhalb einer Query das Startdatum kleiner als das Enddatum ist. Noch komplexere Regeln können auch abhängig von Drittkomponenten sein. Zum Beispiel könnte geprüft werden, ob eine eindeutige Nummer bereits vorhanden ist, wozu eine Datenbank oder eine Schnittstelle zu einem anderen System befragt werden muss.
Abhängig von der Komplexität der Validierungen können zwei unterschiedliche Ansätze verwendet werden.
Validierung mithilfe von Data Annotations
Für einfache Prüfungen, die nur ein einzelnes Property betreffen, können die Data Annotations aus dem .NET Framework verwendet werden. Diese werden über das NuGet-Paket System.ComponentModel.DataAnnotations zur Verfügung gestellt und bieten eine Reihe von vordefinierten Attributen an, die auf Properties angewandt werden können. Dazu zählen beispielsweise das [Required]
Attribut, um festzulegen, dass das Property ein Pflichtfeld ist oder das zuvor schon erwähnte [StringLength(int)]
Attribut, das die maximal gültige Länge einer Zeichenkette festlegt. Es ist auch möglich, eigene Attribute für spezifische Anwendungsfälle zu definieren, indem man vom ValidationAttribute
ableitet und die IsValid(object)
-Methode entsprechend überschreibt.
Ein einfaches Command könnte wie folgt aussehen. Das CreatePerson
Command hat zwei Pflichtfelder für Vor- und Nachname, die auch eine Längenbeschränkung haben. Zusätzlich kann eine E-Mail-Adresse angegeben werden, die optional ist.
public class CreatePerson : ICommand
{
[Required]
[StringLength(100)]
public string FirstName { get; set; }
[Required]
[StringLength(100)]
public string LastName { get; set; }
[EmailAddress]
public string Email { get; set; }
}
Sämtliche Commands und Queries werden nun, bevor sie tatsächlich von den Handlern ausgeführt werden, validiert. Dazu wird ein Decorator verwendet, wie er schon in meinem vorigen Artikel gezeigt wurde. Der Decorator erhält das Command oder die Query als Parameter und validiert dieses, indem er die statische Methode Validator.ValidateObject(object instance, ...)
aus den Data Annotations aufruft. Diese Methode wirft eine ValidationException
im Fall eines ungültigen Objekts. Erst nach der erfolgreichen Validierung leitet der Decorator das Command bzw. die Query an den Handler weiter.
Die vollständige Implementierung des Decorators stellen wir als NuGet-Paket softaware.CQS.Decorators.Validation zur Verfügung. Der Quelltext ist als GitHub Projekt verfügbar.
Validierung mithilfe von FluentValidation
Oft reichen die einfachen Validierungen mithilfe der Data Annotations nicht aus. Man möchte einerseits oft mehrere Properties innerhalb eines Commands oder Queries gemeinsam bzw. abhängig voneinander validieren. Anderseits sind manchmal auch komplexere Abfragen notwendig. Dafür eignet sich das Projekt FluentValidation hervorragend. Das erlaubt stark typisierte Validierungsregeln für Objekte zu erstellen. Im Gegensatz zu den Data Annotations werden hier also ganze Objekte validiert und nicht nur einzelne Poperties. Das ermöglicht wesentlich komplexere Validierungen auf einfache Art und Weise zu erstellen.
Nehmen wir als Beispiel eine Abfrage von Personen. Wir möchten alle Personen abfragen, die zwischen dem angegeben Von- und Bis-Datum geboren sind. Die Query GetPeopleByBirthDate
könnte wie folgt aussehen:
public class GetPeopleByBirthDate : IQuery<IReadOnlyCollection<PersonDto>>
{
public DateTime From { get; set; }
public DateTime To { get; set; }
}
Nun möchten wir sicherstellen, dass das angegebene Bis-Datum nach dem angegebenen Von-Datum liegt. Außerdem möchten wir, dass das Startdatum größer oder gleich dem 1.1.1900 ist. Dazu erstellen wir mithilfe von FluentValidation einen Validator, der diese Regel abbildet:
public class GetPeopleByBirthDateValidator : AbstractValidator<GetPeopleByBirthDate>
{
public GetPeopleByBirthDateValidator()
{
this.RuleFor(c => c.To).GreaterThan(c => c.From);
this.RuleFor(x => x.From).GreaterThanOrEqualTo(new DateTime(1900, 1, 1));
}
}
Der Validator ist stark typisiert, indem der generische AbstractValidator<T>
mit dem zu validierenden Typ ausgeprägt wird. Mit diesem Ansatz haben wir nun also spezifische Validatoren für die jeweiligen Commands und Queries. Was wir als nächstes erreichen wollen ist, dass wir - wie bei den Data Annotations - einen Decorator haben, der automatisch den passenden Validator für das jeweilige Command oder Query verwendet, um die Validierung damit durchzuführen. In diesem Fall ist das etwas komplexer, weil wir hier die passenden Typen zusammenfinden müssen. Im Fall der Data Annotations war das kein Problem, das wir hier jedes Command und Query gleich behandeln und nicht typsicher sind. Der Data Annotations Validator nimmt einfach eine Instanz vom Typ object
und validiert dieses anhand dessen Attribute.
Für die Lösung der Aufgabe kommt wieder das großartige Dependency Injection Framework Simple Injector zum Einsatz. AbstractValidator<T>
leitet vom Interface IValidator<T>
ab, das ebenfalls aus dem FluentValidation-Paket kommt. Daher können wir alle Validatoren innerhalb eines Assemblies auf einmal wie folgt registrieren. Hierbei werden alle Typen registiert, die IValidator<T>
implementieren.
this.container.Register(typeof(IValidator<>), new[] { Assembly.GetExecutingAssembly() });
Als zweiten Schritt benötigen wir erneut einen Decorator, der im Konstruktor einen IValidator<T>
für das jeweilige Command bzw. Query erhält und die ValidateAndThrowAsync(...)
Methode aufruft. Erst nach erfolgreicher Validierung wird an den Handler weitergeleitet.
public class FluentValidationCommandHandlerDecorator<TCommand> : ICommandHandler<TCommand>
where TCommand : ICommand
{
private readonly IValidator<TCommand> validator;
private readonly ICommandHandler<TCommand> decoratee;
public FluentValidationCommandHandlerDecorator(
IValidator<TCommand> validator,
ICommandHandler<TCommand> decoratee)
{
this.validator = validator ?? throw new ArgumentNullException(nameof(validator));
this.decoratee = decoratee ?? throw new ArgumentNullException(nameof(decoratee));
}
public async Task HandleAsync(TCommand command, CancellationToken cancellationToken)
{
await this.validator.ValidateAndThrowAsync(command, cancellationToken: cancellationToken);
await this.decoratee.HandleAsync(command, cancellationToken);
}
}
Jetzt gilt es noch eine Kleinigkeit zu lösen: Nicht alle unsere Command und Queries werden einen eigenen FluentValidation-Validator haben. Das wird nur einige wenige Commands und Queries betreffen, die eine spezielle Validierungslogik benötigen. Deshalb wird Simple Injector nicht für jedes Command und Query einen passenden IValidator<T>
finden, der dann in den Konstruktor des Decorators injiziert werden kann. Um das zu lösen, wenden wir das Null Object Pattern an. Wir implementieren einen NullValidator<T>
, der nichts validiert und als Validator für Commands und Queries verwendet wird, sofern es keinen spezifischeren Validator gibt.
Simple Injector bietet hierzu die Möglichkeit von bedingten Registierungen an. Der NullValidator<T>
wird nur dann registiert, wenn zuvor noch keine Registrierung für den generischen Typ T
(also für ein Command bzw. Query) erfolgt ist.
public class NullValidator<T> : AbstractValidator<T> { }
this.container.RegisterConditional(typeof(IValidator<>), typeof(NullValidator<>), c => !c.Handled);
Damit gibt es nun für alle Commands und Queries einen passenden IValidator<T>
, nämlich entweder einen spezifischen Validator, wie etwa den GetPeopleByBirthDateValidator
oder den NullValidator<T>
, wenn das Command oder Query keine eigene Validierungslogik hat.
Validierung als Teil der Geschäftslogik
Die Validierung von Commands und Queries ist Teil der Geschäftslogik. Die Geschäftslogik bestimmt, welche Validierungsregeln es gibt. Nicht selten ändern sich diese Regeln auch mit einer Änderung der Anforderungen über die Zeit hinweg. Daher ist es sinnvoll, die Validierungslogik auf Ebene der Commands und Queries anzusetzen, da diesen Ebene die Geschäftslogik-Schicht in einer Anwendung widerspiegelt. Damit bleibt man unabhängig von diversen Frameworks. Beispielsweise bietet ASP.NET Core ebenfalls die Möglichkeit, die eingehenden Models durch Data Annotations zu prüfen. Dadurch werden jedoch die Validierungsregeln auf einer höheren Schicht - nämlich auf Web-Ebene - durchgeführt und sind nicht mehr Teil der Geschäftslogik. Würde man dieselben Commands und Queries in einem anderen Kontext verwenden wollen, zum Beispiel in einer Konsolen- oder einer WPF-Anwendung, müsste man dort die Validierungslogik duplizieren. Daher ist der bessere Ansatz, die Validierung als Teil der Geschäftslogik-Schicht zu sehen und damit die Validierung - genauso wie die Commands und Queries - unabhängig vom verwendeten Framework wiederverwenden zu können.
Fazit
Wieder zeigt sich, dass die Anwendung des CQS-Musters ein sehr mächtiger Architekturansatz ist. Wir können die Validierungslogik komplett von der Implementierungslogik trennen. Mithilfe von Decoratorn lässt sich aspektorientierte Programmierung betreiben, das zu besserer Codequalität führt. Insbesondere das Single-Resposibility-Prinzip und das Open-Closed-Prinzip werden mit diesem Ansatz perfekt umgesetzt. Die Decorator selbst beinhalten auch keine Geschäftslogik, sondern sind lediglich Infrastrukturcode, um die notwendigen Teile zu verbinden.
Simple Injector ist hierfür ein wichtiges Werkzeug, das dabei hilft, das CQS-Muster auf einfache Art und Weise umzusetzen. Die automatische Registrierung aller Klassen innerhalb eines Assemblies, die ein bestimmtes Interface implementieren, ist hierbei besonders hervorzuheben. Das spart Zeit und Aufwand für den Entwickler und senkt die Fehleranfälligkeit. Die praktische Verify()
Methode des Simple Injector Containers prüft, ob der Abhängigkeitsgraph richtig aufgebaut werden kann. Platziert man diese Methode direkt beim Start der Anwendung, wo der Container erstellt wird, fallen etwaige fehlende Registierungen dem Entwickler sofort auf, da die Anwendung sofort eine Exception wirft und gar nicht richtig startet.
Ressourcen
- softaware CQS Library auf GitHub mit den benötigen CQS Klassen, wie Commands, Queries und diversen Decorators.
- ASP.NET Core CQS Beispielprojekt inkl. Authentifizierung und Rechte-Prüfungen mithilfe von CQS.
- System.Component.DataAnnotations Microsoft Dokumentation
- FluentValidation
- Simple Injector
- Eine SOLIDe Architektur: Erster Blogartikel zum Thema CQS.