Immutable Commands und Blazor EditForm: das Mirror-Shape-Pattern sauber umsetzen

07/2026
Thomas Rib

TL;DR

Unveränderliche Command-Records und Blazors EditForm passen nicht zusammen: EditForm braucht beschreibbare Properties, ein Command ist per Definition abgeschlossen. Statt eine Seite zu verbiegen, gibt das Mirror-Shape-Pattern beiden Verantwortlichkeiten ein eigenes Objekt: ein mutierbares Form Model in der Presentation-Schicht spiegelt das Command, das unveränderlich in der Application-Schicht bleibt. Der Source Generator Mapperly übersetzt zwischen beiden zur Compile-Zeit: Läuft die Struktur auseinander, bricht der Build statt der Produktion. Geteilte FluentValidation-Bausteine verhindern doppelte Validierungslogik. Ergebnis: Binding-Ergonomie im Frontend und volle Command-Garantien in der Pipeline.

Wer in einer Clean-Architecture-Lösung konsequent mit unveränderlichen Command-Records arbeitet, stößt in Blazor schnell an eine harte Grenze: EditForm verlangt ein Objekt mit beschreibbaren Properties. Dieser Artikel zeigt, warum beide Seiten gute Gründe für ihr Verhalten haben, wie das Mirror-Shape-Pattern den Konflikt architektonisch sauber auflöst und wie der Source Generator Mapperly dafür sorgt, dass der gespiegelte Code nicht zur Wartungsfalle wird.

Das Wichtigste in Kürze: Commands bleiben unveränderliche Records in der Application-Schicht. Für jedes Formular entsteht ein dediziertes, mutierbares Form Model in der Presentation-Schicht, das die Command-Struktur spiegelt. Mapperly übersetzt zwischen beiden zur Compile-Zeit. Driftet die Struktur auseinander, bricht der Build statt der Produktion.

Zwei Welten, ein Konflikt

In einer typischen Clean-Architecture-Lösung mit CQRS sind Commands unveränderliche Records. Sie beschreiben eine abgeschlossene Absicht ("lege diesen Kontakt an") und wandern über MediatR durch eine Pipeline aus Logging und Validierung, bevor sie schließlich den Handler erreichen:

1// Application.Abstracts/Kontakte/Commands/CreateKontakt/CreateKontaktCommand.cs
2public sealed record CreateKontaktCommand(
3    string Vorname,
4    string Nachname,
5    string Email,
6    DateOnly? Geburtsdatum) : IRequest<Result<Guid>>;

In vielen Clean-Architecture-Templates ist IRequest<Result<T>> zusätzlich hinter einem Marker-Interface wie ICommand<T> gekapselt. Am Grundprinzip ändert das nichts: Das Command ist nach seiner Konstruktion nicht mehr veränderbar.

Auf der anderen Seite steht das Blazor-Frontend. Der naheliegende erste Versuch sieht so aus:

1<EditForm Model="_command" OnValidSubmit="HandleValidSubmitAsync">
2    <InputText @bind-Value="_command.Vorname" />
3</EditForm>
4
5@code {
6    private CreateKontaktCommand _command = new("", "", "", null);
7}

Der Compiler quittiert das mit CS8852: Init-only property or indexer can only be assigned in an object initializer. Positionale Record-Parameter werden zu init-only Properties, und genau die kann der von @bind-Value generierte Setter-Delegate nicht beschreiben. Der Konflikt ist also kein Laufzeitproblem, das man wegkonfigurieren könnte, sondern ein struktureller Widerspruch, den der Compiler zu Recht sichtbar macht.

Warum Commands unveränderlich bleiben sollten

Die pragmatische Abkürzung liegt auf der Hand: Man gibt dem Command einfach { get; set; }-Properties und bindet es direkt. Dass sich das falsch anfühlt, hat handfeste Gründe.

Ein Command ist konzeptionell eine Nachricht, keine Arbeitsfläche. Sobald es per Send auf die Reise geht, durchläuft es die MediatR-Pipeline: Ein ValidationBehavior prüft es gegen seinen FluentValidation-Validator, ein LoggingBehavior protokolliert es, erst danach erreicht es den Handler. Diese Kette funktioniert nur verlässlich, wenn das Objekt zwischen Validierung und Verarbeitung garantiert unverändert bleibt. Bei einem mutierbaren Command validiert die Pipeline im schlimmsten Fall einen anderen Zustand, als der Handler später verarbeitet. Das ist ein subtiler Fehler, der sich in Tests kaum zeigt.

