Section 23

.NET engineering deep dive

The .NET topics that come up in almost every senior engineer and solution architect interview: what the runtime does with your code, how memory and the garbage collector behave, async and threading traps, DI lifetimes, the ASP.NET Core pipeline and middleware, Web API, MVC and Blazor, authentication and authorisation, EF Core, configuration and hosting, outbound HTTP, high-performance code, the road from .NET Framework and WCF to modern .NET, and what's new in C# and .NET. Each section shows the wrong way and the right way in C#.

Targets .NET 10 (LTS) and C# 14. Version-specific facts were checked on learn.microsoft.com and nuget.org in October 2026.

23.1 CLR, JIT & memory basics Added

The CLR runs your code: C# compiles to IL, the JIT turns IL into machine code at run time, and the runtime manages memory, types and threads for you.

OptionWhat it doesTrade-off
Tiered compilationQuick JIT first, optimised JIT for hot code. On by defaultCode is slower for the first seconds (warm-up)
Dynamic PGOUses live profile data to optimise Tier 1 code. Default since .NET 8None for most apps; just keep tiering on
ReadyToRunPre-compiles IL to native code at publish; still JITs hot paths laterFaster startup, bigger files
Native AOTWhole app compiled to native code, no JIT at all (see 23.9)Fastest startup; limits reflection

Stack vs heap, value vs reference

Stack

  • One per thread. Holds locals and call frames.
  • Freed automatically when the method returns. Very cheap.
  • Value-type locals and references (pointers) live here.

Managed heap

  • Shared by all threads. Holds objects created with new for classes, arrays, strings and boxed values.
  • Freed later by the garbage collector (23.2).
  • More objects = more GC work.
TypeCopy semanticsWhere it livesUse for
class / recordCopies the reference; both point to one objectHeapEntities, services, anything with identity
structCopies the whole valueInline: on the stack as a local, inside the object as a fieldSmall values (≈16 bytes or less)
readonly record structCopied, immutable, value equality for freeInlineMoney, coordinates, IDs
ref structCan never be boxed or capturedStack onlySpan<T>, parsers, buffers

📦 Boxing

Putting a value type into an object or interface variable copies it into a new heap object. Unboxing copies it back.

🕵️ Hidden boxing

Non-generic collections (ArrayList), object parameters, and casting a struct to an interface.

✅ Avoid it

Use generics (List<int>), generic constraints (where T : IComparable<T>) and typed APIs.

  • "Structs live on the stack" is only half true. A value type lives wherever its container lives: a struct field inside a class is on the heap.
  • Value types copy on assignment. Big or mutable structs cause bugs and slow copies; keep them small and readonly.
  • Warm-up is real: the first requests run Tier 0 code. Use ReadyToRun or warm-up requests if cold starts matter.
How to implement it
Library / APIPackageUse it for
struct, record struct, ref structbuilt-in C# 14Pick value vs reference semantics on purpose
PublishReadyToRun, TieredPGObuilt-in MSBuild propertiesFaster startup; PGO is on by default, leave it on
JIT escape analysisbuilt-in .NET 10 runtime.NET 10 can stack-allocate small arrays and some delegates that never leave the method
dotnet-countersglobal tool dotnet-countersWatch dotnet.jit.compiled_methods and GC metrics live
// ❌ boxing: every int is copied into a new heap object
ArrayList list = new();
for (int i = 0; i < 1_000; i++) list.Add(i);

// ✅ generic collection: ints stored inline, no boxing
List<int> nums = [];
for (int i = 0; i < 1_000; i++) nums.Add(i);

// ✅ small immutable value with value equality
public readonly record struct Money(decimal Amount, string Currency);

// ❌ mutable struct: changes a copy, not the original
var pos = positions[0]; pos.Qty += 10;   // positions[0] is unchanged

"Structs live on the stack." Half true, and interviewers know it 👀

📦 Value types live where their container lives: a struct field inside a class is on the heap

🔁 Value types copy on assignment, reference types share one object

🎁 Boxing copies a value into a new heap object. ArrayList and object parameters do it silently

⚡ The JIT compiles twice: quick Tier 0 first, optimised Tier 1 with Dynamic PGO for hot code

Know where your bytes live and the GC will thank you. What .NET myth do you hear most?

#dotnet #csharp #CLR #SoftwareEngineering

23.2 Garbage collection Added

The garbage collector frees objects that nothing references any more. It splits the heap into generations because most objects die young.

ModeHow it worksDefault for
WorkstationOne heap, GC runs on the thread that triggered itConsole, desktop and most client apps
ServerOne heap and GC thread per core; built for throughputASP.NET Core apps
DATASServer GC that grows and shrinks the number of heaps with load, keeping the heap close to the live data sizeOn by default with Server GC since .NET 9
Background GCGen 2 collections run mostly alongside your threadsOn by default
POHPinned object heap for buffers that must not move (native I/O)GC.AllocateArray(…, pinned: true)

DATAS trades a few percent of peak throughput for a much smaller heap. That's good in containers. On a dedicated machine that needs every request per second, you can turn it off with GarbageCollectionAdaptationMode=0.

🔥 GC pressure causes

String concatenation in loops, LINQ and closures on hot paths, boxing, big temporary arrays (LOH), and caches that grow without limit.

🧹 Finalizers

Objects with a finalizer survive at least one extra GC and are cleaned up later on one finalizer thread.

🔑 IDisposable

Releases sockets, file handles and connections now, deterministically. Call it with using or await using.

🛡️ SafeHandle

Wraps an unmanaged handle and owns the finalizer for you. You almost never need to write your own finalizer.

Memory leaks in a "managed" language

CauseWhy it leaksFix
Event handlersThe publisher holds a reference to every subscriberUnsubscribe in Dispose; weak events for long-lived publishers
Static cachesA static Dictionary only ever growsMemoryCache / HybridCache with size limits and expiry
Captured closuresA lambda keeps every captured variable alive, often a big objectCapture only what you need; use static lambdas
TimersAn undisposed timer roots its callback and its targetDispose timers; prefer PeriodicTimer in a BackgroundService
Undisposed objectsStreams, responses and DB connections hold native resourcesusing / await using; let DI own lifetimes
  • Gen 0 GCs are cheap; gen 2 and LOH churn are what hurt. Keep short-lived objects short-lived and long-lived objects few.
  • Dispose is about resources, not memory. The GC still frees the object's memory later.
  • Diagnose with dotnet-counters (heap size, GC count, % time in GC), then dotnet-gcdump or dotnet-dump to find what roots the objects.
  • Never call GC.Collect() to "fix" memory in production. Find the root instead.
How to implement it
Library / APIPackageUse it for
IDisposable / IAsyncDisposablebuilt-in SystemDeterministic clean-up with using / await using
SafeHandlebuilt-in System.Runtime.InteropServicesOwn unmanaged handles instead of writing finalizers
GC settingsbuilt-in ServerGarbageCollector, GarbageCollectionAdaptationModeChoose Server/Workstation GC; turn DATAS off or on
GC.GetGCMemoryInfobuilt-in SystemHeap size, fragmentation and pause data from inside the app
dotnet-counters, dotnet-gcdump, dotnet-dumpglobal toolsWatch GC live, snapshot the heap, find GC roots (gcroot)
// ❌ leak: the static event keeps every watcher alive forever
public sealed class RateWatcher
{
    public RateWatcher() => FxFeed.RateChanged += OnRate;
    void OnRate(object? sender, decimal rate) { /* ... */ }
}

// ✅ unsubscribe in Dispose, and dispose deterministically
public sealed class RateWatcher : IDisposable
{
    public RateWatcher() => FxFeed.RateChanged += OnRate;
    void OnRate(object? sender, decimal rate) { /* ... */ }
    public void Dispose() => FxFeed.RateChanged -= OnRate;
}

// ✅ await using calls DisposeAsync at the end of the scope
await using var file = File.OpenRead(path);

Yes, you can leak memory in .NET. Here are the usual suspects 🕵️

📣 Event handlers you never unsubscribe: the publisher keeps every subscriber alive

🗄️ Static dictionaries used as caches: they only ever grow

🧷 Lambdas that capture a big object, and timers nobody disposed

🐘 Big arrays (85 KB+) land on the large object heap and trigger expensive gen 2 GCs

Measure with dotnet-counters, find the root with dotnet-gcdump. What's the worst leak you've chased?

#dotnet #GarbageCollection #Performance #csharp

23.3 async/await internals Added

async/await lets a thread start I/O, go back to the pool while it waits, and carry on later. The compiler rewrites your method into a state machine to make that work.

TypeUse whenRules
Task / Task<T>The default for any async methodCan be awaited many times, cached, passed to WhenAll
ValueTask<T>Hot paths that usually finish synchronously (cache hit, buffered read)Await it once. Never await twice or call .Result before it completes
IAsyncEnumerable<T>Streaming results as they arrive (DB rows, paged APIs)Consume with await foreach; pass a token with WithCancellation

SynchronizationContext and ConfigureAwait(false)

Where the code runsContext captured?ConfigureAwait(false)?
ASP.NET CoreNo SynchronizationContextMakes no real difference in app code
Library / NuGet packageUnknown: your caller might be a UI appYes, use it on every await
WPF, WinForms, MAUIYes, the UI threadNot in code that touches the UI after the await
Old ASP.NET (System.Web)Yes, the request contextYes; .Result here can deadlock

💥 async void

The caller can't await it, and an exception crashes the process. Only use it for UI event handlers.

🔒 Sync over async

.Result, .Wait(), GetAwaiter().GetResult() block a thread. With a context they deadlock; without one they starve the thread pool (23.4).

🚀 Fire and forget

An un-awaited task loses its exceptions. Put background work on a queue or Channel read by a BackgroundService.

🛑 Cancellation

Accept a CancellationToken and pass it to every call. ASP.NET Core binds HttpContext.RequestAborted to it when the client disconnects.

  • Async is about not blocking threads during I/O. It doesn't make one call faster, and it doesn't help CPU-bound work.
  • Go "async all the way". One .Result in the middle of the call chain undoes the benefit.
  • Use ConfigureAwait(false) in libraries. In ASP.NET Core app code it's optional.
How to implement it
Library / APIPackageUse it for
Task, ValueTask, Task.WhenAllbuilt-in System.Threading.TasksAsync methods and fan-out
ConfigureAwait / ConfigureAwaitOptionsbuilt-inSkip context capture in libraries; options added in .NET 8
CancellationToken, CancelAfterbuilt-in System.ThreadingTime-outs and client disconnects
IAsyncEnumerable, [EnumeratorCancellation]built-inStream results with await foreach
Async analyzersMicrosoft.VisualStudio.Threading.Analyzers; CA2007Flag async void, sync-over-async and missing ConfigureAwait
// ❌ sync over async: blocks a pool thread (deadlocks where a context exists)
public decimal GetBalance(string id) => _client.GetBalanceAsync(id).Result;

// ❌ async void: exceptions crash the process, callers can't await it
public async void Refresh() => await _cache.ReloadAsync();

// ✅ async all the way, token passed through
public async Task<decimal> GetBalanceAsync(string id, CancellationToken ct)
{
    var dto = await _client.GetBalanceAsync(id, ct);
    return dto.Available;
}

// ✅ library code: don't capture the caller's context
var body = await reader.ReadToEndAsync(ct).ConfigureAwait(false);

What actually happens at an await? 🧵

⚙️ The compiler turns your method into a state machine

⏸️ If the task isn't done, it saves your locals and returns to the caller

🏊 The thread goes back to the pool. While the DB works, no thread is waiting

▶️ When the I/O completes, any pool thread resumes the method where it left off

🚫 .Result and async void break the deal

Async isn't faster, it's more scalable. Did you know ConfigureAwait(false) does nothing useful in ASP.NET Core app code?

#dotnet #csharp #async #Backend

23.4 Threading & the thread pool Added

The thread pool is a shared set of worker threads. Blocking them starves everything else, and the fix is almost always to stop blocking, not to add threads.

Threads vs processes, concurrency vs parallelism and race conditions are covered on page 01: 1.2 Concurrency vs Parallelism. This section is about the .NET specifics.

Symptoms

  • Latency climbs while CPU stays low.
  • Thread count creeps up steadily.
  • Thread-pool queue length keeps growing.
  • Health checks time out under load.

