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.
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.
Option
What it does
Trade-off
Tiered compilation
Quick JIT first, optimised JIT for hot code. On by default
Code is slower for the first seconds (warm-up)
Dynamic PGO
Uses live profile data to optimise Tier 1 code. Default since .NET 8
None for most apps; just keep tiering on
ReadyToRun
Pre-compiles IL to native code at publish; still JITs hot paths later
Faster startup, bigger files
Native AOT
Whole 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.
Type
Copy semantics
Where it lives
Use for
class / record
Copies the reference; both point to one object
Heap
Entities, services, anything with identity
struct
Copies the whole value
Inline: on the stack as a local, inside the object as a field
Small values (≈16 bytes or less)
readonly record struct
Copied, immutable, value equality for free
Inline
Money, coordinates, IDs
ref struct
Can never be boxed or captured
Stack only
Span<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.
.NET 10 How to implement it
Library / API
Package
Use it for
struct, record struct, ref struct
built-in C# 14
Pick value vs reference semantics on purpose
PublishReadyToRun, TieredPGO
built-in MSBuild properties
Faster startup; PGO is on by default, leave it on
JIT escape analysis
built-in .NET 10 runtime
.NET 10 can stack-allocate small arrays and some delegates that never leave the method
dotnet-counters
global tool dotnet-counters
Watch 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.
Mode
How it works
Default for
Workstation
One heap, GC runs on the thread that triggered it
Console, desktop and most client apps
Server
One heap and GC thread per core; built for throughput
ASP.NET Core apps
DATAS
Server GC that grows and shrinks the number of heaps with load, keeping the heap close to the live data size
On by default with Server GC since .NET 9
Background GC
Gen 2 collections run mostly alongside your threads
On by default
POH
Pinned 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
Cause
Why it leaks
Fix
Event handlers
The publisher holds a reference to every subscriber
Unsubscribe in Dispose; weak events for long-lived publishers
Static caches
A static Dictionary only ever grows
MemoryCache / HybridCache with size limits and expiry
Captured closures
A lambda keeps every captured variable alive, often a big object
Capture only what you need; use static lambdas
Timers
An undisposed timer roots its callback and its target
Dispose timers; prefer PeriodicTimer in a BackgroundService
Undisposed objects
Streams, responses and DB connections hold native resources
using / 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.
.NET 10 How to implement it
Library / API
Package
Use it for
IDisposable / IAsyncDisposable
built-inSystem
Deterministic clean-up with using / await using
SafeHandle
built-inSystem.Runtime.InteropServices
Own unmanaged handles instead of writing finalizers
Choose Server/Workstation GC; turn DATAS off or on
GC.GetGCMemoryInfo
built-inSystem
Heap size, fragmentation and pause data from inside the app
dotnet-counters, dotnet-gcdump, dotnet-dump
global tools
Watch 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.
The compiler turns an async method into a struct with a MoveNext() method. Each await is a numbered state.
If the awaited task is already complete, the method just keeps going on the same thread. No allocation, no thread switch.
If not, locals are saved, the method returns an incomplete Task, and a continuation is registered.
While the I/O is in flight, no thread is waiting. That's why one server can handle thousands of concurrent requests.
Type
Use when
Rules
Task / Task<T>
The default for any async method
Can 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 runs
Context captured?
ConfigureAwait(false)?
ASP.NET Core
No SynchronizationContext
Makes no real difference in app code
Library / NuGet package
Unknown: your caller might be a UI app
Yes, use it on every await
WPF, WinForms, MAUI
Yes, the UI thread
Not in code that touches the UI after the await
Old ASP.NET (System.Web)
Yes, the request context
Yes; .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.
.NET 10 How to implement it
Library / API
Package
Use it for
Task, ValueTask, Task.WhenAll
built-inSystem.Threading.Tasks
Async methods and fan-out
ConfigureAwait / ConfigureAwaitOptions
built-in
Skip context capture in libraries; options added in .NET 8
Flag 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.
The pool starts with a minimum number of threads (by default, the number of CPU cores) and hands out work instantly up to that level.
Above the minimum it adds threads gradually, using a hill-climbing algorithm and starvation detection. A burst of blocking calls therefore means seconds of queueing.
Async I/O completions and continuations run on the same pool, so blocked threads also delay the code that would unblock them.
✅ 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.
Lifetime
One instance per
Good for
Watch out
Singleton
App
Caches, clients, config, stateless helpers
Must be thread-safe; never inject scoped services
Scoped
Request (or manual scope)
DbContext, unit of work, current-user info
Doesn't exist outside a scope (background jobs)
Transient
Injection
Lightweight, stateless services
Disposable 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 type
Lifetime
Sees config changes?
Use in
IOptions<T>
Singleton
No, read once
Settings fixed at startup
IOptionsSnapshot<T>
Scoped
Yes, per request
Scoped and transient services
IOptionsMonitor<T>
Singleton
Yes, live + OnChange
Singletons 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.
.NET 10 How to implement it
Library / API
Package
Use it for
AddSingleton / AddScoped / AddTransient
built-inMicrosoft.Extensions.DependencyInjection
Register services with a lifetime
AddKeyedSingleton…, [FromKeyedServices]
built-in .NET 8+
Several implementations of one interface
IServiceScopeFactory.CreateAsyncScope
built-in
Scoped work inside singletons and background services
// ❌ 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
Exception handler first, so it catches failures from everything after it.
CORS → authentication → authorization must be in that order. Output caching comes after all three.
Rate limiter after routing when you use per-endpoint policies. If a policy partitions by user, authentication must run before it.
Minimal APIs
Controllers
Style
Lambdas or static methods mapped to routes
Classes with action methods and attributes
Cross-cutting
Endpoint filters, route groups
Action filters, model binders
Native AOT
Supported
Not supported
Best for
New APIs, microservices
Large 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.
Shared prefixes, auth and filters for minimal APIs
ShortCircuit / MapShortCircuit
built-in .NET 8+
Skip the pipeline for trivial endpoints
AddRateLimiter, AddOutputCache
built-in
Rate 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.
Approach
Sockets
DNS
Verdict
new HttpClient() per call
Exhausted under load
Fresh
❌ Never
static HttpClient
Reused
Stale
⚠️ Only with a pooled lifetime
static + PooledConnectionLifetime
Reused
Refreshed
✅ No-DI apps, libraries
IHttpClientFactory (typed clients)
Reused
Refreshed (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!).
.NET 10 How to implement it
Library / API
Package
Use it for
IHttpClientFactory, AddHttpClient<T>
built-inMicrosoft.Extensions.Http
Typed and named clients with pooled, rotating handlers
SocketsHttpHandler.PooledConnectionLifetime
built-inSystem.Net.Http
Safe long-lived client without DI
AddStandardResilienceHandler
Microsoft.Extensions.Http.Resilience
Timeouts, retries, circuit breaker on top of Polly v8
GetFromJsonAsync / PostAsJsonAsync
built-inSystem.Net.Http.Json
JSON 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.
Pitfall
Symptom
Fix
N+1 queries
Hundreds of tiny queries per request
Include, or better a Select projection
Cartesian explosion
Including 2+ collections returns huge result sets
AsSplitQuery()
Tracking read-only data
High memory and CPU on reads
AsNoTracking()
Whole entities
Wide rows over the wire
Project into a DTO with Select
Load, modify, save in bulk
One UPDATE per row
ExecuteUpdateAsync / ExecuteDeleteAsync
ToList() too early
Filtering and paging done in memory
Keep it IQueryable until the end
Hot query re-translation
CPU spent compiling the same LINQ
Compiled 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.
Set-based updates and deletes; EF 10 also accepts a normal lambda
EF.CompileAsyncQuery
EF Core
Skip LINQ translation on very hot queries
AddDbContextPool / AddPooledDbContextFactory
EF Core
Reuse context instances
Migration scripts and bundles
tool dotnet-ef
migrations 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.
Tool
What it does
Use when
Span<T> / ReadOnlySpan<T>
A view over arrays, strings or stack memory. Stack-only
Parsing and slicing in sync code
Memory<T>
Like Span, but can live on the heap and cross await
Async I/O buffers
ArrayPool<T>.Shared
Rent and return big arrays instead of allocating
Buffers over 85 KB, hot loops
stackalloc
Small buffer on the stack, freed on return
A few hundred bytes, known size
StringBuilder, string.Create
Build strings without a copy per step
Loops and large outputs
Interpolated string handlers
The compiler formats $"..." without boxing; loggers can skip it entirely
Logging, 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.
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.
.NET 10 How to implement it
Library / API
Package
Use it for
Span, Memory, ArrayPool
built-inSystem, System.Buffers
Slicing and buffers without allocations
JsonSerializerContext
built-inSystem.Text.Json
Source-generated JSON
[GeneratedRegex], [LoggerMessage]
built-in
Compile-time regex and logging
PublishAot, CreateSlimBuilder
built-in MSBuild / ASP.NET Core
Native AOT apps
BenchmarkDotNet
BenchmarkDotNet
Micro-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.
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.
Era
What changed
Status (Oct 2026)
.NET Framework 1.0–4.8.1
Windows-only, one machine-wide install, System.Web, WCF
Maintenance: supported as part of Windows, no new features
.NET Core 1.0–3.1
Cross-platform, open source, side-by-side installs, Kestrel
Out 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
OS
Windows only
Windows, Linux, macOS, containers
Install
One version per machine
Side-by-side, or bundled with the app
Web
System.Web on IIS
ASP.NET Core on Kestrel
Services
WCF, Remoting
gRPC, REST, CoreWCF
Config, DI, logs
web.config, add-ons
Built into the Generic Host
Performance
Frozen
Faster every release
SDK vs runtime: the SDK builds, the runtime runs; servers only need the runtime. Pin the SDK with global.json.
Target framework (TFM):net10.0 for apps; netstandard2.0 or multi-targeting only for libraries that Framework apps still use.
Generic Host: DI, config, logging and hosted services in one bootstrap. WebApplication.CreateBuilder for web, Host.CreateApplicationBuilder for workers (23.17).
Deployment:framework-dependent (small, needs the runtime), self-contained (bundles it, one per OS/CPU), Native AOT (see 23.9).
Containers:dotnet publish /t:PublishContainer builds an image without a Dockerfile, non-root by default. ASP.NET Core images listen on 8080.
<!-- 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.
// ✅ 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
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.
// ✅ 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>();
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 question
How to answer
Write a request-timing middleware
Class 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 nextis 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.
.NET 10 How to implement it
Library / API
Package
Use it for
Use, Run, Map, MapWhen, UseWhen
built-in ASP.NET Core
Inline middleware and branches
UseMiddleware<T>, IMiddleware
built-in
Convention-based and factory-based classes
IExceptionHandler, AddExceptionHandler<T>
built-in .NET 8+
Central exception-to-response mapping; handlers run in registration order
AddProblemDetails, IProblemDetailsService
built-in .NET 7+
RFC 9457 application/problem+json bodies
Response.OnStarting, Response.HasStarted
built-in
Add 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
Routing
Attribute routing required
MapGet, MapGroup
Binding
Source inference: complex type = body
Inferred; [AsParameters] for many
Invalid input
Automatic 400 ValidationProblemDetails
.NET 10: AddValidation(), automatic 400
Results
ActionResult<T>
TypedResults, Results<Ok<T>, NotFound>
Cross-cutting
MVC filters
Endpoint filters
Content negotiation
Output formatters, Accept header
JSON
Choose when
Custom model binders, OData, app parts, big existing codebase
New 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 question
How 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
// 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
Scope order: global wraps controller wraps action. IOrderedFilter.Order overrides it.
Razor Pages use page filters (IPageFilter) instead of action filters.
Exception filters don't see middleware or result-execution errors: keep UseExceptionHandler (23.12) as the safety net.
🏷️ 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+)
Mode
Runs where
Trade-off
Static Server (static SSR)
Server, plain HTML
Default. Fast, no interactivity
Interactive Server
Server, UI events over SignalR
Instant start; a circuit per user, latency-sensitive
Interactive WebAssembly
Browser (.NET on WASM)
Offloads server; download size, no secrets in client
Interactive Auto
Server first, WASM on later visits
Best start + scale; both sets of constraints
Which UI stack when
Stack
Pick it for
Watch out
MVC
Large apps, shared controllers, existing codebases
More ceremony per page
Razor Pages
Page-centric forms, CRUD, internal tools
Limited rich interactivity
Blazor
Rich UI by a C# team, shared models
Circuits or WASM payload
SPA + Web API
Separate front-end team, many clients
Two stacks, BFF and token handling
Interview question
How to answer
Walk me through the filter order
Authorization, 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
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
A scheme is a name plus a handler (Cookies, Bearer, OpenIdConnect). The handler can authenticate, challenge (401 or a login redirect) and forbid (403).
AddAuthentication registers schemes. UseAuthentication sets User. UseAuthorization checks the endpoint's metadata. Order: routing, then authentication, then authorisation (see 23.6).
Validate every token: signature key, issuer, audience, lifetime. ClockSkew defaults to 5 minutes; shorten it.
Scheme
Fits
Watch
Cookie
Server-rendered apps; the BFF for a SPA
HttpOnly, Secure, SameSite, anti-forgery
JWT bearer
APIs called by apps and services
Stateless, so hard to revoke: keep lifetimes short
OpenID Connect
Sign-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 question
How 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.
// 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
Topic
Key point
Registration
AddDbContext (scoped) by default; AddDbContextPool for hot APIs; AddDbContextFactory for jobs and Blazor
Configuration
Conventions, then attributes, then Fluent API (wins). One IEntityTypeConfiguration<T> per entity
Migrations
migrations add in dev; ship an idempotent script or a bundle. No auto-migrate at start-up in production
Transactions
One 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 SQL
FromSql($"...{x}") and SqlQuery<T> send x as a parameter. Never concatenate
Query filters
HasQueryFilter for soft delete and tenancy; IgnoreQueryFilters to bypass
Interceptors
SaveChangesInterceptor for audit fields; command interceptors for SQL hints
Complex types
Value 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 Core
Dapper
Best for
Domain writes, migrations, LINQ
Hot read paths, reports, tuned SQL
You write
LINQ; EF writes the SQL
All the SQL
You get
Tracking, unit of work, schema history
Thin 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 question
How 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).
.NET 10 How to implement it
Library / API
Package
Use it for
EF Core 10 provider
Microsoft.EntityFrameworkCore.SqlServer (NuGet, 10.0.x) or Npgsql.EntityFrameworkCore.PostgreSQL
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
Keys are flat paths: Payments:Timeout. In environment variables use __; in Key Vault secret names use --.
Environment comes from ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT. If neither is set, it's Production.
Secrets: user secrets locally; Azure Key Vault with managed identity in the cloud. Never commit them to appsettings.
Interface
Lifetime
Reloads?
Use in
IOptions<T>
Singleton
No, read once
Settings fixed at start-up
IOptionsSnapshot<T>
Scoped
Yes, per request
Scoped services, controllers
IOptionsMonitor<T>
Singleton
Yes, CurrentValue + OnChange
Singletons, 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 check
Asks
On failure
Liveness
Is the process alive? No dependency checks
Orchestrator restarts it
Readiness
Can it serve? DB, queues, caches
Taken out of the load balancer, not restarted
Interview question
How 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.
// 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
Option
Pick it when
Watch out
CoreWCF
Many SOAP clients you can't change; fast exit from Framework
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 question
How 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.
.NET 10 How to implement it
Library / API
Package
Use it for
CoreWCF server
CoreWCF.Primitives + CoreWCF.Http 1.9.1
Host SOAP services on ASP.NET Core (basicHttp, wsHttp)
// ✅ 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" 🔌
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.
Feature
Release
Why it matters
Primary constructors
C# 12
DI without boilerplate. Parameters are captured, not readonly fields
⚡ 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).