Von der Komponente zur Transaktion: Aktionen, Beziehungen und Datenbanken ohne Magie
Ein praktischer Ablauf vom Klick über API-Vertrag und Domänenmodell bis zu Transaktion, optimistischer Nebenläufigkeitskontrolle, Outbox und E2E-Test auf einer relationalen Datenbank.
Der vollständige Artikel steht unten. Aktivieren Sie JavaScript für Quiz und Simulationen.
Systemwerkstatt: vom Klick zum COMMIT
Ein Button speichert keine Daten. Er startet einen Vertrag, den das ganze System erfüllt.
Schwierige Fehler entstehen oft zwischen Komponenten: Die Oberfläche hält den Vorgang für fertig, die API beginnt erst, und die Datenbank lehnt ab, weil ein anderer Nutzer denselben Datensatz gerade geändert hat.
Wir gehen einen konkreten Fall durch: Ein Operator klickt auf „Bearbeitung starten“. Wir verfolgen Komponente, HTTP-Request, Anwendungsschicht, Domänenmodell und relationale Datenbank. Danach beschädigen wir den Ablauf bewusst und prüfen, wo das System „nein“ sagen muss.
Ablaufkarte
Fünf Verantwortungsgrenzen
Komponente
Die UI erklärt Absichten und spielt keine Datenbank
Die Komponente kennt Formular, Button und Nutzermeldung. Sie sollte weder die Reihenfolge von Tabellenänderungen kennen noch entscheiden, ob ein Unternehmen noch Kontingent hat. Diese Regeln müssen auch für mobile Apps, Integrationen und Hintergrundjobs gelten.
Die folgende Komponente blockiert Doppelklicks, sendet die Datensatzversion und ändert die Anzeige erst nach der Serverantwort. Eine Animation darf sofort reagieren; das fachliche Ergebnis steht erst nach der API-Antwort fest.
export class WorkOrderActions {
private readonly api = inject(WorkOrdersApi);
readonly pending = signal(false);
readonly error = signal<string | null>(null);
async start(order: WorkOrderView): Promise<void> {
if (this.pending()) return;
this.pending.set(true);
this.error.set(null);
try {
const result = await firstValueFrom(
this.api.start(order.id, {
expectedVersion: order.version,
requestId: crypto.randomUUID()
})
);
order.status = result.status; // stan potwierdzony przez API
order.version = result.version;
} catch (error) {
this.error.set(mapProblemDetails(error));
} finally {
this.pending.set(false);
}
}
}
Architekturentscheidung
Der Nutzer klickt wegen langsamer Verbindung zweimal. Was tust du?
Ich deaktiviere den Button und hake das Problem ab
Ich blockiere die UI und erzwinge Idempotenz in API und Datenbank
Wähle eine Antwort und prüfe ihre Konsequenz.
Aktion
Der Endpoint soll schlank sein, sein Vertrag aber bewusst gewählt
Der Endpoint verantwortet die Protokollgrenze: Authentifizierung, Autorisierung, Eingabeprüfung, Fehlerabbildung und Antwortcode. Dutzende Zeilen Geschäftslogik gehören dort nicht hinein, sonst lebt dieselbe Regel bald unterschiedlich in API, Worker und Adminbereich.
app.MapPost("/api/work-orders/{id:guid}/start", async (
Guid id,
StartWorkOrderRequest body,
ClaimsPrincipal user,
StartWorkOrderHandler handler,
CancellationToken ct) =>
{
var tenantId = user.RequireTenantId();
var result = await handler.HandleAsync(
new StartWorkOrderCommand(
tenantId,
id,
body.ExpectedVersion,
body.RequestId),
ct);
return result.Match(
started => Results.Ok(started),
conflict => Results.Conflict(new {
code = "work_order_changed",
message = "Der Auftragszustand hat sich geändert. Lade die Daten neu."
}),
invalid => Results.ValidationProblem(invalid.Errors));
})
.RequireAuthorization("WorkOrderOperator")
.RequireRateLimiting("work-order-write");
Fehlende requestId, falsche Version oder Daten außerhalb des Vertrags.
Der Nutzer ist angemeldet, darf diesen Auftrag aber nicht starten.
Jemand hat den Datensatz seit dem Lesen geändert, oder die Absicht wurde bereits verarbeitet.
Die Antwort enthält neue Version und datenbankbestätigten Status.
Domänenmodell
Die Geschäftsregel hat einen Ort, an dem sie „nein“ sagt
Ein Auftrag darf erst nach Freigabe des Umfangs starten und nicht bereits abgeschlossen sein. Diese Regel gehört weder Controller noch Komponente oder SQL-Abfrage, sondern dem Modell: Sie beschreibt einen erlaubten Zustandswechsel.
public sealed class WorkOrder
{
public Guid Id { get; private set; }
public WorkOrderStatus Status { get; private set; }
public bool ScopeApproved { get; private set; }
public int Version { get; private set; }
public DomainResult Start(DateTimeOffset now)
{
if (!ScopeApproved)
return DomainResult.Invalid("scope_not_approved");
if (Status == WorkOrderStatus.Completed)
return DomainResult.Invalid("work_order_completed");
if (Status == WorkOrderStatus.InProgress)
return DomainResult.NoChange();
Status = WorkOrderStatus.InProgress;
Version += 1;
AddDomainEvent(new WorkOrderStarted(Id, now));
return DomainResult.Changed();
}
}
Beachte NoChange(). Die Wiederholung derselben gültigen Absicht muss kein Fehler sein. Sie kann den aktuellen Zustand liefern, wenn das System die Identität des Vorgangs nachweist. So führt Retry nach Timeout nicht zu manueller Datenbereinigung.
Relationale Datenbank
Beziehungen sind keine Modelldekoration, sondern die letzte Verteidigung der Daten
Anwendungsvalidierung liefert verständliche Fehler. Die Datenbank muss jedoch garantieren, dass zwei parallele Vorgänge keine Eindeutigkeit verletzen. Das Schema drückt unveränderliche Regeln aus: Fremdschlüssel, Pflichtwerte, Bereiche, Eindeutigkeit und Nebenläufigkeitstoken.
Id PK
TenantId FK
Status
Version
Id PK
WorkOrderId FK
RequestId UNIQUE
ResultJson
Id PK
AggregateId
Type
ProcessedAt
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 });
});
modelBuilder.Entity<WorkOrderRequest>(entity =>
{
entity.HasKey(x => x.Id);
entity.HasIndex(x => new { x.TenantId, x.RequestId })
.IsUnique();
entity.HasOne(x => x.WorkOrder)
.WithMany()
.HasForeignKey(x => x.WorkOrderId)
.OnDelete(DeleteBehavior.Restrict);
});
}
Rennen zweier Anfragen
Beide Prozesse haben geprüft: requestId existiert nicht. Was verhindert das Duplikat?
Ein zusätzliches AnyAsync() vor dem Speichern
Ein eindeutiger Index auf TenantId + RequestId
Wähle einen Mechanismus, dem du bei Parallelität vertrauen kannst.
Transaktion
COMMIT markiert den Zeitpunkt, ab dem „erledigt“ zulässig ist
Betrifft die Änderung Auftrag, Idempotenzversuch und Outbox-Nachricht, müssen alle gemeinsam gespeichert werden oder keiner. Ein einzelnes
SaveChangesAsync ist bei üblichen relationalen Providern transaktional. Bei mehreren Schreibschritten muss die Transaktion ausdrücklich kontrolliert und mit dem verwendeten Provider getestet werden.
public async Task<StartWorkOrderView> HandleAsync(
StartWorkOrderCommand command,
CancellationToken ct)
{
var previous = await db.WorkOrderRequests.SingleOrDefaultAsync(
x => x.TenantId == command.TenantId
&& x.RequestId == command.RequestId,
ct);
if (previous is not null)
return previous.ReadResult();
var order = await db.WorkOrders.SingleAsync(
x => x.Id == command.WorkOrderId
&& x.TenantId == command.TenantId,
ct);
if (order.Version != command.ExpectedVersion)
throw new WorkOrderConflictException();
order.Start(clock.UtcNow);
var view = StartWorkOrderView.From(order);
db.WorkOrderRequests.Add(
WorkOrderRequest.Completed(command, view));
db.OutboxMessages.Add(
OutboxMessage.From(new WorkOrderStarted(order.Id, clock.UtcNow)));
await db.SaveChangesAsync(ct);
return view;
}
Das bedeutet noch nicht, dass eine E-Mail versendet wurde. Die Datenbank hat die Sendeabsicht dauerhaft gespeichert. Nach COMMIT liest der Worker die Outbox, führt die externe Aktion aus und markiert die Nachricht. Bei Ausfall des Mailanbieters bleibt der Datensatz erhalten.
Externe Auswirkung
Wann wird „Bearbeitung gestartet“ per E-Mail versendet?
Vor SaveChanges, damit der Nutzer sie schneller erhält
Nach COMMIT über Transactional Outbox
Wähle den Zeitpunkt ohne falsche Bestätigung.
Nebenläufigkeit
409 ist kein Ausfall, sondern die ehrliche Meldung einer Entscheidung auf altem Stand
Operator A öffnet Version 7. B ändert den Umfang und speichert Version 8. Startet A aus der alten Ansicht, darf das System B nicht still überschreiben. Das Nebenläufigkeitstoken erlaubt das Update nur, wenn die Version noch passt.
Beide Oberflächen zeigen denselben Stand.
Die Datenbank wechselt auf Version 8.
UPDATE findet keine passende Version.
Die UI aktualisiert und bittet um eine neue Entscheidung.
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new WorkOrderConflictException(
"Der Auftrag wurde von einem anderen Operator geändert.");
}
E2E-Test
Prüfe nicht nur 200, sondern die Spuren des Vorgangs
Der Ablauftest sollte den echten Endpoint über Autorisierung, Handler und relationalen Provider ausführen und anschließend JSON sowie Tabellenzustand prüfen. Ein Datenbank-Mock zeigt weder Indexverletzung noch Isolation oder falsches Typmapping.
[Fact]
public async Task Start_is_idempotent_and_creates_one_outbox_message()
{
await using var app = new ApiFactory()
.WithPostgresContainer()
.WithOperator("tenant-a");
var client = app.CreateClient();
var requestId = Guid.NewGuid();
var first = await client.PostAsJsonAsync(
$"/api/work-orders/{Seed.OrderId}/start",
new { expectedVersion = 1, requestId });
var retry = await client.PostAsJsonAsync(
$"/api/work-orders/{Seed.OrderId}/start",
new { expectedVersion = 1, requestId });
first.EnsureSuccessStatusCode();
retry.EnsureSuccessStatusCode();
await app.InDatabase(async db =>
{
Assert.Equal(1, await db.WorkOrderRequests.CountAsync());
Assert.Equal(1, await db.OutboxMessages.CountAsync());
Assert.Equal(WorkOrderStatus.InProgress,
await db.WorkOrders.Select(x => x.Status).SingleAsync());
});
}
| Szenario | Erwartetes HTTP | Datenbankzustand | UI-Reaktion |
|---|---|---|---|
| Erster gültiger Start | 200 | Status + Request + Outbox | Zeigt den neuen Zustand |
| Retry derselben requestId | 200 | Kein zweiter Eintrag | Zeigt dasselbe Ergebnis |
| Alte Datensatzversion | 409 | Keine Änderung | Aktualisiert und erklärt den Konflikt |
| Keine Berechtigung | 403 | Keine Änderung | Legt keine Vorgangsdaten offen |
| Ausfall des Mailanbieters | 200 | Outbox wartet auf Retry | Nimmt den korrekten Vorgang nicht zurück |
Abnahmesimulation
Führe die Änderung durch die gesamte Pipeline
Ablauftest starten
Testbereit: Idempotenz, Versionskonflikt, Transaktion und Wirkung nach COMMIT.
Definition of Done
Gesunde Schichtbeziehungen erlauben diese Antworten mit „ja“
Zeigt sie Warten und Fehler, ohne Erfolg vorzutäuschen?
Hat die Anfrage bei Bedarf eine ausdrückliche requestId und ein Versionstoken?
Gilt die Regel unabhängig vom Aufrufer?
Schützen Fremdschlüssel, Indizes und Nebenläufigkeitstoken bei einem Rennen?
Starten E-Mail, Webhook und Queue erst nach bestätigtem Speichern?
Prüft der Test Antwort und tatsächliche Spuren in der relationalen Datenbank?
Die wichtigste Erkenntnis
Schichten vermehren nicht bloß Dateien. Sie geben jeder Entscheidung einen Verantwortlichen.
Die Komponente führt den Nutzerdialog, der Endpoint schützt Protokoll und Rechte, das Domänenmodell erlaubt Zustandsänderungen, die Datenbank sichert Atomarität und Beziehungen, die Outbox den Systemübergang. Sind diese Rollen klar, lässt sich ein Fehler lokalisieren, nachstellen, beheben und testen.
Unten wartet ein Quiz zu genau diesen Problemen. Die Auswertung erfolgt auf dem Server. Mit mindestens vier von fünf Punkten und bestätigter E-Mail erhältst du einen einmaligen Rabattcode für dein erstes kostenpflichtiges technisches Audit.