Diagnosis

  • dotnet-counters monitor -n MyApi --counters System.Runtime
  • Watch dotnet.thread_pool.queue.length and dotnet.thread_pool.thread.count (.NET 9+ names).
  • dotnet-stack report -p <pid> shows where threads are stuck.
  • Fix the blocking call; SetMinThreads only hides it.
PrimitiveUse it forNote
lock + System.Threading.LockShort critical sections in sync codeNew Lock type in .NET 9+; no await inside
SemaphoreSlimAsync mutual exclusion; capping concurrent callsWaitAsync + release in finally
InterlockedAtomic counters, compare-and-swapLock-free, very cheap
Concurrent collectionsShared dictionaries, queues, bagsGetOrAdd's factory can run more than once
Channel<T>In-process producer/consumer queuesBounded channels give back-pressure
  • Starvation = threads blocked on I/O they should have awaited. Low CPU with high latency is the giveaway.
  • Never hold a lock across an await. Use SemaphoreSlim.WaitAsync for async code.
  • Bound every queue. An unbounded channel just moves the out-of-memory error somewhere else.
How to implement it
Library / APIPackageUse it for
System.Threading.Lockbuilt-in .NET 9+Faster, clearer lock object; the C# lock statement uses it
SemaphoreSlim, Interlockedbuilt-in System.ThreadingAsync throttling and atomic updates
Channelsbuilt-in System.Threading.ChannelsBounded producer/consumer pipelines
Parallel.ForEachAsyncbuilt-inProcess a list with a concurrency cap
dotnet-counters, dotnet-stackglobal toolsSpot queue growth; see blocked stacks
// ✅ .NET 9+: a dedicated Lock instead of lock(object)
private readonly Lock _gate = new();
public void Add(decimal amount) { lock (_gate) { _total += amount; } }

// ❌ blocking inside a request: starves the pool under load
var limit = _limits.GetAsync(id).Result;

// ✅ async throttle: at most 4 calls to the core system at once
private readonly SemaphoreSlim _sem = new(4);
public async Task<Limit> GetLimitAsync(string id, CancellationToken ct)
{
    await _sem.WaitAsync(ct);
    try { return await _limits.GetAsync(id, ct); }
    finally { _sem.Release(); }
}

// ✅ bounded channel: producers wait when it's full (back-pressure)
var queue = Channel.CreateBounded<Payment>(new BoundedChannelOptions(1_000)
{
    FullMode = BoundedChannelFullMode.Wait
});

Your API is slow but CPU is at 15%. It's probably thread-pool starvation 🏊

🔒 Somewhere, code calls .Result or .Wait() on async I/O

🐢 The pool adds new threads slowly, so requests queue up

📈 dotnet-counters: thread-pool queue length climbing = smoking gun

✅ Fix: async all the way. SemaphoreSlim for throttling, Channels for queues, the new Lock type for sync code

Raising min threads hides the problem, it doesn't fix it. Have you hit this in production?

#dotnet #Performance #Concurrency #csharp

23.5 Dependency injection lifetimes Added

The built-in container creates your services and decides how long each instance lives: one for the whole app, one per request (scope), or a new one every time.

LifetimeOne instance perGood forWatch out
SingletonAppCaches, clients, config, stateless helpersMust be thread-safe; never inject scoped services
ScopedRequest (or manual scope)DbContext, unit of work, current-user infoDoesn't exist outside a scope (background jobs)
TransientInjectionLightweight, stateless servicesDisposable transients from the root live until shutdown

🪤 Captive dependency

A longer-lived service holds a shorter-lived one: singleton → scoped, or singleton → transient with state. Rule: only depend on equal or longer lifetimes.

🔍 Scope validation

ValidateScopes and ValidateOnBuild fail fast on captive dependencies and missing registrations. They're on by default in Development; consider them in CI too.

🔑 Keyed services (.NET 8+)

Register several implementations under keys ("npp", "bpay") and inject with [FromKeyedServices].

⚙️ Background services

A BackgroundService is a singleton. Inject IServiceScopeFactory and create a scope per message or batch.

Options typeLifetimeSees config changes?Use in
IOptions<T>SingletonNo, read onceSettings fixed at startup
IOptionsSnapshot<T>ScopedYes, per requestScoped and transient services
IOptionsMonitor<T>SingletonYes, live + OnChangeSingletons and background services
  • The container disposes what it creates: scoped services at the end of the scope, singletons at shutdown. Instances you new up and register yourself are yours to dispose.
  • Injecting IOptionsSnapshot into a singleton is itself a captive dependency. Use IOptionsMonitor.
  • Add ValidateOnStart() to options so bad config fails at startup, not on the first request.
How to implement it
Library / APIPackageUse it for
AddSingleton / AddScoped / AddTransientbuilt-in Microsoft.Extensions.DependencyInjectionRegister services with a lifetime
AddKeyedSingleton…, [FromKeyedServices]built-in .NET 8+Several implementations of one interface
IServiceScopeFactory.CreateAsyncScopebuilt-inScoped work inside singletons and background services
IOptions / IOptionsSnapshot / IOptionsMonitorbuilt-in Microsoft.Extensions.OptionsTyped config; ValidateDataAnnotations().ValidateOnStart()
ValidateScopes, ValidateOnBuildbuilt-in ServiceProviderOptionsCatch lifetime bugs at startup
// ❌ captive dependency: a singleton that takes a scoped DbContext
builder.Services.AddSingleton<RateCache>();   // RateCache(AppDbContext db)

// ✅ fail fast in every environment, not just Development
builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

// ✅ a singleton worker creates one scope per unit of work
public sealed class SettlementWorker(IServiceScopeFactory scopes) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            await SettleBatchAsync(db, ct);
        }
    }
}

// ✅ keyed services (.NET 8+)
builder.Services.AddKeyedSingleton<IPaymentRail, NppRail>("npp");
app.MapGet("/rail", ([FromKeyedServices("npp")] IPaymentRail rail) => rail.Name);

The DI bug that passes every unit test: the captive dependency 🪤

🟦 Singleton = one per app. 🟩 Scoped = one per request. 🟪 Transient = new every time

❌ A singleton that injects a scoped DbContext keeps request 1's context forever

🔍 ValidateScopes + ValidateOnBuild catch it at startup (on by default in Development only)

⚙️ Background services: inject IServiceScopeFactory and create a scope per batch

Rule of thumb: only depend on equal or longer lifetimes. Which lifetime bug has bitten you?

#dotnet #DependencyInjection #aspnetcore #csharp

23.6 ASP.NET Core request pipeline Added

Each request flows through a chain of middleware in the order you add them, reaches an endpoint, and the response flows back out in reverse order.

Recommended order (Microsoft docs)

UseExceptionHandler
→
UseHsts
→
UseHttpsRedirection
→
Static filesMapStaticAssets in .NET 9+
→
UseRouting
→
UseRateLimiter
→
UseCors
→
UseAuthentication
→
UseAuthorization
→
UseOutputCache
→
Endpoints
Minimal APIsControllers
StyleLambdas or static methods mapped to routesClasses with action methods and attributes
Cross-cuttingEndpoint filters, route groupsAction filters, model binders
Native AOTSupportedNot supported
Best forNew APIs, microservicesLarge existing MVC codebases

🧩 Middleware

Runs for every request. Good for global concerns: logging, headers, exception handling.

🎯 Endpoint filters

Run only for the endpoints they're attached to, with access to the bound arguments. Good for validation and idempotency keys.

⏩ Short-circuit

.ShortCircuit() runs an endpoint straight after routing and skips the rest (health checks, robots.txt).

🖥️ Kestrel

The cross-platform web server inside every app. HTTP/1.1, 2 and 3. Usually sits behind a gateway or load balancer.

  • Order is behaviour. A misplaced UseAuthorization or UseCors is a security or outage bug, not a style issue.
  • Each middleware sees the request twice: code before await next() runs on the way in, code after it on the way out.
  • Don't write to the response after calling next() if the response has already started.
How to implement it
Library / APIPackageUse it for
UseExceptionHandler + AddProblemDetails, IExceptionHandlerbuilt-in ASP.NET CoreOne place to turn exceptions into RFC 9457 errors
MapGroup, AddEndpointFilterbuilt-inShared prefixes, auth and filters for minimal APIs
ShortCircuit / MapShortCircuitbuilt-in .NET 8+Skip the pipeline for trivial endpoints
AddRateLimiter, AddOutputCachebuilt-inRate limits and server-side response caching
builder.Services.AddProblemDetails();
var app = builder.Build();

app.UseExceptionHandler();   // ✅ first: catches everything below it
app.UseHsts();
app.UseHttpsRedirection();
app.UseRouting();
app.UseRateLimiter();        // after routing for per-endpoint policies
app.UseCors();
app.UseAuthentication();     // ✅ who are you, then...
app.UseAuthorization();      // ✅ ...are you allowed
app.UseOutputCache();
app.MapStaticAssets();

var payments = app.MapGroup("/payments").RequireAuthorization();
payments.MapPost("/", CreatePayment)
        .AddEndpointFilter<IdempotencyKeyFilter>()
        .RequireRateLimiting("per-client");
app.MapGet("/healthz", () => "ok").ShortCircuit();

// ❌ UseAuthorization() before UseAuthentication(): every user is anonymous
// ❌ UseExceptionHandler() last: it can't catch errors from earlier middleware

In ASP.NET Core, middleware order isn't style. It's behaviour 🧅

🛡️ Exception handler first, so it catches everything after it

🔐 CORS → authentication → authorization, always in that order

🚦 Rate limiter after routing when you use per-endpoint policies

⏩ Short-circuit health checks so they skip the whole pipeline

Requests go in through each layer and responses come back out in reverse. What's the worst ordering bug you've seen?

#aspnetcore #dotnet #WebAPI #Backend

23.7 HttpClient & outbound calls Added

HttpClient is meant to be reused. Create one per call and you run out of sockets; keep one forever and you miss DNS changes. IHttpClientFactory or a pooled connection lifetime solves both.

ApproachSocketsDNSVerdict
new HttpClient() per callExhausted under loadFresh❌ Never
static HttpClientReusedStale⚠️ Only with a pooled lifetime
static + PooledConnectionLifetimeReusedRefreshed✅ No-DI apps, libraries
IHttpClientFactory (typed clients)ReusedRefreshed (handlers rotate, 2 min default)✅ ASP.NET Core and worker apps

🏷️ Typed clients

A class that takes HttpClient in its constructor and wraps one API. Config, base address and resilience live in one registration.

🛡️ Resilience handler

AddStandardResilienceHandler() adds rate limiting, total and per-attempt timeouts, retries and a circuit breaker. See 3.6 Resilience patterns.

🪤 Typed client in a singleton

Typed clients are transient. Capture one in a singleton and its handler never rotates: stale DNS again.

  • Disposing HttpClient is the bug, not the fix. Reuse the handler, not just the client.
  • Every outbound call needs a timeout. The default HttpClient.Timeout is 100 seconds, which is far too long for a request path.
  • Only retry idempotent calls, or calls that carry an idempotency key (payments!).
How to implement it
Library / APIPackageUse it for
IHttpClientFactory, AddHttpClient<T>built-in Microsoft.Extensions.HttpTyped and named clients with pooled, rotating handlers
SocketsHttpHandler.PooledConnectionLifetimebuilt-in System.Net.HttpSafe long-lived client without DI
AddStandardResilienceHandlerMicrosoft.Extensions.Http.ResilienceTimeouts, retries, circuit breaker on top of Polly v8
GetFromJsonAsync / PostAsJsonAsyncbuilt-in System.Net.Http.JsonJSON calls in one line
// ❌ new client per call: sockets pile up in TIME_WAIT under load
using var http = new HttpClient();
var rate = await http.GetFromJsonAsync<FxRate>(url, ct);

// ✅ typed client + standard resilience pipeline
builder.Services.AddHttpClient<FxClient>(c =>
        c.BaseAddress = new Uri("https://fx.example.com/"))
    .AddStandardResilienceHandler();

public sealed class FxClient(HttpClient http)
{
    public Task<FxRate?> GetAsync(string pair, CancellationToken ct) =>
        http.GetFromJsonAsync<FxRate>($"rates/{pair}", ct);
}

// ✅ no DI (console tool, library)? one static client with a pooled lifetime
static readonly HttpClient Http = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});

The resilience handler sets its own total timeout. Don't also set a short HttpClient.Timeout, or it cuts off the retries.

"Just wrap HttpClient in a using." The most expensive line in .NET history? 🔌

❌ new HttpClient() per call: sockets stuck in TIME_WAIT, then SocketException under load

⚠️ One static HttpClient forever: reuses sockets, but never sees DNS changes

✅ IHttpClientFactory with typed clients, or SocketsHttpHandler with PooledConnectionLifetime

🛡️ Add AddStandardResilienceHandler() for timeouts, retries and a circuit breaker

Reuse the handler, not just the client. How do you call downstream APIs?

#dotnet #HttpClient #Resilience #aspnetcore

23.8 EF Core performance & pitfalls Added

EF Core turns LINQ into SQL. Most performance problems come from loading too much, too often: N+1 queries, tracking you don't need, and whole entities when you only need three columns.

PitfallSymptomFix
N+1 queriesHundreds of tiny queries per requestInclude, or better a Select projection
Cartesian explosionIncluding 2+ collections returns huge result setsAsSplitQuery()
Tracking read-only dataHigh memory and CPU on readsAsNoTracking()
Whole entitiesWide rows over the wireProject into a DTO with Select
Load, modify, save in bulkOne UPDATE per rowExecuteUpdateAsync / ExecuteDeleteAsync
ToList() too earlyFiltering and paging done in memoryKeep it IQueryable until the end
Hot query re-translationCPU spent compiling the same LINQCompiled queries (EF.CompileAsyncQuery)

DbContext lifetime

  • A unit of work: scoped, one per request.
  • Not thread-safe. Two parallel queries on one context throw "A second operation was started".
  • AddDbContextPool reuses context instances for high-throughput APIs.
  • Background jobs and Blazor: IDbContextFactory<T>, one context per operation.

Migrations in production

  • Avoid Database.Migrate() at app startup in production: the app then needs schema-change rights, and a failed migration takes every instance down.
  • Generate an idempotent SQL script or a migration bundle and run it as a pipeline step.
  • Review the SQL; prefer additive, backward-compatible changes (expand/contract).
  • Log the generated SQL in development (LogTo or the EF Core logging category). You can't fix queries you never look at.
  • FromSql($"... {value}") sends value as a parameter. FromSqlRaw with string concatenation is SQL injection.
  • EF 10 sends parameterised collections (ids.Contains(x.Id)) as one parameter per value by default, which helps the query planner.
How to implement it
Library / APIPackageUse it for
EF Core 10 providerMicrosoft.EntityFrameworkCore.SqlServer (or Npgsql.EntityFrameworkCore.PostgreSQL)Include, AsSplitQuery, AsNoTracking, projections
ExecuteUpdateAsync / ExecuteDeleteAsyncEF Core 7+Set-based updates and deletes; EF 10 also accepts a normal lambda
EF.CompileAsyncQueryEF CoreSkip LINQ translation on very hot queries
AddDbContextPool / AddPooledDbContextFactoryEF CoreReuse context instances
Migration scripts and bundlestool dotnet-efmigrations script --idempotent, migrations bundle
// ❌ N+1 (with lazy loading): one extra query per account, all entities tracked
var accounts = await db.Accounts.ToListAsync(ct);
foreach (var a in accounts)
    total += a.Transactions.Sum(t => t.Amount);

// ✅ one query, only the columns you need (projections aren't tracked)
var totals = await db.Accounts
    .Select(a => new { a.Id, Total = a.Transactions.Sum(t => t.Amount) })
    .ToListAsync(ct);

// ✅ bulk update without loading rows
await db.Cards.Where(c => c.ExpiresOn < today)
    .ExecuteUpdateAsync(s => s.SetProperty(c => c.Status, CardStatus.Expired), ct);

// ❌ string concatenation = SQL injection
db.Accounts.FromSqlRaw("SELECT * FROM Accounts WHERE Bsb = '" + bsb + "'");
// ✅ interpolated FromSql: bsb is sent as a parameter
db.Accounts.FromSql($"SELECT * FROM Accounts WHERE Bsb = {bsb}");

EF Core isn't slow. Loading 10x the data you need is slow 🐌

🔁 N+1: a lazy-loaded navigation in a loop = one query per row

🎯 Project with Select: one query, only the columns you need

👀 AsNoTracking for reads, AsSplitQuery when you include several collections

⚡ ExecuteUpdate/ExecuteDelete for bulk changes, no entities loaded

🛑 FromSql with interpolation is parameterised. FromSqlRaw with + is SQL injection

Always read the SQL EF generates. When did you last check yours?

#EFCore #dotnet #SQL #Performance

23.9 High-performance .NET Added

High-performance .NET is mostly about allocating less and moving work from run time to compile time, then proving the gain with a benchmark.

ToolWhat it doesUse when
Span<T> / ReadOnlySpan<T>A view over arrays, strings or stack memory. Stack-onlyParsing and slicing in sync code
Memory<T>Like Span, but can live on the heap and cross awaitAsync I/O buffers
ArrayPool<T>.SharedRent and return big arrays instead of allocatingBuffers over 85 KB, hot loops
stackallocSmall buffer on the stack, freed on returnA few hundred bytes, known size
StringBuilder, string.CreateBuild strings without a copy per stepLoops and large outputs
Interpolated string handlersThe compiler formats $"..." without boxing; loggers can skip it entirelyLogging, assertions

🧾 System.Text.Json source gen

JsonSerializerContext generates serializers at compile time. Faster startup, no reflection, AOT-friendly.

🔤 [GeneratedRegex]

The regex is compiled to C# at build time instead of at run time.

📝 [LoggerMessage]

Generates strongly typed, allocation-free log methods.

⚙️ Config binding generator

Binds options without reflection, which Native AOT needs.

Native AOT: gains

  • Fast startup, no JIT warm-up.
  • Smaller memory footprint and a single self-contained file.
  • Great for functions, sidecars, CLIs and scale-to-zero containers.

Native AOT: costs

  • No runtime code generation; reflection-heavy libraries break.
  • Trimming removes unused code; fix every trim/AOT warning.
  • Minimal APIs and gRPC are supported; MVC controllers aren't. EF Core support is still limited.
  • Peak throughput can be a little lower than JIT with Dynamic PGO.

Capacity planning, latency budgets and load testing are on page 03: 3.8 Performance.

  • Measure first. Profile with dotnet-trace or dotnet-counters, then benchmark the hot spot with BenchmarkDotNet.
  • Allocation rate is often the best proxy for GC cost. Watch "Allocated" as closely as "Mean".
  • C# 14 makes arrays convert to Span / ReadOnlySpan implicitly, so span-based overloads are picked more often.
How to implement it
Library / APIPackageUse it for
Span, Memory, ArrayPoolbuilt-in System, System.BuffersSlicing and buffers without allocations
JsonSerializerContextbuilt-in System.Text.JsonSource-generated JSON
[GeneratedRegex], [LoggerMessage]built-inCompile-time regex and logging
PublishAot, CreateSlimBuilderbuilt-in MSBuild / ASP.NET CoreNative AOT apps
BenchmarkDotNetBenchmarkDotNetMicro-benchmarks with memory stats
// ❌ allocates a string[] and three strings per line
var parts = line.Split('|');
decimal amt = decimal.Parse(parts[2], CultureInfo.InvariantCulture);

// ✅ zero-allocation parse with spans
ReadOnlySpan<char> s = line;
var field = s[(s.LastIndexOf('|') + 1)..];   // a view, not a copy
decimal amount = decimal.Parse(field, CultureInfo.InvariantCulture);

// ✅ compile-time regex and JSON (also trimming and AOT friendly)
public static partial class Bsb
{
    [GeneratedRegex(@"^\d{3}-\d{3}$")]
    public static partial Regex Pattern();
}

[JsonSerializable(typeof(Payment))]
internal partial class AppJson : JsonSerializerContext { }

var json = JsonSerializer.Serialize(payment, AppJson.Default.Payment);

[MemoryDiagnoser]   // BenchmarkDotNet: adds an "Allocated" column
public class ParseBench { /* [Benchmark] methods */ }

Most .NET performance wins come from one thing: allocate less 🧮

✂️ Span<T> slices strings and arrays without copying

♻️ ArrayPool rents big buffers instead of feeding the large object heap

🏗️ Source generators move work to compile time: JSON, Regex, logging

🚀 Native AOT: fast startup and low memory, but reflection-heavy code breaks

📏 BenchmarkDotNet with [MemoryDiagnoser], or it didn't happen

What's the biggest win you've had from removing allocations?

#dotnet #Performance #csharp #NativeAOT

23.10 Actor model with Orleans Added

Orleans is Microsoft's virtual actor framework. You write small, single-threaded objects (grains) with an identity, and the runtime places, activates and recovers them across a cluster of servers (silos).

🧱 Grain

An actor with a key (e.g. account ID), its own state and async methods. Processes one request at a time by default.

🖥️ Silo

A host process running grains. Silos form a cluster with membership stored in Azure Table, SQL, Kubernetes and others.

👻 Virtual actor

A grain always "exists". It's activated when called and deactivated when idle. You never create or destroy it.

💾 State, timers, reminders

Pluggable persistence, in-memory timers and durable reminders that survive restarts.

Good fit

  • Many independent entities with their own state: accounts, devices, carts, game sessions.
  • Hot in-memory state with low latency, and a cluster that scales out.
  • Avoiding distributed locks: each entity is single-threaded by design.

Poor fit

  • Simple CRUD over a database: a plain API is easier.
  • Big set-based queries across all entities ("all accounts over $10k").
  • Teams without the appetite to run a stateful cluster.
  • Virtual actor = location transparency plus automatic lifecycle. The caller only knows the grain's interface and key.
  • Single-threaded grains remove locks, but a hot grain (one very busy account) becomes a bottleneck. Design keys so load spreads.
  • Grains calling each other in a cycle (A → B → A) can deadlock unless you allow reentrancy.
How to implement it
Library / APIPackageUse it for
Orleans siloMicrosoft.Orleans.Server (Orleans 10)Host grains; includes the client
Orleans clientMicrosoft.Orleans.ClientCall grains from an app that doesn't host a silo
Grain interfaces and code generationMicrosoft.Orleans.SdkGrain interface projects and serializers
Clustering and storage providersMicrosoft.Orleans.Clustering.AzureStorage, Microsoft.Orleans.Persistence.*Membership and grain state in Azure, SQL and more
public interface IAccountGrain : IGrainWithStringKey
{
    Task<decimal> Deposit(decimal amount);
}

[GenerateSerializer]
public sealed class AccountState { [Id(0)] public decimal Balance { get; set; } }

public sealed class AccountGrain(
    [PersistentState("account", "accounts")] IPersistentState<AccountState> state)
    : Grain, IAccountGrain
{
    public async Task<decimal> Deposit(decimal amount)
    {
        state.State.Balance += amount;   // ✅ one call at a time: no lock needed
        await state.WriteStateAsync();
        return state.State.Balance;
    }
}

builder.UseOrleans(silo => silo
    .UseLocalhostClustering()
    .AddMemoryGrainStorage("accounts"));   // dev only; use real storage in prod

var balance = await grains.GetGrain<IAccountGrain>("acc-123").Deposit(50m);

// ❌ blocking inside a grain (.Result) stalls every caller of that grain

Need per-customer state without distributed locks? Meet the virtual actor 🎭

🧱 Grain = an object with an ID, its own state and async methods

👻 It always "exists": activated when called, deactivated when idle

🔒 One call at a time per grain, so no locks in your code

🔁 A silo dies? The next call re-activates the grain on another one

Great for accounts, devices and sessions, overkill for CRUD. Have you run Orleans in production?

#dotnet #Orleans #ActorModel #DistributedSystems

23.11 .NET Framework → .NET Core → modern .NET Added

.NET Framework is the Windows-only original, now in maintenance. .NET Core rebuilt it as cross-platform and open source, and since .NET 5 it's simply ".NET", with a new major version every November.

EraWhat changedStatus (Oct 2026)
.NET Framework 1.0–4.8.1Windows-only, one machine-wide install, System.Web, WCFMaintenance: supported as part of Windows, no new features
.NET Core 1.0–3.1Cross-platform, open source, side-by-side installs, KestrelOut of support
.NET 5+One unified platform, yearly releases.NET 10 LTS current; .NET 8 and 9 both end 10 Nov 2026

🛡️ LTS

Even numbers. 3 years of support.

⚡ STS

Odd numbers. 24 months from .NET 9 on (was 18).

🔜 .NET 11

RC2 out (go-live). GA expected Nov 2026.

🩹 Patch Tuesday

Monthly patches; apps roll forward to them.

Framework vs modern .NET

.NET Framework 4.8.x.NET 10
OSWindows onlyWindows, Linux, macOS, containers
InstallOne version per machineSide-by-side, or bundled with the app
WebSystem.Web on IISASP.NET Core on Kestrel
ServicesWCF, RemotinggRPC, REST, CoreWCF
Config, DI, logsweb.config, add-onsBuilt into the Generic Host
PerformanceFrozenFaster every release
<!-- App: modern .NET only -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>

<!-- Shared library still used by a .NET Framework app -->
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>

# Deploy
dotnet publish -c Release                                  # framework-dependent
dotnet publish -c Release -r linux-x64 --self-contained    # runtime bundled
dotnet publish -c Release /t:PublishContainer              # OCI image, no Dockerfile

Migration path

🌿 Strangler fig

  • YARP proxy in front of the old app; move routes one at a time.
  • System.Web adapters share session and auth during the move.
  • Libraries first (multi-target), then apps. See 13.4.

🚧 Blockers

  • WCF server: see 23.18.
  • Web Forms: no port, rewrite (Razor, Blazor).
  • AppDomains, Remoting: gone. BinaryFormatter: removed in .NET 9.
Interview questionHow to answer
Why move off .NET Framework?It's supported but frozen: no Linux containers, no performance gains, harder hiring. Migrate by business value, not all at once.
.NET 8 ends 10 Nov 2026. Plan?Inventory, bump to net10.0, update packages, review breaking changes, test in CI, roll out.
Self-contained or framework-dependent?Containers: framework-dependent on the official image. Self-contained when you don't control the host. AOT for startup.
  • .NET Framework isn't end-of-life: 4.8.x is supported with Windows, but gets no new features.
  • Even = LTS (3 years), odd = STS (24 months). .NET 10 is the LTS to be on now.
  • The SDK builds, the runtime runs. A newer SDK can target older runtimes.
  • netstandard2.0 is only for libraries shared with Framework apps.
  • Migrate incrementally: YARP strangler fig, libraries first, WCF and Web Forms are the hard parts.
How to implement it
Library / APIPackageUse it for
WebApplication.CreateBuilder, Host.CreateApplicationBuilderbuilt-in Generic HostBootstrap web apps and workers with DI, config and logging
Windows service / systemd hostingMicrosoft.Extensions.Hosting.WindowsServices, .SystemdReplace a hand-written Windows service (details in 23.17)
SDK container publish (/t:PublishContainer)built-in .NET SDKImages without a Dockerfile; non-root by default
PublishAot, SelfContained, RuntimeIdentifierbuilt-in MSBuildChoose the deployment model
Incremental ASP.NET migrationYarp.ReverseProxy 2.3, Microsoft.AspNetCore.SystemWebAdapters 2.3Strangler fig from System.Web to ASP.NET Core
Windows-only APIs on modern .NETMicrosoft.Windows.CompatibilityRegistry, EventLog and similar during a port
Upgrade toolingGitHub Copilot app modernization (Visual Studio)Replaces the deprecated .NET Upgrade Assistant
// ✅ global.json: pin the SDK so every build machine behaves the same
{ "sdk": { "version": "10.0.100", "rollForward": "latestFeature" } }

<!-- ✅ A library mid-migration: one codebase, two targets -->
<PropertyGroup>
  <TargetFrameworks>net48;net10.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
  <Reference Include="System.Web" />
</ItemGroup>

// ✅ Isolate the few platform differences behind #if
#if NETFRAMEWORK
    string? user = HttpContext.Current?.User?.Identity?.Name;           // System.Web
#else
    string? user = accessor.HttpContext?.User.Identity?.Name;          // IHttpContextAccessor
#endif

// ❌ Big-bang rewrite of every project at once: long freeze, high risk

".NET Framework is dead" is a common interview mistake 🧭

🪟 .NET Framework 4.8.x is still supported as part of Windows, but it's frozen

🐧 .NET Core made it cross-platform, open source and side-by-side

📅 Since .NET 5: a release every November. Even = LTS (3 years), odd = STS (now 24 months)

⏰ .NET 8 and .NET 9 both reach end of support on 10 Nov 2026. .NET 10 is the LTS to be on

🌿 Migrate with a strangler fig: YARP in front, move routes one by one, libraries first

Where is your estate on this timeline?

#dotnet #csharp #SoftwareArchitecture #Modernisation

23.12 Writing middleware: examples and patterns Added

A middleware is a function that gets the HttpContext and the next delegate: do work, call next (or don't), then do work on the way out. 23.6 covers the order; this section is about writing one.

Five ways to write one

StyleShapeUse for
Inline Useapp.Use(async (ctx, next) => { …; await next(ctx); })Small one-offs
Terminal Runapp.Run(ctx => …), no nextEnd of a branch
BranchMap (path), MapWhen (predicate): no rejoin. UseWhen: rejoinsHealth, legacy paths, extra checks on /api
Convention classCtor takes RequestDelegate; InvokeAsync(HttpContext, …); UseMiddleware<T>()Reusable. Built once per app
Factory IMiddlewareRegister in DI; resolved per requestNeeds scoped deps in the ctor
// ✅ correlation ID + timing: a convention-based middleware
public sealed class CorrelationMiddleware(RequestDelegate next, ILogger<CorrelationMiddleware> log)
{
    const string Header = "X-Correlation-ID";

    // scoped services go here, not in the constructor (the class lives for the whole app)
    public async Task InvokeAsync(HttpContext ctx, IRequestContext requestContext)
    {
        var id = ctx.Request.Headers[Header].FirstOrDefault() is { Length: > 0 and <= 64 } h ? h : ctx.TraceIdentifier;
        requestContext.CorrelationId = id;
        ctx.Response.OnStarting(() => { ctx.Response.Headers[Header] = id; return Task.CompletedTask; });

        var start = Stopwatch.GetTimestamp();
        using (log.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
        {
            try { await next(ctx); }                                    // in ... endpoint ... out
            finally
            {
                log.LogInformation("{Method} {Path} {Status} in {Ms:0} ms", ctx.Request.Method,
                    ctx.Request.Path, ctx.Response.StatusCode, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
            }
        }
    }
}
// Program.cs: app.UseMiddleware<CorrelationMiddleware>();

Middleware vs MVC filters vs endpoint filters

MiddlewareMVC filtersEndpoint filters
ScopeEvery request (or a branch)Controllers, Razor PagesMinimal API endpoints, groups
Sees bound model?No, raw HttpContextYes: ActionArguments, ModelStateYes: context.Arguments
Typical useCorrelation, logging, headers, errorsAudit, per-action rules (23.14)Validation, idempotency keys

💥 Response already started

Setting headers or the status after next() has flushed the body throws. Check Response.HasStarted or use Response.OnStarting.

🔀 Order bugs

Correlation added after the exception handler means error logs have no ID. Need endpoint metadata? Run after UseRouting.

🪤 Scoped in the ctor

A convention middleware is effectively a singleton, so a scoped DbContext in its constructor is a captive dependency. Inject it into InvokeAsync.

🛡️ Global errors

UseExceptionHandler + IExceptionHandler (.NET 8+) + AddProblemDetails. No try/catch in every handler.

Interview questionHow to answer
Write a request-timing middlewareClass with RequestDelegate; start timer, await next in try, log in finally
Use vs Run vs Map?Use passes on; Run ends; Map forks by path and doesn't rejoin
Convention vs IMiddleware?Convention: one instance, scoped deps per call. IMiddleware: DI creates it per request
Middleware or filter?Needs bound arguments or action metadata: filter. Every request: middleware
  • Code before await next() runs going in; code after it runs coming out.
  • Not calling next is the short-circuit. Set a status code first.
  • Scoped services go into InvokeAsync, or use IMiddleware.
  • Prefer W3C traceparent (Activity.Current) for tracing; a custom correlation header is for callers that don't send it. Validate it before logging it.
  • .NET 10: the exception handler middleware no longer logs exceptions that an IExceptionHandler handled. Log them yourself.
How to implement it
Library / APIPackageUse it for
Use, Run, Map, MapWhen, UseWhenbuilt-in ASP.NET CoreInline middleware and branches
UseMiddleware<T>, IMiddlewarebuilt-inConvention-based and factory-based classes
IExceptionHandler, AddExceptionHandler<T>built-in .NET 8+Central exception-to-response mapping; handlers run in registration order
AddProblemDetails, IProblemDetailsServicebuilt-in .NET 7+RFC 9457 application/problem+json bodies
Response.OnStarting, Response.HasStartedbuilt-inAdd headers safely before the body flushes
// inline short-circuit
app.Use(async (ctx, next) =>
{
    if (!ctx.Request.Headers.ContainsKey("X-Api-Key"))
    {
        ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return;                                  // next never runs
    }
    await next(ctx);
});

// branches
app.Map("/legacy", b => b.Run(ctx => { ctx.Response.StatusCode = 410; return Task.CompletedTask; }));
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"),
            b => b.UseMiddleware<TenantMiddleware>());   // rejoins the main pipeline

// factory-based: resolved from DI per request, so scoped ctor deps are fine
public sealed class TenantMiddleware(AppDbContext db) : IMiddleware
{
    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        ctx.Items["Tenant"] = await db.Tenants.FindAsync(ctx.Request.Headers["X-Tenant"].ToString());
        await next(ctx);
    }
}
builder.Services.AddScoped<TenantMiddleware>();

// global exception handling (.NET 8+)
public sealed class RuleExceptionHandler(IProblemDetailsService problems, ILogger<RuleExceptionHandler> log)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not BusinessRuleException rule) return false;   // next handler, then the default
        log.LogWarning(ex, "Business rule failed");               // .NET 10 doesn't log handled ones
        ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            ProblemDetails = new ProblemDetails { Status = 422, Title = "Business rule violated", Detail = rule.Message }
        });
    }
}
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RuleExceptionHandler>();
app.UseExceptionHandler();                     // first in the pipeline (23.6)

// ❌ convention middleware with a scoped dependency in the constructor
public class BadMiddleware(RequestDelegate next, AppDbContext db) { /* captive DbContext */ }

Every ASP.NET Core middleware is the same three lines: do work, await next(), do work 🔁

⛔ Don't call next() and you've short-circuited: perfect for an API key check

🌿 Map forks the pipeline for good; UseWhen forks and rejoins

🪤 Convention middleware lives for the whole app. Scoped services go into InvokeAsync, not the constructor

💥 Headers after the response has started? Exception. Use Response.OnStarting

🛡️ Global errors: IExceptionHandler + AddProblemDetails, not try/catch everywhere

What's the most useful middleware you've written?

#aspnetcore #dotnet #middleware #Backend

23.13 ASP.NET Core Web API Added

HTTP APIs built with controllers or minimal APIs, with routing, model binding, validation, ProblemDetails errors and OpenAPI built in. For REST design itself, see 08 · REST.

Controllers vs minimal APIs

Controllers + [ApiController]Minimal APIs
RoutingAttribute routing requiredMapGet, MapGroup
BindingSource inference: complex type = bodyInferred; [AsParameters] for many
Invalid inputAutomatic 400 ValidationProblemDetails.NET 10: AddValidation(), automatic 400
ResultsActionResult<T>TypedResults, Results<Ok<T>, NotFound>
Cross-cuttingMVC filtersEndpoint filters
Content negotiationOutput formatters, Accept headerJSON
Choose whenCustom model binders, OData, app parts, big existing codebaseNew APIs (Microsoft's recommendation), Native AOT
builder.Services.AddValidation();      // .NET 10: DataAnnotations on minimal APIs
builder.Services.AddProblemDetails();  // RFC 9457 errors
builder.Services.AddOpenApi();         // .NET 9+: built-in, OpenAPI 3.1 by default in .NET 10
var app = builder.Build();
app.MapOpenApi();                      // /openapi/v1.json

var accounts = app.MapGroup("/api/v1/accounts").WithTags("Accounts");

accounts.MapGet("/{id:guid}", async Task<Results<Ok<AccountDto>, NotFound>>
    (Guid id, IAccountStore store, CancellationToken ct) =>
        await store.FindAsync(id, ct) is { } a ? TypedResults.Ok(a) : TypedResults.NotFound());

accounts.MapPost("/", async (OpenAccount cmd, IAccountStore store, CancellationToken ct) =>
{
    var a = await store.OpenAsync(cmd, ct);              // invalid cmd never gets here: 400
    return TypedResults.Created($"/api/v1/accounts/{a.Id}", a);
}).RequireRateLimiting("per-client");

public record OpenAccount([Required, StringLength(80)] string Name, [Range(0, 1_000_000)] decimal Deposit);

🧭 Routing

Constraints {id:int}, {id:guid}, {n:min(1)}, {**path}. They pick routes; they're not validation (a mismatch is a 404, not a 400).

📥 Binding sources

[FromRoute], [FromQuery], [FromHeader], [FromBody] (one per action), [FromForm], [FromServices].

🧾 ProblemDetails

RFC 9457 (replaced 7807): type, title, status, detail, instance + extensions such as traceId. One error shape for every client.

📘 OpenAPI

Microsoft.AspNetCore.OpenApi since .NET 9; Swashbuckle left the template. .NET 10: OpenAPI 3.1 default, YAML via MapOpenApi("/openapi/{documentName}.yaml"). Add a UI yourself.

🚦 Rate limit, cache, CORS

AddRateLimiter and AddOutputCache (built in since .NET 7). Rejections are 503 by default: set 429. CORS is enforced by browsers, not a security control.

🧪 Testing

WebApplicationFactory<Program> runs the real pipeline in memory. .NET 10 generates public partial class Program for you.

Interview questionHow to answer
Controllers or minimal APIs?Minimal for new APIs and AOT; controllers for MVC features or an existing codebase. Both run on the same routing and middleware
What does [ApiController] do?Requires attribute routing, auto-400 on invalid model, infers binding sources, ProblemDetails for error codes
How do you version an API?URL segment by default, Asp.Versioning.*, sunset headers. See 08 and 09
How do clients see errors?Always ProblemDetails with a traceId; no stack traces outside Development
Make POST safe to retry?Idempotency key endpoint filter: 08 · Idempotency
  • Return TypedResults with Results<…>: compile-time checked and documented in OpenAPI.
  • Validate at the edge (400), enforce business rules in the domain (409/422).
  • Rate limiter rejects with 503 unless you set RejectionStatusCode = 429. Design: 08 · Rate limiting.
  • Never combine AllowAnyOrigin with credentials; list the origins.
  • Integration-test through WebApplicationFactory, swapping only external dependencies.
How to implement it
Library / APIPackageUse it for
MapGroup, TypedResults, AddEndpointFilterbuilt-in ASP.NET CoreMinimal APIs
AddControllers, [ApiController]built-inController APIs
AddValidationbuilt-in .NET 10; types in Microsoft.Extensions.ValidationDataAnnotations validation for minimal APIs (and Blazor forms)
AddOpenApi, MapOpenApiMicrosoft.AspNetCore.OpenApi 10.xOpenAPI 3.1 documents (JSON or YAML)
Swagger UI / Scalar UISwashbuckle.AspNetCore or Scalar.AspNetCoreInteractive docs page (no longer in the template)
API versioningAsp.Versioning.Http (minimal), Asp.Versioning.Mvc, Asp.Versioning.Mvc.ApiExplorer 10.xURL, query, header or media-type versions
AddRateLimiter, AddOutputCache, AddCorsbuilt-in429s, server-side response cache, browser cross-origin rules
WebApplicationFactory<T>Microsoft.AspNetCore.Mvc.Testing 10.xIn-memory integration tests
// rate limiting: per-user fixed window, 429 instead of the default 503
builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.AddPolicy("per-client", ctx => RateLimitPartition.GetFixedWindowLimiter(
        ctx.User.Identity?.Name ?? "anonymous",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));
});
builder.Services.AddOutputCache(o => o.AddPolicy("short", p => p.Expire(TimeSpan.FromSeconds(30)).Tag("rates")));
builder.Services.AddCors(o => o.AddPolicy("web", p =>
    p.WithOrigins("https://app.example.com").AllowAnyHeader().WithMethods("GET", "POST")));

// add traceId to every ProblemDetails
builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = c =>
    c.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? c.HttpContext.TraceIdentifier);

// controller equivalent: [ApiController] gives auto-400 and binding inference
[ApiController, Route("api/v1/accounts")]
public class AccountsController(IAccountStore store) : ControllerBase
{
    [HttpGet("{id:guid}")]
    [ProducesResponseType<AccountDto>(200), ProducesResponseType(404)]
    public async Task<ActionResult<AccountDto>> Get(Guid id, CancellationToken ct)
        => await store.FindAsync(id, ct) is { } a ? a : NotFound();
}

// integration test (xUnit)
public class AccountsApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Invalid_body_returns_problem_details()
    {
        var res = await factory.CreateClient().PostAsJsonAsync("/api/v1/accounts", new { name = "", deposit = -5 });
        Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
        Assert.Equal("application/problem+json", res.Content.Headers.ContentType?.MediaType);
    }
}

Five ASP.NET Core Web API defaults worth knowing before your next interview 👇

✅ .NET 10 validates minimal API input with AddValidation(): bad input is a 400 before your code runs

📘 OpenAPI is built in (Microsoft.AspNetCore.OpenApi) and .NET 10 emits 3.1 by default. Swashbuckle left the template in .NET 9

🧾 Errors should be ProblemDetails (RFC 9457) with a traceId, every time

🚦 The built-in rate limiter rejects with 503 unless you set 429

🧭 Route constraints pick routes; they don't validate input

Controllers or minimal APIs for your next service?

#aspnetcore #dotnet #WebAPI #OpenAPI

23.14 MVC, Razor Pages and Blazor Added

ASP.NET Core's server-side UI stacks: MVC splits a request into Model, View and Controller; Razor Pages puts the handler next to the page; Blazor builds the UI from C# components.

Routing
Controlleraction method
Modelservices, EF Core
ViewRazor .cshtml
IActionResultHTML, redirect, JSON

🏷️ Tag helpers

HTML-friendly server code: asp-for, asp-action, asp-route-id, asp-validation-for.

🧱 Layouts & partials

_Layout.cshtml with @RenderBody() and sections. Partial views reuse markup only: <partial name="_Row" />.

🧩 View components

A class plus a view with its own logic and DI, e.g. a basket summary. Like a mini controller you call from a view.

🛡️ Antiforgery

The form tag helper adds the token. MVC needs [ValidateAntiForgeryToken] (or the global auto filter); Razor Pages validate automatically. Blazor uses UseAntiforgery().

public sealed class AuditFilter(IAuditLog audit) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
    {
        var done = await next();                         // model is bound; the action runs here
        if (done.Exception is null)
            await audit.WriteAsync(ctx.ActionDescriptor.DisplayName, ctx.ActionArguments);
    }
}

[Authorize]                                               // authorization filter: runs first
public class PaymentsController(IPaymentService payments) : Controller
{
    [HttpPost, ValidateAntiForgeryToken, ServiceFilter(typeof(AuditFilter))]
    public async Task<IActionResult> Create(PaymentForm form, CancellationToken ct)
    {
        if (!ModelState.IsValid) return View(form);       // redisplay with errors
        var id = await payments.CreateAsync(form, ct);
        return RedirectToAction("Details", new { id });  // Post/Redirect/Get
    }
}
// Program.cs: builder.Services.AddScoped<AuditFilter>();

Blazor render modes (.NET 8+)

ModeRuns whereTrade-off
Static Server (static SSR)Server, plain HTMLDefault. Fast, no interactivity
Interactive ServerServer, UI events over SignalRInstant start; a circuit per user, latency-sensitive
Interactive WebAssemblyBrowser (.NET on WASM)Offloads server; download size, no secrets in client
Interactive AutoServer first, WASM on later visitsBest start + scale; both sets of constraints

Which UI stack when

StackPick it forWatch out
MVCLarge apps, shared controllers, existing codebasesMore ceremony per page
Razor PagesPage-centric forms, CRUD, internal toolsLimited rich interactivity
BlazorRich UI by a C# team, shared modelsCircuits or WASM payload
SPA + Web APISeparate front-end team, many clientsTwo stacks, BFF and token handling
Interview questionHow to answer
Walk me through the filter orderAuthorization, resource, binding, action, exception, result; resource closes last
Filter or middleware?Needs action context or bound arguments: filter. Every request: middleware
Razor Pages vs MVC?Same engine. Pages: one page, one model. MVC: controllers serve many views
Blazor Server vs WebAssembly?Server: thin client, stateful circuits. WASM: runs on the client, needs an API, bigger first load
  • Filter order: Authorization → Resource → (binding) → Action → Exception → Result.
  • Post/Redirect/Get after a successful form POST stops double submits on refresh.
  • Partial view = markup reuse. View component = markup + logic + DI.
  • Blazor's default is static SSR; opt into interactivity per page or component with @rendermode.
  • Interactive Server keeps state in a server circuit: plan for memory, reconnects and sticky sessions behind a load balancer.
How to implement it
Library / APIPackageUse it for
AddControllersWithViews, MapControllerRoutebuilt-in ASP.NET CoreMVC with views
AddRazorPages, MapRazorPagesbuilt-inRazor Pages
IAsyncActionFilter, IAsyncResourceFilter, IPageFilter, ServiceFilterbuilt-inFilters with DI
AddRazorComponents, MapRazorComponents<App>built-in .NET 8+Blazor Web App; add AddInteractiveServerComponents / AddInteractiveServerRenderMode
AddInteractiveWebAssemblyComponentsMicrosoft.AspNetCore.Components.WebAssembly.Server 10.xHost WebAssembly and Auto components
AddAntiforgery, UseAntiforgerybuilt-in .NET 8+CSRF tokens for Blazor forms and minimal API form posts
@* Pages/Customers/Edit.cshtml: a Razor Page *@
@page "{id:guid}"
@model EditCustomerModel
<form method="post">                        @* token added automatically *@
    <input asp-for="Input.Name" /> <span asp-validation-for="Input.Name"></span>
    <button>Save</button>
</form>

// Edit.cshtml.cs
public class EditCustomerModel(ICustomerService customers) : PageModel
{
    [BindProperty] public CustomerInput Input { get; set; } = new();

    public async Task OnGetAsync(Guid id) => Input = await customers.GetAsync(id);

    public async Task<IActionResult> OnPostAsync(Guid id)
    {
        if (!ModelState.IsValid) return Page();
        await customers.UpdateAsync(id, Input);
        return RedirectToPage("Index");
    }
}

@* Components/Pages/Counter.razor: Blazor, interactive over SignalR *@
@page "/counter"
@rendermode InteractiveServer
<button @onclick="() => count++">Clicked @count times</button>
@code { int count; }

// Program.cs (Blazor Web App)
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
app.UseAntiforgery();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();

Asked to explain the ASP.NET Core MVC filter pipeline? Here's the order 👇

🔐 Authorization: first, short-circuits with 401/403

📦 Resource: wraps everything, runs before model binding

🎯 Action: arguments are bound, the action runs inside it

💥 Exception: only for action or filter errors, not middleware

🖼️ Result: wraps view rendering or JSON writing

And the UI choice: Razor Pages for forms, MVC for big apps, Blazor for a C# team building rich UI, SPA + Web API for a separate front-end team. Which one does your team use?

#aspnetcore #dotnet #Blazor #RazorPages

23.15 Authentication and authorisation in ASP.NET Core Added

Authentication proves who the caller is and builds HttpContext.User (a ClaimsPrincipal). Authorisation decides what that user may do, ideally through named policies, not role checks scattered through the code.

How it works

SchemeFitsWatch
CookieServer-rendered apps; the BFF for a SPAHttpOnly, Secure, SameSite, anti-forgery
JWT bearerAPIs called by apps and servicesStateless, so hard to revoke: keep lifetimes short
OpenID ConnectSign-in through an IdP (Entra ID, Okta)Code flow + PKCE, then a cookie session

Roles and claims

[Authorize(Roles = "Admin")] is fine for coarse checks. Roles alone soon explode into "Admin-NSW-ReadOnly".

Policies

A named set of requirements, each checked by an AuthorizationHandler. Endpoints name the policy, not the rule.

Resource-based

When the answer depends on the record ("is this your account?"), load it, then call IAuthorizationService.AuthorizeAsync(user, resource, policy).

Identity vs IdP

ASP.NET Core Identity stores users and passwords. .NET 8 added MapIdentityApi endpoints; .NET 10 adds passkeys. In an enterprise, prefer Entra ID through Microsoft.Identity.Web.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; // issuer + keys
        o.Audience = "api://payments";
        o.MapInboundClaims = false;                     // keep "scp", not a long URI
        o.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
    });
builder.Services.AddSingleton<IAuthorizationHandler, LimitHandler>();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("HighValue", p => p.RequireClaim("scp", "payments.write")
                                 .AddRequirements(new LimitRequirement(10_000m)));

public record LimitRequirement(decimal Max) : IAuthorizationRequirement;
public class LimitHandler : AuthorizationHandler<LimitRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext ctx, LimitRequirement req)
    {
        if (decimal.TryParse(ctx.User.FindFirst("pay_limit")?.Value, out var limit) && limit >= req.Max)
            ctx.Succeed(req);                            // no Succeed = 403
        return Task.CompletedTask;
    }
}
Interview questionHow to answer
401 vs 403?401: we don't know who you are (challenge). 403: we know, and the answer is no (forbid).
Tokens in a SPA?Not localStorage: any XSS can read it. Use a BFF: the server keeps the tokens and the browser gets an HttpOnly cookie.
Revoke a JWT?You can't, cheaply. Short access tokens, revocable refresh tokens; introspection only if you must.
Roles or policies?Policies. Roles become one claim inside a policy, and rules live in one tested handler.

Pitfalls: UseAuthorization before UseAuthentication (everyone is anonymous); decoding a JWT without validating it; turning off issuer or audience checks "to make it work"; roles-only authorisation; tokens in localStorage.

Theory: 14 AuthN vs AuthZ, IAM, SSO, OWASP, API auth and OAuth flows.

  • AuthN builds User; AuthZ reads it. Authentication always runs first.
  • Validate signature, issuer, audience and lifetime on every token. Let Authority fetch and rotate the keys.
  • Name policies after the business rule; put the logic in requirement handlers.
  • Use a fallback policy so every endpoint needs a signed-in user unless it says [AllowAnonymous].
  • .NET 10: cookie auth returns 401/403 to API endpoints instead of a login redirect.
How to implement it
Library / APIPackageUse it for
AddAuthentication, AddCookie, UseAuthentication, UseAuthorizationbuilt-in ASP.NET CoreSchemes, cookie sessions, the middleware
AddJwtBearerMicrosoft.AspNetCore.Authentication.JwtBearer (NuGet, 10.0.x)Validate bearer tokens in APIs
AddOpenIdConnectMicrosoft.AspNetCore.Authentication.OpenIdConnect (NuGet, 10.0.x)Sign in through an external IdP
AddMicrosoftIdentityWebApi / WebAppMicrosoft.Identity.Web (NuGet, 4.16.x)Entra ID sign-in, token validation, calling downstream APIs
AddIdentityApiEndpoints, MapIdentityApiMicrosoft.AspNetCore.Identity.EntityFrameworkCoreOwn user store; /register, /login, /refresh (.NET 8+)
AddAuthorizationBuilder, IAuthorizationService, AuthorizationHandler<T, TResource>built-inPolicies, fallback policy, resource-based checks
// Entra ID: reads Instance, TenantId, ClientId from the "AzureAd" section
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

builder.Services.AddSingleton<IAuthorizationHandler, AccountOwnerHandler>();
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("AccountOwner", p => p.AddRequirements(new AccountOwnerRequirement()));

// ✅ Resource-based: the rule needs the record, so check after loading it
app.MapGet("/accounts/{id:int}", async (int id, ClaimsPrincipal user,
    IAuthorizationService authz, AppDb db, CancellationToken ct) =>
{
    var acct = await db.Accounts.FindAsync([id], ct);
    if (acct is null) return Results.NotFound();
    var result = await authz.AuthorizeAsync(user, acct, "AccountOwner");
    return result.Succeeded ? Results.Ok(acct) : Results.Forbid();
});
app.MapGet("/status", () => "ok").AllowAnonymous();

public class AccountOwnerRequirement : IAuthorizationRequirement { }
public class AccountOwnerHandler : AuthorizationHandler<AccountOwnerRequirement, Account>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext ctx,
        AccountOwnerRequirement req, Account acct)
    {
        if (acct.OwnerId == ctx.User.GetObjectId()) ctx.Succeed(req); // Entra "oid"
        return Task.CompletedTask;
    }
}

// ❌ ValidateIssuer = false, ValidateAudience = false: accepts any tenant's tokens
// ❌ new JwtSecurityTokenHandler().ReadJwtToken(raw): decodes, doesn't validate

401 or 403? If you can't answer that in one breath, your API's security is probably a bit vague 🔐

🪪 Authentication: who are you? It builds the ClaimsPrincipal

🚦 Authorisation: what may you do? Named policies, not role checks everywhere

✅ Validate every JWT: signature, issuer, audience, expiry

🧩 Resource rules ("is this your account?") go through IAuthorizationService

🍪 SPAs: keep tokens on the server (BFF) and give the browser an HttpOnly cookie. Never localStorage

And UseAuthentication always goes before UseAuthorization. What's the worst auth bug you've reviewed?

#aspnetcore #dotnet #security #OAuth #EntraID

23.16 EF Core essentials Added

A DbContext is a short-lived unit of work: it tracks the entities you load or add, and SaveChanges writes every change in one transaction. For performance (N+1, AsNoTracking, projections) see 23.8.

How it works

TopicKey point
RegistrationAddDbContext (scoped) by default; AddDbContextPool for hot APIs; AddDbContextFactory for jobs and Blazor
ConfigurationConventions, then attributes, then Fluent API (wins). One IEntityTypeConfiguration<T> per entity
Migrationsmigrations add in dev; ship an idempotent script or a bundle. No auto-migrate at start-up in production
TransactionsOne SaveChanges = one transaction. Several saves: BeginTransactionAsync, inside the execution strategy if retries are on
Concurrency[Timestamp] rowversion; a stale update throws DbUpdateConcurrencyException, so return 409 or merge
Raw SQLFromSql($"...{x}") and SqlQuery<T> send x as a parameter. Never concatenate
Query filtersHasQueryFilter for soft delete and tenancy; IgnoreQueryFilters to bypass
InterceptorsSaveChangesInterceptor for audit fields; command interceptors for SQL hints
Complex typesValue objects with no identity, as columns or one JSON column. Prefer them to owned types in EF 10

Eager

Include(a => a.Cards): loaded in the same query. The safe default when you need the graph.

Explicit

Entry(a).Collection(x => x.Cards).LoadAsync(): you decide when to load.

Lazy

Proxies load on first access. Hidden queries and N+1 in loops; avoid it in APIs.

public class Account
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    public bool IsDeleted { get; set; }
    public Address Address { get; set; } = null!;               // complex type
    [Timestamp] public byte[] RowVersion { get; set; } = null!; // concurrency token
}

// OnModelCreating
b.Entity<Account>(e =>
{
    e.HasQueryFilter("SoftDelete", a => !a.IsDeleted);   // EF 10 named filter
    e.ComplexProperty(a => a.Address, c => c.ToJson()); // EF 10: JSON column
    e.Property(a => a.Balance).HasPrecision(18, 2);
});

// A unit of work
var acct = await db.Accounts.SingleAsync(a => a.Id == id, ct); // Unchanged
acct.Balance -= amount;                                        // Modified
try { await db.SaveChangesAsync(ct); }      // UPDATE ... WHERE RowVersion = @old
catch (DbUpdateConcurrencyException) { return Results.Conflict(); }

New in EF Core 9

  • UseSeeding / UseAsyncSeeding
  • A lock stops concurrent MigrateAsync runs
  • Compiled models are picked up automatically
  • Read-only primitive collections

New in EF Core 10

  • Named query filters; turn one off by name
  • LeftJoin / RightJoin operators
  • Complex types: JSON mapping, structs, ExecuteUpdate on JSON
  • SQL Server 2025 / Azure SQL vector and json types
  • ExecuteUpdateAsync takes a normal lambda
EF CoreDapper
Best forDomain writes, migrations, LINQHot read paths, reports, tuned SQL
You writeLINQ; EF writes the SQLAll the SQL
You getTracking, unit of work, schema historyThin mapping, near-ADO.NET speed

Interview answer: use both. EF Core for commands and the schema; Dapper or EF projections for heavy queries (CQRS-style).

Interview questionHow to answer
Why not one long-lived DbContext?Not thread-safe, the tracker keeps growing, data goes stale. One per unit of work.
Two users edit the same row?Concurrency token; catch the exception; reload and retry, or return 409.
Migrations in production?A reviewed idempotent script or bundle run by the pipeline, not Migrate() at start-up.
  • One DbContext per unit of work: scoped, short-lived, not thread-safe.
  • SaveChanges is atomic. Use an explicit transaction only when you need several saves or raw SQL in the same unit.
  • Fluent API beats attributes and keeps entities clean.
  • Rowversion plus DbUpdateConcurrencyException gives you optimistic concurrency without locks.
  • Global filters hide rows silently, so name them and test the bypass paths (admin, reports).
How to implement it
Library / APIPackageUse it for
EF Core 10 providerMicrosoft.EntityFrameworkCore.SqlServer (NuGet, 10.0.x) or Npgsql.EntityFrameworkCore.PostgreSQLDbContext, migrations, LINQ
dotnet-eftool dotnet-ef + Microsoft.EntityFrameworkCore.Designmigrations add, script --idempotent, bundle
UseLazyLoadingProxiesMicrosoft.EntityFrameworkCore.ProxiesLazy loading (use with care)
SaveChangesInterceptor, CreateExecutionStrategyEF CoreAudit fields; transactions that survive retries
DapperDapper (NuGet, open source)Hand-written SQL mapped to objects
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<AuditInterceptor>();
builder.Services.AddDbContextPool<AppDb>((sp, o) => o
    .UseSqlServer(builder.Configuration.GetConnectionString("Db"),
                  sql => sql.EnableRetryOnFailure())
    .AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));

public sealed class AuditInterceptor(TimeProvider clock) : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData e, InterceptionResult<int> result, CancellationToken ct = default)
    {
        foreach (var entry in e.Context!.ChangeTracker.Entries<IAudited>())
            if (entry.State is EntityState.Added or EntityState.Modified)
                entry.Entity.UpdatedAt = clock.GetUtcNow();
        return base.SavingChangesAsync(e, result, ct);
    }
}

// ✅ Several saves in one transaction, retried as a whole
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);
    db.Transfers.Add(transfer);              await db.SaveChangesAsync(ct);
    await db.Database.ExecuteSqlAsync($"EXEC PostLedger {transfer.Id}", ct);
    await tx.CommitAsync(ct);
});

// ✅ EF 10 LeftJoin instead of GroupJoin + SelectMany + DefaultIfEmpty
var rows = await db.Accounts.LeftJoin(db.Branches, a => a.BranchId, b => b.Id,
    (a, b) => new { a.Id, Branch = b == null ? "(none)" : b.Name }).ToListAsync(ct);
var deletedToo = await db.Accounts.IgnoreQueryFilters(["SoftDelete"]).ToListAsync(ct);

// ✅ Dapper for a tuned read: parameters, never concatenation
var list = await conn.QueryAsync<AccountDto>(
    "SELECT Id, Balance FROM Accounts WHERE BranchId = @branchId", new { branchId });

// dotnet ef migrations add AddRowVersion
// dotnet ef migrations script --idempotent -o migrate.sql   (review, run in the pipeline)
// dotnet ef migrations bundle -o efbundle                    (self-contained runner)

Most EF Core bugs I review aren't about performance. They're about not knowing what the change tracker is doing 🧠

🧾 DbContext = unit of work: scoped, short-lived, not thread-safe

🔁 Added → Unchanged → Modified → Deleted. SaveChanges turns states into SQL, in one transaction

🛡️ A rowversion column + DbUpdateConcurrencyException = optimistic concurrency, no locks

🚫 Never auto-migrate at start-up in production: ship an idempotent script or a migration bundle

🆕 EF Core 10: named query filters, LeftJoin, complex types as JSON

EF Core for writes, Dapper for the hot reads. Where do you draw the line?

#EFCore #dotnet #SQL #SoftwareArchitecture

23.17 Configuration, options and hosting Added

Configuration is a stack of providers where the last one wins. The options pattern binds it to typed, validated classes, and the Generic Host runs your app, its background services and its health checks.

How it works

InterfaceLifetimeReloads?Use in
IOptions<T>SingletonNo, read onceSettings fixed at start-up
IOptionsSnapshot<T>ScopedYes, per requestScoped services, controllers
IOptionsMonitor<T>SingletonYes, CurrentValue + OnChangeSingletons, background services

Validate early

ValidateDataAnnotations() + ValidateOnStart(): bad config fails at boot, not on the first payment. [OptionsValidator] generates the validator at compile time (no reflection, AOT-friendly).

Generic Host

IHost owns DI, config, logging and lifetime. IHostApplicationLifetime raises Started, Stopping and Stopped. ShutdownTimeout defaults to 30 s.

Background work

IHostedService: you write StartAsync/StopAsync. BackgroundService: one long ExecuteAsync loop. In .NET 10 all of ExecuteAsync runs off the start-up path.

Scoped-in-singleton trap

A hosted service is a singleton. Injecting a scoped DbContext fails (or lives forever). Create a scope per unit of work with IServiceScopeFactory (see 23.5).

public sealed class PaymentsOptions
{
    [Required, Url] public string GatewayUrl { get; set; } = "";
    [Range(1, 60)] public int Timeout { get; set; } = 10;
}
builder.Services.AddOptions<PaymentsOptions>()
    .BindConfiguration("Payments")
    .ValidateDataAnnotations()
    .ValidateOnStart();                          // ✅ fail fast at boot
builder.Services.AddHostedService<OutboxWorker>();

public sealed class OutboxWorker(IServiceScopeFactory scopes,
    IOptionsMonitor<PaymentsOptions> options) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
        while (await timer.WaitForNextTickAsync(stop))
        {
            await using var scope = scopes.CreateAsyncScope(); // ✅ fresh DbContext per tick
            var db = scope.ServiceProvider.GetRequiredService<AppDb>();
            await Outbox.PublishPendingAsync(db, options.CurrentValue, stop);
        }
    }
}
Health checkAsksOn failure
LivenessIs the process alive? No dependency checksOrchestrator restarts it
ReadinessCan it serve? DB, queues, cachesTaken out of the load balancer, not restarted
Interview questionHow to answer
Which value wins?The provider added last. Command line beats environment variables beats JSON.
Snapshot vs Monitor?Snapshot: scoped, per request. Monitor: singleton, live value plus change events.
Why not check the DB in liveness?A DB outage would restart every pod in a loop and fix nothing.

Deployment side: Kubernetes probes, observability and secrets management.

  • Last provider wins; environment variables and Key Vault override JSON.
  • Options: IOptions fixed, IOptionsSnapshot per request, IOptionsMonitor live.
  • ValidateOnStart turns a 3 a.m. runtime error into a failed deployment.
  • Hosted services are singletons: create a scope for scoped dependencies.
  • Liveness checks the process; readiness checks dependencies.
How to implement it
Library / APIPackageUse it for
Configuration providers, user secretsbuilt-in WebApplication.CreateBuilderJSON, environment, command line; dotnet user-secrets set
AddOptions, BindConfiguration, ValidateDataAnnotations, ValidateOnStart, [OptionsValidator]built-in (Microsoft.Extensions.Options 8+)Typed, validated settings
AddAzureKeyVaultAzure.Extensions.AspNetCore.Configuration.Secrets (NuGet, 1.5.x) + Azure.IdentityKey Vault secrets as configuration
BackgroundService, IHostedService, IHostedLifecycleServicebuilt-in Microsoft.Extensions.HostingWorkers; lifecycle hooks (.NET 8+)
AddHealthChecks, MapHealthChecksbuilt-inLiveness and readiness endpoints
AddDbContextCheckMicrosoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCoreReadiness check for EF Core
// Key Vault, added last so it overrides earlier providers ("Db--Password" => "Db:Password")
if (!builder.Environment.IsDevelopment())
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
        new DefaultAzureCredential());            // managed identity when running in Azure

// Compile-time validator instead of reflection-based ValidateDataAnnotations
builder.Services.AddSingleton<IValidateOptions<PaymentsOptions>, PaymentsOptionsValidator>();

builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(45));
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDb>(tags: ["ready"]);

var app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });

app.Lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Draining"));
app.Run();

[OptionsValidator]
public partial class PaymentsOptionsValidator : IValidateOptions<PaymentsOptions> { }

// ❌ public OutboxWorker(AppDb db): a scoped DbContext captured by a singleton
// ❌ IOptionsSnapshot<T> in a singleton: scoped service, resolution fails

"It worked on my machine" is usually a configuration bug ⚙️

🥞 .NET config is a stack: appsettings → environment file → user secrets → env vars → command line. Last one wins

🔑 Secrets go in Key Vault with managed identity, never in appsettings

✅ ValidateOnStart: bad config fails the deployment, not the first customer

🧵 BackgroundService is a singleton. Need a DbContext? Create a scope per unit of work

❤️ Liveness checks the process; readiness checks the database

Which of these has bitten your team?

#dotnet #aspnetcore #DevOps #CloudNative

23.18 WCF and migrating off it Added

WCF is .NET Framework's SOAP and RPC stack. Its server side isn't part of modern .NET, so you either lift it to CoreWCF or move each contract to gRPC, REST or messaging.

🔤 What WCF gave you

SOAP and WS-* (WS-Security, reliable messaging, transactions), duplex callbacks, many transports. Very config-heavy.

🚫 Status

WCF server isn't in modern .NET. It stays on .NET Framework 4.8.x, supported but frozen.

🔁 CoreWCF

Open-source, community-driven port of the server side (.NET Foundation). Microsoft publishes a support policy: 1.9 is supported on .NET 10.

📞 WCF client

Still available on .NET 10: System.ServiceModel.* packages plus dotnet-svcutil to generate proxies from WSDL.

Choose a target per contract

OptionPick it whenWatch out
CoreWCFMany SOAP clients you can't change; fast exit from FrameworkNot every WCF feature is ported; still SOAP
gRPCInternal service-to-service, netTcp speed, streamingBrowsers need gRPC-Web or JSON transcoding; HTTP/2 end to end
REST / Web APIPartners, web and mobile, broad reachRewrite contracts; replace WS-Security with OAuth
MessagingOne-way calls, MSMQ, decouplingEventual consistency, new ops model
using CoreWCF;
using CoreWCF.Configuration;
using CoreWCF.Description;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceModelServices();
builder.Services.AddServiceModelMetadata();          // serves WSDL

var app = builder.Build();
app.UseServiceModel(sb =>
{
    sb.AddService<PaymentService>();
    sb.AddServiceEndpoint<PaymentService, IPaymentService>(
        new BasicHttpBinding(BasicHttpSecurityMode.Transport), "/PaymentService.svc");
    app.Services.GetRequiredService<ServiceMetadataBehavior>().HttpsGetEnabled = true;
});
app.Run();

[ServiceContract]                                    // the same contract as on .NET Framework
public interface IPaymentService { [OperationContract] string Submit(string paymentXml); }
Interview questionHow to answer
What is WCF's ABC?Address (where), Binding (transport, encoding, security), Contract (operations and data).
How would you migrate a WCF estate?Inventory bindings, features and consumers. CoreWCF where clients can't change; new gRPC or REST endpoints side by side; retire old ones by usage.
Can .NET 10 call a WCF service?Yes. System.ServiceModel.Http or .NetTcp, with a proxy from dotnet-svcutil.
gRPC from a browser?Not directly. Use gRPC-Web, or JSON transcoding to expose the same service as REST.
  • WCF server = .NET Framework only. The WCF client works on .NET 10.
  • CoreWCF is the lift-and-shift: same contracts, ASP.NET Core host, clients unchanged.
  • Decide per contract and per consumer, not one big-bang rewrite.
  • gRPC for internal RPC, REST for reach, messaging for one-way work.
  • .NET Upgrade Assistant is deprecated; use GitHub Copilot app modernization.
How to implement it
Library / APIPackageUse it for
CoreWCF serverCoreWCF.Primitives + CoreWCF.Http 1.9.1Host SOAP services on ASP.NET Core (basicHttp, wsHttp)
More CoreWCF transportsCoreWCF.NetTcp, CoreWCF.WebHttp, CoreWCF.NetNamedPipe 1.9.1netTcp, WebHttp, named pipes (Windows)
Reuse old configCoreWCF.ConfigurationManager 1.9.1Load an existing system.serviceModel file
WCF clientSystem.ServiceModel.Http, .Primitives, .NetTcp 10.0.652802Call SOAP services from .NET 10
Proxy generatortool dotnet-svcutil 8.0.0Generate client code from WSDL
gRPCGrpc.AspNetCore, Grpc.AspNetCore.Web 2.84.0gRPC services, and gRPC-Web for browsers
gRPC JSON transcodingMicrosoft.AspNetCore.Grpc.JsonTranscoding 10.0.xExpose a gRPC service as REST + JSON too
Upgrade toolingGitHub Copilot app modernizationReplaces the deprecated .NET Upgrade Assistant
// ✅ Calling a WCF/SOAP service from .NET 10 (System.ServiceModel.Http)
[ServiceContract]
public interface IPaymentService
{
    [OperationContract] Task<string> SubmitAsync(string paymentXml);  // maps to "Submit"
}

var factory = new ChannelFactory<IPaymentService>(
    new BasicHttpBinding(BasicHttpSecurityMode.Transport),
    new EndpointAddress("https://payments.example.com/PaymentService.svc"));
IPaymentService client = factory.CreateChannel();
string reply = await client.SubmitAsync(xml);
((IClientChannel)client).Close();

// ✅ CoreWCF reusing an existing config file (CoreWCF.ConfigurationManager)
builder.Services.AddServiceModelConfigurationManagerFile("wcf.config");
builder.WebHost.UseNetTcp(8090);   // netTcp port is set in code, not in <host>

// ❌ Rewriting every service to REST in one go: months of risk, no business value

Still running WCF? You have more options than "rewrite everything" 🔌

🔤 WCF = Address + Binding + Contract, SOAP, WS-*, lots of config

🚫 The WCF server isn't part of modern .NET. The client still is

🔁 CoreWCF: same contracts on ASP.NET Core, SOAP clients don't change

⚡ gRPC for internal RPC; 🌐 REST for partners and browsers; 📨 messaging for one-way calls

🧭 Decide per contract and per consumer, then retire endpoints by usage

Which WCF binding is hardest to leave in your world?

#dotnet #WCF #gRPC #Modernisation #SoftwareArchitecture

23.19 Modern .NET and C# features that come up in interviews Added

Each November brings a new C# and .NET. You don't need every feature, just a few per release, what problem each one solves, and one you've actually used.

FeatureReleaseWhy it matters
Primary constructorsC# 12DI without boilerplate. Parameters are captured, not readonly fields
Collection expressionsC# 12[1, 2, ..rest] for arrays, lists and spans
Keyed DI services.NET 8Several implementations by key (23.5)
TimeProvider.NET 8Testable time: fake the clock in tests
FrozenDictionary.NET 8Build once, read fast: lookups and config
params collectionsC# 13params ReadOnlySpan<T>: no array allocation
Lock type.NET 9Faster lock; just change the field type (23.4)
Task.WhenEach.NET 9await foreach over tasks as they finish
Built-in OpenAPIASP.NET Core 9AddOpenApi / MapOpenApi; 3.1 by default in .NET 10 (23.13)
HybridCacheGA Mar 2025 (9.3.0)Memory + distributed cache, stampede protection, tags (5.1)
field keywordC# 14Logic in a property without a backing field
Extension membersC# 14Extension properties and static extensions
File-based apps.NET 10dotnet run app.cs with #:package; scripts and tools
Minimal API validationASP.NET Core 10AddValidation(): DataAnnotations → 400 ProblemDetails
Native AOT.NET 8 → 10Minimal APIs and gRPC since 8; file-based apps publish AOT by default (23.9)
using System.Collections.Frozen;

public sealed class RateService(HttpClient http, TimeProvider clock)     // C# 12 primary ctor
{
    private static readonly FrozenDictionary<string, decimal> Fees =    // .NET 8
        new Dictionary<string, decimal> { ["AUD"] = 0.5m, ["USD"] = 1m }.ToFrozenDictionary();
    private readonly Lock _gate = new();                                  // .NET 9 Lock
    private readonly List<string> _log = [];                              // C# 12 collection expression

    public string Region { get; set => field = value.Trim(); } = "AU";    // C# 14 field keyword

    public decimal FeeFor(string ccy) => Fees.TryGetValue(ccy, out var fee) ? fee : 2m;

    public async Task WarmAsync(string[] currencies, CancellationToken ct)
    {
        Task<string>[] calls = [.. currencies.Select(c => http.GetStringAsync($"rates/{c}", ct))];
        await foreach (Task<string> done in Task.WhenEach(calls))          // .NET 9
            lock (_gate) _log.Add($"{clock.GetUtcNow():O} {done.Result.Length} bytes");
    }
}

public static class MoneyExtensions                                       // C# 14 extension members
{
    extension(decimal amount) { public bool IsZero => amount == 0m; }
}

🎤 "What's new in .NET?" in 60 seconds

  1. Frame: "We standardise on LTS. .NET 10 is current; 8 and 9 end on 10 Nov 2026."
  2. Language: two C# features and the problem each solves (primary constructors, field).
  3. Platform: performance every release, Native AOT, smaller non-root containers.
  4. Web: built-in OpenAPI, minimal API validation, HybridCache.
  5. Proof: one you adopted and the result ("TimeProvider removed our flaky tests").
  6. Next: .NET 11 expected Nov 2026; STS now gets 24 months.
Interview questionHow to answer
Primary constructor parameter vs field?It's a captured parameter, mutable and not a property (except in records). Assign it to a readonly field if you need that guarantee.
How do you test code that uses time?Inject TimeProvider; use FakeTimeProvider in tests and advance it.
IMemoryCache or HybridCache?HybridCache: one GetOrCreateAsync, local + distributed, stampede protection, tag invalidation.
When not to use FrozenDictionary?When data changes, or is built often: creation is slower than Dictionary.
  • Name 2-3 features per release, each with the problem it solves.
  • C# 12 ↔ .NET 8, C# 13 ↔ .NET 9, C# 14 ↔ .NET 10.
  • HybridCache shipped after .NET 9, as a NuGet package (GA in 9.3.0).
  • OpenAPI generation is built in since ASP.NET Core 9; Swashbuckle is optional.
  • Validation in minimal APIs needs AddValidation() in .NET 10.
How to implement it
Library / APIPackageUse it for
TimeProvider, FrozenDictionary, Lock, Task.WhenEachbuilt-in BCLTestable time, fast lookups, locking, completion order
FakeTimeProviderMicrosoft.Extensions.TimeProvider.Testing 10.11.0Control time in unit tests
AddKeyedSingleton, [FromKeyedServices]built-in .NET 8+Keyed DI
HybridCacheMicrosoft.Extensions.Caching.Hybrid 10.11.0Two-level cache, stampede protection, tag invalidation
AddOpenApi, AddValidationMicrosoft.AspNetCore.OpenApi 10.0.x; validation built-inSee 23.13
File-based appsbuilt-in .NET 10 SDKdotnet run app.cs, #:package, #:project
// ✅ Testable time: inject TimeProvider, fake it in tests
public sealed class CardExpiry(TimeProvider clock)
{
    public bool IsExpired(DateOnly expiresOn) =>
        DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime) > expiresOn;
}

[Fact]
public void Expires_after_the_date()
{
    var clock = new FakeTimeProvider(new DateTimeOffset(2026, 10, 17, 0, 0, 0, TimeSpan.Zero));
    var card = new CardExpiry(clock);
    Assert.False(card.IsExpired(new DateOnly(2026, 10, 31)));
    clock.Advance(TimeSpan.FromDays(30));                 // no Thread.Sleep, no flaky test
    Assert.True(card.IsExpired(new DateOnly(2026, 10, 31)));
}

// ✅ HybridCache: cache-aside, stampede protection and tags in one call
public interface IFxClient { ValueTask<decimal> FetchAsync(string pair, CancellationToken ct); }

public sealed class FxRates(HybridCache cache, IFxClient fx)
{
    public ValueTask<decimal> GetAsync(string pair, CancellationToken ct) =>
        cache.GetOrCreateAsync($"fx:{pair}", token => fx.FetchAsync(pair, token),
            tags: ["fx"], cancellationToken: ct);

    public ValueTask ResetAsync(CancellationToken ct) => cache.RemoveByTagAsync("fx", ct);
}

// ✅ .NET 10 file-based app (tool.cs, no .csproj): dotnet run tool.cs
#:package Humanizer@2.14.1
using Humanizer;
Console.WriteLine(TimeSpan.FromMinutes(90).Humanize());

// ❌ DateTime.UtcNow inside business logic: untestable, so tests sleep or skip

"What's new in .NET?" is an easy interview question to fumble 🎤

🧱 C# 12 / .NET 8: primary constructors, collection expressions, TimeProvider, keyed DI

⚡ C# 13 / .NET 9: params collections, the new Lock type, Task.WhenEach, built-in OpenAPI

🆕 C# 14 / .NET 10: the field keyword, extension members, file-based apps, minimal API validation

🗄️ HybridCache went GA in 2025: memory + Redis with stampede protection

Pick two per release, say what problem each solves, and name one you've shipped. Which one changed your code most?

#dotnet #csharp #aspnetcore #dotnet10

⚡ Quick-fire Q&A

When would you use a struct instead of a class?

For small, short-lived, immutable values without identity, such as money, coordinates or IDs. Roughly 16 bytes or less, and ideally a readonly record struct. Use a class when the object has identity, is large, or is shared and changed in place.

What's the difference between IDisposable and a finalizer?

Dispose releases resources deterministically, when you call it (via using). A finalizer is a safety net the GC runs at some unknown later time, on one thread, and it makes the object survive an extra GC. Implement IDisposable, wrap native handles in SafeHandle, and almost never write a finalizer yourself.

Task vs ValueTask?

Use Task by default. ValueTask avoids an allocation when a method usually completes synchronously (a cache hit, for example). But it may only be awaited once, and never consumed with .Result before it completes. Only use it on measured hot paths.

Why can calling .Result on a task deadlock or slow everything down?

It blocks the current thread until the task finishes. If there's a SynchronizationContext (UI, old ASP.NET), the continuation needs that same blocked thread: deadlock. In ASP.NET Core there's no context, but each blocked call takes a pool thread. Under load the pool starves and latency climbs while CPU stays low.

When does ConfigureAwait(false) matter?

In library code that might be called from a UI app or old ASP.NET, where it avoids resuming on the captured context (and possible deadlocks). ASP.NET Core has no SynchronizationContext, so in app code it makes no real difference. In UI code, don't use it before touching UI controls.

What is a captive dependency, and how do you catch it?

A longer-lived service holding a shorter-lived one, typically a singleton that injects a scoped DbContext. The scoped instance then lives forever and is shared across requests. Turn on ValidateScopes and ValidateOnBuild (on by default in Development), and use IServiceScopeFactory inside singletons.

Why not create a new HttpClient for each request?

Disposing it closes the connection, and the socket stays in TIME_WAIT. Under load you run out of ports. A single static client fixes that but never refreshes DNS. Use IHttpClientFactory with typed clients, or one static client with SocketsHttpHandler.PooledConnectionLifetime.

How would you find a memory leak in a production .NET service?

Confirm the trend with dotnet-counters (GC heap size and gen 2 size keep growing). Take two heap snapshots with dotnet-gcdump and compare which types grow. Then use dotnet-dump analyze with gcroot to see what keeps them alive. Common causes: event handlers, static caches, timers and captured closures.

Is .NET Framework end-of-life?

No. .NET Framework 4.8.x is supported as part of Windows and gets security fixes, but no new features. New work should target modern .NET (.NET 10 LTS); migrate existing apps by business value, not all at once.

What's the difference between LTS and STS releases?

Even-numbered releases are LTS with 3 years of support; odd-numbered are STS with 24 months (extended from 18, starting with .NET 9). Quality is the same; only the support length differs. .NET 8 and .NET 9 both end on 10 November 2026.

How do you inject a scoped service such as a DbContext into middleware?

Convention-based middleware is created once per app, so add the scoped service as a parameter of InvokeAsync, not the constructor. Or implement IMiddleware and register the class in DI, which resolves it per request.

What's the difference between Map and UseWhen?

Map (and MapWhen) fork the pipeline and the branch never rejoins, so it usually ends in Run. UseWhen runs extra middleware for matching requests and then rejoins the main pipeline.

How does validation work in minimal APIs in .NET 10?

Call builder.Services.AddValidation(). A source generator finds the handler parameter types, and an endpoint filter checks their DataAnnotations (and IValidatableObject). Invalid input gets a 400 ProblemDetails response before your handler runs.

Why shouldn't you use route constraints for validation?

A failed constraint means no route matches, so the client gets a 404 instead of a 400 with a useful error. Constraints like {id:guid} are for choosing between routes; validate the values with binding and validation.

In what order do MVC filters run?

Authorization, then resource filters (before model binding), then model binding, action filters around the action, exception filters if something threw, and result filters around the result. On the way out, result and then resource filters finish. Within a type, global wraps controller wraps action unless Order is set.

What are Blazor's render modes in .NET 8 and later?

Static Server (static SSR, the default), Interactive Server (events over a SignalR circuit), Interactive WebAssembly (runs in the browser) and Interactive Auto (server first, then WebAssembly once the bundle is cached). Apply them with @rendermode.

How would you secure a SPA that calls your ASP.NET Core API?

Use a BFF. The server signs in with OpenID Connect (code flow + PKCE), keeps the tokens and gives the browser an HttpOnly, Secure, SameSite cookie. The BFF calls the API with a bearer token. No tokens in localStorage, where any XSS can read them.

When do you need resource-based authorisation instead of a policy attribute?

When the decision depends on the data, such as "only the owner can edit this account". An attribute runs before the record is loaded, so load it, then call IAuthorizationService.AuthorizeAsync(user, resource, "Policy") with an AuthorizationHandler<TRequirement, TResource>, and return 403 if it fails.

How does EF Core know what to UPDATE when you call SaveChanges?

The change tracker keeps a snapshot of each tracked entity. SaveChanges runs DetectChanges, compares current values with the snapshot, marks entities Added, Modified or Deleted, and sends INSERT, UPDATE (changed columns only) or DELETE statements in one transaction. Untracked (Detached) entities are ignored.

How do you implement soft delete and multi-tenancy in EF Core?

Global query filters: HasQueryFilter(e => !e.IsDeleted) and a tenant filter that reads the current tenant id from the context. EF 10 lets you name each filter and switch one off with IgnoreQueryFilters(["SoftDelete"]). Set IsDeleted in a SaveChangesInterceptor instead of deleting the row.

How do you use a scoped DbContext from a BackgroundService?

A hosted service is a singleton, so don't inject the DbContext. Inject IServiceScopeFactory, call CreateAsyncScope() for each unit of work (each message or timer tick), resolve the DbContext from that scope and dispose the scope when done.

IOptions, IOptionsSnapshot or IOptionsMonitor: which one and why?

IOptions is a singleton read once, for settings that never change. IOptionsSnapshot is scoped and re-read per request, for controllers and scoped services. IOptionsMonitor is a singleton with CurrentValue and OnChange, for singletons and background services that must see reloaded values.

Can you run WCF services on .NET 10?

Not with WCF itself: the server side stayed on .NET Framework. CoreWCF, an open-source port, hosts the same contracts and bindings on ASP.NET Core, and Microsoft publishes a support policy for it. The WCF client (System.ServiceModel.*) works on .NET 10.

CoreWCF, gRPC or REST when leaving WCF?

CoreWCF when many SOAP clients can't change. gRPC for internal, high-throughput or streaming service-to-service calls (browsers need gRPC-Web or JSON transcoding). REST for partners, web and mobile. Decide per contract and per consumer.

What does the C# 14 field keyword do?

It refers to the compiler-generated backing field inside a property accessor, so you can add logic (trim, validate, raise PropertyChanged) without declaring a private field: public string Name { get; set => field = value.Trim(); }.

What is HybridCache and when did it ship?

A caching library (Microsoft.Extensions.Caching.Hybrid) that combines an in-memory cache with an optional distributed cache such as Redis behind one GetOrCreateAsync call, with stampede protection and tag invalidation. It was built for .NET 9 and went GA in March 2025 (9.3.0).

← 22 · Top 10 .NET Use Cases