Dazu kommen die praktischen Vorteile von Records: strukturelle Gleichheit macht Handler-Tests trivial (Assert.Equal(expectedCommand, capturedCommand)), with-Expressions erlauben abgeleitete Varianten ohne Seiteneffekte, und wer Commands über die Prozessgrenze schickt (etwa von einem Blazor-WASM-Client über Refit an eine WebAPI), profitiert davon, dass eine Nachricht per Definition ein Snapshot ist und kein lebendes Objekt.

Kurz: Die Unveränderlichkeit ist kein Stilmittel, sondern eine Garantie, auf die sich alles hinter dem Send-Aufruf verlässt.

Warum EditForm das nicht mitmachen kann

EditForm und der darunterliegende EditContext sind für den gegenteiligen Anwendungsfall gebaut. Ein Formular ist ein Entwurf, der über die gesamte Eingabedauer inkrementell wächst: Jeder Tastendruck in einem InputText löst über das Two-Way-Binding einen Property-Setter aus, der EditContext verfolgt pro FieldIdentifier, welche Felder sich geändert haben, und der ValidationMessageStore hängt Fehlermeldungen an genau diese Felder.

Dieses Modell setzt zwingend voraus, dass das gebundene Objekt Stück für Stück beschreibbar ist. Ein Command dagegen ist das fertige Ergebnis dieses Prozesses. Der Konflikt zwischen beiden ist damit kein technisches Versäumnis von Blazor, sondern ein Hinweis darauf, dass hier zwei verschiedene Verantwortlichkeiten in einem Objekt zusammengezwungen werden sollen: der wachsende Entwurf und die abgeschlossene Absichtserklärung.

Die Lösung besteht folgerichtig nicht darin, eine der beiden Seiten zu verbiegen, sondern darin, beiden Verantwortlichkeiten ein eigenes Objekt zu geben.

Das Mirror-Shape-Pattern

Das Pattern ist schnell erklärt: Für jedes Formular existiert ein dediziertes, mutierbares Form Model, das die Struktur des Commands spiegelt, mit gleichen Feldern und gleichen Namen, aber mit normalen { get; set; }-Properties:

1// Presentation/Features/Kontakte/CreateKontaktFormModel.cs
2public sealed class CreateKontaktFormModel
3{
4    public string Vorname { get; set; } = string.Empty;
5    public string Nachname { get; set; } = string.Empty;
6    public string Email { get; set; } = string.Empty;
7    public DateOnly? Geburtsdatum { get; set; }
8}

Die Blazor-Komponente bindet ausschließlich das Form Model. Erst im Submit-Handler, wenn der Entwurf abgeschlossen ist, entsteht daraus das unveränderliche Command:

1@page "/kontakte/neu"
2@inject ISender Sender
3@inject NavigationManager Navigation
4
5<EditForm Model="_form" OnValidSubmit="HandleValidSubmitAsync">
6    <FluentValidationValidator />
7
8    <InputText @bind-Value="_form.Vorname" />
9    <ValidationMessage For="() => _form.Vorname" />
10
11    <InputText @bind-Value="_form.Email" />
12    <ValidationMessage For="() => _form.Email" />
13
14    <button type="submit">Speichern</button>
15</EditForm>
16
17@code {
18    private CreateKontaktFormModel _form = new();
19
20    private async Task HandleValidSubmitAsync()
21    {
22        // Entwurf abgeschlossen -> unveränderliches Command erzeugen
23        var result = await Sender.Send(_form.ToCommand());
24
25        if (result.IsSuccess)
26        {
27            Navigation.NavigateTo($"/kontakte/{result.Value}");
28        }
29        // Fehlerpfad: result.Error dem Nutzer anzeigen (hier gekürzt)
30    }
31}

Der Übergang vom Entwurf zur Nachricht passiert an genau einer Stelle, und ab diesem Moment gelten wieder alle Garantien der Unveränderlichkeit. Das ToCommand() stammt dabei nicht aus handgeschriebenem Mapping-Code. Dazu gleich mehr.

Wohin gehört das Form Model?

Die architektonische Einordnung ist der Punkt, an dem das Pattern in der Praxis am häufigsten verwässert wird. Die Abhängigkeitsrichtung einer Clean-Architecture-Lösung gibt die Antwort eindeutig vor:

Domain  <-  Application.Abstracts  <-  Application  <-  Infrastructure
                     ^
                     |
            Presentation (Blazor)
            ├── CreateKontaktFormModel
            └── KontaktFormMapper

Das Form Model ist ein reines UI-Detail: Es existiert nur, weil EditForm es technisch verlangt. Es gehört deshalb in die Presentation-Schicht, direkt neben die Komponente, die es verwendet. Die Application-Schicht darf das Form Model niemals kennen: Ein Handler, der ein Form Model entgegennimmt, hätte die Schichtgrenze bereits verletzt und würde mit einem halbfertigen Entwurfstyp statt einer validierten Nachricht arbeiten.

Zwei Abgrenzungen haben sich in der Praxis bewährt. Erstens: ein Form Model pro Formular, nicht pro Entität. Das Anlege- und das Bearbeitungsformular eines Kontakts unterscheiden sich fast immer (etwa um die Id), und zwei kleine, exakte Klassen sind wartbarer als eine große mit optionalen Feldern. Zweitens: Query-DTOs nicht als Form Model zweckentfremden. Ein DTO ist die Lese-Projektion einer Query und folgt deren Lebenszyklus; sobald die Query ein Feld wie GeaendertAm dazubekommt, hätte das Formular plötzlich ein Feld zu viel.

Mapping ohne Reibungsverluste: Mapperly

Der ehrliche Preis des Mirror-Shape-Patterns ist das Mapping. Wer new CreateKontaktCommand(_form.Vorname, _form.Nachname, ...) von Hand schreibt, bezahlt bei jeder Command-Änderung doppelt, und der Compiler hilft nur bedingt, weil vertauschte Parameter gleichen Typs (Vorname/Nachname) klaglos durchgehen.

Für diese Aufgabe gibt es zwei etablierte Werkzeugklassen. Laufzeit-Mapper wie AutoMapper konfigurieren das Mapping per Reflection: Fehler zeigen sich erst zur Laufzeit, und in Blazor WASM kollidiert Reflection zunehmend mit Trimming und AOT-Kompilierung. Source Generatoren wie Mapperly erzeugen das Mapping dagegen zur Compile-Zeit als ganz normalen C#-Code: kein Laufzeit-Overhead, voll trimming- und AOT-tauglich, und der generierte Code ist in der IDE einsehbar und debugbar.

Für das Mirror-Shape-Pattern ist Mapperly aus einem weiteren, dem entscheidenden Grund die richtige Wahl:

1// Presentation/Features/Kontakte/KontaktFormMapper.cs
2using Riok.Mapperly.Abstractions;
3
4[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Both)]
5public static partial class KontaktFormMapper
6{
7    public static partial CreateKontaktCommand ToCommand(this CreateKontaktFormModel form);
8}

Mapperly mappt das Form Model automatisch auf den Konstruktor des Command-Records, Zuordnung per Namensgleichheit. Die Strategie RequiredMappingStrategy.Both verschärft die Diagnose: Jeder Konstruktor-Parameter des Commands braucht eine Quelle im Form Model, und jede Property des Form Models ein Ziel im Command. Bekommt das Command ein neues Pflichtfeld, das im Form Model fehlt, bricht der Build. Genau das macht den Spiegel wartbar: Die Strukturgleichheit wird nicht durch Disziplin gesichert, sondern durch den Compiler erzwungen.

Der Edit-Fall: von der Query über das Formular zurück zum Command

Beim Bearbeiten kommt die Gegenrichtung dazu. Der vollständige Fluss:

GetKontaktByIdQuery  ->  KontaktDto  ->  EditKontaktFormModel  ->  UpdateKontaktCommand
     (Query)             (Read-DTO)        (mutierbarer Spiegel)      (unveränderliche Absicht)

Command und Form Model für den Edit-Fall, diesmal inklusive Id:

1public sealed record UpdateKontaktCommand(
2    Guid Id,
3    string Vorname,
4    string Nachname,
5    string Email,
6    DateOnly? Geburtsdatum) : IRequest<Result>;
7
8public sealed class EditKontaktFormModel
9{
10    public Guid Id { get; set; }
11    public string Vorname { get; set; } = string.Empty;
12    public string Nachname { get; set; } = string.Empty;
13    public string Email { get; set; } = string.Empty;
14    public DateOnly? Geburtsdatum { get; set; }
15}

Der Mapper wächst um zwei Methoden: den Weg ins Formular hinein und den Rückweg ins Command:

1[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Both)]
2public static partial class KontaktFormMapper
3{
4    public static partial CreateKontaktCommand ToCommand(this CreateKontaktFormModel form);
5
6    public static partial UpdateKontaktCommand ToCommand(this EditKontaktFormModel form);
7
8    [MapperIgnoreSource(nameof(KontaktDto.GeaendertAm))]
9    public static partial EditKontaktFormModel ToFormModel(this KontaktDto dto);
10}

Das MapperIgnoreSource-Attribut zeigt einen angenehmen Nebeneffekt der strengen Diagnose: Enthält das DTO Felder, die im Formular nichts zu suchen haben (hier der Audit-Zeitstempel GeaendertAm), muss diese Entscheidung explizit im Code dokumentiert werden. Stillschweigend verschluckte Felder gibt es nicht.

In der Komponente bleibt der Ablauf schlank:

1@code {
2    [Parameter] public Guid KontaktId { get; set; }
3
4    private EditKontaktFormModel _form = new();
5
6    protected override async Task OnInitializedAsync()
7    {
8        var result = await Sender.Send(new GetKontaktByIdQuery(KontaktId));
9
10        if (result.IsSuccess)
11        {
12            _form = result.Value.ToFormModel();
13        }
14    }
15
16    private async Task HandleValidSubmitAsync()
17    {
18        var result = await Sender.Send(_form.ToCommand());
19        // Result auswerten wie im Create-Fall
20    }
21}

Validierung ohne Doppelpflege

Mit zwei Objekten stellt sich zwangsläufig die Frage nach der Validierung. In der Pipeline validiert ein ValidationBehavior das Command. Diese Prüfung ist autoritativ und darf nie entfallen, denn insbesondere bei Blazor WASM ist der Client nicht vertrauenswürdig. Für eine gute User Experience braucht das Formular aber Feld-Feedback während der Eingabe, also einen Validator auf dem Form Model.

Regeln in beiden Validatoren zu duplizieren führt erfahrungsgemäß zum Drift: Die Pipeline verlangt irgendwann 100 Zeichen, das Formular erlaubt noch 200. Die tragfähige Lösung sind gemeinsame Regel-Bausteine als FluentValidation-Extensions, die neben dem Command-Validator in Application.Abstracts liegen. Die Presentation-Schicht darf dorthin referenzieren, die Abhängigkeitsrichtung bleibt intakt:

1// Application.Abstracts/Kontakte/Validation/KontaktRules.cs
2public static class KontaktRules
3{
4    public static IRuleBuilderOptions<T, string> VornameRules<T>(
5        this IRuleBuilder<T, string> rule) =>
6        rule.NotEmpty().MaximumLength(100);
7
8    public static IRuleBuilderOptions<T, string> KontaktEmailRules<T>(
9        this IRuleBuilder<T, string> rule) =>
10        rule.NotEmpty().EmailAddress().MaximumLength(256);
11}

Beide Validatoren konsumieren dieselben Bausteine:

1// Application.Abstracts – läuft im ValidationBehavior der Pipeline
2public sealed class CreateKontaktCommandValidator : AbstractValidator<CreateKontaktCommand>
3{
4    public CreateKontaktCommandValidator()
5    {
6        RuleFor(c => c.Vorname).VornameRules();
7        RuleFor(c => c.Email).KontaktEmailRules();
8    }
9}
10
11// Presentation – läuft im EditForm via <FluentValidationValidator />
12public sealed class CreateKontaktFormValidator : AbstractValidator<CreateKontaktFormModel>
13{
14    public CreateKontaktFormValidator()
15    {
16        RuleFor(f => f.Vorname).VornameRules();
17        RuleFor(f => f.Email).KontaktEmailRules();
18    }
19}

Die Einbindung ins Formular übernimmt die Community-Bibliothek Blazored.FluentValidation mit ihrer <FluentValidationValidator />-Komponente. Zur Einordnung der drei denkbaren Strategien:

Tabelle

Strategie Live-Feedback im Formular Drift-Risiko Empfehlung
Nur Pipeline-Validierung Nein – Fehler erst nach Submit Keins Nur für interne Tools akzeptabel
Zwei unabhängige Validatoren Ja Hoch Vermeiden
Geteilte Regel-Bausteine Ja Gering Standard

StrategieLive-Feedback im FormularDrift-RisikoEmpfehlungNur Pipeline-ValidierungNein, Fehler erst nach SubmitKeinsNur für interne Tools akzeptabelZwei unabhängige ValidatorenJaHochVermeidenGeteilte Regel-BausteineJaGeringStandard

Unabhängig von der gewählten Strategie gilt: Die Pipeline-Validierung bleibt die letzte Instanz. Der Formular-Validator ist Komfort, kein Sicherheitsmechanismus.

