Od komponentu do transakcji: akcje, relacje i baza danych bez ukrytej magii

Praktyczny przepływ od kliknięcia w komponencie przez kontrakt API i model domenowy aż do transakcji, optimistic concurrency, outboxa i testu E2E na relacyjnej bazie.

Poniżej znajduje się pełna treść artykułu. Quizy i symulacje wymagają włączenia JavaScript.

Warsztat systemowy: od kliknięcia do COMMIT

Przycisk nie zapisuje danych. Uruchamia kontrakt, za który odpowiada cały system.

Najwięcej trudnych błędów nie powstaje wewnątrz pojedynczej funkcji. Powstaje pomiędzy elementami: komponent uznaje operację za zakończoną, API dopiero ją zaczyna, a baza odrzuca zapis, bo w tej samej chwili drugi użytkownik zmienił ten sam rekord.

W tym materiale przejdziemy przez jeden konkretny przypadek: operator naciska „Uruchom realizację”. Zobaczymy, co powinno wydarzyć się w komponencie, żądaniu HTTP, warstwie aplikacyjnej, modelu domenowym i relacyjnej bazie danych. Potem celowo zepsujemy ten przepływ i sprawdzimy, gdzie system powinien powiedzieć „nie”.

Mapa przepływu

Pięć granic odpowiedzialności

klik → walidacja → reguła → transakcja → odpowiedź
01 Komponent Zbiera intencję i pokazuje stan operacji.
02 Akcja HTTP Przenosi jawny kontrakt i identyfikator żądania.
03 Model domenowy Pilnuje reguł, których nie wolno ominąć.
04 Baza relacyjna Domyka atomowość, relacje i unikalność.
05 Nowy stan UI Pokazuje wynik potwierdzony przez serwer.
01

Komponent

UI ma tłumaczyć intencję, a nie udawać bazę danych

Komponent zna formularz, przycisk i komunikat dla użytkownika. Nie powinien znać kolejności zapisów w tabelach ani decydować, czy firma ma jeszcze wolny limit. Te reguły muszą działać również wtedy, gdy to samo API wywoła aplikacja mobilna, integracja albo zadanie w tle.

Poniższy komponent blokuje podwójne kliknięcie, wysyła wersję rekordu i dopiero po odpowiedzi serwera podmienia widoczny stan. To ważna różnica: animacja może reagować od razu, ale wynik biznesowy jest prawdziwy dopiero po odpowiedzi API.

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);
    }
  }
}

Decyzja architektoniczna

Użytkownik kliknął dwa razy, bo sieć działa wolno. Co robisz?

Wyłączam przycisk i uznaję problem za zamknięty

Blokuję UI i egzekwuję idempotencję po stronie API oraz bazy

Wybierz odpowiedź i sprawdź konsekwencję.

02

Akcja

Endpoint ma być cienki, ale jego kontrakt nie może być przypadkowy

Endpoint odpowiada za granicę protokołu: uwierzytelnienie, autoryzację, poprawność wejścia, mapowanie błędów i kod odpowiedzi. Nie powinien zawierać kilkudziesięciu linii logiki biznesowej, bo wtedy ta sama reguła zacznie żyć inaczej w API, workerze i panelu administracyjnym.

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 = "Stan zlecenia zmienił się. Odśwież dane."
        }),
        invalid => Results.ValidationProblem(invalid.Errors));
})
.RequireAuthorization("WorkOrderOperator")
.RequireRateLimiting("work-order-write");
400 Wejście jest niepoprawne

Brak requestId, błędna wersja albo dane poza kontraktem.

403 Brak uprawnienia

Użytkownik jest zalogowany, ale nie może uruchomić tego zlecenia.

409 Konflikt stanu

Ktoś zmienił rekord po jego odczytaniu albo ta intencja już została obsłużona.

200 Stan zapisany

Odpowiedź zawiera nową wersję i status potwierdzony przez bazę.

03

Model domenowy

Reguła biznesowa ma jedno miejsce, w którym może powiedzieć „nie”

Załóżmy, że zlecenie może ruszyć tylko po akceptacji zakresu i gdy nie jest już zakończone. Ta reguła nie należy do kontrolera, komponentu ani zapytania SQL. Należy do modelu, bo opisuje dozwoloną zmianę stanu.

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();
    }
}

Zwróć uwagę na NoChange(). Ponowienie tej samej poprawnej intencji nie musi być błędem. Może zwrócić aktualny stan, o ile system potrafi udowodnić, że to ta sama operacja. Dzięki temu retry po timeoutcie nie zamienia się w ręczne sprzątanie danych.

04

Relacyjna baza danych

Relacje nie są dekoracją modelu. Są ostatnią linią obrony danych

Walidacja aplikacyjna daje czytelny błąd, ale to baza musi gwarantować, że dwie równoległe operacje nie złamią unikalności. Schemat powinien wyrażać to, co zawsze musi być prawdą: klucze obce, wartości wymagane, zakresy, unikalność i token współbieżności.

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);
    });
}

Wyścig dwóch żądań

