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
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ę.
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");
Brak requestId, błędna wersja albo dane poza kontraktem.
Użytkownik jest zalogowany, ale nie może uruchomić tego zlecenia.
Ktoś zmienił rekord po jego odczytaniu albo ta intencja już została obsłużona.
Odpowiedź zawiera nową wersję i status potwierdzony przez bazę.
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.
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.
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);
});
}
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.
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ń.
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.
Oba interfejsy pokazują ten sam stan.
Baza przechodzi do wersji 8.
UPDATE nie znajduje pasującej wersji.
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.");
}
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
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”
Czy pokazuje stan oczekiwania i błąd bez udawania sukcesu?
Czy żądanie ma jawny requestId i token wersji, gdy operacja tego wymaga?
Czy reguła działa niezależnie od tego, kto wywołał operację?
Czy klucze obce, indeksy i concurrency token bronią danych przy wyścigu?
Czy e-mail, webhook i kolejka ruszają dopiero po potwierdzonym zapisie?
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.