Del 10 av 10

Blazor-klienten — typed HTTP, optimistic UI och Ctrl+Z

Vi knyter ihop hela kursen i ett Blazor WebAssembly-UI: typed HttpClient, idempotency-nycklar per knapptryck, optimistic UI med rollback, och en undo-stack som genererar compensating commands mot API:t.

Lektion 10 — V5 tor. Sista pusselbiten: vad användaren faktiskt ser och klickar på.

Projektstruktur

/src
  /Bank.Domain          (klassbibliotek — Aggregate, Events, Commands)
  /Bank.Application     (handlers, validators, AggregateCommandQueue)
  /Bank.Infrastructure  (SqlStreamStore, projections, snapshot-store)
  /Bank.Api             (ASP.NET Core Minimal API)
  /Bank.Contracts       (DTO:er som delas mellan API och klient)
  /Bank.Client          (Blazor WebAssembly)
/tests
  /Bank.Domain.Tests
  /Bank.Integration.Tests
Bank.Contracts Lägg DTO:er, request- och response-records i ett delat klassbibliotek som både API och klient refererar. Aggregat och domänobjekt ska inte exponeras direkt till klienten — DTO:er ger oss kontroll över wire-formatet.

Typed HttpClient

// Bank.Client/Services/AccountClient.cs
public interface IAccountClient
{
    Task<AccountSummaryDto?> GetAsync(Guid id, CancellationToken ct = default);
    Task DepositAsync(Guid id, decimal amount, string currency, string reference,
                      Guid idempotencyKey, CancellationToken ct = default);
    Task WithdrawAsync(Guid id, decimal amount, string currency, string reference,
                       Guid idempotencyKey, CancellationToken ct = default);
    Task RevertDepositAsync(Guid id, Guid originalEventId, string reason,
                            Guid idempotencyKey, CancellationToken ct = default);
}

public sealed class AccountClient : IAccountClient
{
    private readonly HttpClient _http;
    public AccountClient(HttpClient http) => _http = http;

    public Task<AccountSummaryDto?> GetAsync(Guid id, CancellationToken ct = default) =>
        _http.GetFromJsonAsync<AccountSummaryDto>($"accounts/{id}", ct);

    public async Task DepositAsync(Guid id, decimal amount, string currency, string reference,
                                   Guid idempotencyKey, CancellationToken ct = default)
    {
        using var req = new HttpRequestMessage(HttpMethod.Post, $"accounts/{id}/deposit")
        {
            Content = JsonContent.Create(new { Amount = amount, Currency = currency, Reference = reference })
        };
        req.Headers.Add("Idempotency-Key", idempotencyKey.ToString());
        (await _http.SendAsync(req, ct)).EnsureSuccessStatusCode();
    }
    // ... withdraw, revert: analogt
}

Registrering i Program.cs

builder.Services.AddHttpClient<IAccountClient, AccountClient>(c =>
{
    c.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
});

builder.Services.AddScoped<UndoStack>();

AccountPage.razor — komponenten

@page "/accounts/{Id:guid}"
@inject IAccountClient Client
@inject UndoStack Undo

<h1>Konto @Id</h1>

@if (_loading)
{
    <p>Laddar…</p>
}
else if (_account is null)
{
    <p class="error">Kontot finns inte.</p>
}
else
{
    <p class="balance">Saldo: @_account.Balance.ToString("F2") @_account.Currency</p>

    <EditForm Model="_form" OnValidSubmit="OnDepositAsync">
        <InputNumber @bind-Value="_form.Amount" />
        <button type="submit" disabled="@_busy">Sätt in</button>
    </EditForm>

    <button @onclick="OnUndoAsync" disabled="@(!Undo.CanUndo || _busy)">
        ⟲ Ångra (Ctrl+Z)
    </button>

    @if (_error is not null)
    {
        <div class="alert">@_error</div>
    }
}

@code {
    [Parameter] public Guid Id { get; set; }
    private AccountSummaryDto? _account;
    private DepositForm _form = new();
    private bool _loading = true;
    private bool _busy;
    private string? _error;

    protected override async Task OnInitializedAsync() => await ReloadAsync();

    private async Task ReloadAsync()
    {
        _loading = true;
        _account = await Client.GetAsync(Id);
        _loading = false;
    }
}

Optimistic UI med rollback

Användaren ska inte vänta på server-svaret för att se kontosaldot uppdatera. Vi uppdaterar UI:t direkt, skickar requesten i bakgrunden, och rullar tillbaka om det misslyckas.

