Mert Özen Her Satırda Daha İleri
Frontend Blazor — #09

Blazor'da DI, State ve Veri Erişimi: Service Lifetime'ları, Circuit Scope, DbContext ve Auto Mode için Ortak Abstraction

Mert Özen 15 Kas 2026 16 dk 7 görüntülenme
Blazor'da DI, State ve Veri Erişimi: Service Lifetime'ları, Circuit Scope, DbContext ve Auto Mode için Ortak Abstraction

Blazor'da Scoped service InteractiveServer'da circuit boyunca, WebAssembly'de sekme boyunca yaşar; bu yüzden circuit'te DbContext IDbContextFactory ya da OwningComponentBase ile kullanılır, Auto mode'da veri erişimi bir interface'in iki implementasyonuyla çözülür. Bookstore panelinde Singleton'da paylaşılan teklifi, circuit boyunca yaşayan context'in eski verisini, Bookstore.Data kararını, IBookCatalog'u ve .NET 10'daki WebAssembly response streaming değişikliğini gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ve Entity Framework Core 10.0.15 ile derlenip denenmiştir; testlerde bUnit 2.15.2 kullanılmış, tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelindeki teklif sayfası bu yazıda yeniden yazılıyor: kurumsal bir müşteri için katalogdan kitap arayıp teklif hazırlıyorsun. Teklif satırları QuoteState adında, DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) ile AddScoped kaydedilmiş bir service'te duruyor ve sayfa InteractiveAuto. Satırları ekleyip menüden kitap listesine, oradan tekrar teklif sayfasına dönersen satırlar yerinde olur mu? Cevap ilk ziyarette ve sonraki ziyarette aynı mı? İkinci soru: aynı service'i yanlışlıkla AddSingleton ile kaydedersen ve iki farklı kullanıcı aynı anda teklif hazırlarsa ne olur? Üçüncüsü: bir InteractiveServer component'ine BookstoreContext'i doğrudan inject edip bir kitabın stoğunu okuyorsun, sonra stok Bookstore.Api üzerinden değişiyor ve aynı sorguyu tekrar çalıştırıyorsun. Hangi değeri görürsün?

Lifetime'lar Server'da ve WebAssembly'de

ASP.NET Core Web API serisinin DI yazısındaki üç lifetime burada da var, ama "scope" Blazor'da farklı bir şeye karşılık geliyor:

09-di-lifetimes-tr.png

Web API'de scope bir HTTP isteğidir. InteractiveServer'da ise scope circuit'tir: kullanıcının sekmesi açık kaldığı sürece aynı scoped instance yaşar, sekmedeki bütün interaktif component'ler onu paylaşır. Prerender ve Static SSR (Static Server-Side Rendering; sayfanın her istekte sunucuda render edilmesi) sayfaları ayrı bir HTTP isteği olarak çalıştığı için kendi scope'larını alır. WebAssembly'de sunucu yok; uygulamanın tamamı bir sekmede çalışan tek bir kullanıcıya ait ve scoped service pratikte singleton gibi davranıyor. Teklif sayfasını Playwright ile iki kez açtım; ilk ziyaret, sonra WebAssembly dosyaları indikten sonra yeni bir sayfa yüklemesi:

first visit: running on Server
   lines: 12 x Emma, 12 x Pride and Prejudice
   24 items, subtotal 5,877.60 TRY, discount 587.76 TRY, total 5,289.84 TRY
   requests to /api/catalog from the browser: none
   after Books -> Quote preview (enhanced navigation): running on WebAssembly, lines: (none)
second visit (full page load): running on WebAssembly
   lines: 12 x Emma, 12 x Pride and Prejudice
   24 items, subtotal 5,877.60 TRY, discount 587.76 TRY, total 5,289.84 TRY
   requests to /api/catalog from the browser: GET /api/catalog/books?page=1&pageSize=5&search=Austen
   after Books -> Quote preview (enhanced navigation): running on WebAssembly, lines: 12 x Emma, 12 x Pride and Prejudice

İlk sorunun cevabı: ilk ziyarette hayır, ikincide evet, ve sebebi lifetime'ın kendisi değil, render mode'un değişmesi. İlk ziyarette component Server'da çalıştı; kitap listesine gidip döndüğümde WebAssembly dosyaları inmişti ve Auto yeni component'i tarayıcıda başlattı. Tarayıcıdaki QuoteState sunucudakinden habersiz, boş bir instance. İkinci ziyarette baştan WebAssembly'deydik; enhanced navigation sayfayı baştan yüklemediği için WebAssembly uygulaması ve onun scoped QuoteState'i yaşamaya devam etti. Aynı denemeyi component'i InteractiveServer'a sabitleyip yaptığımda:

on the quote page: 1 x Dune
via the inventory page (InteractiveServer) and back: 1 x Dune
via the book list (Static SSR) and back: 1 x Dune

Satırlar başka bir interaktif sayfaya ve bir Static SSR sayfasına gidip gelince de durdu; scope circuit, circuit de sekme açık kaldıkça yaşıyor. Tam sayfa yenileme ise her iki modda da yeni bir scope demek. Auto'da kullanıcının state'i iki ayrı dünyada yaşayabileceği için önemli state'i service'te değil, kalıcı bir yerde tut ya da Server'a sabitle.

Singleton'da Kullanıcı Verisi

06'da notlar için yazdığımız StaffNoteStore bir Singleton; bilinçli olarak bütün kullanıcıların paylaştığı bir veri. Kullanıcıya ait bir state'i Singleton'a koymak ise sunucuda bütün kullanıcıların aynı instance'ı görmesi demek. Deneme için QuoteState'i AddSingleton ile kaydedip component'i InteractiveServer'a sabitledim ve Playwright'ta iki ayrı browser context'i (iki ayrı cookie deposu, yani iki ayrı kullanıcı) açtım:

Ada's quote:   30 x Dune
Grace's quote: (none)  (a separate browser context, nothing added)
Ada's quote:   30 x Dune
Grace's quote: 30 x Dune  (a separate browser context, nothing added)

İlki doğru kayıt (Scoped), ikincisi Singleton. İkinci sorunun cevabı: Grace, Ada'nın teklifini görüyor ve üzerine ekleme yaparsa Ada'nınkini değiştiriyor. Derleme hatası yok, tek kullanıcıyla yapılan testte de hiçbir şey görünmüyor; hata ancak production'da iki kullanıcı aynı anda çalışınca ortaya çıkıyor. Ayrıca bir Singleton'ın içine scoped bir service inject edilirse o scoped instance da sonsuza kadar yaşar; Development ortamında ASP.NET Core bunu başlangıçta yakalıyor, Production'da yakalamıyor.

Panel Veritabanına Nasıl Ulaşacak: İki Seçenek

Panel 02'den beri bütün veriyi typed BookstoreApiClient ile Bookstore.Api'den alıyor. BookstoreContext ise Bookstore.Api web projesinin içinde; panelin Entity Framework Core (EF Core; nesneleri veritabanı tablolarına eşleyen .NET library'si) ile veritabanına erişmesi için bir karar gerekiyordu. İki seçenek vardı:

  • Her şey HTTP ile Bookstore.Api'den: tek kapı, iş kuralları tek yerde, panelin veritabanı bilgisi ve connection string'i yok. Bedeli: her okuma bir ağ gidiş-dönüşü daha, panelin her ekranı için API'de ona özel bir endpoint ve EF Core'un projection'larını panelde kullanamamak.
  • BookstoreContext'i, entity'leri ve migration'ları ortak bir class library'ye (Bookstore.Data) taşımak: panel okumaları doğrudan veritabanından yapar, yazmalar yine API'den geçer. Bedeli: panel şemaya bağlanır (bir migration iki uygulamayı da etkiler), panel de connection string'i bilir, yani attack surface büyür.

Panelin okuma ekranları (arama, listeler, raporlar) API'ye eklenecek admin endpoint'lerini çoğaltıyordu; yazma tarafında ise stok rezervasyonu gibi kurallar API'de olmalıydı. Bu yüzden ikinciyi seçtim: okumalar Bookstore.Data üzerinden EF Core ile, her yazma API'den. Panel projesinden doğrudan Bookstore.Api projesine referans vermek de bir yol gibi görünüyor ama bir web projesinin başka bir web projesine bağlanması Program, endpoint ve configuration dosyalarını birbirine karıştırıyor; bunu denemedim bile. Taşıma sonrasında migration'ların modelle uyumunu kontrol ettim:

Build started...
Build succeeded.
No changes have been made to the model since the last migration.

Namespace'ler aynı kaldığı için API'de kod değişmedi; değişen proje dosyaları. Panelin Program.cs'i:

// Okumalar Bookstore.Data üzerinden doğrudan veritabanına gider; her yazma yine Bookstore.Api'den geçer.
string connectionString = builder.Configuration.GetConnectionString("BookstoreDb") is { Length: > 0 } value
    ? value
    : throw new InvalidOperationException(
        "Connection string 'BookstoreDb' is missing. Set the same one as Bookstore.Api's (user-secrets or ConnectionStrings__BookstoreDb).");
builder.Services.AddDbContextFactory<BookstoreContext>(options => options
    .UseSqlServer(connectionString)
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
builder.Services.AddScoped<IBookCatalog, EfBookCatalog>();
builder.Services.AddScoped<QuoteState>();

Panel artık API ile aynı connection string'i istiyor; API'de olduğu gibi user-secrets (--project src/Bookstore.Admin) ya da ConnectionStrings__BookstoreDb environment variable'ıyla ver. NoTracking varsayılanı panelin okuma amaçlı olduğunu kodda da söylüyor.

DbContext'i Component'te Kullanmak: IDbContextFactory ve OwningComponentBase

AddDbContext context'i scoped kaydediyor. Web API'de bu, istek başına bir context demek ve doğru. InteractiveServer'da ise context circuit boyunca yaşıyor ve sekmedeki bütün component'ler onu paylaşıyor. DbContext thread-safe değil, uzun yaşaması için de tasarlanmadı. Üçüncü soruyu deneme amaçlı bir diagnostics sayfasıyla sınadım:

@page "/diagnostics/scoped-context"
@rendermode InteractiveServer
@using Bookstore.Data
@using Bookstore.Inventory
@using Microsoft.EntityFrameworkCore
@inject BookstoreContext Db
@inject BookstoreApiClient Api

<button class="btn btn-primary" @onclick="RunAsync">Run</button>
<pre>@output</pre>

@code {
    private string output = "";

    // Db scoped: sunucuda, AddDbContext'in varsayılan kaydındaki gibi circuit boyunca yaşar.
    private async Task RunAsync()
    {
        StockItem dune = await Db.StockItems.AsTracking().SingleAsync(s => s.BookId == 1);
        int firstRead = dune.QuantityOnHand;
        await Api.SetQuantityOnHandAsync("9780441172719", firstRead + 15);

        StockItem again = await Db.StockItems.AsTracking().SingleAsync(s => s.BookId == 1);
        int noTracking = await Db.StockItems.Where(s => s.BookId == 1).Select(s => s.QuantityOnHand).SingleAsync();
        output = $"""
            first read:                         {firstRead}
            Bookstore.Api set it to:            {firstRead + 15}
            second read, same tracked context:  {again.QuantityOnHand}
            projection (not tracked):           {noTracking}
            """;
    }
}
first read:                         25
Bookstore.Api set it to:            40
second read, same tracked context:  25
projection (not tracked):           40

Üçüncü sorunun cevabı: 25, yani eski değer. Veritabanındaki satır 40 oldu ama ikinci sorgu, context'in zaten takip ettiği StockItem instance'ını döndürdü; EF Core takip edilen bir entity'yi sorgu sonucuyla varsayılan olarak ezmiyor. Bir istek boyunca yaşayan context'te bu fark edilmez; circuit boyunca yaşayan context'te kullanıcı saatlerce eski veriye bakabilir. Aynı context'te iki işi aynı anda başlatmak da (örneğin iki sorguyu Task.WhenAll ile) EF Core'un "A second operation was started on this context instance" hatasına yol açıyor; bu denemeyi yerelde kullandığım veritabanı sağlayıcısı sorguları senkron çalıştırdığı için tekrarlayamadım, kuralı EF Core ve Blazor dokümanları açıkça söylüyor.

Dokümanın önerisi her işlem için kısa ömürlü bir context: IDbContextFactory<T>. Panelin katalog araması böyle:

using Bookstore.Admin.Client.Catalog;
using Bookstore.Admin.Client.Contracts;
using Bookstore.Catalog;
using Bookstore.Data;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Admin.Services;

// Sunucu implementasyonu: kataloğu doğrudan veritabanından okur. Her çağrıda kısa ömürlü bir DbContext;
// böylece aynı circuit'teki iki component hiçbir zaman aynı context'i paylaşmaz.
public sealed class EfBookCatalog(IDbContextFactory<BookstoreContext> dbFactory) : IBookCatalog
{
    public async Task<PagedResult<BookSummary>> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
    {
        page = Math.Max(page, 1);
        pageSize = Math.Clamp(pageSize, 1, 50);
        await using BookstoreContext db = await dbFactory.CreateDbContextAsync(ct);

        IQueryable<Book> query = db.Books;
        if (!string.IsNullOrWhiteSpace(search))
        {
            string term = search.Trim();
            query = query.Where(b => b.Title.Contains(term) || b.Author.Name.Contains(term) || b.Isbn == term);
        }

        int total = await query.CountAsync(ct);
        List<BookSummary> items = await query
            .OrderBy(b => b.Title)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(b => new BookSummary(b.Isbn, b.Title, b.Author.Name, b.Category.Name, b.Price, b.PublishedYear))
            .ToListAsync(ct);
        return new PagedResult<BookSummary>(items, total, page, pageSize);
    }
}

Change tracking'den faydalanmak istediğin, bir component yaşadığı sürece süren işler için ikinci yol OwningComponentBase<T>: component kendine ait bir DI scope açıyor, T'yi o scope'tan alıyor ve component dispose edilince scope'u da kapatıyor. Yazarlar sayfası artık API yerine bunu kullanıyor:

@page "/authors"
@rendermode InteractiveServer
@inherits OwningComponentBase<BookstoreContext>
@using Bookstore.Data
@using Microsoft.AspNetCore.Components.QuickGrid
@using Microsoft.EntityFrameworkCore
@code {
    private readonly PaginationState pagination = new() { ItemsPerPage = 5 };

    // Prerender sırasında bir kez yüklenir (bkz. 06. yazı); sıralama ve sayfalama sonra circuit üzerinde bellekte yapılır.
    [PersistentState]
    public List<AuthorView>? Authors { get; set; }

    // Service, bu component'e ait bir DI scope'tan gelen BookstoreContext'tir ve component'le birlikte dispose edilir.
    protected override async Task OnInitializedAsync() =>
        Authors ??= await Service.Authors
            .OrderBy(a => a.Name)
            .Select(a => new AuthorView(a.Id, a.Name, a.Books.Count))
            .ToListAsync();
}

AddDbContextFactory context'in kendisini de scoped olarak kaydediyor; OwningComponentBase<BookstoreContext> bu yüzden ek bir kayıt olmadan çalıştı. Kural: circuit'te context'i scoped inject etme; işlem başına factory, component başına OwningComponentBase.

Auto Mode İçin Ortak Abstraction: IBookCatalog

Teklif sayfası InteractiveAuto. Aynı component ilk ziyarette sunucuda, sonra tarayıcıda çalışıyor; sunucuda veritabanına erişebilir, tarayıcıda erişemez. Component'in bu farkı bilmemesi için arada bir interface var ve .Client projesinde duruyor, çünkü iki taraf da onu görmeli:

using Bookstore.Admin.Client.Contracts;

namespace Bookstore.Admin.Client.Catalog;

// Bir Auto component'in katalogdan ihtiyacı. Sunucuda veritabanını EF Core ile okur,
// tarayıcıda panelin kendi /api/catalog endpoint'ini HTTP ile çağırır.
public interface IBookCatalog
{
    Task<PagedResult<BookSummary>> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default);
}

Sunucu implementasyonu yukarıdaki EfBookCatalog. Tarayıcı implementasyonu HTTP ile panelin kendi endpoint'ini çağırıyor:

using System.Globalization;
using System.Net.Http.Json;
using Bookstore.Admin.Client.Contracts;

namespace Bookstore.Admin.Client.Catalog;

// WebAssembly implementasyonu: sayfayla aynı origin; CORS yok, tarayıcıda API adresi yok.
public sealed class HttpBookCatalog(HttpClient http) : IBookCatalog
{
    public async Task<PagedResult<BookSummary>> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
    {
        string uri = $"api/catalog/books?page={page.ToString(CultureInfo.InvariantCulture)}&pageSize={pageSize.ToString(CultureInfo.InvariantCulture)}"
            + (string.IsNullOrWhiteSpace(search) ? "" : $"&search={Uri.EscapeDataString(search)}");
        return await http.GetFromJsonAsync<PagedResult<BookSummary>>(uri, ct)
            ?? new PagedResult<BookSummary>([], 0, page, pageSize);
    }
}
using Bookstore.Admin.Client.Catalog;
using Bookstore.Admin.Client.Contracts;

namespace Bookstore.Admin.Services;

// HttpBookCatalog'un tarayıcıdan çağırdığı endpoint. Stok endpoint'leri gibi 11. yazıda korunacak.
public static class CatalogEndpoints
{
    public static IEndpointRouteBuilder MapPanelCatalogEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/api/catalog/books", (IBookCatalog catalog, string? search, CancellationToken ct, int page = 1, int pageSize = 10)
            => catalog.SearchAsync(search, page, pageSize, ct));
        return app;
    }
}

Tarayıcı Bookstore.Api'yi doğrudan çağırabilirdi; o zaman API'de CORS (Cross-Origin Resource Sharing; başka bir origin'den gelen tarayıcı isteklerine izin verme kuralları) açmak ve API adresini tarayıcıya vermek gerekirdi. Panelin aynı origin'deki endpoint'i hem bunu hem de 11. yazıdaki BFF (Backend for Frontend; tarayıcının yalnız kendi sunucusuyla konuştuğu yapı) yaklaşımını kolaylaştırıyor. Bu endpoint de stok endpoint'leri gibi şimdilik korumasız; 11'de korunacak. İki tarafın kayıtları:

using Bookstore.Admin.Client.Catalog;
using Bookstore.Admin.Client.Ordering;
using Bookstore.Admin.Client.Shared;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddCascadingValue(_ => new PanelOptions(Currency: "TRY", AmountFormat: "N2"));

// Tarayıcıda API panelin kendisi: aynı origin, istek panelin cookie'lerini taşır.
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<IBookCatalog, HttpBookCatalog>();
builder.Services.AddScoped<QuoteState>();

await builder.Build().RunAsync();

Sunucu tarafında AddScoped<IBookCatalog, EfBookCatalog>(), tarayıcıda AddScoped<IBookCatalog, HttpBookCatalog>(). Yukarıdaki teklif çıktısındaki "requests to /api/catalog from the browser" satırı farkı gösteriyor: Server'da tarayıcıdan hiçbir katalog isteği çıkmadı, sorgu sunucuda EF Core ile çalıştı; WebAssembly'de arama bir GET /api/catalog/books oldu. İki implementasyonun da testi var; component testinde sahte bir IBookCatalog, EfBookCatalog testinde bellekte açılan bir SQLite veritabanı kullandım:

passed EfBookCatalogTests.Searches_by_author_and_pages_by_title
passed QuoteTests.The_picker_adds_a_search_result_to_the_shared_quote
passed QuoteTests.Twenty_copies_get_the_institutional_discount_and_every_change_is_announced
Test run summary: Passed!
  total: 17
  failed: 0
  succeeded: 17

Çıktı kısaltılmış (yalnız bu yazıda eklenen testler). BookPicker testinin ilk çalıştırması şu hatayla düştü:

failed Bookstore.Admin.Tests.QuoteTests.The_picker_adds_a_search_result_to_the_shared_quote (152ms)
  Xunit.MicrosoftTestingPlatform.XunitException: Bunit.Rendering.MissingRendererInfoException : A component under test is trying to access the 'RendererInfo' property, which has not been specified. Set it via TestContext.Renderer.SetRendererInfo.

Component RendererInfo.IsInteractive'e baktığı için bUnit'e hangi renderer'ı taklit edeceğini söylemek gerekiyor: Renderer.SetRendererInfo(new RendererInfo("WebAssembly", isInteractive: true)).

WebAssembly'de HttpClient ve .NET 10'daki Response Streaming

.NET 10'da WebAssembly'deki HttpClient response'ları varsayılan olarak stream ediyor; resmî dokümanlar bunu breaking change olarak listeliyor. ReadAsStreamAsync artık bir MemoryStream değil, senkron okumayı desteklemeyen bir BrowserHttpReadStream döndürüyor. Deneme için HttpBookCatalog'u şöyle yazdım:

    public async Task<PagedResult<BookSummary>> SearchAsync(string? search, int page, int pageSize, CancellationToken ct = default)
    {
        string uri = $"api/catalog/books?page={page}&pageSize={pageSize}&search={Uri.EscapeDataString(search ?? "")}";
        using HttpResponseMessage response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
        using Stream body = await response.Content.ReadAsStreamAsync(ct);
        // Senkron deserialization stream'i Stream.Read ile okur.
        return JsonSerializer.Deserialize<PagedResult<BookSummary>>(body, JsonSerializerOptions.Web)
            ?? new PagedResult<BookSummary>([], 0, page, pageSize);
    }
running on WebAssembly
results: 0
crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: net_http_synchronous_reads_not_supported
System.NotSupportedException: net_http_synchronous_reads_not_supported
   at System.Net.Http.BrowserHttpReadStream.Read(Byte[] buffer, Int32 offset, Int32 count)

JsonSerializer.Deserialize(Stream) senkron bir okuma yapıyor ve tarayıcıda NotSupportedException fırlatıyor; mesaj tarayıcıda tam metin yerine kaynak anahtarı olarak görünüyor. Aynı kodu HttpCompletionOption.ResponseHeadersRead olmadan, yani varsayılan GetAsync ile denediğimde hata çıkmadı; içerik önce belleğe alındığı için stream senkron okunabiliyordu. Çözüm, sonuna kadar async kalmak: GetFromJsonAsync, ReadFromJsonAsync ya da DeserializeAsync. Eski davranışa dönmek gerekirse proje dosyasında WasmEnableStreamingResponse'u false yapmak ya da istek başına SetBrowserResponseStreamingEnabled(false) çağırmak mümkün.

State Container ve Tarayıcı Storage'ı

QuoteState bir state container: veri ve onu değiştiren method'lar bir service'te, değişiklik bir event ile duyuruluyor:

using Bookstore.Admin.Client.Contracts;

namespace Bookstore.Admin.Client.Ordering;

public sealed record QuoteLine(string Isbn, string Title, decimal Price, int Quantity)
{
    public decimal Total => Price * Quantity;
}

// Hazırlanan teklif; teklif sayfasındaki component'ler tarafından paylaşılır.
// Scoped kayıtlı: sunucuda circuit başına, WebAssembly'de tarayıcı sekmesi başına bir tane.
public sealed class QuoteState
{
    // Kurumsal siparişler: en az 20 adete %10 indirim (Bookstore'un B2B kuralı).
    public const int InstitutionalMinimum = 20;
    public const decimal InstitutionalDiscountRate = 0.10m;

    private readonly List<QuoteLine> lines = [];

    public event Action? Changed;

    public IReadOnlyList<QuoteLine> Lines => lines;
    public int TotalQuantity => lines.Sum(l => l.Quantity);
    public decimal Subtotal => lines.Sum(l => l.Total);
    public decimal Discount =>
        TotalQuantity >= InstitutionalMinimum ? Math.Round(Subtotal * InstitutionalDiscountRate, 2) : 0m;
    public decimal Total => Subtotal - Discount;

    public void Add(BookSummary book, int quantity)
    {
        int index = lines.FindIndex(l => l.Isbn == book.Isbn);
        if (index >= 0)
        {
            lines[index] = lines[index] with { Quantity = lines[index].Quantity + quantity };
        }
        else
        {
            lines.Add(new QuoteLine(book.Isbn, book.Title, book.Price, quantity));
        }
        Changed?.Invoke();
    }

    public void Remove(string isbn)
    {
        lines.RemoveAll(l => l.Isbn == isbn);
        Changed?.Invoke();
    }
}

BookPicker satır ekliyor, CartPreview gösteriyor; ikisi de aynı QuoteBuilder'ın içinde ama birbirinden habersiz:

@code {
    // QuoteState bu component'in dışında değişir (satırları BookPicker ekler), bu yüzden dinle.
    protected override void OnInitialized() => Quote.Changed += OnQuoteChanged;

    private void OnQuoteChanged() => _ = InvokeAsync(StateHasChanged);

    public void Dispose() => Quote.Changed -= OnQuoteChanged;
}

Event başka bir component'in event handler'ından ya da bir background thread'den gelebileceği için StateHasChanged, 03'teki timer'da olduğu gibi InvokeAsync ile çağrılıyor. Dispose'da aboneliği bırakmak şart: service component'ten uzun yaşıyor ve abonelik kalırsa her değişiklikte dispose edilmiş component'i render etmeye çalışır, component de garbage collector tarafından toplanamaz.

06'da stok filtresinin circuit duraklayınca korunduğunu ama sayfa yenilenince kaybolduğunu görmüştük. Yenilemeden sonra da kalması için filtreyi tarayıcıda saklıyorum. InteractiveServer'da bunun için ProtectedLocalStorage ve ProtectedSessionStorage var; değer Data Protection ile şifrelenip tarayıcının localStorage'ına ya da sessionStorage'ına yazılıyor:

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender)
        {
            return;
        }

        Logger.LogInformation("OnAfterRender(firstRender: true): starting the refresh timer");
        timer = new System.Timers.Timer(TimeSpan.FromSeconds(30));
        timer.Elapsed += OnTimerElapsed;
        timer.Start();

        // Tarayıcı storage'ı JS interop gerektirir; ancak sayfa interaktif olunca okunabilir.
        ProtectedBrowserStorageResult<InventoryFilter> saved = await Storage.GetAsync<InventoryFilter>(FilterKey);
        if (saved.Success && saved.Value is { } stored
            && (stored.Threshold != Filter!.Threshold || stored.OnlyLow != Filter.OnlyLow))
        {
            Logger.LogInformation("Filter restored from local storage: threshold {Threshold}, only low {OnlyLow}",
                stored.Threshold, stored.OnlyLow);
            Filter = stored;
            await LoadAsync();
            StateHasChanged();
        }
    }
before reload: threshold=12, onlyLow=true, rows=2
localStorage keys: inventory-filter
stored value starts with: CfDJ8F...
right after reload (prerendered HTML): threshold=5, onlyLow=false, rows=20
once interactive: threshold=12, onlyLow=true, rows=2
info: InventoryPage: Filter restored from local storage: threshold 12, only low True

Filtre yenilemeden sonra geri geldi, ama önce prerender edilmiş varsayılan hâli göründü. Tarayıcı storage'ına erişim JS interop demek ve JS interop prerender sırasında yok; değer ancak ilk interaktif render'dan sonra okunabiliyor. Kullanıcı bir an 20 satırlık tabloyu, sonra filtrelenmiş 2 satırı görüyor. Bu sayfa için kabul edilebilir; kabul edilemiyorsa tercih sunucuda (kullanıcı ayarı olarak) tutulmalı, o da 11'deki kimlik bilgisiyle mümkün olacak. ProtectedBrowserStorage yalnız sunucu tarafında var; WebAssembly'de şifreleme anahtarı tarayıcıya verilemeyeceği için düz localStorage'a JS interop ile yazılır ve içine hassas bir şey konmaz.

Üretimde Dikkat

Scoped service'ler InteractiveServer'da circuit'le birlikte bellekte duruyor. Her açık sekme için bir QuoteState, bir BookstoreApiClient ve onların tuttuğu her şey; bağlantısı kopan circuit 06'da gördüğümüz gibi üç dakika daha yaşıyor. Scoped service'lerde büyük listeler ya da cache tutmaktan kaçın; gereken veriyi işlem anında oku.

Panelin kendi connection string'i olması, panelin de veritabanı kimliği taşıdığı demek. Production'da panelin veritabanı kullanıcısına yalnız okuma yetkisi ver; kod zaten okuma amaçlı ama yetkinin de buna uyması bir hata ya da güvenlik açığı durumunda hasarı sınırlar.

Auto mode'da aynı ekranın iki implementasyonu var ve ikisi farklı yollardan farklı hatalar alabilir: EfBookCatalog bir veritabanı timeout'u, HttpBookCatalog bir 401 ya da ağ hatası. İki yolu da test et ve log'la; Playwright ile ilk ziyaret ve sonraki ziyaret ayrı senaryolar.

Sık Düşülen Tuzaklar

Kullanıcıya ait state'i Singleton'a koymak. Tek kişiyle yapılan testte görünmez; yukarıdaki Ada ve Grace örneği production'da olan şeyin aynısı.

AddDbContext ile kaydedilmiş context'i InteractiveServer component'ine inject etmek. Context circuit boyunca yaşar; eski veri, büyüyen change tracker ve eş zamanlı işlem hataları getirir. Factory ya da OwningComponentBase.

Auto ya da WebAssembly component'inde sunucu tipine bağımlı olmak. .Client projesi BookstoreContext'i göremez ve göremesin; component'in ihtiyacı bir interface ile .Client'ta tanımlanmalı, iki taraf ayrı implementasyon kaydetmeli. Kayıt bir tarafta unutulursa hata yalnız o render mode'da çıkar.

