Eine SOLIDe Architektur
Warum wir Geschäftslogik seit heuer anders implementieren: Zu Beginn eines jeden neuen Projekts entscheidet man sich für die grundlegende Architektur der neuen Anwendung.
In vielen unserer (Web-)Projekte verwenden wir die sogenannte 3-Schichten-Architektur.
Dazu zählen die Präsentations-Schicht, die Geschäftslogik-Schicht sowie die Datenzugriffs-Schicht.
In diesem Artikel wollen wir uns der Geschäftslogik-Schicht widmen und den bisherigen Ansatz kritisch hinterfragen.
Wir werden ein anderes Architekturmuster kennenlernen und sehen, welche Vorteile daraus entstehen.
Klassische Geschäftslogik-Schicht
Doch zunächst der klassische Ansatz:
In der Geschäftslogik werden üblicherweise mehrere Schnittstellen erstellt, die unterschiedliche Teile der Anwendung abdecken.
Das Verwalten von Benutzern könnte beispielsweise durch die folgende Schnittstelle beschrieben werden:
public interface IUserLogic
{
Task<IEnumerable<User>> GetAllUsersAsync();
Task<User> GetUserByEmailAsync(string email);
Task AddOrUpdateUserAsync(User user);
}
Gehen wir nun davon aus, dass das Verwalten von Benutzern nicht die einzige Aufgabe der gesamten Anwendung ist.
Es gibt also mehrere Bereiche, die jeweils durch eigene Schnittstellen abgebildet werden.
Wir wissen, dass Software nie fertig ist und im Laufe der Zeit neue Anforderungen an das Softwaresystem herangetragen werden.
Das ist nichts Außergewöhnliches und sollte uns als Softwareentwickler auch nicht überraschen.
Wir können also davon ausgehen, dass unsere Schnittstellen mit immer mehr Methoden ausgestattet werden, um auch die neuen Funktionen bereitstellen zu können.
Mit diesem Ansatz wird also die Anzahl der Schnittstellen immer größer und die Schnittstellen selbst beinhalten immer mehr Methoden.
Hier sollten schon die ersten Alarmglocken eines Softwarearchitekten läuten.
(Falls nicht, lesen Sie unter SOLID nach.)
Cross-Cutting Concerns
Stellen wir uns nun vor, wir wollen für all unsere Methoden in all unseren Schnittstellen sogenannte “Cross-Cutting Concerns” implementieren.
Cross-Cutting Concerns, zu Deutsch etwa “querschneidende Belange”, sind Anforderungen an das Softwaresystem, die das ganze System unabhängig vom konkreten Anwendungsfall betreffen.
Dazu zählen etwa Logging, Transaktionen, Caching, Validierung, Rechteprüfung und vieles mehr.
Werden wir konkreter: Wir wollen für all unsere Methoden die Dauer der Ausführungszeit messen und in Azure Application Insights schreiben (wozu sich übrigens das softaware NuGet-Paket UsageAware hervorragend eignet).
Mit dem bisherigen Ansatz müsste man in allen Methoden eine Änderung vornehmen, um die neue Logik mit aufzunehmen.
Das ist keine zufriedenstellende Variante im Sinne einer guten Architektur.
Eine mögliche Lösung ist das Anwenden des Decorator-Architekturmusters.
Mit weiteren Werkzeugen wie etwa PostSharp ist das jedoch nicht möglich, da die Methoden keine gemeinsame Schnittstelle aufweisen.
Erweiterbarkeit durch eine gemeinsame Schnittstelle
Wir müssen es also schaffen, dass Methoden eine gemeinsame Signatur aufweisen.
Dazu unterscheiden wir in dem neuen Architekturmuster grundlegend zwischen Queries, also Operationen, die etwas abfragen und Commands, also Operationen, die eine Aktion ausführen.
Mit dieser Definition lassen sich zwei sehr einfache, aber mächtige, generische Schnittstellen definieren:
public interface IQuery<TResult> { }
public interface ICommand { }
Die Methode GetUserByEmailAsync(string email)
von oben wird nun zu einer eigenen Klasse.
Die Methodenparameter werden zu Eigenschaften in dieser Klasse:
public class GetUserByEmail : IQuery<User>
{
public string Email { get; set; }
}
Nun benötigen wir noch ein Konstrukt, das die eigentliche Logik ausführt.
Wir nennen diese Konstrukte Handler.
Für die beiden neu definierten Schnittstellen oben benötigen wir zwei unterschiedliche Handler – einen für Queries und einen für Commands:
public interface IQueryHandler<TQuery, TResult>
where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query);
}
public interface ICommandHandler<TCommand>
where TCommand : ICommand
{
Task HandleAsync(TCommand command);
}
Für unsere GetUserByEmail
-Query sieht der entsprechende Handler wie folgt aus:
public class GetUserByEmailHandler : IQueryHandler<GetUserByEmail, User>
{
public async Task<User> HandleAsync(GetUserByEmail query)
{
var user = await ... // get user from data-access-layer
return user;
}
}
Der Ansatz scheint auf den ersten Blick recht aufwändig zu sein: Anstatt einer Schnittstelle mit vielen Methoden und einer Klasse, die diese Schnittstelle implementiert, sind plötzlich zwei Klassen pro Aktion (Query und Query-Handler bzw. Command und Command-Handler) notwendig.
Der entscheidende und überwiegende Vorteil ist, dass wir nun eine gemeinsame Schnittstelle für alle Queries und alle Commands haben.
Dadurch ist es ein Leichtes, Cross-Cutting Concerns anzuwenden, indem ein Decorator dafür erstellt wird.
Unser Zeitmessungs-Beispiel von vorhin könnte mit folgendem Decorator für alle Queries realisiert werden:
public class TimeMeasurementQueryDecorator<TQuery, TResult>
: IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
private readonly IQueryHandler<TQuery, TResult> decoratee;
public TimeMeasurementQueryDecorator(IQueryHandler<TQuery, TResult> decoratee)
{
this.decoratee = decoratee;
}
public async Task<TResult> HandleAsync(TQuery query)
{
Stopwatch sw = Stopwatch.StartNew();
TResult result = await this.decoratee.HandleAsync(query);
sw.Stop();
// log elapsed time ...
return result;
}
}
In unseren Decorator injizieren wir über den Konstruktur den Handler, der die eigentliche Logik aufruft.
Der Decorator delegiert an diesen Handler und retourniert das Ergebnis des Handlers an den Aufrufer.
Die letzte wichtige Komponente für die Realisierung dieses Architekturmusters ist ein Dependency-Injection-Framework, das auf möglichst einfache Art und Weise die Queries mit den dazugehörigen Query-Handlern bzw. die Commands mit ihren dazugehörigen Command-Handlern zusammenfügt.
Wir wollen nämlich keinesfalls alle Handler händisch registrieren.
Das ist aufwändig und fehleranfällig.
Simple Injector eignet sich dazu hervorragend.
Damit lassen sich mit wenigen Code-Zeilen alle zugehörigen Handler innerhalb von Assemblies zusammenfinden, wie folgender Code zeigt:
Assembly[] handlerAssemblies = new[] { Assembly.GetExecutingAssembly() };
container.Register(typeof(IQueryHandler<,>), handlerAssemblies);
container.Register(typeof(ICommandHandler<>), handlerAssemblies);
Fazit
Wir haben gesehen, dass es durch ein anderes Design der Geschäftslogik-Schnittstellen möglich ist, Cross-Cutting Concerns auf einfache Art und Weise anzuwenden.
Neue Funktionalität wird durch neue Klassen hinzugefügt und die Cross-Cutting Concerns werden zudem automatisch auf diese neuen Klassen angewandt.
Wir verwenden dieses Muster bereits erfolgreich in mehreren unserer Projekte.
Insbesondere das Thema von Benutzerberechtigungen ist dadurch sehr gut an einer zentralen Stelle gelöst.
Das System bleibt wartbar, leicht erweiterbar und ist robuster, da man auf Cross-Cutting Concerns für neue Funktionen nicht mehr vergessen kann.
Weitere Ressourcen
Das vorgestellte Architekturmuster ist nicht neu und auch nicht von uns erfunden.
Man findet es unter dem Namen “Command-Query-Separation” (CQS).
Der Autor des Dependency-Injection-Frameworks Simple Injector, Steven van Deursen, hat einen großen Beitrag geleistet, um das Muster in der .NET-Welt einsatzbereit zu machen.
Er ist auch Co-Autor der zweiten Auflage des Buchs “Dependency-Injection in .NET”, das am 30. Dezember 2018 erscheint und ein Kapitel diesem Thema widmet.
Das Architekturmuster stellen wir gerne auf unserem GitHub-Repository bereit.