Command and Query Pattern – klar getrennte Verantwortlichkeiten
Das Command-and-Query-Pattern basiert auf einem einfachen Prinzip: Methoden, die Daten verändern (Commands), sollten keine Rückgabewerte liefern. Methoden, die Daten abfragen (Queries), sollten keine Seiteneffekte haben. Diese Trennung erhöht die Lesbarkeit, Testbarkeit und Wartbarkeit von Code – besonders bei komplexer Business-Logik.
Ein typisches Setup besteht aus zwei Interfaces: ICommandHandler<TCommand>
und IQueryHandler<TQuery, TResult>
. Jede Operation wird durch einen eigenen Datentyp modelliert – ein sogenanntes Command oder Query-Objekt. Dadurch werden alle Operationen explizit und klar strukturiert.
public interface ICommandHandler<in TCommand> { void Handle(TCommand command); } public interface IQueryHandler<in TQuery, out TResult> { TResult Handle(TQuery query); }
Ein Command beschreibt eine Operation mit Seiteneffekt, z. B. das Erstellen eines Benutzers:
public record CreateUser(string Name); public class CreateUserHandler : ICommandHandler<CreateUser> { public void Handle(CreateUser command) { // z. B. persistieren } }
Ein Query hingegen fragt nur Daten ab:
public record GetUserById(Guid Id); public class GetUserByIdHandler : IQueryHandler<GetUserById, User> { public User Handle(GetUserById query) { // Datenbankabfrage } }
Features per Decorator ein- und ausschalten
Durch das gemeinsame Interface lassen sich Handler leicht per Decorator erweitern. Typische Features wie Logging, Validierung oder Performance-Messung lassen sich modular hinzufügen – ohne den ursprünglichen Code zu verändern.
public class LoggingCommandDecorator<TCommand> : ICommandHandler<TCommand> { private readonly ICommandHandler<TCommand> _inner; private readonly ILogger<LoggingCommandDecorator<TCommand>> _logger; public LoggingCommandDecorator(ICommandHandler<TCommand> inner, ILogger<LoggingCommandDecorator<TCommand>> logger) { _inner = inner; _logger = logger; } public void Handle(TCommand command) { _logger.LogInformation($"Handling {typeof(TCommand).Name}"); _inner.Handle(command); _logger.LogInformation($"{typeof(TCommand).Name} handled"); } }
Zentraler Dispatcher
Um Commands und Queries noch besser entkoppeln zu können, bietet sich ein Dispatcher an. Statt Handler direkt aufzurufen, nutzt die Anwendung ICommandDispatcher
und IQueryDispatcher
. Diese abstrahieren die Auflösung und Ausführung und machen das Pattern testbar, UI-freundlich und erweiterbar.
public interface ICommandDispatcher { void Dispatch<TCommand>(TCommand command); } public class CommandDispatcher : ICommandDispatcher { private readonly IServiceProvider _provider; public CommandDispatcher(IServiceProvider provider) { _provider = provider; } public void Dispatch<TCommand>(TCommand command) { var handler = _provider.GetRequiredService<ICommandHandler<TCommand>>(); handler.Handle(command); } }
Attribute-basierte Decorator-Steuerung
Decorator Chains können auch automatisch aktiviert werden – z. B. per Marker-Attributen. Dadurch lässt sich Logging oder Tracing zentral per Konvention oder Source Generator aktivieren, ohne manuelle Registrierung für jeden Handler.
[UseLogging] public class CreateUserHandler : ICommandHandler<CreateUser> { public void Handle(CreateUser command) { // ... } }
Zur Laufzeit (z. B. beim DI-Setup) kann per Reflection entschieden werden, ob ein LoggingDecorator benötigt wird. Diese Technik funktioniert besonders gut mit Source Generators oder zentralen Registrierungsservices.
TransactionScopeDecorator
Ein weiterer praktischer Decorator ist der Transaktions-Wrapper. Er startet eine Datenbanktransaktion vor der Command-Ausführung und committet sie danach – oder rollt im Fehlerfall zurück.
public class TransactionScopeDecorator<T> : ICommandHandler<T> { private readonly ICommandHandler<T> _inner; public TransactionScopeDecorator(ICommandHandler<T> inner) { _inner = inner; } public void Handle(T command) { using var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); _inner.Handle(command); tx.Complete(); } }
Environment-basierte Decorator-Aktivierung
Decorators können je nach Umgebung unterschiedlich aktiviert werden. So ist Logging im Development aktiv, aber in Production deaktiviert – Audit-Trails hingegen nur in Produktivsystemen. Die Entscheidung trifft das DI-Setup auf Basis von Umgebungsvariablen oder Konfiguration.
if (env.IsDevelopment()) { services.DecorateAllWith<LoggingCommandDecorator>(); } if (env.IsProduction()) { services.DecorateAllWith<AuditTrailDecorator>(); }
Automatische Handler-Registrierung
Mit Tools wie Scrutor oder selbstgebauten Registrier-Helfern lassen sich alle Handler automatisch erkennen und registrieren – inklusive ihrer Decorators. So bleibt das Setup schlank und neue Commands benötigen nur eine Klasse – keine Änderungen im DI-System.
services.Scan(scan => scan .FromAssemblyOf<ICommandHandler<>>() .AddClasses(c => c.AssignableTo(typeof(ICommandHandler<>))) .AsImplementedInterfaces() .WithTransientLifetime());
Dieses Setup reduziert Boilerplate-Code und erhöht die Skalierbarkeit erheblich.