WebAssembly'de stream'i senkron okumak. .NET 10'da ResponseHeadersRead ile alınan yanıtlarda NotSupportedException; hep async API'ler.

Event'e abone olup Dispose'da bırakmamak. Memory leak ve dispose edilmiş component'te StateHasChanged hataları.

Küçük Bir Deneme

★☆☆ QuoteBuilder'ı InteractiveWebAssembly'ye sabitle ve teklif çıktısındaki denemeyi tekrarla. İlk ziyarette Server'dan hiç geçmeden ne kadar süre boş bir alan görüyorsun? Prerendering'de IBookCatalog'un hangi implementasyonu çalışıyor?

★★☆ QuoteState'i RegisterPersistentService ile 06'daki persistent state'e bağla ve ilk ziyarette Server'dan WebAssembly'ye geçerken satırların taşınıp taşınmadığına bak. Dokümanın .Client projesinde singleton kayıt şartını unutursan ne oluyor?

★★★ Panele "en çok satan 5 kitap" kartı ekle: OrderItem'lardan EF Core ile gruplayan bir sorgu, bunun için IBookCatalog'a bir method ve iki implementasyon. Panelin veritabanı kullanıcısı yalnız okuma yetkisine sahip olsaydı bu sorgu çalışır mıydı? Yazma yapan bir method ekleseydin ne olurdu?

Panel artık veriye iki yoldan ulaşıyor ve state'in nerede yaşadığını biliyoruz. Bir sonraki yazıda Blazor'un JavaScript ile konuşmasına geçiyoruz: IJSRuntime, collocated JS module'leri, DotNetObjectReference, .NET 10'daki yeni interop API'leri ve bir grafik library'siyle satış grafiği.