Oba procesy sprawdziły, że requestId nie istnieje. Co gwarantuje brak duplikatu?

Dodatkowe AnyAsync() przed zapisem

Unikalny indeks na TenantId + RequestId

Wybierz mechanizm, któremu można zaufać przy równoległości.

05

Transakcja

COMMIT wyznacza moment, od którego wolno mówić „zrobione”

Jeśli zmiana obejmuje rekord zlecenia, zapis próby idempotentnej i komunikat outbox, te elementy muszą wejść razem albo nie wejść wcale. Jedno SaveChangesAsync jest transakcyjne dla typowych relacyjnych providerów. Gdy przepływ wymaga kilku zapisów, transakcję trzeba kontrolować jawnie i przetestować na używanym providerze.

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;
}

To nadal nie znaczy, że e-mail został wysłany. Znaczy tylko, że baza zapisała niepodważalny zamiar wysłania. Worker odczyta outbox po COMMIT, wykona operację zewnętrzną i oznaczy wiadomość jako obsłużoną. Jeżeli dostawca poczty ma awarię, rekord nie znika.

Efekt zewnętrzny

Kiedy wysłać e-mail „realizacja uruchomiona”?

Przed SaveChanges, żeby użytkownik dostał go szybciej

Po COMMIT przez transactional outbox

Wybierz moment, który nie tworzy fałszywych potwierdzeń.

06

Współbieżność

409 to nie awaria. To uczciwa informacja, że decyzja dotyczyła starego stanu

Operator A otwiera zlecenie w wersji 7. Operator B zmienia zakres i zapisuje wersję 8. Jeśli A uruchomi realizację na podstawie starego widoku, system nie powinien po cichu nadpisać decyzji B. Token współbieżności sprawia, że aktualizacja dotknie rekord tylko wtedy, gdy wersja nadal pasuje.

T0 A i B czytają wersję 7

Oba interfejsy pokazują ten sam stan.

T1 B zapisuje zmianę

Baza przechodzi do wersji 8.

T2 A wysyła expectedVersion = 7

UPDATE nie znajduje pasującej wersji.

T3 API zwraca 409

UI odświeża dane i prosi o ponowną decyzję.

try
{
    await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
    throw new WorkOrderConflictException(
        "Zlecenie zostało zmienione przez innego operatora.");
}
07

Test E2E

Nie testuj tylko odpowiedzi 200. Sprawdź ślad po operacji

Test przepływu powinien uruchomić prawdziwy endpoint, przejść przez autoryzację, handler i relacyjny provider, a potem sprawdzić nie tylko JSON, lecz także stan tabel. Mock bazy nie pokaże naruszenia indeksu, izolacji ani błędnego mapowania typów.

[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());
    });
}
Scenariusz Oczekiwany HTTP Stan bazy Reakcja UI
Pierwsze poprawne uruchomienie 200 Status + request + outbox Pokazuje nowy stan
Retry tego samego requestId 200 Bez drugiego zapisu Pokazuje ten sam wynik
Stara wersja rekordu 409 Bez zmiany Odświeża i wyjaśnia konflikt
Brak uprawnienia 403 Bez zmiany Nie ujawnia danych operacji
Awaria dostawcy e-mail 200 Outbox czeka na retry Nie cofa poprawnej operacji

Symulacja odbioru

Przepuść zmianę przez pełny pipeline

Uruchom test przepływu

UI API Domena PostgreSQL Outbox

Gotowe do testu: idempotencja, konflikt wersji, transakcja i efekt po COMMIT.

Definition of Done

Relacja między warstwami jest zdrowa, jeżeli potrafisz odpowiedzieć „tak”

Komponent

Czy pokazuje stan oczekiwania i błąd bez udawania sukcesu?

Kontrakt

Czy żądanie ma jawny requestId i token wersji, gdy operacja tego wymaga?

Domena

Czy reguła działa niezależnie od tego, kto wywołał operację?

Baza

Czy klucze obce, indeksy i concurrency token bronią danych przy wyścigu?

Efekty

Czy e-mail, webhook i kolejka ruszają dopiero po potwierdzonym zapisie?

Odbiór

Czy test sprawdza odpowiedź i rzeczywisty ślad w relacyjnej bazie?

Najważniejszy wniosek

Warstwy nie są po to, by mnożyć pliki. Są po to, by każda decyzja miała właściciela.

Komponent odpowiada za rozmowę z użytkownikiem. Endpoint za protokół i uprawnienia. Model domenowy za dozwoloną zmianę. Baza za atomowość i relacje. Outbox za bezpieczne przekroczenie granicy systemu. Gdy te role są jawne, błąd nie wędruje po kodzie, a zespół może go odtworzyć, naprawić i przetestować.

Poniżej czeka quiz oparty na dokładnie tych problemach. Wynik jest liczony po stronie serwera. Po zdobyciu co najmniej 4 z 5 punktów i potwierdzeniu e-maila otrzymasz jednorazowy kod rabatowy na pierwszy płatny audyt techniczny.

Źródła i dalsza praktyka