private async Task OnDepositAsync()
{
    if (_account is null) return;

    var key = Guid.NewGuid();
    var amount = _form.Amount;
    var previousBalance = _account.Balance;

    // 1) Uppdatera UI optimistiskt
    _account = _account with { Balance = previousBalance + amount };
    _busy = true;
    _error = null;
    StateHasChanged();

    try
    {
        // 2) Skicka till API
        await Client.DepositAsync(Id, amount, _account.Currency, "manual deposit", key);

        // 3) Registrera undo
        Undo.Record(
            description: $"Insättning {amount} {_account.Currency}",
            compensation: () => Client.RevertDepositAsync(Id, key, "User undo", Guid.NewGuid()));

        // 4) Hämta serverns sanning för att se eventual consistency
        _ = ReloadAfterDelayAsync();
    }
    catch (Exception ex)
    {
        // 5) Rollback UI vid fel
        _account = _account with { Balance = previousBalance };
        _error = $"Kunde inte spara: {ex.Message}";
    }
    finally
    {
        _busy = false;
        StateHasChanged();
    }
}

private async Task ReloadAfterDelayAsync()
{
    // Projection-workern är typiskt klar inom 500 ms — ge den lite tid
    await Task.Delay(750);
    await ReloadAsync();
}
Eventual consistency syns När klienten direkt frågar GET /accounts/{id} efter en POST kan read modellen ha en kort latens. Antingen: (a) acceptera optimistic UI som "sanning" tills servern hinner ifatt, (b) returnera den nya versionen direkt i POST-svaret från en read-your-own-writes-källa, eller (c) öppna en SignalR-kanal och pusha uppdateringen när projektionen är klar. Lektionen visar (a) som default; (c) är fördjupning.

UndoStack på klienten

public sealed class UndoStack
{
    private readonly Stack<UndoEntry> _undo = new();
    private readonly Stack<RedoEntry> _redo = new();

    public bool CanUndo => _undo.Count > 0;
    public bool CanRedo => _redo.Count > 0;

    public event Action? OnChanged;

    public void Record(string description, Func<Task> compensation)
    {
        _undo.Push(new UndoEntry(description, compensation));
        _redo.Clear();
        OnChanged?.Invoke();
    }

    public async Task UndoAsync()
    {
        if (_undo.Count == 0) return;
        var entry = _undo.Pop();
        await entry.Compensation();
        OnChanged?.Invoke();
    }

    private sealed record UndoEntry(string Description, Func<Task> Compensation);
    private sealed record RedoEntry(string Description, Func<Task> Redo);
}

Knytning till tangentbordet

<div @onkeydown="OnKeyDownAsync" tabindex="0">
    @* sidans innehåll *@
</div>

@code {
    private async Task OnKeyDownAsync(KeyboardEventArgs e)
    {
        if ((e.CtrlKey || e.MetaKey) && e.Key == "z")
            await Undo.UndoAsync();
    }
}
Vad undo INTE är Det är inte en magisk knapp som raderar något ur historiken. Den skickar ett nytt compensating command till API:t. På serversidan blir det ett nytt event — MoneyDepositReverted — som påverkar saldot men låter den ursprungliga insättningen ligga kvar. Audit-trailen förblir komplett.

Loading-, fel- och tomtillstånd

TillståndUI
LaddarSkeleton-rektanglar i stället för text. Undvik spinner — den signalerar "långsam".
Tomt"Inga transaktioner ännu" + CTA till första insättningen.
FelTydligt felmeddelande + retry-knapp. Logga till console för utvecklare.
Concurrency 409"Någon annan ändrade samtidigt — uppdaterar…" → automatisk reload.

Sammanfattning

Referenser

Elektroniska resurser

Böcker

Övningar

Lös övningarna självständigt. Det finns inget facit — lärandet sker i processen.

  1. AccountClient + AccountPage Skapa Blazor WASM-projektet, koppla mot ditt API från lektion 4–8, implementera deposit och withdraw. Verifiera att Idempotency-Key skickas i headers (DevTools → Network).
  2. Optimistic UI + rollback Lägg till artificiell server-fördröjning (500 ms) i din endpoint. Verifiera att UI uppdateras direkt och att rollback fungerar vid 4xx-svar (testa med t.ex. InsufficientFundsException).
  3. Ctrl+Z-undo Implementera UndoStack + tangentbordsbindning. Verifiera att två insättningar följt av Ctrl+Z+Ctrl+Z ger två MoneyDepositReverted-events i strömmen.
  4. SignalR-push (fördjupning) Lägg till en AccountHub i API:t. Pusha från projektionen efter varje commit. Klienten lyssnar och uppdaterar UI utan polling. Jämför upplevelsen.

Soloprojektor

Projekt 1 — Komplett bankapp end-to-end Knyt ihop alla lektioner i en fungerande applikation: Domain + API + SqlStreamStore + projection + Blazor + undo. Demonstrera med en screencast (5 min) som visar deposit, withdraw, undo, eventual consistency, och en concurrency-konflikt som retryas automatiskt.
Projekt 2 — Reaktiv timeline (fördjupning) Visualisera kontots event-historik i en tidslinje. Varje event syns som ett kort med tid, typ, payload. Vid undo: markera det ursprungliga eventet som "kompenserat" med ett pil-grafiskt till kompensationen. Lägg en filter-meny för event-typer.

← Föregående: Snapshots & Sagas Nästa: Examination →