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.
/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.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
}
builder.Services.AddHttpClient<IAccountClient, AccountClient>(c =>
{
c.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
});
builder.Services.AddScoped<UndoStack>();
@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;
}
}
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();
}
/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.
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);
}
<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();
}
}
MoneyDepositReverted — som påverkar saldot men låter den ursprungliga insättningen ligga kvar. Audit-trailen förblir komplett.
| Tillstånd | UI |
|---|---|
| Laddar | Skeleton-rektanglar i stället för text. Undvik spinner — den signalerar "långsam". |
| Tomt | "Inga transaktioner ännu" + CTA till första insättningen. |
| Fel | Tydligt felmeddelande + retry-knapp. Logga till console för utvecklare. |
| Concurrency 409 | "Någon annan ändrade samtidigt — uppdaterar…" → automatisk reload. |
HttpClient ger statiskt typad åtkomst till API:t.Guid i klienten innan POST.EventCallback, StateHasChanged och async event-handlers.Lös övningarna självständigt. Det finns inget facit — lärandet sker i processen.
InsufficientFundsException).UndoStack + tangentbordsbindning. Verifiera att två insättningar följt av Ctrl+Z+Ctrl+Z ger två MoneyDepositReverted-events i strömmen.AccountHub i API:t. Pusha från projektionen efter varje commit. Klienten lyssnar och uppdaterar UI utan polling. Jämför upplevelsen.