This page is for developers facing a .NET backend round, from a first job to a senior role. Most interviews start with the ASP.NET Core request pipeline and dependency injection lifetimes, move to configuration, routing, controllers and minimal APIs, then test JWT authentication and authorization and Entity Framework Core: tracking, loading, migrations and concurrency. Senior rounds add performance, background services, integration testing and a production story. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. C# language questions have their own page.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Pipeline: each middleware gets the HttpContext, can act, then call the next one or stop the request there.
Two directions: the request goes in in registration order; the response comes back out in reverse.
Order bugs: exception handling first, authentication before authorization, both before the endpoints.
"Middleware is a chain of small components that every request passes through. Each one gets the HttpContext, can do some work, and then either calls the next one or short-circuits and writes the response itself, like the static files middleware does when it finds a file. The request travels down the chain in the order I registered things, and the response travels back up in reverse. That's why order matters. The exception handler goes near the top so it can catch errors from everything below it. Authentication has to run before authorization, otherwise authorization sees an anonymous user and every protected call fails. And anything that needs to know which endpoint was picked, like authorization or CORS, has to sit after routing. Most odd pipeline bugs I've seen came down to one line in the wrong place."
Treating the registration order as cosmetic, or not knowing that the response passes back through the middleware in reverse.
Shape: a class taking RequestDelegate in the constructor and exposing InvokeAsync(HttpContext).
Measure around next: start the timer, await next, log in a finally block so failures are timed too.
Lifetime: the class is created once, so scoped services go in InvokeAsync parameters, not the constructor.
"I'd write a class that takes the next RequestDelegate and a logger in its constructor, and has an InvokeAsync method. In InvokeAsync I take a timestamp, await the next delegate, and in a finally block I work out the elapsed time and log the method, path, status code and milliseconds. The finally matters, because I want slow requests that throw to show up too. I'd log with a message template and named placeholders, not string interpolation, so the log system can filter by path or status. Then I register it with UseMiddleware near the top of the pipeline so it measures almost everything. One detail people miss: a middleware class like this is built once for the life of the app, so if I needed a scoped service, like a DbContext, I'd add it as a parameter on InvokeAsync, not in the constructor."
public sealed class RequestTimingMiddleware(
RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
long start = Stopwatch.GetTimestamp();
try
{
await next(context);
}
finally
{
logger.LogInformation("{Method} {Path} -> {StatusCode} in {ElapsedMs} ms",
context.Request.Method, context.Request.Path, context.Response.StatusCode,
Stopwatch.GetElapsedTime(start).TotalMilliseconds);
}
}
}
// Program.cs: app.UseMiddleware<RequestTimingMiddleware>();
Injecting a scoped service such as a DbContext into the middleware constructor, or logging with string interpolation.
Kestrel: the cross-platform web server built into ASP.NET Core; the app runs on it by default.
Proxy jobs: TLS termination, load balancing, many sites on one port, extra hardening.
Forwarded headers: behind a proxy, restore the real client IP and scheme with the forwarded headers middleware.
"Kestrel is the web server that ships inside ASP.NET Core. When I call app.Run, the host starts Kestrel and it listens for HTTP requests on whatever ports I've configured. It's fast and can face the internet directly, but in practice teams often put something in front of it: IIS on Windows, Nginx on Linux, or a cloud load balancer. That front layer handles TLS certificates, spreads traffic across instances, lets several apps share port 443, and adds limits and filtering. On IIS there's also in-process hosting, where the app runs inside the IIS worker process instead of behind it. The catch with any proxy is that the app now sees the proxy's IP and maybe plain HTTP. So I add the forwarded headers middleware, and tell it which proxies to trust, so redirects, logs and rate limits use the real client details."
Thinking an ASP.NET Core app needs IIS to run, or never having heard of forwarded headers.
Inventory: which dependencies and APIs had no modern equivalent, such as System.Web or Windows-only pieces.
Path: libraries first, then the web layer, ideally incrementally behind a proxy.
Safety: tests around behaviour, side-by-side running, a rollback route, clear metrics.
"Yes, at my last company we moved an ASP.NET MVC app on .NET Framework to ASP.NET Core. The biggest blockers were code that used HttpContext.Current and other System.Web types deep in the business layer, settings in web.config, and a couple of Windows-only libraries. We started with an inventory, then moved the class libraries first, because they had the fewest web dependencies. HttpContext.Current had to be replaced by passing the data we actually needed, which was a good cleanup anyway. For the web layer we didn't do a big bang. We put a reverse proxy in front, moved a few routes to the new app at a time, and left the rest on the old one until each slice was proven. Before each move we wrote API-level tests against the old behaviour. Sharing authentication cookies across both apps was the fiddliest part."
Describing a big-bang rewrite with no tests or rollback plan, or not knowing what System.Web dependencies mean for a move.
Transient: a new instance every time it is resolved; for light, stateless services.
Scoped: one instance per scope, which in a web app means per request; DbContext is the classic case.
Singleton: one instance for the app; must be thread-safe; caches, clients, configuration holders.
"The lifetime decides how long the container keeps an instance. Transient means I get a new object every time something asks for it, so it suits small stateless helpers, like a price formatter. Scoped means one object per scope, and in ASP.NET Core every request gets its own scope, so everything in one request shares it. The DbContext is the classic example, because I want one unit of work and one change tracker per request, not shared across users. Singleton means one object for the whole life of the app, shared by every request on every thread, so it has to be thread-safe. I'd use it for things that are expensive to build and safe to share, like an in-memory cache or a client that holds connections. The container also disposes what it created when the scope ends, so scoped and transient disposables get cleaned up per request."
Saying scoped means per thread, or making everything singleton for speed without mentioning thread safety.
Captured: the scoped object is created once and lives as long as the singleton, shared by every request.
Symptoms: stale data, a DbContext hit by two threads at once, memory growth from one change tracker.
Guard and fix: scope validation throws in Development; fix the lifetime or create a scope per unit of work.
"That's a captive dependency. The singleton is built once, so the scoped service it receives is also built once and then held for the life of the app. It stops being per request. If that scoped service is a DbContext, every request now shares one context: its change tracker keeps growing, data goes stale, and sooner or later two requests use it at the same moment and EF throws because a second operation started before the first finished. The good news is that the default host validates scopes in the Development environment, so it usually fails at startup with a message saying it can't consume a scoped service from a singleton. The fix depends on the design. Often the outer service shouldn't be a singleton at all, so I make it scoped. If it really must live long, I inject IServiceScopeFactory and create a scope for each piece of work."
Fixing it by making the DbContext a singleton too, or by turning off scope validation.
Layered sources: appsettings.json, then the environment file, user secrets in Development, environment variables, command line.
Last wins: a later source overrides an earlier one for the same key.
Secrets: never in committed files; user secrets locally, environment variables or a secret store in production.
"Configuration is built from several providers stacked in layers, and the last one to set a key wins. With the default builder, it reads appsettings.json first, then the file for the current environment, like appsettings.Production.json. In Development it adds user secrets. Then environment variables, then command-line arguments on top. So if a connection string is in appsettings.json but also set as an environment variable on the server, the environment variable wins. Nested keys use a colon in code, like ConnectionStrings:Default, and a double underscore in environment variable names, because some platforms don't allow colons there. The environment itself comes from the ASPNETCORE_ENVIRONMENT variable. For secrets, I keep them out of any committed file. Locally I use user secrets, and in production I use environment variables or a proper secret store that the app loads as another configuration source."
Committing production passwords in appsettings.json, or not knowing that later sources override earlier ones.
Typed settings: bind a config section to a class instead of reading string keys everywhere.
Three flavours: IOptions is read once; IOptionsSnapshot is scoped and recomputed per request; IOptionsMonitor is a singleton that tracks changes.
Validate at startup: data annotations or custom rules plus ValidateOnStart, so bad config stops the deploy.
"The options pattern binds a configuration section to a plain class, so my code asks for a typed SmtpOptions instead of reading string keys. There are three ways to consume it. IOptions gives me the value computed once, so later changes to the file are never seen. It's fine for settings that only change with a deploy. IOptionsSnapshot is scoped: it's recomputed for each request, so it picks up reloaded files, but because it's scoped I can't inject it into a singleton. IOptionsMonitor is a singleton, its CurrentValue always gives the latest value, and it has an OnChange callback, so it's what I use in singletons and background services. I also validate options. I add data annotation rules and call ValidateOnStart, so a missing host name stops the app at startup instead of failing on the first email at two in the morning."
public sealed class SmtpOptions
{
[Required] public string Host { get; set; } = "";
[Range(1, 65535)] public int Port { get; set; } = 587;
}
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
Injecting IOptionsSnapshot into a singleton, or expecting IOptions to pick up changes without a restart.
Templates: named placeholders keep values as searchable properties; interpolation flattens them to text.
Levels and categories: ILogger<T> sets the category; config filters levels per category.
Hygiene: scopes for correlation, no secrets or personal data, source-generated logging for hot paths.
"I inject ILogger of my class, which makes the class name the log category, and I pick a level that matches the event: Information for normal business events, Warning for something odd but handled, Error with the exception object when something failed. I always use a message template with named placeholders, like 'Order {OrderId} shipped', and pass the values as arguments. With a structured sink, OrderId becomes a real field I can filter on. If I interpolate the string myself, it's just text, and I also pay the formatting cost even when that level is switched off. Levels are set per category in configuration, so I can turn up EF Core's SQL logging without touching code. I use scopes to attach a correlation ID to everything in a request, and I never log passwords, tokens or personal data. For very hot paths there's the LoggerMessage source generator."
Logging with string interpolation or Console.WriteLine, or logging full request bodies with tokens in them.
Match then run: routing picks the endpoint; middleware in between can read its metadata; the endpoint runs last.
Templates: attribute routes on controllers, Map methods for minimal APIs, parameters in braces.
Constraints: like int or guid, to separate routes that would otherwise clash, not to validate input.
"ASP.NET Core uses endpoint routing. Early in the pipeline, routing looks at the path and method and picks the matching endpoint. Middleware that runs after that can see which endpoint was chosen and its metadata, which is how authorization knows an action has an Authorize attribute. The endpoint itself runs at the end. For APIs I use attribute routing on controllers, like a Route attribute of api/orders on the class and HttpGet with an id template on the method, or MapGet and MapGroup in minimal APIs. Route parameters sit in braces and bind to method parameters. Constraints like colon int or colon guid help pick between routes that look alike. I don't use them as validation, though, because a failed constraint gives a 404, and for bad input the client deserves a 400 with a reason."
Believing attribute routes are matched in the order they were declared, or using constraints as the main input validation.
Controllers: classes grouped by resource, ApiController conventions, automatic 400s, filters, familiar structure.
Minimal APIs: lambdas or methods mapped directly, less ceremony, route groups and endpoint filters.
Choice: team habits, size of the API and the features you rely on; both run on the same pipeline.
"Both sit on the same routing, dependency injection and middleware, so the difference is mostly how I organise and describe endpoints. Controllers are classes with action methods. With the ApiController attribute I get conventions like automatic 400 responses when model validation fails, and I can use the full set of MVC filters. It's a structure most .NET developers already know, which helps on a big API with many people. Minimal APIs map a route straight to a lambda or a method with MapGet or MapPost. There's less ceremony, it starts a bit lighter, and I can group routes with MapGroup and share things like authorization or endpoint filters on the group. For a small service or a new team I'd lean minimal. For a large existing codebase built on controllers, or one that relies on MVC-specific filters and conventions, I'd stay with controllers. I wouldn't mix them randomly in one service."
Saying minimal APIs are only for demos, or that controllers are deprecated.
Middleware: sees every request but only the raw HttpContext.
Filters: run inside the action pipeline and see action arguments, model state and the result.
Order: authorization, resource, then model binding, action, exception, result; endpoint filters for minimal APIs.
"Middleware runs for every request and only knows the HttpContext. Filters run inside the MVC action pipeline, after an action has been chosen, so they can see things middleware can't: the bound action arguments, the model state, and the result object before it's written. The order goes authorization filters first, then resource filters, which run before model binding and are good for short-circuiting, like a cache. Then model binding, then action filters around the action itself, exception filters for errors thrown by actions and filters, and result filters around writing the result. I use middleware for things that apply to everything, like correlation IDs or request logging. I use a filter when the logic is about the action, like auditing with the actual arguments, or shaping validation errors. Minimal APIs have their own endpoint filters that play a similar role."
Using an exception filter as the only global error handler, or saying filters and middleware are the same thing.
One place: the exception handler middleware at the top, not try/catch in every action.
Standard shape: Problem Details with a status, a title and a trace ID the client can quote.
Safe: log the full exception on the server; never send stack traces to clients outside Development.
"I handle it once, in the pipeline, instead of wrapping every action in try and catch. I register the exception handler middleware near the top so it catches anything thrown below it. In recent versions I implement IExceptionHandler: it logs the exception with the request path, sets the status code, and writes a Problem Details body, which is the standard JSON error shape with a status, a title and extra fields. I add the trace identifier so a customer can quote it and I can find the exact logs. I can also map known exceptions, like a not-found exception from my domain layer, to 404 instead of 500. What I never do is return the exception message or stack trace to clients in production, because it leaks internals. The developer exception page is for Development only."
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// after builder.Build():
app.UseExceptionHandler();
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
{
logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Something went wrong.",
Extensions = { ["traceId"] = context.TraceIdentifier }
}, ct);
return true;
}
}
Returning exception.Message or the stack trace to the client, or copying try/catch into every controller action.
Flow: an identity provider issues the token; the client sends it in the Authorization header as Bearer.
Validation: signature, issuer, audience and expiry, with a small clock skew allowance.
Result: claims become HttpContext.User; 401 for no or bad token, 403 for valid token without permission.
"The client signs in with an identity provider and gets an access token, which is a signed JWT. On each call it sends it in the Authorization header as Bearer. In the API I add authentication with the JWT bearer handler, point it at the authority, and set the expected audience. For every request the handler checks the signature against the provider's signing keys, checks the issuer is the one I trust, the audience is my API, and the token hasn't expired, allowing a small clock skew. If all that passes, the claims become the ClaimsPrincipal on HttpContext.User, and authorization decides what that user may do. A missing or invalid token gives a 401, and a valid token without the right permission gives a 403. One thing I always point out: a JWT is signed, not encrypted, so anyone can read it, and nothing secret should go inside."
Saying a JWT is encrypted, or disabling issuer and audience validation to make a token work.
Policies over roles: name the rule once and apply it by name, not with role strings everywhere.
Requirement and handler: a handler that gets the user and the resource and calls Succeed when allowed.
Resource-based: load the order first, then call IAuthorizationService with it; return 403 or 404 if denied.
"A role check alone can't answer this, because the rule depends on the order itself. So I'd use resource-based authorization. I define a requirement, say CanViewOrder, and a handler for that requirement and the Order type. The handler succeeds if the user has the support role, or if the user's ID claim matches the order's customer ID. I register a policy that uses the requirement and register the handler in DI. In the endpoint, I load the order, then call IAuthorizationService.AuthorizeAsync with the user, the order and the policy name. If it fails, I return Forbid, or sometimes 404 so I don't confirm the order exists. The benefit is one tested place for the rule. Every endpoint that touches orders reuses it, instead of each developer writing their own slightly different if statement."
public sealed class CanViewOrderRequirement : IAuthorizationRequirement { }
public sealed class CanViewOrderHandler : AuthorizationHandler<CanViewOrderRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, CanViewOrderRequirement requirement, Order order)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (context.User.IsInRole("Support") || order.CustomerId == userId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Program.cs
builder.Services.AddAuthorization(o =>
o.AddPolicy("CanViewOrder", p => p.AddRequirements(new CanViewOrderRequirement())));
builder.Services.AddSingleton<IAuthorizationHandler, CanViewOrderHandler>();
// In the endpoint, after loading the order:
var result = await authz.AuthorizeAsync(User, order, "CanViewOrder");
if (!result.Succeeded) return Forbid();
Trusting a customer ID sent in the request body or query string instead of the claim from the validated token.
Context: who called the API, users or other services, and what data it protected.
Choices: token type and provider, policies and permissions, secure by default.
Checks: how you tested it and one thing you would do differently.
"At my last company I built an API used by a web app and by one internal service. We didn't write our own login. We used an external identity provider, and the API validated JWT bearer tokens, checking issuer, audience and expiry. For the internal service we used client credentials, so it had its own identity and limited scopes instead of sharing a user account. The key decision was making the API secure by default: I added a fallback policy that requires an authenticated user, so a new endpoint without an attribute is protected rather than accidentally open. Permissions were policies based on claims, not role strings scattered through the code, and ownership checks used resource-based handlers. I wrote integration tests that call each endpoint anonymously and with the wrong user. Looking back, I'd have added token revocation earlier."
Building a home-made password and token system, or leaving endpoints open unless someone remembered to add an attribute.
Stop the bleeding: everyone is locked out, so roll back first if you can.
Read the reason: the bearer handler logs why it rejected the token; the WWW-Authenticate header often says too.
Usual suspects: issuer or audience config per environment, signing keys, middleware order, server clock.
"Every user is locked out, so the first move is to roll back to the last good version if I can, and then debug calmly. I'd compare the config and code between the two versions. The JWT bearer handler logs the reason a token failed, like an audience mismatch or a bad signature, and the WWW-Authenticate header on the 401 often names the error too, so I'd look at that before guessing. The usual causes are an environment setting that changed the expected issuer or audience, the authority URL pointing at the wrong environment, a signing key that didn't load, a clock that drifted on the new servers, or someone moving UseAuthorization above UseAuthentication. Once I find it, I fix it and add a test that calls a protected endpoint with a real token in the pipeline. What I would never do is turn off validation to get it working."
Disabling issuer, audience or signature validation to make the errors stop.
Tracking: the context remembers each loaded entity and its original values.
SaveChanges: compares current and original values and writes only what changed.
AsNoTracking: for read-only queries; less memory and work, but no automatic updates or identity resolution.
"When a query returns entities, the DbContext tracks them by default. It keeps a record of each entity and its original values. When I call SaveChanges, it compares what's there now with those originals and generates UPDATE statements only for the columns that changed, plus inserts and deletes for added and removed entities. That's convenient for updates, but it costs memory and CPU. For read-only work, like a list page or a report, I add AsNoTracking, so EF just materialises the objects and forgets them. That's faster, and on big result sets the difference is noticeable. The trade-off is that changes to those objects won't be saved, and if the same row appears twice I get two separate objects unless I use the identity-resolution variant. For a read-heavy context I sometimes make no-tracking the default and opt into tracking where I update."
Using AsNoTracking and then being surprised that SaveChanges does nothing, or never having heard of the change tracker.
Spot it: a loop over parents that touches a navigation or runs a query per item.
Confirm: turn on SQL logging and count the queries for one call.
Fix: Include to load related rows up front, or better, project only the fields you need.
"The problem is the loop. The first query loads the orders, and then touching the lines of each order, through lazy loading here, sends another query per order. Fifty orders means fifty-one round trips, and it gets worse as the customer grows. First I'd prove it: turn on EF's SQL logging in a dev environment and count the queries for one request. Then I'd fix it one of two ways. Include loads the orders and their lines in one query, which is fine if I actually need the full entities. But here I only need a total per order, so the best fix is a projection with Select. EF translates the sum into SQL, the database does the math, and only the ID and total come back. I'd reach for projection by default on read endpoints, because it moves less data and avoids tracking."
// Before: one query, then one more per order when Lines is touched (lazy loading on)
var orders = await db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
foreach (var order in orders)
totals[order.Id] = order.Lines.Sum(l => l.UnitPrice * l.Quantity);
// Fix 1: load the lines in the same query
var ordersWithLines = await db.Orders
.Include(o => o.Lines)
.Where(o => o.CustomerId == customerId)
.ToListAsync();
// Fix 2: let the database add it up and return only what is needed
var orderTotals = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new { o.Id, Total = o.Lines.Sum(l => l.UnitPrice * l.Quantity) })
.ToListAsync();
Suggesting caching the whole table or adding more database capacity without removing the extra queries.
Eager: Include and ThenInclude load related data in the same query.
Lazy: proxies and virtual navigations load data on first access; easy, but hides queries.
Explicit and split: load a navigation on demand with Entry; AsSplitQuery avoids huge joined result sets.
"Eager loading means I say up front what I need with Include and ThenInclude, and EF fetches it in the same query. Lazy loading means the related data loads the first time I touch the navigation property. The usual setup needs the proxies package and virtual navigations, and it's where most N+1 problems come from, because the extra queries are invisible in the code. Explicit loading is in between: I load the parent, then call Entry, Collection and LoadAsync when I decide I need it. My default is no lazy loading, eager loading only where I really need entities, and projection for reads. A split query matters when I include two or more collections: a single SQL join multiplies the rows, so the result set can explode. AsSplitQuery sends one query per collection instead. It's faster in that case, but the queries aren't one snapshot, so data could change between them."
Turning lazy loading on everywhere for convenience, or not knowing that it issues a query each time a navigation is first touched.
Create and review: add the migration, read the generated code and SQL, commit it with the model change.
Apply deliberately: an idempotent SQL script or a migration bundle run by the pipeline, not Migrate at startup on many instances.
Stay compatible: expand then contract, so old and new app versions both work during the rollout.
"I create a migration with the EF tools after changing the model, and then I actually read it, plus the SQL it generates. EF sometimes turns a rename into a drop and an add, which loses data, so I fix that by hand. For production, I don't let the app call Migrate on startup. With several instances they can race, and the app's database user shouldn't have rights to change the schema anyway. Instead the pipeline generates an idempotent script or a migration bundle and runs it as its own step, with a backup taken first. For breaking changes I use expand and contract. First add the new column and deploy code that writes both. Then backfill. Only in a later release do I drop the old column. That way the old version still running during the rollout never meets a schema it can't handle."
Running Migrate on every app start in production across several instances, or shipping a generated migration nobody read.
Unit of work: one context per request, holding one change tracker and one connection at a time.
Not thread-safe: a second operation before the first finishes throws an InvalidOperationException.
Options: await queries one by one, use IDbContextFactory for separate contexts, pooling to cut setup cost.
"A DbContext is meant to be a short unit of work. It holds a change tracker and uses one connection at a time, so it fits one request, which is exactly what scoped gives me. It isn't thread-safe. If I start two queries on the same context and await them together with Task.WhenAll, EF throws an InvalidOperationException saying a second operation started before the previous one completed. That's not a bug in EF; it's protecting the context from corruption. Usually the fix is simply to await the queries one after another. If I really need parallel queries, for example to build a dashboard faster, I inject IDbContextFactory and create a separate context for each, disposing each one when done. If creating contexts shows up as a cost under heavy load, DbContext pooling reuses instances and resets their state between uses."
Registering DbContext as a singleton, or wrapping it in a lock to allow parallel queries.
Token: a concurrency column, such as a rowversion, marked with Timestamp or configured as a concurrency token.
Check in SQL: the UPDATE includes the original token in the WHERE clause; zero rows means someone else won.
Handle it: catch DbUpdateConcurrencyException, then return 409 or reload, merge and retry.
"That's a lost update, and the usual fix is optimistic concurrency. I add a concurrency token to the entity. On SQL Server that's a rowversion column marked with the Timestamp attribute, and the database changes it on every update. Other providers have their own options, or I can manage a version number myself. EF then puts the original token value in the WHERE clause of the UPDATE. If another admin saved in between, the token no longer matches, zero rows are affected, and EF throws DbUpdateConcurrencyException. I catch that and decide with the product owner what should happen. For an admin screen I usually return a 409 Conflict and show the latest values so the person can reapply their change. Over HTTP, I send the version as an ETag and expect it back in If-Match, so the check covers the whole round trip, not just the time inside one request."
public class Product
{
public int Id { get; set; }
public decimal Price { get; set; }
[Timestamp] public byte[] RowVersion { get; set; } = default!;
}
product.Price = request.Price;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict("This product was changed by someone else. Reload and try again.");
}
Relying on a transaction alone, which does not stop two read-modify-write requests from overwriting each other.
Acknowledge: the pain of forgetting Include is real.
The risk: hidden queries, N+1 in loops and during serialization, sync I/O behind a property.
Alternative: projections for reads, Include where entities are needed, a query-count check in tests.
"I'd start by agreeing the pain is real: forgetting an Include and getting an empty list is annoying. But I'd explain what lazy loading costs us. Every navigation becomes a hidden database call, so a loop over fifty items can quietly become fifty-one queries, and serialising an entity to JSON can wander through navigations and trigger even more. Those lazy loads are also synchronous database calls behind a property, which is bad for a busy API. I'd show it rather than argue: turn on SQL logging, run one list endpoint with lazy loading on, and count the queries together. Then I'd suggest a better fix for the real problem: use projections into response types for reads, so there's nothing to Include, use Include where we really need entities, and add a test that fails if an endpoint runs more queries than expected."
Agreeing because it saves typing, or refusing without explaining the cost or offering an alternative.
Blocking: each blocked request holds a thread pool thread while it waits for I/O.
Starvation: the pool adds threads slowly, so work queues up; latency climbs while CPU stays low.
Prove and fix: thread pool counters and thread dumps show it; async all the way is the real fix.
"ASP.NET Core doesn't have the synchronization context old ASP.NET had, so .Result usually won't deadlock the classic way. The damage is different. Each request that blocks holds a thread pool thread doing nothing while it waits for the database or an HTTP call. Under load, all the threads end up blocked, and new requests queue up. The pool does add threads, but slowly on purpose, so latency climbs and timeouts appear while CPU looks almost idle. That's the signature. To prove it I'd watch the thread pool counters with dotnet-counters, especially the queue length and thread count, and take a dump or trace to see many threads parked in Wait or Result. The fix is async all the way: make the calling methods async and await. Raising the minimum thread count can buy time in an emergency, but it hides the problem instead of fixing it."
Saying the fix is to increase the thread pool size, or that blocking is harmless because nothing deadlocked.
New per call: each one opens connections that linger after disposal; under load you run out of sockets.
One static client: reuses connections but can hold on to stale DNS results unless configured.
Factory: IHttpClientFactory pools and recycles handlers, and adds named or typed clients and delegating handlers.
"HttpClient looks disposable, so people wrap it in a using block per call. The trouble is that each new client opens its own connections, and after disposal those sockets linger for a while in the operating system. Under real traffic the server can run out of sockets and outbound calls start failing. The opposite mistake is one static client forever: connections get reused, but it can keep talking to an old IP after a DNS change unless the connection lifetime is configured. In ASP.NET Core I use IHttpClientFactory. I register a typed client with its base address and timeout, and the factory pools and recycles the underlying handlers, so I get reuse without stale DNS. It also lets me add delegating handlers for things like attaching a token, logging and retry or circuit breaker policies, all configured in one place."
Wrapping a new HttpClient in using for every request, or adding retries to non-idempotent calls such as payments without a key.
Symptom: which endpoint, how slow, who noticed and how it was measured.
Evidence: traces, SQL logs, query plans, profiler output; what they pointed to.
Fix and proof: the change, the before and after numbers, and what stops it coming back.
"At my last company the order history endpoint had become slow for our biggest customers, a few seconds at the high end, while small accounts were fine. That pattern suggested work growing with data. I took a trace of one slow request and turned on EF's SQL logging in staging, and one call was sending a couple of hundred queries: the classic N+1, from lazy loading inside a mapping step. There was also a missing index on the customer ID column. I replaced the mapping with a projection that selected just the fields the page showed, which cut it to one query, and added the index after checking the query plan. The slow end dropped to well under half a second. Then I added a test that counts queries for that endpoint and turned lazy loading off, so the same mistake couldn't creep back quietly."
A story where the fix was a guess, such as adding caching or a bigger server, with no measurement before or after.
Scope per run: hosted services are singletons, so create a scope each run and resolve the DbContext from it.
Shutdown: pass the stopping token everywhere so the loop ends cleanly when the host stops.
Resilience: catch and log per run; an unhandled exception stops the host by default in recent versions.
"A hosted service is registered as a singleton, so I can't inject a DbContext into it directly. That would capture a scoped service. Instead I inject IServiceScopeFactory. In ExecuteAsync I use a PeriodicTimer for the ten-second tick, and on each tick I create an async scope, resolve the DbContext from it, do the work, and let the scope dispose everything at the end of the run. I pass the stopping token into every async call, so when the app shuts down the loop ends promptly instead of being cut off halfway. I wrap each run in a try and catch that logs and moves on, because in recent versions an unhandled exception in a background service stops the whole host by default. Finally I register it with AddHostedService. If the work must never be lost, I'd also make each item idempotent."
public sealed class OutboxWorker(IServiceScopeFactory scopes, ILogger<OutboxWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
int sent = await SendPendingAsync(db, stoppingToken); // your own query and updates
logger.LogInformation("Outbox run sent {Count} messages", sent);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Outbox run failed");
}
}
}
}
// Program.cs: builder.Services.AddHostedService<OutboxWorker>();
Injecting the DbContext straight into the hosted service, or ignoring the cancellation token so shutdown hangs.
Spot the problem: four instances means four runs; a deploy or restart can kill a run halfway.
Options: a separate worker or scheduled job that runs once, or a lock or lease so only one instance runs it.
Make it safe: idempotent work, a record of each run, alerts when a run fails or never starts.
"My first answer would be that a simple BackgroundService would run four times a night, once per instance, so customers could get four reports. A deploy or a scale-in could also stop a run halfway, and nobody would know. So I'd suggest moving it out of the API. Either a small worker service that runs as a single instance, or a scheduled job on whatever platform we already use, which triggers one run at the right time. If it really has to live in the API, I'd use a scheduler library with a shared database store, or take a lease in the database so only one instance does the work. Either way I'd make the job idempotent, store a record of each run with its status, and alert if a run fails or doesn't happen. That keeps it simple but trustworthy."
Adding the BackgroundService as asked without noticing it will run once per instance.
Host in memory: WebApplicationFactory starts the app on a test server and gives you an HttpClient.
Swap services: ConfigureTestServices replaces things like the clock, email sender or auth.
Real database: the same engine in a container beats the in-memory provider, which behaves differently.
"I use WebApplicationFactory from the MVC testing package. It boots my real Program, with the same middleware, routing and DI, on an in-memory test server, and gives me an HttpClient to call it. So a test sends a real HTTP request and checks the status code and JSON body, which catches wiring mistakes that unit tests never see. With the minimal hosting model I may need to make the Program class visible to the test project. To control dependencies, I use WithWebHostBuilder and ConfigureTestServices to replace, say, the clock or the email sender with fakes, and I add a test authentication handler so I can act as different users. For the database, I avoid EF's in-memory provider for these tests because it doesn't enforce constraints or behave like SQL. I run the real engine in a throwaway container instead, and reset data between tests."
public class OrdersApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Unknown_order_returns_404()
{
var client = factory.WithWebHostBuilder(b =>
b.ConfigureTestServices(s => s.AddSingleton<IClock, FixedClock>()))
.CreateClient();
var response = await client.GetAsync("/orders/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
Only testing controllers by calling their methods directly, or trusting the EF in-memory provider to catch SQL and constraint bugs.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.