Command and Query Pattern

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.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert