Von der Aktion zur Datenbank: eine interaktive Reise durch Delegates, Events und Architektur
Ein interaktiver Workshop vom Klick in der Komponente bis zum dauerhaften Speichern: Aktionen, Delegates, Hierarchien, Beziehungen, EF Core, PostgreSQL, Outbox, Nebenläufigkeit und E2E-Tests.
Der vollständige Artikel steht unten. Aktivieren Sie JavaScript für Quiz und Simulationen.
Interaktiver Architektur-Workshop
Wir lernen Klassen nicht getrennt von der Datenbank. Wir bauen einen durchgängigen Ablauf, der im Produktivbetrieb bestehen muss.
In dieser Lektion baust du ein Modul zur Auftragsbearbeitung: vom Klick in der Oberfläche über Aktion und Delegate bis zur Domänenregel, Transaktion, Datenbankbeziehung und dem Ereignis nach dem Speichern. Dabei triffst du sechs Architekturentscheidungen, ordnest die Pipeline richtig an und startest eine Abnahmesimulation.
Das Material hat drei Ebenen. Einsteiger sehen, was welchen Schritt auslöst. Fortgeschrittene verstehen, wo die Verantwortung einer Schicht endet. Erfahrene Entwickler erhalten Prüfpunkte für Nebenläufigkeit, Idempotenz, Beobachtbarkeit und die Weiterentwicklung des Modells.
Unser Beispielprojekt
System: WorkFlow
Ein Kunde erstellt einen Auftrag. Ein Operator nimmt ihn an. Das System speichert den Verlauf, veröffentlicht ein Ereignis und aktualisiert die Ansicht. Klingt einfach – bis jemand zweimal klickt, die Datenbank langsamer antwortet oder der Worker neu startet.
Neue Werkzeuge helfen. Sie nehmen uns die Verantwortung für den Entwurf nicht ab.
Extension Members und einfachere Lambdas
Kleine Verhaltensweisen lassen sich heute verständlicher ausdrücken. Das ist aber keine Einladung, Domänenlogik in einer Sammlung beliebiger Erweiterungen zu verstecken.
Ein besseres Modell, benannte Filter und neue LINQ-Operatoren
LeftJoin, benannte Filter und die weiterentwickelte JSON-Unterstützung erleichtern die Arbeit. Trotzdem müssen wir generiertes SQL, Abfragepläne und Migrationsfolgen prüfen.
Ein schnellerer Entwurf, dieselbe Verantwortung
Ein Modell kann eine Entität, einen Handler und einen Test vorschlagen. Es kennt aber weder sämtliche Invarianten des Unternehmens noch die Produktionsdaten oder die Folgen einer falschen Entscheidung.
Die Regel für 2026: Nutze Neuerungen dort, wo sie den Vertrag vereinfachen. Nicht, damit die Architektur auf dem Diagramm moderner aussieht.
Das Denkmodell
Aktion, Command, Ereignis und Delegate sind nicht vier Namen für dasselbe
Eine Nutzeraktion findet in der Oberfläche statt: ein Klick, das Absenden eines Formulars oder das Verschieben eines Elements. Ein Command beschreibt eine beabsichtigte Änderung: „Auftrag 42 annehmen“. Ein Ereignis beschreibt einen bereits eingetretenen Sachverhalt: „Auftrag 42 wurde angenommen“. Ein Delegate ist ein typisierter Vertrag für ein Verhalten, das sich als Wert übergeben lässt.
Der wichtigste Unterschied ist der Zeitpunkt. Ein Command kann abgelehnt werden. Ein Domänenereignis lehnen wir nicht ab: Es beschreibt einen vom Modell festgehaltenen Sachverhalt. Ein Delegate ist weder Sachverhalt noch Absicht. Er sagt nur: „Hier brauche ich eine Funktion mit dieser Eingabe und diesem Ergebnis.“
Auf eine Schaltfläche geklickt, ein Formular abgeschickt, eine Variante ausgewählt.
Eine ausdrückliche Absicht, Eingabedaten und eine Kennung des Versuchs.
Ein Sachverhalt, der nach erfolgreicher Prüfung der Domänenregeln entstanden ist.
Eine typisierte Funktion, die ohne Kenntnis ihrer Implementierung genutzt wird.
Der Kunde hat auf „Annehmen“ geklickt. Darf die Oberfläche sofort „gespeichert“ anzeigen?
Ja. Der Klick ist Beweis genug.
Nein. Zuerst braucht sie die Bestätigung des Servers.
Wähle eine Antwort. Du kannst jede Entscheidung korrigieren.
Die Grenze der Oberfläche
Die Komponente erfasst die Absicht. Sie sollte keinen Geschäftsprozess koordinieren.
Die Komponente kennt Formularfelder, die Verfügbarkeit der Schaltfläche, den Ladezustand und Meldungen. Sie sollte nicht wissen müssen, dass das Backend drei Tabellen beschreibt, eine Nachricht veröffentlicht und eine Projektion aktualisiert. Steuert der Browser diese Reihenfolge, hinterlässt eine mittendrin geschlossene Registerkarte einen unvollständigen Systemzustand.
type AcceptState =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'accepted'; version: number }
| { kind: 'conflict'; message: string }
| { kind: 'failed'; message: string };
async accept(order: WorkOrderView): Promise<void> {
if (this.state().kind === 'sending') return;
this.state.set({ kind: 'sending' });
try {
const result = await firstValueFrom(this.api.accept(order.id, {
expectedVersion: order.version,
idempotencyKey: crypto.randomUUID()
}));
this.state.set({
kind: 'accepted',
version: result.version
});
} catch (error) {
this.state.set(mapAcceptError(error));
}
}
Die Komponente ruft einzeln auf: Auftrag speichern, Kontingent reduzieren, E-Mail senden. Was änderst du?
Ich ersetze die drei Requests durch einen fachlichen Command.
Ich behalte die drei Requests und verwende Promise.all.
Entscheide, wer die Operation koordinieren soll.
Delegates und Events
Ein Delegate übergibt Verhalten. Ein Event begrenzt, wer es auslösen darf.
Delegates sind typisiert und lassen sich wie Daten übergeben. Sie eignen sich für kleine Strategien, Prädikate, Pipeline-Schritte und Callbacks. Ein Event verwendet einen Delegate, setzt aber eine wichtige Grenze: Von außen kann man sich an- und abmelden, auslösen darf das Event nur sein Besitzer.
Soll ein Empfänger eine Frage erhalten und eine einzige Antwort liefern, verwende einen Delegate oder ein Interface. Veröffentlicht ein Besitzer einen Sachverhalt an keinen, einen oder mehrere Zuhörer, passt ein Event besser. Verlässt die Kommunikation den Prozess oder muss sie einen Neustart überstehen, brauchst du eine dauerhafte Nachricht, einen Broker und einen idempotenten Consumer.
public delegate ValueTask<StepResult> WorkOrderStep(
WorkOrderContext context,
CancellationToken cancellationToken);
public sealed class WorkOrderPipeline(IEnumerable<WorkOrderStep> steps)
{
public async ValueTask<StepResult> ExecuteAsync(
WorkOrderContext context,
CancellationToken ct)
{
foreach (var step in steps)
{
var result = await step(context, ct);
if (!result.CanContinue)
return result;
}
return StepResult.Success();
}
}
Eine Preisberechnung braucht einen kleinen, kampagnenabhängigen Rabattalgorithmus. Was wählst du?
Einen typisierten Delegate, den ich dem Rechner übergebe.
Zehn Klassen, die von AbstractDiscountBase erben.
Wähle den kleinsten Vertrag, der das Problem tatsächlich löst.
Die Domänenhierarchie
Eine Hierarchie soll Regeln zeigen, keine Ordnerstruktur
Wir beginnen mit dem Verhalten. Ein Auftrag darf nur vom Entwurf in den angenommenen Zustand wechseln, wenn er mindestens eine Position, einen gültigen Besitzer und eine aktuelle Version hat. Die Entität schützt diesen Übergang. Der Handler lädt Daten und speichert das Ergebnis. Das Repository trifft keine fachlichen Entscheidungen.
public sealed class WorkOrder
{
private readonly List<IDomainEvent> _events = [];
public WorkOrderId Id { get; private set; }
public WorkOrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<WorkOrderLine> Lines => _lines;
public Result Accept(UserId actor, DateTimeOffset now)
{
if (_lines.Count == 0)
return Result.Invalid("work_order_has_no_lines");
if (Status is WorkOrderStatus.Accepted)
return Result.NoChange();
if (Status is not WorkOrderStatus.Draft)
return Result.Conflict("invalid_status_transition");
Status = WorkOrderStatus.Accepted;
Version++;
_events.Add(new WorkOrderAccepted(Id, actor, now, Version));
return Result.Changed();
}
}
Wo gehört die Regel „keinen Auftrag ohne Positionen annehmen“ hin?
Ausschließlich in die Komponente, über disabled.
Ins Domänenmodell; die Oberfläche bildet sie ebenfalls ab.
Wähle eine Grenze, die auch ein anderer API-Client nicht umgehen kann.
Die relationale Datenbank
Das Modell im Code erzählt die Geschichte. Das Datenbankschema stellt sicher, dass sie möglich ist.
Ein relationales Modell sollte nicht jedes Objekt spiegeln. Es soll die Fakten, Beziehungen und Einschränkungen dauerhaft speichern, die das System schützen muss. Ein Auftrag hat mehrere Positionen. Jeder Änderungsversuch hat einen eindeutigen Idempotenzschlüssel. Jede Outbox-Nachricht verweist auf das Aggregat, aus dem sie entstanden ist.
Die Datenbank entscheidet den Wettlauf zwischen parallelen Schreibvorgängen. Eine Validierung in C# ermöglicht bessere Meldungen. Ein Unique Index, Fremdschlüssel und Concurrency Token bilden aber die letzte Verteidigungslinie, wenn neue Integrationen oder Datenimporte hinzukommen.
Id PK
CustomerId FK
Status
Version concurrency
Id PK
WorkOrderId FK
ProductId FK
Quantity CHECK > 0
Id PK
TenantId FK
IdempotencyKey UNIQUE
ResultJson
Id PK
AggregateId
EventType
ProcessedAt NULL
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<WorkOrder>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Version).IsConcurrencyToken();
entity.HasIndex(x => new { x.TenantId, x.Status });
entity.HasMany(x => x.Lines)
.WithOne()
.HasForeignKey(x => x.WorkOrderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<CommandReceipt>(entity =>
{
entity.HasIndex(x => new { x.TenantId, x.IdempotencyKey })
.IsUnique();
});
}
Füge die Spalte als nullable oder mit einem sicheren Standardwert hinzu.
Ergänze Daten in Batches und beobachte Sperren und Laufzeit.
Stelle Schreib- und Lesezugriffe auf den neuen Vertrag um.
Entferne die alte Spalte erst, wenn der neue Datenverkehr bestätigt ist.
Zwei Prozesse haben AnyAsync ausgeführt und beide keinen Idempotenzschlüssel gefunden. Was verhindert das Duplikat?
Ein weiteres AnyAsync direkt vor SaveChanges.
Ein Unique Index und die Behandlung des Schreibkonflikts.
Wähle einen Mechanismus, der auch bei echter Parallelität funktioniert.
Transaktion und Outbox
COMMIT bestätigt den Zustand. Die Outbox trägt den Sachverhalt über die Datenbankgrenze hinaus.
Auftrag, Idempotenzbeleg und Outbox-Eintrag müssen in derselben Transaktion gespeichert werden. E-Mail, Webhook oder Veröffentlichung an einen Broker gehören nicht in diese Transaktion: Ein externer Anbieter kann nicht an der PostgreSQL-Transaktion teilnehmen. Deshalb speichern wir die Zustellabsicht, führen COMMIT aus und lassen einen Worker die Nachricht mit Retry und Deduplizierung zustellen.
public async Task<AcceptWorkOrderResult> Handle(
AcceptWorkOrder command,
CancellationToken ct)
{
var receipt = await db.CommandReceipts.SingleOrDefaultAsync(
x => x.TenantId == command.TenantId
&& x.IdempotencyKey == command.IdempotencyKey,
ct);
if (receipt is not null)
return receipt.ReadResult<AcceptWorkOrderResult>();
var order = await db.WorkOrders
.Include(x => x.Lines)
.SingleAsync(x => x.Id == command.WorkOrderId, ct);
if (order.Version != command.ExpectedVersion)
throw new WorkOrderConflictException();
order.Accept(command.ActorId, clock.UtcNow);
var result = AcceptWorkOrderResult.From(order);
db.CommandReceipts.Add(CommandReceipt.From(command, result));
db.OutboxMessages.AddRange(order.DequeueEvents().Select(OutboxMessage.From));
await db.SaveChangesAsync(ct);
return result;
}
Zwei Operatoren lesen denselben Datensatz.
Die Datenbank erhöht die Version auf 8.
Die Aktualisierungsbedingung passt nicht mehr.
Die Oberfläche lädt den Zustand neu, statt die Änderung zu überschreiben.
Wann darf die Nachricht „Auftrag angenommen“ gesendet werden?
Nach dem COMMIT, über einen Worker, der die Transactional Outbox liest.
Vor SaveChanges, damit es schneller geht.
Wähle den Zeitpunkt, der keine falschen Fakten erzeugt.
Beobachtbarkeit und Tests
Ein System ist erst bereit, wenn du seine Entscheidung nachvollziehen kannst
Ein Logeintrag „etwas ist schiefgelaufen“ reicht nicht. Jede Operation sollte eine Correlation ID, den Namen des Anwendungsfalls, eine anonymisierte Akteurskennung, die Aggregatversion und die Dauer ihrer Schritte mitführen. Speichere keine vollständigen Payloads, Tokens oder sensiblen Daten. Telemetrie soll den Ablauf nachvollziehbar machen und keine zweite Kundendatenbank in den Logs erzeugen.
using var activity = telemetry.StartActivity("work_order.accept");
activity?.SetTag("work_order.id", command.WorkOrderId);
activity?.SetTag("work_order.expected_version", command.ExpectedVersion);
activity?.SetTag("operation.idempotency_hash",
hasher.ShortHash(command.IdempotencyKey));
var result = await handler.Handle(command, ct);
metrics.WorkOrdersAccepted.Add(1,
new("result", result.Kind),
new("source", command.Source));
Prüft Zustandsübergänge und Invarianten schnell.
Führt EF Core mit dem echten Provider aus und prüft Indizes und Transaktion.
Bestätigt die Statuscodes 200, 400, 403, 409 und ein stabiles Problem Details.
Prüft den Weg von der Oberfläche bis zur dauerhaften Wirkung und Telemetriespur.
Spiel: Baue den Ablauf
Wähle die Kacheln in der Reihenfolge, in der eine sichere Operation das System durchläuft
DomänenregelDas Aggregat entscheidet
Eine NutzeraktionDie Absicht entsteht
Outbox-Workerführt die Wirkung nach dem COMMIT aus
Anwendungshandlerkoordiniert den Anwendungsfall
Neuer UI-Zustandzeigt das bestätigte Ergebnis
HTTP-Vertragautorisiert und validiert die Eingabe
DatenbanktransaktionZustand, Beleg und Outbox gemeinsam
Beginne dort, wo der Nutzer seine Absicht ausdrückt.
Neu anordnen
Deployment-Simulator
Führe die Änderung durch die Produktionsabnahme
Die Simulation prüft Vertrag, Autorisierung, Versionskonflikt, Schlüsseleindeutigkeit, atomare Outbox, Worker-Retry und die Aktualisierung der Ansicht.
Vollständigen Test starten
Umgebung bereit. Starte einen kontrollierten Durchlauf.
Stelle vor dem Merge die Fragen, die im Happy Path unsichtbar bleiben
Wer verantwortet die Entscheidung?
Gilt dieselbe Regel für API, Worker und Import?
Was passiert bei einer Wiederholung?
Liefert die Operation dasselbe Ergebnis oder erzeugt sie eine zweite Wirkung?
Was machen zwei Prozesse gleichzeitig?
Löst die Datenbank den Konflikt atomar auf, und erklärt die Oberfläche den Status 409?
Was kann zwischen den Schritten ausfallen?
Erlauben Transaktion und Outbox eine Wiederaufnahme ohne manuelle Aufräumarbeiten?
Wie ändern wir das Modell ohne Ausfallzeit?
Unterstützt die Migration während des Rollouts zwei Anwendungsversionen?
Wie belegen wir das Ergebnis?
Zeigen Test und Telemetrie eine dauerhafte Wirkung, ohne Daten offenzulegen?
Wähle das Werkzeug passend zum Problem
| Bedarf | Kleinster sinnvoller Mechanismus | Gefährliche Abkürzung | Abnahmenachweis |
|---|---|---|---|
| Kleines Verhalten übergeben | Delegate / Func / Strategie | Hierarchie ohne Zustand und Regeln | Test von Eingabe und Funktionsergebnis |
| Mehrere Zuhörer innerhalb eines Prozesses benachrichtigen | Event mit eindeutigem Besitzer | globaler statischer Event Bus | Test von Abonnements und fehlenden Speicherlecks |
| Einen Sachverhalt über Prozessgrenzen übertragen | Outbox + Broker + idempotenter Consumer | Publish vor COMMIT | Test von Retry und Deduplizierung |
| Datenbeziehungen schützen | FK, UNIQUE, CHECK, Concurrency Token | Validierung ausschließlich in der Oberfläche | Integrationstest mit dem Provider |
| Den vollständigen Ablauf sehen | Trace + Metriken + sichere Logs | vollständiger Payload in jedem Log | Correlation ID von der Oberfläche bis zum Worker |
Was du ins Projekt mitnimmst
Gute Architektur bemisst sich nicht an der Zahl der Schichten. Sie zeigt sich in ausdrücklichen Entscheidungen und überprüfbaren Ergebnissen.
Die Aktion beginnt den Dialog. Der Command trägt die Absicht. Der Handler koordiniert den Anwendungsfall. Die Domäne sagt „ja“ oder „nein“. Die Datenbank sichert die Konsistenz. Die Outbox überquert die Systemgrenze sicher. Die Oberfläche zeigt das Ergebnis erst, wenn sie einen Nachweis hat.
Stehst du am Anfang, gehe zur Begriffsübersicht zurück und ordne den Ablauf noch einmal. Arbeitest du bereits als Entwickler, formuliere die Verträge für deinen eigenen Anwendungsfall. Trägst du als Senior Verantwortung für ein System, prüfe mit dem Team Retries, Nebenläufigkeit, Migrationen und die Telemetriespur.
Unten findest du das abschließende Quiz, das serverseitig ausgewertet wird. Es prüft keine auswendig gelernten Definitionen. Es prüft, ob du eine Lösung wählen kannst, die ehrlich reagiert, sobald die Produktion nicht mehr dem lokalen Happy Path entspricht.