DI · lifetimes · HttpClientFactory

Dependency Injection og services

ASP.NET Core leverer et indbygget DI-container. I registrerer abstraktioner i Program.cs og får dem ind i komponenter via @inject — så Pokémon-lister, LoL-champions og auth håndteres i testbare services.

Læringsmål

Hvornår bruger I DI? Når mere end én komponent kalder samme API, når I vil mock’e i test, eller når konfiguration (base URL, nøgle-kilde) skal samles ét sted — typisk første uge i et OpgaverAPI-projekt.

Tjek dig selv før du går videre

  • Du kan forklare Scoped vs Singleton med to samtidige brugere (favorit-lab).
  • Du ved hvornår de ser ens ud (én fane) og hvornår de divergerer (to circuits).
  • Du registrerer HttpClient via AddHttpClient — ikke new HttpClient().
  • Du har kørt DI-lab for alle tre lifetimes.

Når en Blazor-side skal hente Pokémon fra /go/pokemon og champions fra LoL med X-Api-Key, hører HTTP-logikken ikke hjemme direkte i markup. I opretter fx IPokemonService og ILolService, registrerer dem én gang, og injicerer dem i komponenter — samme idé som i et ASP.NET Web API-projekt.

Program.cs — registrering

I en Blazor Web App er Program.cs composition root: her tilføjer I render modes, Razor Components, og alle services.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// Base URL til OpgaverAPI (miljøspecifik i Docker/Dokploy)
var apiBase = builder.Configuration["OpgaverApi:BaseUrl"]
    ?? "https://api-opgaver.mercantec.tech";

builder.Services.AddHttpClient<IPokemonService, PokemonService>(client =>
{
    client.BaseAddress = new Uri(apiBase);
});

builder.Services.AddScoped<ILolService, LolService>();
builder.Services.AddSingleton<IApiKeyStore, SessionApiKeyStore>();

var app = builder.Build();
// … middleware, MapRazorComponents, Run

AddHttpClient<TClient, TImplementation> kombinerer DI med en korrekt konfigureret HttpClient (handler-pool, DNS, timeouts). Det er Microsofts anbefalede vej — ikke new HttpClient() i hver komponent.

Trin når I tilføjer et nyt API-modul (fx Rick and Morty): definer interface → implementation med HttpClient → AddHttpClient<…> → @inject i page. Composition root er altid Program.cs — ikke static helpers i Razor.

@inject i komponenter

@page "/pokemon-di"
@inject IPokemonService Pokemon

<h1>Pokémon</h1>
@if (loading) { <p>Henter…</p> }
else if (error is not null) { <p class="err">@error</p> }
else
{
  <ul>@foreach (var p in list!) { <li>@p.Name</li> }</ul>
}

@code {
  List<PokemonDto>? list;
  bool loading;
  string? error;

  protected override async Task OnInitializedAsync()
  {
    loading = true;
    try { list = await Pokemon.GetFirstAsync(24); }
    catch (Exception ex) { error = ex.Message; }
    finally { loading = false; }
  }
}
public interface IPokemonService
{
    Task<List<PokemonDto>> GetFirstAsync(int limit, CancellationToken ct = default);
}

public sealed class PokemonService(HttpClient http) : IPokemonService
{
    public async Task<List<PokemonDto>> GetFirstAsync(int limit, CancellationToken ct = default)
    {
        var url = $"/go/pokemon?limit={limit}";
        return await http.GetFromJsonAsync<List<PokemonDto>>(url, ct)
            ?? throw new InvalidOperationException("Tomt svar");
    }
}

public record PokemonDto(string Name, int Id);

Hvornår @inject HttpClient direkte? Kun i meget små øvelser. Så snart I har headers (X-Api-Key), fejlhåndtering eller flere endpoints: inject IPokemonService / ILolService i stedet.

Elev-fejl → symptom → fix: @inject HttpClient Http i ti komponenter + manuel BaseAddress i hver — symptom: inkonsistent URL og svær test. Fix: typed client + én registrering.

Lifetimes — hvornår hvad?

Vend kortene

Klik for at afsløre — typiske censor-spørgsmål om DI-lifetimes.

LifetimeBetyderTypisk brug i Blazor
TransientNy instans hver gang den anmodesLette, stateless helpers (formatters, validators uden state)
ScopedÉn instans pr. scope (ofte pr. HTTP-request / circuit på Server)Services der cacher per bruger-session, DbContext-lignende state
SingletonÉn instans for hele appenRead-only config, caches med thread-safety, IHttpClientFactory selv

Lifetime-simulator

Vælg lifetime og «anmod» om service flere gange — loggen viser om I får samme instans-id (scoped/singleton) eller nye (transient).

# Vælg lifetime og tryk Anmod

Faldgrube: At registrere DbContext eller bruger-specifik state som Singleton deler data mellem alle brugere. At registrere HttpClient som Singleton uden factory kan give socket-udmattelse — brug IHttpClientFactory.

Hvornår Transient? Fx en stateless PokemonNameFormatter injiceret mange steder — billigt at oprette. Hvornår Scoped? Brugerens valgte region, indkøbskurv-lignende UI-state, eller cache der må leve i ét Blazor Server-circuit.

Scoped vs Singleton — hvornår er de forskellige?

Hvorfor elever går galt i byen: Med én fane ser Scoped og Singleton ens ud — I får samme instans igen og igen. Forskellen viser sig først når der er flere brugere / circuits (to browsere, underviser + elev, eller to faner på Blazor Server).

Tænk: Scoped = én per bruger-session (circuit). Singleton = én for hele serveren.

SituationScopedSingletonSamme resultat?
Én bruger, tre komponenter inject’er samme service Samme instans i det scope Samme instans Ja — I kan ikke se forskel
Elev A og Elev B åbner appen samtidig (to circuits) Hver sin instans → A’s favorit er ikke B’s Samme instans → A’s valg overskriver B’s UI Nej — her knækker Singleton
Service holder SelectedRegion / kurv / «sidst klikket» Korrekt: state følger brugeren Bug: state er fælles for alle Nej
Read-only Pokémon-type-cache (ingen brugerdata) Virker, men spilder hukommelse (kopi pr. bruger) Korrekt: én delt cache Funktionelt OK — Singleton er bedre
Tæller i felter: RequestCount++ Tæller pr. bruger/session Global tæller for hele appen Nej — forskellige tal

To brugere — prøv selv

Vælg lifetime. Lad Elev A og Elev B vælge favorit-Pokémon. Se om B «stjæler» A’s valg.

Elev A

Favorit: —

Elev B

Favorit: —

Tre konkrete eksempler I kan huske til censor:

  1. IFavoriteStore med SelectedId → Scoped (brugerstate).
  2. IPokemonTypeCache der loader types én gang fra API → Singleton (delt, read-only).
  3. ILolRegionPreference («EUW» / «NA») i memory → Scoped. Samme klasse som Singleton? Så ser Elev B Elev A’s region.
// Samme klasse — lifetime afgør adfærd mellem brugere
public sealed class FavoriteStore : IFavoriteStore
{
    public string? SelectedName { get; private set; }
    public void Set(string name) => SelectedName = name;
}

// ✅ Isoleret per Blazor Server-circuit / HTTP-scope
builder.Services.AddScoped<IFavoriteStore, FavoriteStore>();

// ❌ Alle samtidige brugere deler SelectedName
// builder.Services.AddSingleton<IFavoriteStore, FavoriteStore>();

// ✅ Singleton er rigtigt når der IKKE er brugerfelter:
builder.Services.AddSingleton<IPokemonTypeCache, PokemonTypeCache>();

Blazor WASM-nuance: I ren WASM kører DI i browseren — Singleton er «pr. fane», ikke «pr. elev på serveren». Scoped vs Singleton betyder stadig noget (nye scopes ved navigation), men den klassiske «Elev A stjæler Elev B»-bug er især Blazor Server / delt ASP.NET-host. På Server: to browsere = to circuits = Scoped adskiller dem; Singleton gør ikke.

IHttpClientFactory og typed client

Typed clients er klasser der tager HttpClient i konstruktoren og kapsler endpoints. Factory’en håndterer levetid af handlers.

builder.Services.AddHttpClient<ILolService, LolService>(client =>
{
    client.BaseAddress = new Uri(apiBase);
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

// I LolService tilføjes X-Api-Key per kald fra IApiKeyStore — ikke hardcodet i git

Hvis I både har WASM og Server i samme solution, kan base URL komme fra appsettings.json + miljøvariabler i Docker — præcis som APIer-projektets compose-mønster.

Named clients når I har flere base URLs eller forskellige handlers — fx OpgaverAPI + jeres egen backend med Bearer:

builder.Services.AddHttpClient("opgaver", c =>
    c.BaseAddress = new Uri(apiBase));

builder.Services.AddHttpClient("own-api", c =>
{
    c.BaseAddress = new Uri(builder.Configuration["OwnApi:BaseUrl"]!);
}).AddHttpMessageHandler<BearerTokenHandler>();

// I service: IHttpClientFactory factory.CreateClient("opgaver")

ConfigurePrimaryHttpMessageHandler bruges sjældent i skoleprojekter (certifikater, proxy) — men factory-mønsteret er det samme: konfigurer i Program.cs, ikke i komponent.

Interfaces og test

Med IPokemonService kan I i en unit test mocke API’et og teste komponentlogik uden netværk. Det er sværere hvis komponenten kalder Http.GetFromJsonAsync direkte overalt.

Hvornår interface? Når I forventer alternativ implementation (mock, offline demo) eller når censor skal kunne skifte datakilde uden at røre Razor. Små lektionsøvelser kan undvære interface — produktionsprojekter bør have det.

Quiz 1

Hvorfor bør I undgå new HttpClient() i hver Blazor-komponent?

Quiz 2

En service der gemmer den aktuelle brugers valgte LoL-region i memory — bedste default-lifetime på Blazor Server?

Scoped vs Singleton — konkret bug

Hvorfor det betyder noget: Lifetime bestemmer om state følger én bruger eller hele app-poolen. I et Pokémon-/LoL-projekt er det forskellen på «min favorit» og «klassens favorit».

Typisk fejl: IFavoriteChampionStore registreres som Singleton, fordi «vi vil kun have én instans». Implementeringen gemmer CurrentChampionId og LastRegion i felter. Elev A vælger Ahri i EUW — Elev B åbner samme side og ser Ahri uden at have klikket.

// BUG: alle Blazor Server circuits deler samme store
builder.Services.AddSingleton<IFavoriteChampionStore, FavoriteChampionStore>();

// FIX: scoped til circuit / request — isoleret per bruger-session
builder.Services.AddScoped<IFavoriteChampionStore, FavoriteChampionStore>();

Sammenligning i klasseværelset: åbn samme app i to browsere (to brugere). Med Singleton ændrer A’s valg B’s UI; med Scoped gør det ikke.

public sealed class FavoriteChampionStore : IFavoriteChampionStore
{
    public string? CurrentChampionId { get; private set; }

    public void Select(string id) => CurrentChampionId = id;
}

// Singleton er stadig rigtigt til read-only, thread-safe cache:
builder.Services.AddSingleton<IPokemonTypeCache, PokemonTypeCache>();

Sådan fejler det i praksis: Med én developer-tab ser Singleton «effektivt» ud. Fejlen opdages først ved sideløbende test, demo for censor, eller når underviser og elev deler samme Dokploy-miljø. Symptom: «ghost clicks» — valg skifter uden input.

Mini-opgave

Test ICounterService som Scoped vs Singleton: to komponenter viser delt count kun ved Singleton — dokumentér med screenshot.

Quiz 3

Hvem ejer valgt Pokémon-id for to samtidige brugere?

Læs hos Microsoft

Prøv med API

  • Udskil et PokemonService i jeres projekt og kald Pokémon-demo’s endpoints — sammenlign med rå HttpClient i Network-fanen.
  • Tilføj LoL med nøgle fra kataloget via en ILolService der læser nøglen fra sessionStorage-wrapper.
16476 #1516475 #15

Baseret på MAGS Blazor-wiki og Microsoft Learn — Blazor.