Grenzen, Einwände, Alternativen

"Ist die zusätzliche Klasse nicht Boilerplate?" Ja, pro Formular entsteht eine kleine zusätzliche Klasse. Der Einwand verliert allerdings an Gewicht, sobald man sie nicht als Duplikat, sondern als eigenständige Verantwortlichkeit begreift. Form Models entwickeln in realen Projekten schnell ein Eigenleben: ein Bestätigungs-Checkbox-Feld, das nie Teil des Commands wird, ein string-Feld für eine Eingabe, die erst beim Mapping in ein Value Object geparst wird. Spätestens dann ist der Spiegel kein Spiegel mehr, sondern ein echtes Eingabemodell.

"Warum nicht das Form Model direkt als Command senden?" Damit würde die Application-Schicht einen UI-Typ kennen (Verletzung der Abhängigkeitsrichtung) oder das Command in die Presentation wandern, und der Handler bekäme in beiden Fällen ein mutierbares Objekt, dessen Zustand zwischen Validierung und Verarbeitung nicht garantiert ist. Der ursprüngliche Konflikt wäre nicht gelöst, nur verschoben.

"Kann man die Form Models nicht generieren?" Technisch ja, ein eigener Source Generator könnte aus jedem Command per Attribut ein Form Model ableiten. Der Aufwand lohnt sich in den meisten Projekten nicht: Sobald Form Models zusätzliche UI-Felder tragen, ist die Generierung im Weg, und die Drift-Absicherung liefert Mapperly über die Build-Fehler bereits vollständig.

Fazit

Der scheinbare Konflikt zwischen unveränderlichen Commands und Blazors EditForm ist bei genauem Hinsehen ein Hinweis auf zwei verschiedene Verantwortlichkeiten: das Formular als wachsender Entwurf, das Command als abgeschlossene Nachricht. Das Mirror-Shape-Pattern gibt beiden ein eigenes Objekt in der jeweils richtigen Schicht, und Mapperly sorgt als Source Generator dafür, dass der Preis dafür nicht in Wartungsaufwand, sondern in einer einzigen, compilergeprüften Mapping-Klasse besteht.

Tabelle

Baustein Schicht Verantwortung
CreateKontaktCommand (Record) Application.Abstracts Unveränderliche, validierte Absicht
CreateKontaktFormModel Presentation Mutierbarer Entwurf für das Binding
KontaktFormMapper (Mapperly) Presentation Compile-time-geprüfte Übersetzung
KontaktRules (Extensions) Application.Abstracts Geteilte Validierungsregeln

BausteinSchichtVerantwortungCreateKontaktCommand (Record)Application.AbstractsUnveränderliche, validierte AbsichtCreateKontaktFormModelPresentationMutierbarer Entwurf für das BindingKontaktFormMapper (Mapperly)PresentationCompile-time-geprüfte ÜbersetzungKontaktRules (Extensions)Application.AbstractsGeteilte Validierungsregeln

Wer das Pattern konsequent anwendet, bekommt beides: die Binding-Ergonomie, für die EditForm gebaut wurde, und die Garantien, auf die sich die gesamte Command-Pipeline verlässt.

Thomas Rib

Mehr zum Thema

Pfeil nach rechts (Verlinkung)
Blazor mit TypeScript kombinieren: Typsichere JS Interop, Setup mit .NET 10, npm und tsconfig für stabile, wartbare Webanwendungen.
08/2026

Blazor mit TypeScript: Integration & Best Practices

Blauer Pfeil nach rechts (Verlinkung)
Aspire vereinfacht das lokale Setup verteilter .NET-Anwendungen: Orchestrierung, Service Discovery und Observability als versionierbarer C#-Code.
07/2026

Aspire: Schnellere Entwicklung, einfacherer Start

Blauer Pfeil nach rechts (Verlinkung)
Erfahren Sie, wie SQL Indizes Datenbankabfragen beschleunigen, Fallstricke vermeiden und die Performance Ihrer Anwendung nachhaltig verbessern.
06/2026

SQL Indizes Datenbankabfragen effizient optimieren

Blauer Pfeil nach rechts (Verlinkung)
TUnit im Praxistest: das moderne .NET-Test-Framework mit Source-Generierung, paralleler Ausführung & Native-AOT für schnellere Tests.
06/2026

NuGet Showcase: TUnit, das moderne .NET-Test-Framework

Blauer Pfeil nach rechts (Verlinkung)