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

Klick → Validierung → Regel → Transaktion → Antwort
01 Komponente Erfasst die Absicht und zeigt den Vorgangsstatus.
02 HTTP-Aktion Überträgt einen ausdrücklichen Vertrag und eine Request-ID.
03 Domänenmodell Wahrt Regeln, die nicht umgangen werden dürfen.
04 Relationale Datenbank Sichert Atomarität, Beziehungen und Eindeutigkeit.
05 Neuer UI-Zustand Zeigt das vom Server bestätigte Ergebnis.
01

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.

02

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");
400 Ungültige Eingabe

Fehlende requestId, falsche Version oder Daten außerhalb des Vertrags.

403 Keine Berechtigung

Der Nutzer ist angemeldet, darf diesen Auftrag aber nicht starten.

409 Zustandskonflikt

Jemand hat den Datensatz seit dem Lesen geändert, oder die Absicht wurde bereits verarbeitet.

200 Zustand gespeichert

Die Antwort enthält neue Version und datenbankbestätigten Status.

03

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.

04

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.

WorkOrders Id PK TenantId FK Status Version
1
N
WorkOrderRequests Id PK WorkOrderId FK RequestId UNIQUE ResultJson
1
N
OutboxMessages 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.

05

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.

06

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.

T0 A und B lesen Version 7

Beide Oberflächen zeigen denselben Stand.

T1 B speichert die Änderung

Die Datenbank wechselt auf Version 8.

T2 A sendet expectedVersion = 7

UPDATE findet keine passende Version.

T3 Die API liefert 409

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.");
}
07

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

UI API Domäne PostgreSQL Outbox

Testbereit: Idempotenz, Versionskonflikt, Transaktion und Wirkung nach COMMIT.

Definition of Done

Gesunde Schichtbeziehungen erlauben diese Antworten mit „ja“

Komponente

Zeigt sie Warten und Fehler, ohne Erfolg vorzutäuschen?

Vertrag

Hat die Anfrage bei Bedarf eine ausdrückliche requestId und ein Versionstoken?

Domäne

Gilt die Regel unabhängig vom Aufrufer?

Datenbank

Schützen Fremdschlüssel, Indizes und Nebenläufigkeitstoken bei einem Rennen?

Auswirkungen

Starten E-Mail, Webhook und Queue erst nach bestätigtem Speichern?

Abnahme

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.

Quellen und weitere Praxis