.NET Interview Questions and Answers

Last updated:

Check out 45 of the most common .NET interview questions, then take an AI-powered practice interview

C#ASP.NETEntity FrameworkAzureMicroservices
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

What exactly do you install with the .NET SDK, and what happens between typing `dotnet run` and the first request being served?

BasicPlatform

Answer

The SDK is a superset of the runtime. It contains MSBuild, Roslyn (the C# compiler), the `dotnet` CLI verbs (`new`, `build`, `restore`, `publish`, `test`), NuGet client bits, and one or more runtimes. A production server usually needs only the ASP.NET Core Runtime, not the SDK, which is why Microsoft ships separate `sdk` and `aspnet` container image tags.

Running `dotnet --info` prints the installed SDKs and runtimes plus the RID (runtime identifier) such as linux-x64 or win-x64. `dotnet run` performs restore, build and launch in one step: MSBuild evaluates the .csproj, NuGet resolves packages into `obj/project.assets.json`, Roslyn compiles your C# into an IL assembly plus a `.deps.json` and `runtimeconfig.json`, and then `dotnet` starts the host, which loads CoreCLR, reads `runtimeconfig.json` for the target framework and roll-forward policy, resolves assemblies through the deps file, and JIT-compiles `Main` on first call. Only then does Kestrel bind its socket. Two practical consequences follow.

First, `dotnet run` is a development command: it rebuilds, watches nothing unless you use `dotnet watch`, and adds startup overhead, so containers should run the published DLL directly with `dotnet MyApi.dll`. Second, roll-forward means a binary targeting net8.0 will happily run on a machine that only has the .NET 10 runtime unless you pin it, which is a common source of environment drift between a developer laptop and a production node.

# What is actually installed on this machine
dotnet --info
dotnet --list-sdks
dotnet --list-runtimes

# Development loop (rebuilds and restarts on file change)
dotnet watch run --project src/Api

# What production runs: no SDK, no MSBuild, just the host
dotnet publish src/Api -c Release -o /app
cd /app && dotnet Api.dll

# Pin the runtime instead of relying on roll-forward
dotnet MyApi.dll --roll-forward Disable

Key Points

  • SDK = runtime + MSBuild + Roslyn + CLI; servers need only the runtime
  • runtimeconfig.json and deps.json drive host startup and assembly resolution
  • Containers should run `dotnet App.dll`, never `dotnet run`
  • Roll-forward silently allows a newer runtime unless you disable it
💡 Pro Tip: In a Dockerfile use the `sdk` image for the build stage and the smaller `aspnet` image for the runtime stage. Shipping the SDK to production roughly triples image size for no benefit.
Q2

How do the .NET release cadence and LTS windows decide which TargetFramework you ship?

BasicPlatform

Answer

.NET ships every November. Even-numbered releases (.NET 6, 8, 10) are Long Term Support with three years of patches; odd-numbered releases (.NET 7, 9) are Standard Term Support with eighteen months. Support ending is not cosmetic: once a version is out of support Microsoft stops shipping security patches for it, and most enterprise security teams in India will flag an out-of-support runtime during an audit.

The practical rule for a team shipping a product is to target the current LTS in `<TargetFramework>` and upgrade in the quarter after the next LTS lands, so you are never running the last few months of a support window. Teams that want the newest runtime features can ride STS releases, but only if they accept a mandatory upgrade every eighteen months. Multi-targeting matters for libraries rather than applications: a NuGet package often declares `<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>` so it can be consumed from .NET Framework 4.8 as well as modern .NET.

Upgrades themselves are usually a one-line change to the TargetFramework plus a NuGet bump, because the platform holds a strong binary compatibility bar, but you should always read the breaking-change list for the target release and run the full test suite. Interviewers ask this to check whether you understand that a version number is a support commitment and not just a feature list.

<!-- Application: single target, current LTS -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

<!-- Library: multi-target so old estates can consume it -->
<PropertyGroup>
  <TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
</PropertyGroup>

Key Points

  • Even releases are LTS (3 years), odd releases are STS (18 months)
  • Applications target the current LTS; libraries multi-target
  • netstandard2.0 is the bridge back to .NET Framework 4.8
  • Out-of-support runtimes fail security audits, not just feature checklists
Q3

What does `WebApplication.CreateBuilder(args)` actually configure, and what replaced the old Startup.cs split?

BasicHosting

Answer

`WebApplication.CreateBuilder(args)` is the minimal hosting model introduced in .NET 6 that collapsed `Program.cs` plus `Startup.cs` into a single file. Behind that one call the framework sets the content root, loads configuration in a fixed provider order, wires up the default logging providers (Console, Debug, EventSource, and EventLog on Windows), registers the service collection with core framework services, and configures Kestrel as the web server with defaults read from the `Kestrel` configuration section. It returns a `WebApplicationBuilder` exposing `Configuration`, `Services`, `Logging`, `Environment` and `Host`.

You register services on `builder.Services` (the equivalent of the old `ConfigureServices`), call `builder.Build()` to produce a `WebApplication`, then compose the middleware pipeline on that object (the old `Configure`), and finally `app.Run()`. The important behavioural detail is that the DI container is built when `Build()` is called: you cannot register services after that point, and attempting to do so throws. Anything that needs to run at startup after the container exists belongs either in a hosted service or in an explicit scope created from `app.Services`.

The old `IWebHostBuilder`/`Startup` pattern still works through `Host.CreateDefaultBuilder`, and plenty of enterprise codebases in India still use it, so be ready to explain both. For tests, the class generated from top-level statements is internal, which is why integration tests either add `public partial class Program { }` at the bottom of Program.cs or use `InternalsVisibleTo`.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddOpenApi();

var app = builder.Build();  // container is sealed here

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

// Makes the implicit Program class visible to WebApplicationFactory<Program>
public partial class Program { }
💡 Pro Tip: If you get "Cannot access a disposed object" or "services cannot be added after the provider is built", you are mutating builder.Services after calling Build(). Move that registration above the Build() line.
Q4

How does ASP.NET Core layer configuration providers, and which source wins when the same key appears twice?

BasicConfiguration

Answer

Configuration is an ordered list of providers merged into a single flat key-value dictionary, and last provider registered wins. The default order from `CreateBuilder` is: appsettings.json, then appsettings.{Environment}.json, then User Secrets (Development only), then environment variables, then command-line arguments. So a value in an environment variable overrides the same key in appsettings.json, and a command-line switch beats everything.

Keys are hierarchical and use a colon separator (`ConnectionStrings:Default`), but colons are illegal in environment variable names on Linux, so the double underscore is the portable substitute: `ConnectionStrings__Default`. This one detail causes a large share of "works locally, broken in Kubernetes" incidents, because engineers set `ConnectionStrings:Default` in a ConfigMap and it silently does nothing. `IConfiguration` is case-insensitive on keys. Arrays are expressed with numeric indices (`Cors__Origins__0`).

The environment name comes from `ASPNETCORE_ENVIRONMENT` (or `DOTNET_ENVIRONMENT` for non-web hosts) and defaults to Production when unset, which is deliberately the safe default: forgetting to set it will not accidentally expose a developer exception page. You can add providers explicitly with `builder.Configuration.AddJsonFile(..., optional: false, reloadOnChange: true)` or plug in Azure App Configuration, AWS Parameter Store, or HashiCorp Vault. Never read configuration by string key throughout the codebase; bind it into typed options once at startup so misconfiguration surfaces in one place.

// appsettings.json
// { "ConnectionStrings": { "Default": "Host=localhost;Database=app" },
//   "Payments": { "TimeoutSeconds": 30 } }

// Overriding from a container (note the DOUBLE underscore)
// ASPNETCORE_ENVIRONMENT=Production
// ConnectionStrings__Default=Host=pg.prod;Database=app
// Payments__TimeoutSeconds=10

builder.Configuration
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args);

var timeout = builder.Configuration.GetValue<int>("Payments:TimeoutSeconds");

Key Points

  • Providers merge in order; the last one registered wins
  • Use __ instead of : for environment variables on Linux
  • ASPNETCORE_ENVIRONMENT defaults to Production when unset
  • Bind to typed options rather than reading string keys everywhere
Q5

AddSingleton vs AddScoped vs AddTransient: what is a scope in ASP.NET Core, and how do you use a scoped service from a singleton?

BasicDependency Injection

Answer

Singleton means one instance for the lifetime of the application, created on first resolution (or supplied as an instance at registration). Scoped means one instance per scope, and in ASP.NET Core the framework creates exactly one scope per HTTP request, so scoped effectively means per request. Transient means a new instance every time the container is asked. `DbContext` is registered scoped by `AddDbContext` because it is not thread-safe and holds change-tracking state that should die with the request.

The classic failure is the captive dependency: injecting a scoped service into a singleton captures the first request's instance forever. With `DbContext` that produces intermittent "A second operation was started on this context instance" errors under concurrency, plus a change tracker that grows without bound. The default provider will throw at startup for the direct case if scope validation is enabled, which it is in the Development environment; enable `ValidateScopes` and `ValidateOnBuild` in every environment so the failure is loud on day one rather than at 2 AM.

When a singleton (or a `BackgroundService`, which is itself singleton) genuinely needs scoped work, inject `IServiceScopeFactory` and create a scope explicitly per unit of work, disposing it afterwards. Keyed services (`AddKeyedScoped`, `[FromKeyedServices]`) added in .NET 8 let you register several implementations of the same interface and select one by key, which removes a lot of hand-rolled factory code.

builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;   // catch captive dependencies
    o.ValidateOnBuild = true;  // fail at startup, not first request
});

builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
builder.Services.AddKeyedScoped<IPaymentGateway, RazorpayGateway>("razorpay");

public sealed class ReconciliationWorker(IServiceScopeFactory scopeFactory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
            await repo.ReconcileAsync(ct);
            await Task.Delay(TimeSpan.FromMinutes(1), ct);
        }
    }
}

Key Points

  • Scoped = one per HTTP request; the framework owns scope creation
  • Captive dependency: scoped inside singleton lives forever
  • Turn on ValidateScopes and ValidateOnBuild in all environments
  • Use IServiceScopeFactory inside singletons and BackgroundService
Q6

Why does middleware registration order change behaviour, and what is the difference between Use, Run and Map?

BasicMiddleware

Answer

The pipeline is a chain of delegates built in registration order. Each component receives the `HttpContext` and a `next` delegate, can run code before calling `next`, can short-circuit by not calling it, and can run code after `next` returns while the response is unwinding. Because both an inbound and an outbound pass exist, order determines both what a component can see and what it can still change. `UseAuthentication` must come before `UseAuthorization`, otherwise `HttpContext.User` is unpopulated and every `[Authorize]` endpoint returns 401. `UseRouting` must precede `UseAuthorization` so that endpoint metadata (the attributes on the matched endpoint) is available to the authorization middleware. `UseCors` must sit before anything that short-circuits, or the browser will see a failure response with no CORS headers and report a misleading cross-origin error instead of the real 500.

Exception handling goes first so it can catch everything downstream. Response-modifying middleware such as compression must be registered before whatever writes the body. `Use` adds a component with a `next`; `Run` adds a terminal component that never calls `next`; `Map` branches the pipeline on a path prefix, and `MapWhen`/`UseWhen` branch on an arbitrary predicate, with `UseWhen` rejoining the main pipeline afterwards while `MapWhen` does not. A second rule interviewers like: once the response has started (`HttpContext.Response.HasStarted` is true) you cannot change status codes or headers, so an exception thrown after the first byte is flushed cannot be turned into a clean 500.

app.UseExceptionHandler();      // outermost: sees everything below
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();               // endpoint is selected here
app.UseCors("spa");
app.UseRateLimiter();
app.UseAuthentication();        // populates HttpContext.User
app.UseAuthorization();         // reads endpoint metadata + User
app.UseOutputCache();
app.MapControllers();

// Inline middleware: note the before/after halves
app.Use(async (ctx, next) =>
{
    var sw = Stopwatch.StartNew();
    await next();                              // downstream runs here
    if (!ctx.Response.HasStarted)
        ctx.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
});

app.Map("/internal", branch => branch.Run(async ctx => await ctx.Response.WriteAsync("ok")));
💡 Pro Tip: When a 401 appears on an endpoint that should be reachable, check the order of UseAuthentication and UseAuthorization before you check the token. Wrong order is the more common cause.
Q7

What does the `[ApiController]` attribute actually change about a controller?

BasicWeb API

Answer

`[ApiController]` opts a controller into a set of API-specific conventions that would otherwise need manual code. First, automatic model state validation: if `ModelState.IsValid` is false, the framework short-circuits and returns a 400 with a `ValidationProblemDetails` body before your action method runs, so the `if (!ModelState.IsValid) return BadRequest(ModelState)` boilerplate becomes unnecessary. Second, binding source inference: complex types are assumed to come from the body, simple types from the route or query string, and `IFormFile` from form data, so most `[FromBody]` and `[FromQuery]` attributes can be dropped.

Third, attribute routing becomes mandatory: conventional routes configured through `MapControllerRoute` no longer apply, and the controller must carry a `[Route]` template. Fourth, error responses become `ProblemDetails` shaped per RFC 7807, giving clients a consistent `type`/`title`/`status`/`detail`/`errors` contract. Fifth, multipart/form-data inference for `IFormFile` parameters.

Each behaviour can be disabled individually through `ApiBehaviorOptions`, most commonly `SuppressModelStateInvalidFilter` when a team wants its own error envelope. The gotcha worth mentioning in an interview is that automatic 400s bypass your action entirely, which means they also bypass any logging you do inside the action; if your observability depends on action-level logs you will see validation failures as silent 400s in the client metrics with nothing in your own logs. Register a custom `InvalidModelStateResponseFactory` to log and shape those responses centrally.

[ApiController]
[Route("api/v1/orders")]
public sealed class OrdersController(IOrderService orders) : ControllerBase
{
    // CreateOrderRequest is inferred as [FromBody]; id as [FromRoute]
    [HttpPost]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<OrderDto>> Create(CreateOrderRequest request, CancellationToken ct)
    {
        var order = await orders.CreateAsync(request, ct);
        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

// Log and reshape the automatic 400 instead of losing it
builder.Services.Configure<ApiBehaviorOptions>(o =>
{
    o.InvalidModelStateResponseFactory = ctx =>
    {
        var logger = ctx.HttpContext.RequestServices
            .GetRequiredService<ILoggerFactory>().CreateLogger("Validation");
        logger.LogWarning("Validation failed for {Path}", ctx.HttpContext.Request.Path);
        return new BadRequestObjectResult(new ValidationProblemDetails(ctx.ModelState));
    };
});
Q8

How does ASP.NET Core decide where each handler parameter comes from, and what is the single-body-parameter rule?

BasicModel Binding

Answer

Binding sources are resolved by convention unless you override them. In minimal APIs the order is: an explicit attribute (`[FromRoute]`, `[FromQuery]`, `[FromHeader]`, `[FromBody]`, `[FromServices]`, `[FromForm]`, `[AsParameters]`); then special types such as `HttpContext`, `HttpRequest`, `CancellationToken`, `ClaimsPrincipal` and `Stream`; then a name match against the route template; then, for simple types (anything with a `TryParse` or a `BindAsync` static method), the query string; and finally, for complex types, the JSON body. Controllers with `[ApiController]` follow essentially the same inference.

The rule that trips people up is that at most one parameter may be bound from the body, because the request body is a forward-only stream that is read once. Two `[FromBody]` parameters throw at startup in minimal APIs and produce an ambiguous binding error in MVC. If you genuinely need several pieces from the body, bind one wrapper record.

Custom binding is available two ways: implement `static bool TryParse(string, out T)` on a type to make it bindable from route or query, or implement `static ValueTask<T?> BindAsync(HttpContext)` for full control over reading headers or the body. Buffering matters too: if you need to read the raw body in middleware and then let model binding read it again, call `HttpRequest.EnableBuffering()` first and rewind the stream, otherwise the second read returns zero bytes. In .NET 10, minimal APIs also support DataAnnotations validation via `AddValidation()`, so `[Required]` and `[Range]` on a bound record produce a 400 without a manual check.

// One body parameter only; everything else is inferred
app.MapPost("/orders/{customerId:guid}", async (
    Guid customerId,                       // route
    [FromQuery] bool notify,               // query
    [FromHeader(Name = "Idempotency-Key")] string key,
    CreateOrder body,                      // body (complex type)
    IOrderService svc,                     // DI
    CancellationToken ct) =>
{
    var id = await svc.CreateAsync(customerId, body, key, notify, ct);
    return Results.Created($"/orders/{id}", new { id });
});

// Make a custom type bindable from the query string
public readonly record struct Pincode(string Value)
{
    public static bool TryParse(string? s, out Pincode result)
    {
        result = default;
        if (s is null || s.Length != 6 || !s.All(char.IsDigit)) return false;
        result = new Pincode(s);
        return true;
    }
}

Key Points

  • Explicit attribute beats convention; complex types default to the body
  • Only one parameter can bind from the body, it is a single-read stream
  • TryParse makes a type query/route bindable; BindAsync gives full control
  • Call EnableBuffering() before reading the body in middleware
Q9

How do you return consistent error responses across an ASP.NET Core API using ProblemDetails and IExceptionHandler?

BasicError Handling

Answer

`ProblemDetails` is the RFC 7807 media type (`application/problem+json`) that ASP.NET Core uses as its standard error envelope: `type`, `title`, `status`, `detail`, `instance`, plus arbitrary extensions. Calling `builder.Services.AddProblemDetails()` makes the framework generate that shape for status-code-only responses, and `app.UseExceptionHandler()` converts unhandled exceptions into a 500 ProblemDetails instead of leaking a stack trace. Since .NET 8 the clean extension point is the `IExceptionHandler` interface: implement `TryHandleAsync`, register it with `AddExceptionHandler<T>()`, and return true when your handler has written the response.

Multiple handlers run in registration order until one returns true, so you can map domain exceptions to specific status codes and let a final catch-all handle the rest. Three production details are worth stating in an interview. First, never put exception messages into `detail` for 500s, because ORM and driver exceptions routinely contain connection strings, table names and parameter values.

Second, always attach a correlation identifier as an extension (`traceId` from `Activity.Current?.Id` or `HttpContext.TraceIdentifier`) so a support ticket maps to a log line. Third, `UseDeveloperExceptionPage` is only registered in Development by default and should stay that way; shipping it to production is a real information disclosure finding in security audits. Business rule violations should throw typed exceptions that map to 409 or 422 rather than returning 200 with an error field, because a 200 defeats client retries, alerting and HTTP-level metrics.

public sealed class DomainExceptionHandler(ILogger<DomainExceptionHandler> log)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not DomainException dex) return false;

        log.LogWarning(dex, "Domain rule {Code} violated", dex.Code);

        await Results.Problem(
            title: "Request could not be completed",
            detail: dex.SafeMessage,
            statusCode: StatusCodes.Status409Conflict,
            extensions: new Dictionary<string, object?>
            {
                ["code"] = dex.Code,
                ["traceId"] = Activity.Current?.Id ?? ctx.TraceIdentifier,
            }).ExecuteAsync(ctx);

        return true;
    }
}

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();
app.UseExceptionHandler();
💡 Pro Tip: Always include a traceId extension in your ProblemDetails. It turns "the API failed" support tickets into a one-query log lookup.
Q10

How does endpoint routing work in ASP.NET Core, including route constraints and MapGroup?

BasicRouting

Answer

Routing is two-phase. `UseRouting` matches the incoming path against the endpoint data source and stores the selected `Endpoint` on the `HttpContext`; `UseEndpoints` (implicit at the end of the pipeline in the minimal hosting model, or explicit via `MapControllers` and friends) executes it. Everything registered between those two points can inspect endpoint metadata, which is how authorization reads `[Authorize]` and how CORS reads its policy. Route templates support literals, parameters (`{id}`), optional parameters (`{id?}`), defaults (`{page=1}`), catch-alls (`{**slug}`) and inline constraints (`{id:int}`, `{id:guid}`, `{code:length(6)}`, `{price:min(1)}`).

Constraints are matching rules, not validation: a request that fails a constraint yields a 404, not a 400, which surprises people who expect a validation error. Precedence goes from most literal segments to least, so `/orders/pending` beats `/orders/{id}` regardless of registration order, and two endpoints of equal precedence throw an `AmbiguousMatchException` at request time rather than at startup. `MapGroup` (added in .NET 7) creates a route group so a common prefix, filters, authorization and OpenAPI metadata are applied once to many endpoints, which is the minimal API answer to a controller-level `[Route]` and `[Authorize]`. Endpoint filters (`AddEndpointFilter`) run around the handler and are the minimal API counterpart of MVC action filters, useful for validation, idempotency checks and per-endpoint logging.

var orders = app.MapGroup("/api/v1/orders")
    .RequireAuthorization("OrdersWrite")
    .WithTags("Orders")
    .AddEndpointFilter<IdempotencyFilter>();

// {id:guid} is a MATCH constraint: a non-guid gives 404, not 400
orders.MapGet("/{id:guid}", async (Guid id, IOrderService s, CancellationToken ct)
    => await s.FindAsync(id, ct) is { } o ? Results.Ok(o) : Results.NotFound());

// More literal segments win regardless of registration order
orders.MapGet("/pending", (IOrderService s, CancellationToken ct) => s.PendingAsync(ct));

orders.MapGet("/{id:guid}/invoice/{**path}", (Guid id, string path) => Results.Ok(new { id, path }));

// Paging with defaults and range constraints
app.MapGet("/api/v1/reports/{page:int:min(1)=1}", (int page) => Results.Ok(page));

Key Points

  • UseRouting selects the endpoint; middleware after it can read metadata
  • Inline constraints affect matching only, so failures return 404
  • Literal segments outrank parameter segments in precedence
  • MapGroup applies prefix, auth, filters and OpenAPI tags to many endpoints
Q11

How do you bind strongly typed settings and make the application fail fast when configuration is wrong?

BasicConfiguration

Answer

Bind a configuration section to a POCO with the options pattern rather than calling `IConfiguration["Some:Key"]` inside services. `builder.Services.AddOptions<PaymentOptions>().Bind(builder.Configuration.GetSection("Payments")).ValidateDataAnnotations().ValidateOnStart()` gives you three things: typed access, declarative validation through DataAnnotations attributes, and a startup crash if validation fails. `ValidateOnStart()` is the piece most teams miss; without it, validation runs lazily on first resolution, so a bad connection string or a missing API key surfaces as a 500 on a real user request several minutes after deployment instead of failing the health check and stopping the rollout. Three consumption interfaces exist and the difference matters. `IOptions<T>` is a singleton computed once, so it never picks up file changes and cannot be injected with per-request values. `IOptionsSnapshot<T>` is scoped and recomputed once per request, which is what you want when `reloadOnChange` is enabled and an operator edits a ConfigMap. `IOptionsMonitor<T>` is a singleton that exposes `CurrentValue` plus an `OnChange` callback, and is the only one safely usable from a singleton or a `BackgroundService`. Also use `ValidateOnStart` together with a custom `IValidateOptions<T>` for cross-field rules that attributes cannot express, such as "retry count must be zero when the endpoint is non-idempotent". Never log the bound options object wholesale, because secrets end up in your log aggregator.

public sealed class PaymentOptions
{
    public const string Section = "Payments";

    [Required, Url]
    public string BaseUrl { get; init; } = default!;

    [Range(1, 120)]
    public int TimeoutSeconds { get; init; } = 30;

    [Required, MinLength(20)]
    public string ApiKey { get; init; } = default!;
}

builder.Services.AddOptions<PaymentOptions>()
    .Bind(builder.Configuration.GetSection(PaymentOptions.Section))
    .ValidateDataAnnotations()
    .Validate(o => !o.BaseUrl.Contains("sandbox") || !builder.Environment.IsProduction(),
              "Sandbox payment URL configured in Production")
    .ValidateOnStart();

// Safe from a singleton or BackgroundService
public sealed class PaymentClient(IOptionsMonitor<PaymentOptions> opts)
{
    public string Endpoint => opts.CurrentValue.BaseUrl;
}
💡 Pro Tip: ValidateOnStart turns a silent misconfiguration into a failed container start, which your orchestrator will roll back automatically. That is exactly the behaviour you want.
Q12

How does ILogger work in .NET: categories, levels, filters, scopes and message templates?

BasicLogging

Answer

`ILogger<T>` resolves a logger whose category is the fully qualified name of `T`, and categories are how filtering works. Configuration under `Logging:LogLevel` maps category prefixes to minimum levels, so `"Microsoft.AspNetCore": "Warning"` silences per-request info noise while `"MyApp": "Debug"` keeps your own detail. Levels run Trace, Debug, Information, Warning, Error, Critical, None.

Providers (Console, Debug, OpenTelemetry, Serilog, Seq) each receive the log record and can have their own filters, which is why a message can appear in one sink and not another. The single most important habit is structured logging: pass a message template with named placeholders plus arguments, never an interpolated string. `logger.LogInformation("Order {OrderId} settled in {Elapsed}ms", id, ms)` preserves `OrderId` and `Elapsed` as queryable fields in Seq, Elastic or SigNoz, whereas `logger.LogInformation($"Order {id} settled")` produces an opaque string and a new unique message template on every call, which destroys aggregation. Placeholder order matters and names do not have to match variable names; they are positional. `BeginScope` attaches key-value pairs to every log inside a using block, which is how you stamp a tenant or correlation id across a request without threading it through method signatures.

For hot paths use the `LoggerMessage` source generator (a partial method with `[LoggerMessage]`), which avoids boxing and skips formatting entirely when the level is disabled. Always guard expensive argument computation with `logger.IsEnabled(LogLevel.Debug)`.

public sealed partial class OrderService(ILogger<OrderService> log)
{
    // Source-generated, allocation-free, no formatting when Info is disabled
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Order {OrderId} settled for {Amount} in {ElapsedMs}ms")]
    private partial void OrderSettled(string orderId, decimal amount, long elapsedMs);

    public async Task SettleAsync(string orderId, decimal amount, CancellationToken ct)
    {
        using var scope = log.BeginScope(new Dictionary<string, object>
        {
            ["OrderId"] = orderId,
            ["TenantId"] = CurrentTenant.Id,
        });

        var sw = Stopwatch.StartNew();
        await GatewayAsync(ct);
        OrderSettled(orderId, amount, sw.ElapsedMilliseconds);
    }
}

// appsettings.json
// "Logging": { "LogLevel": {
//   "Default": "Information",
//   "Microsoft.AspNetCore": "Warning",
//   "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }

Key Points

  • Category = type name; filters are configured by category prefix
  • Use message templates with named placeholders, never string interpolation
  • BeginScope stamps correlation data onto every nested log entry
  • [LoggerMessage] source generator removes allocation on hot paths
Q13

What does `dotnet publish` produce, and how do framework-dependent, self-contained, single-file and trimmed outputs differ?

BasicDeployment

Answer

`dotnet publish -c Release -o out` runs a Release build and copies everything needed to run into one folder: your assemblies, referenced NuGet DLLs, `App.deps.json`, `App.runtimeconfig.json`, static assets from wwwroot, and an apphost executable. The default is framework-dependent: the output is portable IL that requires a matching runtime installed on the target machine, which keeps the payload small (a few megabytes) and lets the machine take runtime security patches independently. Adding `-r linux-x64 --self-contained true` bundles the runtime itself, producing roughly seventy megabytes but removing any runtime prerequisite from the host; the cost is that you now own patching, because a CVE in the runtime means a rebuild and redeploy of every service. `-p:PublishSingleFile=true` packs that output into a single executable that extracts or memory-maps its contents at start, which is convenient for CLI tools and awkward for servers because it complicates layer caching in Docker. `-p:PublishTrimmed=true` runs the IL trimmer to remove unreferenced code, which is only safe when the app avoids unbounded reflection; the build emits IL2xxx trim warnings you must not ignore, because the failure mode is a `MissingMethodException` at runtime rather than a build error. `-p:PublishReadyToRun=true` adds ahead-of-time compiled native code alongside IL, cutting startup time at the cost of a larger artifact. For containers, framework-dependent publish onto the `mcr.microsoft.com/dotnet/aspnet` base image is the default sensible choice.

# Default: portable, needs the ASP.NET Core runtime on the host
dotnet publish src/Api -c Release -o out

# Self-contained, no runtime prerequisite, you own CVE patching
dotnet publish src/Api -c Release -r linux-x64 --self-contained true -o out

# Faster startup via precompiled native code alongside IL
dotnet publish src/Api -c Release -r linux-x64 \
  -p:PublishReadyToRun=true -p:TieredPGO=true -o out

# Small CLI tool: single file, trimmed (watch the IL2xxx warnings)
dotnet publish src/Tool -c Release -r linux-x64 --self-contained true \
  -p:PublishSingleFile=true -p:PublishTrimmed=true -o out

Key Points

  • Framework-dependent is the default and the right choice for containers
  • Self-contained removes the runtime prerequisite but transfers patching to you
  • PublishTrimmed breaks reflection-heavy code; IL2xxx warnings are real bugs
  • PublishReadyToRun trades artifact size for lower startup latency
Q14

How do you lay out a multi-project .NET solution with the CLI, and what does Central Package Management give you?

BasicTooling

Answer

The CLI covers the whole lifecycle: `dotnet new sln -n Shop`, `dotnet new webapi -o src/Shop.Api`, `dotnet new classlib -o src/Shop.Domain`, `dotnet new xunit -o tests/Shop.Api.Tests`, then `dotnet sln add` for each project and `dotnet add reference` to wire dependencies. A conventional layout is `src/` for shippable projects and `tests/` for test projects, with the dependency direction pointing inward: Api references Application, Application references Domain, and Infrastructure references Domain while being referenced only by Api through DI registration. `dotnet new list` shows available templates and `dotnet new gitignore` and `dotnet new editorconfig` seed the repository conventions. Two files belong at the repository root. `Directory.Build.props` sets properties for every project at once (TargetFramework, Nullable, LangVersion, TreatWarningsAsErrors, deterministic builds), which stops the slow drift where one project has nullable disabled. `Directory.Packages.props` with `<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>` enables Central Package Management: individual csproj files list `<PackageReference Include="Serilog" />` with no version, and every version lives in one file.

That removes the classic bug where two projects reference different versions of the same package and NuGet silently unifies to the higher one at build time, producing behaviour that differs from what any single csproj claims. Pair it with `dotnet list package --vulnerable --include-transitive` in CI, and `dotnet restore --locked-mode` with committed `packages.lock.json` files when you need reproducible restores for audits.

dotnet new sln -n Shop
dotnet new webapi   -o src/Shop.Api      --use-controllers
dotnet new classlib -o src/Shop.Domain
dotnet new xunit    -o tests/Shop.Api.Tests
dotnet sln add src/**/*.csproj tests/**/*.csproj
dotnet add src/Shop.Api reference src/Shop.Domain
dotnet add tests/Shop.Api.Tests reference src/Shop.Api

# CI hygiene
dotnet format --verify-no-changes
dotnet list package --vulnerable --include-transitive
dotnet test --collect:"XPlat Code Coverage"

<!-- Directory.Packages.props at the repo root -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
    <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
  </ItemGroup>
</Project>
Q15

How should DbContext be registered in ASP.NET Core, and what breaks with the wrong lifetime or with AddDbContextPool?

BasicEntity Framework Core

Answer

`AddDbContext<AppDbContext>(...)` registers the context as scoped, meaning one instance per HTTP request. That is deliberate: `DbContext` is not thread-safe, it caches every entity it loads in the change tracker, and it holds a database connection open only while a query or a save is executing. Registering it as a singleton is the classic mistake and it produces two distinct failures: concurrent requests hitting the same instance throw "A second operation was started on this context instance before a previous operation completed", and the change tracker accumulates every entity the process has ever touched, so memory climbs until the container is OOMKilled.

Injecting a scoped context into a singleton service produces the same result more subtly, which is why scope validation should be enabled everywhere. Inside a `BackgroundService`, which is a singleton, use `IDbContextFactory<AppDbContext>` (registered by `AddDbContextFactory`) or create a scope per iteration. `AddDbContextPool` reuses context instances from a pool and resets their state between requests, saving the allocation and internal service-provider setup cost; it is measurably faster on high-throughput APIs, but it forbids a context with injected per-request state in its constructor, because the pooled instance outlives the request. If your context takes a tenant id or a current-user accessor in the constructor, pooling will hand you a stale one. Also set `MaxPoolSize` on the pool with the database connection limit in mind, and remember that EF Core pooling and ADO.NET connection pooling are two independent pools.

builder.Services.AddDbContext<AppDbContext>(o => o
    .UseNpgsql(cs, npg =>
    {
        npg.EnableRetryOnFailure(maxRetryCount: 3);
        npg.CommandTimeout(30);
    })
    .EnableDetailedErrors(builder.Environment.IsDevelopment())
    .EnableSensitiveDataLogging(builder.Environment.IsDevelopment()));

// Needed by singletons / BackgroundService / parallel work
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseNpgsql(cs));

public sealed class NightlyJob(IDbContextFactory<AppDbContext> factory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await using var db = await factory.CreateDbContextAsync(ct);
        var stale = await db.Orders.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
                                   .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "expired"), ct);
    }
}
💡 Pro Tip: EnableSensitiveDataLogging writes parameter values (including PII and card tokens) into your logs. Gate it on IsDevelopment and never let it reach production.
Q16

`Database.Migrate()`, `EnsureCreated()` or an idempotent SQL script: how should EF Core schema changes actually reach production?

BasicEntity Framework Core

Answer

`EnsureCreated()` creates the schema directly from the model with no migration history table. It is fine for tests and throwaway local databases and completely wrong for production, because there is no path from a database it created to a migrated one: EF has no `__EFMigrationsHistory` rows to reason about, and your first real migration will fail. `Database.Migrate()` applies pending migrations at application startup. It works for a single instance but is dangerous the moment you scale out, because several replicas start simultaneously and race on the same DDL, and because it requires the application's runtime database user to hold schema-alter privileges, which most security reviews reject.

The production-grade approach is to generate an idempotent SQL script with `dotnet ef migrations script --idempotent --output migrate.sql` (optionally `--from` and `--to` for a specific range) and run it as a separate deployment step: a CI job, a Kubernetes init container or Job, or a DBA-reviewed change. The script wraps each migration in a check against the history table, so re-running it is safe. Bundles are the middle ground: `dotnet ef migrations bundle` produces a self-contained executable that applies migrations without needing the SDK on the deployment host.

Always review generated migrations by hand before merging, because EF cheerfully generates a drop-and-recreate for a rename, which loses data. The design-time tooling comes from the `Microsoft.EntityFrameworkCore.Design` package plus `dotnet tool install --global dotnet-ef`.

dotnet tool install --global dotnet-ef
dotnet ef migrations add AddOrderIdempotencyKey -p src/Shop.Infrastructure -s src/Shop.Api

# Local only
dotnet ef database update -s src/Shop.Api

# Production: reviewable, re-runnable SQL, applied by a separate deploy step
dotnet ef migrations script --idempotent -s src/Shop.Api -o artifacts/migrate.sql

# Or a self-contained executable for hosts without the SDK
dotnet ef migrations bundle -s src/Shop.Api -o artifacts/efbundle
./efbundle --connection "$CONNECTION_STRING"

# Undo a migration that has not shipped yet
dotnet ef migrations remove -s src/Shop.Api

Key Points

  • EnsureCreated is test-only and leaves no migration history
  • Database.Migrate() races across replicas and needs DDL rights at runtime
  • Idempotent scripts or bundles applied as a deploy step are the safe pattern
  • Always read the generated migration; renames can become drop-and-create
Q17

How do you configure CORS correctly in ASP.NET Core, and why does AllowAnyOrigin break with credentials?

BasicSecurity

Answer

Register named policies with `AddCors` and apply them with `app.UseCors("name")` or per endpoint with `RequireCors("name")`. A policy declares allowed origins, headers, methods, whether credentials are allowed, how long the preflight result may be cached (`SetPreflightMaxAge`), and which response headers the browser may expose to JavaScript. The rule people hit is that `AllowAnyOrigin()` and `AllowCredentials()` are mutually exclusive: the CORS specification forbids the wildcard `Access-Control-Allow-Origin: *` on a credentialed request, and ASP.NET Core throws at startup if you configure both.

For cookie-based or `Authorization`-carrying browser calls you must list origins explicitly with `WithOrigins(...)`. For dynamic origins such as per-tenant subdomains, use `SetIsOriginAllowed(origin => ...)` with a real check against your tenant list, never `SetIsOriginAllowed(_ => true)`, which is a wildcard wearing a disguise. Placement in the pipeline matters: `UseCors` must run before anything that can short-circuit the request, otherwise an error response goes back without CORS headers and the browser reports a cross-origin failure that hides the real 401 or 500 underneath. Two more details worth mentioning: custom response headers such as `X-Total-Count` are invisible to `fetch` unless you list them in `WithExposedHeaders`, and CORS is a browser mechanism only, so it is not a substitute for authentication, authorization or CSRF defence on the server.

builder.Services.AddCors(o =>
{
    o.AddPolicy("spa", p => p
        .WithOrigins("https://app.example.in", "https://admin.example.in")
        .AllowCredentials()                       // requires explicit origins
        .WithMethods("GET", "POST", "PATCH", "DELETE")
        .WithHeaders("Authorization", "Content-Type", "Idempotency-Key")
        .WithExposedHeaders("X-Total-Count", "X-RateLimit-Remaining")
        .SetPreflightMaxAge(TimeSpan.FromHours(24)));

    o.AddPolicy("tenants", p => p
        .SetIsOriginAllowed(origin =>
            Uri.TryCreate(origin, UriKind.Absolute, out var u) &&
            u.Host.EndsWith(".example.in", StringComparison.OrdinalIgnoreCase))
        .AllowCredentials()
        .AllowAnyHeader()
        .AllowAnyMethod());
});

app.UseRouting();
app.UseCors("spa");   // before auth and before anything that short-circuits
Q18

What do AddAuthentication and AddAuthorization actually register, and how do schemes, policies and roles differ?

BasicSecurity

Answer

Authentication answers "who is this caller" and authorization answers "may they do this". `AddAuthentication(defaultScheme)` registers the authentication services and one or more handlers, each identified by a scheme name such as `JwtBearerDefaults.AuthenticationScheme`, `CookieAuthenticationDefaults.AuthenticationScheme` or a custom `"ApiKey"`. `app.UseAuthentication()` runs the default scheme's handler, which parses the credential and, on success, sets `HttpContext.User` to a `ClaimsPrincipal`. `AddAuthorization` registers the policy engine, and `app.UseAuthorization()` evaluates the endpoint's authorization metadata against that principal. `[Authorize]` with no arguments means "any authenticated user under the default policy". `[Authorize(Roles = "Admin,Finance")]` is a claim check against `ClaimTypes.Role` and is coarse; role strings scattered across controllers are hard to change later. Policies are the better tool: define named policies in one place with requirements (claims, roles, or a custom `IAuthorizationRequirement` plus handler) and reference the name from endpoints. For resource-based decisions such as "the caller owns this order", roles cannot help; inject `IAuthorizationService` and call `AuthorizeAsync(User, resource, policy)` inside the handler where the resource is loaded.

Two behaviours are commonly confused: an unauthenticated request gets 401 with a `WWW-Authenticate` challenge, while an authenticated but unpermitted request gets 403. If you see 401 where you expect 403, the token is not being parsed at all, which usually means the wrong scheme or middleware ordering.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = builder.Configuration["Auth:Authority"];
        o.Audience = "shop-api";
    })
    .AddScheme<ApiKeyOptions, ApiKeyHandler>("ApiKey", _ => { });

builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("OrdersWrite", p => p
        .RequireAuthenticatedUser()
        .RequireClaim("scope", "orders.write"));

    o.AddPolicy("FinanceOnly", p => p.RequireRole("Finance"));

    // Accept either JWT or API key for machine-to-machine endpoints
    o.AddPolicy("Machine", p => p
        .AddAuthenticationSchemes("ApiKey", JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser());
});

app.MapPost("/orders", CreateOrder).RequireAuthorization("OrdersWrite");

Key Points

  • Schemes identify handlers; UseAuthentication populates HttpContext.User
  • 401 means no valid credential, 403 means valid credential without permission
  • Named policies beat scattered role strings for maintainability
  • Use IAuthorizationService for resource-based ownership checks
Q19

How does JWT bearer validation work in ASP.NET Core, and what are the misconfigurations that produce a silent 401?

IntermediateSecurity

Answer

`AddJwtBearer` builds a handler that reads the `Authorization: Bearer` header, then validates the token against `TokenValidationParameters`: signature (using keys fetched from `Authority` + `/.well-known/openid-configuration` and the JWKS endpoint, or from a configured `IssuerSigningKey`), issuer, audience, lifetime, and token type. Failures produce a 401 with a terse `WWW-Authenticate: Bearer error="invalid_token"` header and, by default, nothing useful in your logs, which is why so many teams burn hours here. Turn on `o.Events` and log `OnAuthenticationFailed` and `OnChallenge`, and set `o.IncludeErrorDetails = true` in non-production to get the actual reason back in the header.

The recurring causes are: audience mismatch (`aud` in the token differs from `o.Audience`); issuer mismatch, often just a trailing slash difference between `iss` and `Authority`; clock skew, where the default `ClockSkew` of five minutes hides small drift but a token issued by a server with a larger drift still fails; the JWKS fetch failing because the container has no egress to the identity provider, which manifests as every request failing after a key rotation; and `RequireHttpsMetadata` being true against an HTTP-only local identity server. Also know that Microsoft's handler historically remaps standard JWT claim names to long WS-Federation URIs, so `User.FindFirst("sub")` returns null while `ClaimTypes.NameIdentifier` works. Setting `o.MapInboundClaims = false` keeps the original short claim names, which is what most modern APIs want.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = cfg["Auth:Authority"];
        o.Audience = "shop-api";
        o.RequireHttpsMetadata = !env.IsDevelopment();
        o.MapInboundClaims = false;            // keep sub, scope, roles as-is
        o.IncludeErrorDetails = !env.IsProduction();

        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromSeconds(30),   // default is 5 minutes
            NameClaimType = "sub",
            RoleClaimType = "roles",
        };

        o.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = ctx =>
            {
                ctx.HttpContext.RequestServices
                    .GetRequiredService<ILoggerFactory>()
                    .CreateLogger("Jwt")
                    .LogWarning(ctx.Exception, "JWT rejected");
                return Task.CompletedTask;
            }
        };
    });
💡 Pro Tip: Before debugging your code, paste the token into a decoder and compare iss and aud character by character with your configuration. A trailing slash on the issuer is the single most common cause.
Q20

Why do cookies and antiforgery tokens start failing the moment you scale an ASP.NET Core app to two replicas?

IntermediateProduction Failure Modes

Answer

ASP.NET Core encrypts authentication cookies, antiforgery tokens, `TempData` and anything else you protect using the Data Protection stack. Each application generates a key ring and, by default, persists it to the local filesystem (`~/.aspnet/DataProtection-Keys` on Linux, or the user profile on Windows) with keys additionally protected by DPAPI on Windows. In a container that directory is ephemeral, and across replicas it is different per pod.

The result is that a cookie encrypted by pod A cannot be decrypted by pod B, so users get logged out at random as the load balancer rotates them, form posts fail antiforgery validation with 400, and a restart logs everybody out. The log line to look for is a warning about being unable to unprotect the payload, or "The key {guid} was not found in the key ring". The fix has two halves and both are required.

First, persist the key ring to shared storage: `PersistKeysToAzureBlobStorage`, `PersistKeysToStackExchangeRedis`, `PersistKeysToDbContext`, or a mounted volume, ideally with `ProtectKeysWithAzureKeyVault` or a certificate so keys are not at rest in plaintext. Second, call `SetApplicationName` with the same value in every replica, because the application name is part of the key derivation and defaults to the content root path, which differs between environments. If two logically different apps share a key ring intentionally (for example an MVC app and its API), they must also share the application name. This question comes up constantly because it is invisible on a developer laptop and shows up on the first day of production traffic.

builder.Services.AddDataProtection()
    .SetApplicationName("shop-web")                       // identical in every replica
    .PersistKeysToStackExchangeRedis(
        ConnectionMultiplexer.Connect(cfg["Redis:Configuration"]!),
        "DataProtection-Keys")
    .SetDefaultKeyLifetime(TimeSpan.FromDays(90));

// Azure variant
// .PersistKeysToAzureBlobStorage(blobUri, credential)
// .ProtectKeysWithAzureKeyVault(keyUri, credential);

// Symptom to grep for in logs:
// warn: Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider
//       The key {xxxxxxxx-...} was not found in the key ring.

Key Points

  • Default key ring is per-pod and ephemeral in containers
  • Symptoms: random logouts, 400 on form posts, sessions lost on restart
  • Persist keys to Redis, Blob Storage or a database, and encrypt them at rest
  • SetApplicationName must match across every replica of the same app
Q21

How do you detect and fix N+1 queries and cartesian explosion in EF Core?

IntermediateEntity Framework Core

Answer

N+1 happens when a query loads N parents and then a navigation property is touched inside a loop, issuing one query per parent. In EF Core lazy loading is off unless you install the proxies package and call `UseLazyLoadingProxies`, so the more common modern cause is a loop that calls a repository method per item, or a projection that hits the database inside a `foreach`. Detection is straightforward once you look: enable `LogTo(Console.WriteLine, LogLevel.Information)` or set `Microsoft.EntityFrameworkCore.Database.Command` to Information and count the statements for a single request; better still, add EF Core instrumentation to OpenTelemetry so each request span shows its child DB spans in SigNoz or Jaeger.

The fix is `Include`/`ThenInclude` for eager loading, or a projection with `Select` that pulls only the columns you need, which is usually faster because it avoids materialising entities and the change tracker entirely. The second problem appears once you fix the first: multiple collection `Include`s in one query produce a single join whose row count multiplies (cartesian explosion), so three collections of ten rows each return a thousand duplicated rows. `AsSplitQuery()` tells EF to issue one query per collection instead, trading a round trip for a much smaller result set; it can be made the default per context with `UseQuerySplittingBehavior`. Note that split queries are not executed in a single snapshot unless you wrap them in a transaction, so concurrent writes can produce a slightly inconsistent graph. Add `AsNoTracking()` for read-only paths and consider a compiled query for the hottest ones.

// N+1: one query for orders, then one per order for lines
var orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync(ct);
foreach (var o in orders)
    total += (await db.Lines.Where(l => l.OrderId == o.Id).ToListAsync(ct)).Sum(l => l.Amount);

// Fix 1: project exactly what you need, no tracking, one round trip
var summary = await db.Orders
    .Where(o => o.CustomerId == id)
    .Select(o => new OrderSummary(o.Id, o.PlacedAt, o.Lines.Sum(l => l.Amount)))
    .AsNoTracking()
    .ToListAsync(ct);

// Fix 2: eager load, and split when several collections are involved
var graph = await db.Orders
    .Include(o => o.Lines)
    .Include(o => o.Shipments)
    .Include(o => o.Refunds)
    .AsSplitQuery()          // avoids Lines x Shipments x Refunds row blowup
    .AsNoTracking()
    .FirstOrDefaultAsync(o => o.Id == orderId, ct);

// Make the SQL visible while you investigate
options.LogTo(Console.WriteLine, LogLevel.Information);
💡 Pro Tip: Put an assertion in integration tests that a given endpoint issues no more than a fixed number of SQL commands. It catches N+1 regressions that code review misses.
Q22

How do you handle concurrent updates in EF Core so two users do not silently overwrite each other?

IntermediateEntity Framework Core

Answer

By default EF Core issues `UPDATE ... WHERE Id = @id`, so the last writer wins and the first user's change vanishes with no error. Optimistic concurrency fixes this by adding the original value of a version column to the WHERE clause: if zero rows are affected, EF throws `DbUpdateConcurrencyException`.

On SQL Server the idiomatic mechanism is a `rowversion` column mapped with `[Timestamp]` or `IsRowVersion()`, which the database bumps automatically on every update. On PostgreSQL you use the system `xmin` column via `UseXminAsConcurrencyToken()`, or a plain integer version property marked `IsConcurrencyToken()` that you increment yourself. You can also mark individual properties as concurrency tokens when only certain fields conflict.

Handling the exception is where candidates are separated: catching it and blindly retrying reintroduces the lost update. The correct pattern is to read the current database values from `ex.Entries`, decide on a resolution strategy (client wins, store wins, or merge field by field), set `OriginalValues` to the freshly read values so the next save has a valid token, and retry a bounded number of times. For an API, the honest response is often a 409 with the current representation so the client can re-present the conflict to the user.

Map the version token to an HTTP `ETag` and require `If-Match` on updates, which pushes the same optimistic concurrency out to your API contract. Where the operation is a pure counter or balance adjustment, prefer a single atomic `ExecuteUpdateAsync` over read-modify-write, so no concurrency token is needed at all.

public sealed class Order
{
    public Guid Id { get; set; }
    public string Status { get; set; } = "pending";

    [Timestamp]                              // SQL Server rowversion
    public byte[] Version { get; set; } = default!;
}

// PostgreSQL alternative:
// modelBuilder.Entity<Order>().UseXminAsConcurrencyToken();

try
{
    order.Status = "shipped";
    await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
    foreach (var entry in ex.Entries)
    {
        var current = await entry.GetDatabaseValuesAsync(ct);
        if (current is null) return Results.NotFound();      // deleted meanwhile
        entry.OriginalValues.SetValues(current);             // refresh the token
    }
    return Results.Conflict(new { message = "Order changed since you loaded it" });
}

// No read-modify-write needed for pure increments
await db.Inventory.Where(i => i.Sku == sku)
    .ExecuteUpdateAsync(s => s.SetProperty(i => i.Reserved, i => i.Reserved + 1), ct);
Q23

What does EnableRetryOnFailure actually do, and why does it throw when you use an explicit transaction?

IntermediateEntity Framework Core

Answer

`EnableRetryOnFailure` installs an execution strategy that catches known transient errors from the provider (deadlock victims, connection resets, Azure SQL throttling with error 40501, PostgreSQL connection failures) and retries the operation with exponential backoff. It is essentially mandatory against managed cloud databases, where a failover or a maintenance event will drop connections and you would otherwise surface 500s to users. The important limitation: the strategy can only retry an operation it fully owns.

When you open a transaction yourself with `db.Database.BeginTransactionAsync()`, EF cannot safely replay it, because a retry would re-execute only part of the work, so it throws an `InvalidOperationException` telling you to use `CreateExecutionStrategy`. The correct pattern is to ask the context for its execution strategy and pass it a delegate that opens the transaction, performs all the work and commits; the strategy then retries the whole delegate as one unit. Your delegate must therefore be idempotent, which in practice means it should not depend on in-memory state mutated by a previous attempt.

Two further gotchas: retries multiply your effective timeout, so a three-retry policy with a thirty second command timeout can hold a request for two minutes unless you also enforce a request-level cancellation token, and retrying a `SaveChanges` that already partially succeeded is impossible to distinguish from one that failed outright, which is exactly why the outbox pattern and idempotency keys exist for anything that touches money. Always pass the request `CancellationToken` into every async EF call so a client disconnect actually stops the retry loop.

options.UseSqlServer(cs, sql => sql.EnableRetryOnFailure(
    maxRetryCount: 5,
    maxRetryDelay: TimeSpan.FromSeconds(10),
    errorNumbersToAdd: null));

// WRONG: throws InvalidOperationException when a retry strategy is registered
// await using var tx = await db.Database.BeginTransactionAsync(ct);

// RIGHT: the strategy owns and can replay the whole unit of work
var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);

    db.Orders.Add(order);
    db.OutboxMessages.Add(OutboxMessage.For(order));
    await db.SaveChangesAsync(ct);

    await tx.CommitAsync(ct);
});

Key Points

  • Retries only transient provider errors, with exponential backoff
  • Explicit BeginTransaction plus retry strategy throws by design
  • Wrap the whole unit of work in CreateExecutionStrategy().ExecuteAsync
  • Retries multiply latency, so keep a request-level cancellation token
Q24

Compare IMemoryCache, IDistributedCache, HybridCache and output caching, and explain cache stampede.

IntermediateCaching

Answer

`IMemoryCache` is an in-process dictionary with expiration and eviction. It is the fastest option and the most dangerous at scale, because each replica has its own copy, so an invalidation on one pod leaves stale data on the others, and an unbounded cache is a memory leak; always set `SizeLimit` on the cache plus a `Size` on every entry, or at minimum an absolute expiration. `IDistributedCache` is a byte-array interface over Redis or SQL Server, giving one shared copy across replicas at the cost of a network hop and serialization. `HybridCache`, added in .NET 9 via `Microsoft.Extensions.Caching.Hybrid`, combines both: an L1 in-process cache in front of an L2 distributed cache, with built-in serialization, tag-based invalidation through `RemoveByTagAsync`, and, crucially, stampede protection. Output caching (`AddOutputCache`, `.CacheOutput()`) caches the whole HTTP response server-side and is keyed by route and configurable varies; response caching by contrast just emits `Cache-Control` headers and relies on the client or a proxy to honour them.

Cache stampede (also called dogpile) is the failure where a popular key expires and hundreds of concurrent requests all miss, all hit the database, and the database falls over exactly when traffic is highest. The classic `if (!cache.TryGetValue(...)) { load; set; }` pattern has this bug. `HybridCache.GetOrCreateAsync` coalesces concurrent callers for the same key into one factory invocation, which is the main reason to adopt it over hand-rolled code. If you stay on `IDistributedCache`, add a per-key `SemaphoreSlim` or a Redis lock, and jitter your expirations so a batch of keys written together does not expire together.

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = cfg["Redis:Configuration"]);
builder.Services.AddHybridCache(o =>
{
    o.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        LocalCacheExpiration = TimeSpan.FromSeconds(30),   // L1
        Expiration = TimeSpan.FromMinutes(10),             // L2
    };
});

public sealed class CatalogService(HybridCache cache, AppDbContext db)
{
    public ValueTask<ProductDto> GetAsync(string sku, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"product:{sku}",
            (sku, db),
            static async (state, token) => await state.db.Products
                .Where(p => p.Sku == state.sku)
                .Select(p => new ProductDto(p.Sku, p.Name, p.Price))
                .AsNoTracking()
                .FirstAsync(token),
            tags: ["catalog", $"sku:{sku}"],
            cancellationToken: ct);

    public ValueTask InvalidateAsync(string sku, CancellationToken ct)
        => cache.RemoveByTagAsync($"sku:{sku}", ct);
}

// Whole-response caching for anonymous read endpoints
app.MapGet("/api/catalog", GetCatalog)
   .CacheOutput(p => p.Expire(TimeSpan.FromSeconds(60)).SetVaryByQuery("page"));
💡 Pro Tip: Add random jitter of ten to twenty percent to cache expirations. Without it, everything warmed during a deploy expires in the same second and you get a synchronised stampede.
Q25

How does the built-in rate limiting middleware work, and how do you pick between fixed window, sliding window, token bucket and concurrency limiters?

IntermediateResilience

Answer

`AddRateLimiter` plus `app.UseRateLimiter()` gives you first-party rate limiting with four algorithms. A fixed window counts requests per interval and is simple but allows a double burst across a boundary: 100 requests at 11:59:59 and another 100 at 12:00:00. A sliding window divides the interval into segments and rolls them, smoothing that boundary at the cost of more state.

A token bucket refills at a steady rate and permits controlled bursts up to the bucket size, which is usually the best fit for public APIs. A concurrency limiter caps simultaneous in-flight requests rather than rate, which is the right tool in front of an expensive downstream such as a report generator or a third-party gateway with its own concurrency cap. Partitioning is the part that matters in production: `PartitionedRateLimiter.Create` lets you key the limiter by API key, tenant id, or authenticated user, with an unauthenticated fallback keyed by IP.

Keying only by IP is a trap in India, where large numbers of mobile users share carrier-grade NAT addresses and you will throttle an entire ISP pool together. Set `RejectionStatusCode` to 429 and add a `Retry-After` header in `OnRejected`, because clients that cannot see a retry hint will hammer you harder. Remember this limiter is per-process: with N replicas your effective limit is N times what you configured, so for a hard global limit you need a shared store (Redis) or enforcement at the gateway. Also apply `.DisableRateLimiting()` to health check endpoints so probes are never throttled.

builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    o.OnRejected = async (ctx, ct) =>
    {
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
            ctx.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        await ctx.HttpContext.Response.WriteAsJsonAsync(new { error = "rate_limited" }, ct);
    };

    o.AddPolicy("per-tenant", ctx =>
        RateLimitPartition.GetTokenBucketLimiter(
            partitionKey: ctx.User.FindFirst("tenant_id")?.Value
                          ?? ctx.Connection.RemoteIpAddress?.ToString()
                          ?? "anonymous",
            factory: _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 200,
                TokensPerPeriod = 50,
                ReplenishmentPeriod = TimeSpan.FromSeconds(10),
                QueueLimit = 0,
                AutoReplenishment = true,
            }));

    o.AddConcurrencyLimiter("reports", l => { l.PermitLimit = 4; l.QueueLimit = 8; });
});

app.UseRateLimiter();
app.MapGet("/api/orders", GetOrders).RequireRateLimiting("per-tenant");
app.MapHealthChecks("/healthz").DisableRateLimiting();

Key Points

  • Token bucket for public APIs, concurrency limiter in front of expensive work
  • Partition by tenant or API key, never by IP alone (carrier NAT)
  • Always return 429 with Retry-After
  • The limiter is per-process, so N replicas multiply your configured limit
Q26

What are the rules for BackgroundService in ASP.NET Core, and why does a worker sometimes stop without any error?

IntermediateBackground Processing

Answer

`BackgroundService` is an `IHostedService` base class with a single `ExecuteAsync(CancellationToken)` you override. The host starts every hosted service in registration order and awaits `StartAsync`, so any synchronous work before your first real `await` delays application startup, including the moment Kestrel begins accepting connections; put `await Task.Yield()` at the top if your loop does setup work. The silent-death problem has two causes.

First, an unhandled exception inside `ExecuteAsync`: since .NET 6 the default `BackgroundServiceExceptionBehavior` is `StopHost`, so the whole application shuts down, which at least is visible; if a team has set `Ignore`, the task faults and the worker simply stops while the API keeps serving, and nothing logs it. Always wrap the loop body in try/catch, log the exception, and continue rather than letting one bad message kill the loop. Second, cancellation: the token passed to `ExecuteAsync` is signalled at shutdown, and `Task.Delay(..., ct)` throws `OperationCanceledException` at that point, which is expected and should be caught quietly.

Since `BackgroundService` is a singleton, never inject a scoped service such as `DbContext` into its constructor; create a scope per iteration through `IServiceScopeFactory` or use `IDbContextFactory`. Graceful shutdown deserves attention: the host gives hosted services a limited window (`HostOptions.ShutdownTimeout`, default 30 seconds) to finish, and Kubernetes will SIGKILL after `terminationGracePeriodSeconds`, so long jobs must be checkpointed and resumable. Finally, in a multi-replica deployment every replica runs the timer, so any scheduled job needs a distributed lock or a leader election, otherwise the nightly reconciliation runs five times.

builder.Services.Configure<HostOptions>(o =>
{
    o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
    o.ShutdownTimeout = TimeSpan.FromSeconds(25);
});

public sealed class OutboxDispatcher(
    IServiceScopeFactory scopes,
    ILogger<OutboxDispatcher> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await Task.Yield();   // do not block host startup

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = scopes.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                await DispatchBatchAsync(db, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;                    // normal shutdown
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Outbox batch failed, continuing");
            }
        }
    }
}
💡 Pro Tip: If a background worker mysteriously stops, check the host exception behaviour first and add a heartbeat metric. A worker with no heartbeat alert is a worker you will discover is dead a week late.
Q27

How do you make outbound HTTP calls resilient with IHttpClientFactory and Microsoft.Extensions.Http.Resilience?

IntermediateResilience

Answer

`IHttpClientFactory` solves handler lifetime: it pools `HttpMessageHandler` instances and rotates them on a schedule (`SetHandlerLifetime`, default two minutes) so you neither exhaust sockets by creating a client per request nor cache stale DNS forever by holding one static client. Register a typed client with `AddHttpClient<TClient>()` so configuration and the client class live together. On top of that, `Microsoft.Extensions.Http.Resilience` (built on Polly v8) adds `AddStandardResilienceHandler()`, which composes five strategies in a specific order: rate limiter, total request timeout, retry, circuit breaker, and per-attempt timeout.

Order matters, because the total timeout must bound the retries, otherwise a three-retry policy with a ten second attempt timeout can hold a caller for over thirty seconds and cascade into thread exhaustion upstream. Configure the retry strategy to only retry idempotent verbs; retrying a POST that creates a payment without an idempotency key is how you create duplicate charges, and interviewers will ask about exactly this. The circuit breaker samples failures over a window and opens once the failure ratio crosses a threshold, failing fast during a downstream outage instead of piling up threads.

Add jitter to backoff (`UseJitter = true`) so all replicas do not retry in lockstep. Two related details worth mentioning: set `PooledConnectionLifetime` on a `SocketsHttpHandler` if you keep a long-lived client outside the factory, and always pass the request `CancellationToken` through so a client disconnect cancels the downstream call rather than leaving it running.

builder.Services.AddHttpClient<PaymentClient>(c =>
{
    c.BaseAddress = new Uri(cfg["Payments:BaseUrl"]!);
    c.Timeout = TimeSpan.FromSeconds(30);          // outer safety net
})
.AddStandardResilienceHandler(o =>
{
    o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20);
    o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);

    o.Retry.MaxRetryAttempts = 3;
    o.Retry.UseJitter = true;
    o.Retry.BackoffType = DelayBackoffType.Exponential;
    o.Retry.ShouldHandle = args => ValueTask.FromResult(
        args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout
            or HttpStatusCode.TooManyRequests
            or >= HttpStatusCode.InternalServerError
        && args.Outcome.Result.RequestMessage?.Method != HttpMethod.Post);

    o.CircuitBreaker.FailureRatio = 0.5;
    o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
    o.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
});

Key Points

  • IHttpClientFactory rotates handlers so DNS changes are picked up
  • Standard handler order: rate limiter, total timeout, retry, breaker, attempt timeout
  • Never blind-retry non-idempotent POSTs without an idempotency key
  • Jitter prevents synchronised retry storms across replicas
Q28

How do you design health checks for Kubernetes, and what is the difference between liveness, readiness and startup probes?

IntermediateOperations

Answer

`AddHealthChecks()` registers the service and each `AddCheck`/`AddDbContextCheck`/`AddRedis` call adds a named check with optional tags and a failure status. `MapHealthChecks("/path", new HealthCheckOptions { Predicate = ... })` exposes a filtered subset. The distinction the interviewer is looking for is what each probe does when it fails. A failed liveness probe causes Kubernetes to kill and restart the container, so liveness must only test whether the process itself is wedged; it must never check the database, because a five minute database outage would then restart every pod in a crash loop and turn a recoverable incident into a total outage.

Liveness should be a trivial endpoint that returns 200 if the pipeline can serve a request. A failed readiness probe removes the pod from the Service endpoints without restarting it, which is exactly right for dependency checks: if Postgres or Redis is unreachable, stop sending this pod traffic but let it recover. A startup probe covers slow boots, giving a long initial grace period before liveness begins, which matters for services that warm caches or run JIT-heavy initialisation.

Tag your checks `live` and `ready` and expose two endpoints with predicates. Also add `AddHealthChecks().AddCheck<...>` for the things that actually break your service, keep each check under a second with its own timeout, and never return sensitive detail in the response body since these endpoints are usually unauthenticated. Pair readiness with `IHostApplicationLifetime` so the pod reports unready as soon as SIGTERM arrives, giving the load balancer time to drain connections before shutdown.

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddDbContextCheck<AppDbContext>("db", tags: ["ready"])
    .AddRedis(cfg["Redis:Configuration"]!, name: "redis", tags: ["ready"])
    .AddUrlGroup(new Uri(cfg["Payments:BaseUrl"] + "/ping"), "payments",
                 failureStatus: HealthStatus.Degraded, tags: ["ready"]);

app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("live")
}).AllowAnonymous().DisableRateLimiting();

app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("ready"),
    ResultStatusCodes =
    {
        [HealthStatus.Healthy] = 200,
        [HealthStatus.Degraded] = 200,
        [HealthStatus.Unhealthy] = 503,
    }
});
💡 Pro Tip: If your liveness probe touches the database, one database blip becomes a cluster-wide restart loop. Liveness checks the process, readiness checks the dependencies.
Q29

How do you instrument a .NET service with OpenTelemetry, and how do traces, metrics and logs get correlated?

IntermediateObservability

Answer

.NET has first-party tracing and metrics primitives, so OpenTelemetry is mostly a matter of exporting what already exists. `System.Diagnostics.ActivitySource` produces spans (an `Activity` is a span), and `System.Diagnostics.Metrics.Meter` produces counters, histograms and gauges. The `OpenTelemetry.Extensions.Hosting` package plus instrumentation packages for AspNetCore, HttpClient, EF Core and runtime metrics wire the built-in sources into an OTLP exporter pointed at a collector, SigNoz, Jaeger or a vendor endpoint. Context propagation uses the W3C `traceparent` header, and because `HttpClient` instrumentation injects it automatically, a trace flows across services with no manual work.

Correlation with logs happens through `Activity.Current`: the logging pipeline stamps `TraceId` and `SpanId` onto every record, so if you also export logs through the OpenTelemetry logging provider you can jump from a slow span straight to the log lines emitted inside it. For custom spans create one static `ActivitySource` per library and start activities around meaningful operations, adding tags for tenant, entity id and outcome; keep tag cardinality bounded, because putting a raw user id on a metric dimension will blow up your time series database. Sampling is the other production lever: head sampling at a fixed ratio is cheap but loses rare errors, so most teams use a parent-based sampler with tail sampling in the collector to keep all error traces. Finally, add the built-in ASP.NET Core meters (`Microsoft.AspNetCore.Hosting`, `Microsoft.AspNetCore.Server.Kestrel`, `System.Runtime`) so you get request duration, active requests, thread pool queue length and GC pause metrics without writing any code.

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("shop-api", serviceVersion: BuildInfo.Version))
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation(o => o.RecordException = true)
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation(o => o.SetDbStatementForText = false)
        .AddSource(Telemetry.ActivitySourceName)
        .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)))
        .AddOtlpExporter())
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddMeter(Telemetry.MeterName)
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(o => { o.IncludeScopes = true; o.IncludeFormattedMessage = true; });

public static class Telemetry
{
    public const string ActivitySourceName = "Shop.Orders";
    public const string MeterName = "Shop.Orders";
    public static readonly ActivitySource Source = new(ActivitySourceName);
    private static readonly Meter Meter = new(MeterName);
    public static readonly Counter<long> OrdersPlaced = Meter.CreateCounter<long>("orders.placed");
}

using var activity = Telemetry.Source.StartActivity("SettleOrder");
activity?.SetTag("order.id", orderId);
activity?.SetTag("tenant", tenantId);
Telemetry.OrdersPlaced.Add(1, new KeyValuePair<string, object?>("channel", channel));
Q30

How do you write integration tests for an ASP.NET Core API with WebApplicationFactory and Testcontainers?

IntermediateTesting

Answer

`WebApplicationFactory<TEntryPoint>` from `Microsoft.AspNetCore.Mvc.Testing` boots your real application in-process with the real DI container and middleware pipeline, then hands you an `HttpClient` that talks to it through a `TestServer` without opening a TCP port. Because the whole pipeline runs, you test routing, model binding, filters, authentication and serialization together, which unit tests cannot cover. Override registrations in `ConfigureWebHost` using `ConfigureTestServices`, which runs after the application's own registrations, so `RemoveAll<T>()` followed by an `AddSingleton` reliably replaces a service; doing it in `ConfigureServices` runs too early and gets overwritten.

Replace only what you must: external payment gateways, SMS providers and identity servers, and keep the database real. Testcontainers gives you a genuine PostgreSQL, SQL Server or Redis in Docker per test class, so migrations, provider-specific SQL, unique constraints and concurrency tokens are all exercised, which the EF Core in-memory provider silently cannot do (it does not enforce relational constraints and it is not a supported testing strategy). Use `IAsyncLifetime` in xUnit to start the container, build the connection string, apply migrations, and dispose everything afterwards, and use `ICollectionFixture` to share one container across a class group so you are not paying container startup per test.

For authenticated endpoints, register a test authentication handler that mints a `ClaimsPrincipal` from a header, rather than issuing real tokens. Reset state between tests with `Respawn` or by wrapping each test in a transaction that rolls back.

public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
        .WithImage("postgres:17-alpine")
        .Build();

    public Task InitializeAsync() => _db.StartAsync();
    public new Task DisposeAsync() => _db.DisposeAsync().AsTask();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));

            services.RemoveAll<IPaymentGateway>();
            services.AddSingleton<IPaymentGateway, FakePaymentGateway>();

            services.AddAuthentication("Test")
                    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
        });
    }
}

public sealed class OrdersTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    [Fact]
    public async Task Rejects_order_without_idempotency_key()
    {
        var client = factory.CreateClient();
        var res = await client.PostAsJsonAsync("/api/v1/orders", new { sku = "ABC", qty = 1 });
        Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
    }
}

Key Points

  • ConfigureTestServices runs after app registrations; ConfigureServices does not
  • Add `public partial class Program { }` so the factory can see the entry point
  • Testcontainers over the EF in-memory provider: constraints and SQL are real
  • Share containers with a collection fixture to keep the suite fast
Q31

Which Kestrel settings actually matter in production, and how do HTTP/2 and HTTP/3 change the picture?

IntermediateHosting

Answer

Kestrel defaults are tuned for safety, not throughput, and a few are worth reviewing per service. `Limits.MaxRequestBodySize` defaults to about 30 MB and returns 413 above that, so file upload endpoints need an explicit override, either globally or per endpoint with `[RequestSizeLimit]`/`DisableRequestSizeLimit`; note that behind IIS or Nginx there is a second limit you must raise as well. `Limits.MaxConcurrentConnections` is unlimited by default, which is usually fine because the real bottleneck is thread pool and database connections, but capping it gives you predictable backpressure instead of a slow collapse. `Limits.KeepAliveTimeout` (default 130 seconds) and `RequestHeadersTimeout` (30 seconds) protect against slowloris-style connections holding sockets. `MinRequestBodyDataRate` and `MinResponseDataRate` drop clients that trickle bytes; mobile clients on poor Indian networks can legitimately trip the response rate limit, so consider relaxing it for large downloads rather than leaving users with truncated responses. HTTP/2 is negotiated over TLS via ALPN and gives you multiplexing over a single connection, which is required for gRPC; set `Protocols = HttpProtocols.Http1AndHttp2`. HTTP/3 runs over QUIC/UDP, needs `Http1AndHttp2AndHttp3` plus an `Alt-Svc` header, and removes head-of-line blocking on lossy networks. Behind a load balancer that terminates TLS, remember `UseForwardedHeaders` with a configured `KnownProxies`/`KnownNetworks` list, otherwise `Request.Scheme` is http, `HttpsRedirection` loops, and `RemoteIpAddress` shows the proxy rather than the client, which silently breaks IP-based rate limiting and audit logs.

builder.WebHost.ConfigureKestrel(k =>
{
    k.AddServerHeader = false;
    k.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
    k.Limits.MaxConcurrentConnections = 5_000;
    k.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
    k.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
    k.Limits.Http2.MaxStreamsPerConnection = 100;

    k.ConfigureEndpointDefaults(e => e.Protocols = HttpProtocols.Http1AndHttp2);
});

// Behind an ingress or ALB that terminates TLS
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    o.KnownNetworks.Clear();
    o.KnownProxies.Clear();          // only when the proxy layer is trusted
});
app.UseForwardedHeaders();          // must run before UseHttpsRedirection

app.MapPost("/upload", Upload).DisableRequestSizeLimit();
💡 Pro Tip: If HTTPS redirection loops forever behind an ingress, you have forgotten UseForwardedHeaders. The app sees http, redirects to https, the proxy forwards http again, and the loop repeats.
Q32

How do you containerise a .NET service properly, and what must you change so the GC respects the container memory limit?

IntermediateDeployment

Answer

A good Dockerfile is multi-stage: build on the `sdk` image, publish to a folder, and copy that folder onto the much smaller `aspnet` runtime image. Copy the csproj and `Directory.Packages.props` first and run `dotnet restore` before copying the rest of the source, so the restore layer is cached and code changes do not re-download packages. Since .NET 7 the SDK can build the image directly with `dotnet publish /t:PublishContainer`, which skips the Dockerfile entirely and produces a well-formed image with sensible defaults.

Prefer the chiseled Ubuntu variants (`-noble-chiseled`) which have no shell and no package manager, cutting both size and CVE surface, and run as a non-root user (`USER $APP_UID` is set in recent base images). Set `ASPNETCORE_HTTP_PORTS=8080` rather than relying on port 80, because the recent images default to a non-privileged port. The GC part is the piece candidates miss.

Server GC is the default for ASP.NET Core and allocates a heap and a dedicated thread per logical core, so a pod limited to 512 MB on a 64-core node can behave badly. Modern runtimes read cgroup limits and cap the heap at roughly 75 percent of the container memory limit, but that only works if the limit is actually set in the pod spec; without a memory limit the GC sizes itself against the host. Since .NET 9, DATAS (Dynamic Adaptation To Application Sizes) is on by default for Server GC and shrinks heap count under low load, which materially reduces memory in small containers. Tune with `DOTNET_GCHeapHardLimitPercent` or switch small sidecars to Workstation GC.

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Directory.Packages.props Directory.Build.props ./
COPY src/Shop.Api/Shop.Api.csproj src/Shop.Api/
RUN dotnet restore src/Shop.Api/Shop.Api.csproj
COPY . .
RUN dotnet publish src/Shop.Api -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENV ASPNETCORE_HTTP_PORTS=8080 \
    DOTNET_gcServer=1 \
    DOTNET_GCDynamicAdaptationMode=1
EXPOSE 8080
ENTRYPOINT ["dotnet", "Shop.Api.dll"]

# No Dockerfile needed at all:
# dotnet publish -c Release /t:PublishContainer \
#   -p:ContainerBaseImage=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled

Key Points

  • Multi-stage build; restore before copying source for layer caching
  • Chiseled runtime images: no shell, smaller CVE surface, non-root by default
  • Always set a pod memory limit so the GC can size the heap from cgroups
  • DATAS shrinks Server GC heaps in small containers on recent runtimes
Q33

When would you choose gRPC over REST in ASP.NET Core, and what are its real constraints?

IntermediateService Communication

Answer

gRPC uses Protocol Buffers over HTTP/2, giving you a strongly typed contract compiled into both client and server, a compact binary payload, multiplexed streams on one connection, and first-class streaming in both directions. In ASP.NET Core you add `Grpc.AspNetCore`, drop a `.proto` file into the project with a `<Protobuf>` item, and the tooling generates a base class you inherit for the server plus a client class for callers. It is the right choice for internal service-to-service traffic where you control both ends, especially chatty low-latency calls or streaming workloads, and it typically beats JSON REST on both payload size and CPU.

The constraints matter as much as the benefits. Browsers cannot speak native gRPC because they cannot control HTTP/2 frames, so browser clients need gRPC-Web (with `UseGrpcWeb`) or a JSON transcoding layer (`Microsoft.AspNetCore.Grpc.JsonTranscoding`) that exposes the same service as REST via annotations. Debugging is harder because payloads are not human readable without tooling.

Any proxy or load balancer in the path must support HTTP/2 end to end, and because gRPC keeps long-lived connections, an L4 load balancer will pin all traffic from one client to one backend unless you use an L7 proxy that balances per request. Client-side, use `GrpcChannel` as a long-lived singleton rather than creating one per call, always set a `Deadline` on calls (gRPC has no default timeout, so a hung server blocks the caller indefinitely), and design proto evolution carefully: never renumber or reuse field numbers, and add new fields as optional.

// orders.proto
// syntax = "proto3";
// service Orders {
//   rpc Get (GetOrderRequest) returns (OrderReply);
//   rpc Stream (StreamRequest) returns (stream OrderReply);
// }

builder.Services.AddGrpc(o => o.EnableDetailedErrors = env.IsDevelopment());
app.MapGrpcService<OrdersService>().EnableGrpcWeb();

public sealed class OrdersService(IOrderRepository repo) : Orders.OrdersBase
{
    public override async Task<OrderReply> Get(GetOrderRequest req, ServerCallContext ctx)
    {
        var order = await repo.FindAsync(Guid.Parse(req.Id), ctx.CancellationToken)
            ?? throw new RpcException(new Status(StatusCode.NotFound, "order not found"));
        return new OrderReply { Id = order.Id.ToString(), Status = order.Status };
    }
}

// Client: one channel for the process, an explicit deadline per call
builder.Services.AddGrpcClient<Orders.OrdersClient>(o =>
    o.Address = new Uri("https://orders.internal:8443"))
    .AddStandardResilienceHandler();

var reply = await client.GetAsync(new GetOrderRequest { Id = id },
    deadline: DateTime.UtcNow.AddSeconds(3), cancellationToken: ct);
Q34

What breaks when you scale a SignalR application to multiple replicas, and how do you fix it?

IntermediateReal-time

Answer

SignalR keeps connection state in the process that owns the connection. With one server everything works; with several, three things break. First, a broadcast from server A only reaches clients connected to server A, because the hub has no knowledge of connections elsewhere.

The fix is a backplane: `AddStackExchangeRedis` publishes hub invocations over Redis pub/sub so every server relays them to its own clients, or you offload the whole problem to Azure SignalR Service, which terminates connections outside your app. Second, the negotiate handshake. A client first POSTs to `/hub/negotiate` and then opens the actual transport connection; if those two requests land on different servers, the connection token is unknown and the client fails or falls back.

Either enable sticky sessions on the load balancer or set `SkipNegotiation = true` with a WebSockets-only client, which is only viable when you know WebSockets are available. Third, connection-to-user mapping: `Clients.User(userId)` works across the backplane because SignalR maps users via `IUserIdProvider`, but any custom dictionary of connection ids you maintain in memory will be wrong on other replicas, so store that mapping in Redis if you need it. Beyond scale-out, watch transport fallback (WebSockets, then Server-Sent Events, then long polling), which quietly degrades throughput when a proxy blocks upgrades, and set `KeepAliveInterval` and `ClientTimeoutInterval` consistently on client and server. Also remember that each connection holds resources for its lifetime, so a hub method doing heavy synchronous work will starve the thread pool much faster than a request-response API would.

builder.Services.AddSignalR(o =>
{
    o.KeepAliveInterval = TimeSpan.FromSeconds(15);
    o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    o.EnableDetailedErrors = env.IsDevelopment();
})
.AddStackExchangeRedis(cfg["Redis:Configuration"]!, o =>
{
    o.Configuration.ChannelPrefix = RedisChannel.Literal("shop-signalr");
});

public sealed class OrderHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var tenant = Context.User?.FindFirst("tenant_id")?.Value;
        if (tenant is not null)
            await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenant}");
        await base.OnConnectedAsync();
    }
}

// Broadcasting from outside the hub (for example a BackgroundService)
public sealed class Notifier(IHubContext<OrderHub> hub)
{
    public Task OrderShippedAsync(string tenant, object payload) =>
        hub.Clients.Group($"tenant:{tenant}").SendAsync("orderShipped", payload);
}

app.MapHub<OrderHub>("/hubs/orders");

Key Points

  • A backplane (Redis or Azure SignalR) is mandatory beyond one replica
  • Negotiate and the transport connection must reach the same server
  • Use Groups and Clients.User, not in-memory connection dictionaries
  • Heavy synchronous hub work starves the thread pool quickly
Q35

Explain Blazor render modes since .NET 8, and the prerendering gotcha that surprises most teams.

IntermediateBlazor

Answer

Since .NET 8 a Blazor Web App can mix render modes per component instead of committing the whole application to Server or WebAssembly. Static server rendering produces plain HTML with no interactivity, which is ideal for content pages and the fastest to first paint. `InteractiveServer` keeps the component model on the server and streams DOM diffs over a SignalR circuit, giving small downloads and full access to server resources but requiring a live connection per user and holding server memory per circuit, which is a real capacity constraint under load and behaves poorly on flaky mobile networks. `InteractiveWebAssembly` downloads the runtime and your assemblies to the browser and runs everything client-side, so it works offline and costs no server memory, at the price of a larger first load and the need for an API for any data access. `InteractiveAuto` uses the Server circuit for the first visit while the WebAssembly payload downloads in the background, then uses WebAssembly on subsequent visits. Enhanced navigation and streaming rendering let a static page send an initial shell and patch in slow content as it resolves.

The gotcha is prerendering: by default an interactive component is rendered once on the server to produce fast HTML, then rendered again when interactivity starts. `OnInitializedAsync` therefore runs twice, so any non-idempotent work (writing a record, incrementing a counter, sending an email) happens twice, and state created during prerender is lost unless you persist it with `PersistentComponentState`. Interviewers also probe whether you know that browser-only APIs such as `localStorage` are unavailable during prerender, which is why so many Blazor apps throw `JSException` on their very first render.

// Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

app.MapRazorComponents<App>()
   .AddInteractiveServerRenderMode()
   .AddInteractiveWebAssemblyRenderMode();

// Counter.razor
// @rendermode InteractiveAuto

@code {
    private ProductDto[]? products;

    [Inject] private PersistentComponentState State { get; set; } = default!;
    private PersistingComponentStateSubscription _sub;

    protected override async Task OnInitializedAsync()
    {
        _sub = State.RegisterOnPersisting(() =>
        {
            State.PersistAsJson(nameof(products), products);
            return Task.CompletedTask;
        });

        // Without this guard the fetch runs twice: prerender + interactive render
        if (State.TryTakeFromJson<ProductDto[]>(nameof(products), out var restored))
            products = restored;
        else
            products = await Api.GetProductsAsync();
    }
}
💡 Pro Tip: Never call JS interop for localStorage in OnInitializedAsync. Move it to OnAfterRenderAsync(firstRender), because during prerender there is no browser to talk to.
Q36

You are asked to migrate a .NET Framework 4.8 ASP.NET MVC application to modern .NET. What actually breaks, and how do you sequence the work?

IntermediateMigration

Answer

This is a standard interview question at Indian services firms because so much of the work is exactly this. The things that do not exist on modern .NET are the ones that shape the plan: `System.Web` and everything that depends on it, so `HttpContext.Current` (a static ambient context) is gone and must be replaced by injected `IHttpContextAccessor` or, better, by passing state explicitly; `Global.asax` becomes `Program.cs`; `web.config` becomes `appsettings.json` plus environment variables, and the IIS-specific handler and module sections have no equivalent; `HttpModule` and `HttpHandler` become middleware; Web Forms has no migration path at all and must be rewritten; WCF servers move to CoreWCF or gRPC; AppDomains and remoting are gone; and full-framework-only packages need modern replacements. Sequence the work rather than attempting a big-bang rewrite.

Start by upgrading the existing app to 4.8 and moving every project to SDK-style csproj files, which builds on both frameworks and shrinks the diff dramatically. Extract business logic into class libraries targeting `netstandard2.0` so both the old and new applications can reference them. Run `dotnet tool install -g upgrade-assistant` and use its analysis to inventory blocking APIs, and add the `Microsoft.DotNet.UpgradeAssistant` or `PlatformCompat` analyzers to catch usage as you go.

Then apply the strangler pattern: put YARP in front, route one path at a time to a new ASP.NET Core app, and keep authentication working across both by sharing the cookie via the Data Protection interop shim or by moving to token auth first. Ship each slice to production. A migration that runs six months without a deployment is a migration that gets cancelled.

# 1. Inventory blocking APIs before promising a timeline
dotnet tool install -g upgrade-assistant
upgrade-assistant analyze .\Legacy.sln

# 2. Move to SDK-style projects (still builds on 4.8)
try-convert -p .\Legacy.Web\Legacy.Web.csproj

# 3. Strangler routing with YARP in the new app
// appsettings.json
// "ReverseProxy": {
//   "Routes": {
//     "legacy": { "ClusterId": "legacy", "Match": { "Path": "{**catch-all}" } },
//     "orders": { "ClusterId": "new",    "Match": { "Path": "/orders/{**rest}" }, "Order": 1 }
//   },
//   "Clusters": {
//     "legacy": { "Destinations": { "d1": { "Address": "http://legacy-iis.internal/" } } },
//     "new":    { "Destinations": { "d1": { "Address": "http://orders-svc:8080/" } } }
//   }
// }

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
app.MapReverseProxy();

Key Points

  • System.Web, HttpContext.Current, Global.asax and web.config have no direct port
  • Web Forms cannot be migrated; WCF servers move to CoreWCF or gRPC
  • SDK-style csproj and netstandard2.0 libraries first, they build on both
  • Strangle path by path behind YARP and ship every slice to production
Q37

Your .NET service in Kubernetes shows p99 latency spikes every few minutes with healthy CPU. How do you prove it is GC and fix it?

AdvancedPerformance

Answer

Start by measuring rather than guessing. `dotnet-counters monitor --process-id 1 System.Runtime Microsoft.AspNetCore.Hosting` inside the pod gives you gen0/gen1/gen2 collection counts, `% Time in GC`, allocation rate, LOH size and working set alongside request rate. A GC-driven spike shows a gen2 or LOH collection landing exactly when p99 jumps, with `% Time in GC` climbing while CPU from your own code stays flat. Correlate with the `dotnet.gc.pause.time` metric if you export runtime instrumentation to your APM.

Once confirmed, there are three usual root causes. Large Object Heap traffic: any allocation of 85,000 bytes or more goes to the LOH, which is not compacted by default, so buffers, big `byte[]` reads, `List<T>` growth past that threshold and large JSON strings fragment it. Fix by pooling with `ArrayPool<byte>.Shared` and `RecyclableMemoryStream`, streaming responses instead of materialising them, and using `System.Text.Json` source generation to avoid intermediate strings.

Second, gen2 pressure from a cache with no bound: an `IMemoryCache` without `SizeLimit`, a static dictionary, or an EF change tracker held open by a long-lived context promotes objects into gen2 where collection is expensive. Third, heap sizing in the container: ensure the pod actually declares a memory limit so the runtime reads the cgroup and caps the heap, and on recent runtimes leave DATAS enabled so Server GC heap count adapts to a small container instead of allocating one per host core. Take a `dotnet-gcdump collect` before and after a spike and diff the type counts to name the retaining type rather than speculating.

# Live counters from inside the pod
dotnet-counters monitor -p 1 System.Runtime Microsoft.AspNetCore.Hosting
#   gen-2-gc-count, loh-size, time-in-gc, alloc-rate, gc-heap-size

# Two heap snapshots, then diff the retained types
dotnet-gcdump collect -p 1 -o /tmp/before.gcdump
dotnet-gcdump collect -p 1 -o /tmp/after.gcdump

# Sampled allocation trace when the counters are ambiguous
dotnet-trace collect -p 1 --providers Microsoft-DotNETCore-SampleProfiler

// Kill LOH churn on a hot path: pool the buffer, stream the response
private static readonly RecyclableMemoryStreamManager Streams = new();

public async Task WriteReportAsync(Stream output, CancellationToken ct)
{
    var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
    try
    {
        await using var staging = Streams.GetStream("report");
        await RenderAsync(staging, buffer, ct);
        staging.Position = 0;
        await staging.CopyToAsync(output, ct);
    }
    finally { ArrayPool<byte>.Shared.Return(buffer); }
}

Key Points

  • Prove it with dotnet-counters before changing anything
  • Allocations of 85,000 bytes or more land on the non-compacting LOH
  • Unbounded IMemoryCache and static dictionaries create gen2 pressure
  • Always set a pod memory limit so the GC sizes the heap from cgroups
Q38

What does it take to make an ASP.NET Core minimal API Native AOT ready, and what do you give up?

AdvancedPerformance

Answer

Native AOT compiles the application ahead of time into a self-contained native binary with no JIT and no IL at runtime. For an API the payoff is startup in the low tens of milliseconds instead of hundreds, a much smaller memory footprint, and a smaller container, which matters for scale-to-zero serverless and for running many small services on a node. Enabling it is `<PublishAot>true</PublishAot>` plus publishing with an explicit runtime identifier, and using `WebApplication.CreateSlimBuilder` (or `CreateEmptyBuilder`) instead of `CreateBuilder`, because the slim builder omits the providers and services that pull in reflection-heavy code.

What you give up is anything that depends on runtime code generation or unbounded reflection. `System.Text.Json` must use source generation through a `JsonSerializerContext`, because the reflection-based serializer is trimmed away. Minimal APIs work because the Request Delegate Generator (RDG) emits the parameter binding code at compile time; MVC controllers, Razor Pages, Blazor Server, SignalR and gRPC server support are not AOT compatible today. EF Core requires compiled models and still has limits; many teams keep the data layer in a non-AOT service.

Expression-tree compilation, `Assembly.Load` of plugins, dynamic proxies used by mocking and AOP libraries, and most reflection-based DI or mapping libraries will fail. The practical workflow is to enable AOT early rather than retrofitting: turn on `IsAotCompatible` on libraries, treat IL2xxx and IL3xxx warnings as errors, and test the published native binary in CI, because the failure mode is a runtime `MissingMetadataException` on a code path your tests may not cover.

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <TrimMode>full</TrimMode>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

var builder = WebApplication.CreateSlimBuilder(args);   // not CreateBuilder

builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();
app.MapGet("/orders/{id:guid}", (Guid id, IOrderStore s) => s.Find(id));
app.Run();

[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(OrderDto[]))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext { }

# dotnet publish -c Release -r linux-x64 -p:PublishAot=true
💡 Pro Tip: Decide on AOT at project start. Retrofitting it onto a codebase that already uses AutoMapper, reflection-based validation and MVC controllers usually costs more than the startup time it saves.
Q39

A .NET API takes several seconds to serve its first requests after each deploy. How do you cut cold start without rewriting it?

AdvancedPerformance

Answer

Break the cold start into its parts and attack each. Process start and assembly loading come first: publishing with `PublishReadyToRun` precompiles IL to native code so startup paths do not wait on the JIT, and it composes with tiered compilation, where tier 0 code is generated quickly and hot methods are re-jitted at tier 1 with Dynamic PGO. Leaving `TieredCompilation` on is right for servers; disabling it makes steady-state slightly better and cold start much worse.

Next, the DI container: `ValidateOnBuild` walks the whole graph at startup, which is worth the milliseconds, but constructing expensive singletons eagerly is not, so move heavy initialisation into an `IHostedService` that runs after the server is listening, or make it lazy. Third, EF Core, which is usually the biggest single contributor: building the model on first use can take hundreds of milliseconds to seconds for a large context, and `dotnet ef dbcontext optimize` generates a compiled model that removes almost all of it. Add `EnableThreadSafetyChecks(false)` only if you have measured it, and use compiled queries for the handful of queries on the hot path.

Fourth, warmup: hit your own critical routes from a startup hosted service so the JIT, the connection pools, the TLS handshake to downstream services and the EF query cache are all primed before the readiness probe passes. Configure a Kubernetes startup probe so slow boots do not trigger liveness restarts, and use `preStop` plus a readiness flip on SIGTERM so old pods drain cleanly. Measure with `dotnet-trace collect --profile cpu-sampling` over the first ten seconds rather than guessing which part dominates.

# Precompile to native alongside IL, keep tiering and PGO enabled
dotnet publish -c Release -r linux-x64 \
  -p:PublishReadyToRun=true -p:TieredPGO=true

# Remove EF model-building cost from startup
dotnet ef dbcontext optimize -p src/Shop.Infrastructure -s src/Shop.Api
options.UseModel(CompiledModels.AppDbContextModel.Instance);

// Compile the hottest query once
private static readonly Func<AppDbContext, Guid, CancellationToken, Task<Order?>> ByIdQuery =
    EF.CompileAsyncQuery((AppDbContext db, Guid id, CancellationToken ct) =>
        db.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));

// Warm the JIT, pools and TLS handshakes before readiness turns green
public sealed class Warmup(IServiceScopeFactory scopes, IHttpClientFactory http) : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        using var scope = scopes.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.ExecuteSqlRawAsync("SELECT 1", ct);
        await http.CreateClient("payments").GetAsync("/ping", ct);
    }
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
Q40

What problem does .NET Aspire solve, and when is it the wrong tool?

AdvancedArchitecture

Answer

.NET Aspire is an opinionated stack for building and running multi-service .NET applications. Its core piece is the app host, a normal C# project where you describe your distributed application as code: which projects to run, which containers they depend on (Postgres, Redis, RabbitMQ, Kafka), and how connection information flows between them. Running the app host starts everything with one `dotnet run`, injects connection strings into each service through configuration, and opens a dashboard showing logs, traces, metrics and dependency health across all of them, because the components ship with OpenTelemetry wired in.

The second piece is service defaults, a shared project that applies health checks, OpenTelemetry, service discovery and standard HTTP resilience to every service, so you stop copy-pasting the same fifty lines of Program.cs into each new microservice. Integration packages such as `Aspire.Npgsql.EntityFrameworkCore.PostgreSQL` register a client with sensible retry, health check and telemetry defaults. Deployment is handled by generating a manifest that tooling can turn into Azure Container Apps resources or Kubernetes manifests.

Where it is the wrong tool: a single deployable service gains almost nothing, since the value is coordination across several; teams already invested in Docker Compose plus a mature Helm and Terraform pipeline may find the manifest generation redundant and prefer to adopt only the service defaults; and polyglot estates where most services are not .NET get partial benefit. It is also a local development and orchestration story, not a runtime: nothing about Aspire runs in production alongside your service, which is a point interviewers like to confirm you understand.

// AppHost/Program.cs, the distributed app described in C#
var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");
var db = builder.AddPostgres("pg")
                .WithDataVolume()
                .AddDatabase("shopdb");

var api = builder.AddProject<Projects.Shop_Api>("api")
    .WithReference(db)
    .WithReference(cache)
    .WithReplicas(2);

builder.AddProject<Projects.Shop_Web>("web")
    .WithReference(api)
    .WithExternalHttpEndpoints();

builder.Build().Run();

// ServiceDefaults: applied by every service with one call
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder b)
{
    b.ConfigureOpenTelemetry();
    b.AddDefaultHealthChecks();
    b.Services.AddServiceDiscovery();
    b.Services.ConfigureHttpClientDefaults(h =>
    {
        h.AddStandardResilienceHandler();
        h.AddServiceDiscovery();
    });
    return b;
}

Key Points

  • App host describes the whole distributed app as C# and runs it locally
  • Service defaults standardise telemetry, health checks and resilience
  • Dashboard gives cross-service traces and logs out of the box
  • It is a dev and orchestration tool, not a production runtime component
Q41

How would you implement multi-tenant data isolation in an ASP.NET Core and EF Core application?

AdvancedArchitecture

Answer

Three isolation models exist and the choice drives everything else. Database per tenant gives the strongest isolation and the simplest compliance story, but connection pooling becomes the constraint: ADO.NET pools per connection string, so a thousand tenants means a thousand pools, and you will exhaust database connections long before CPU. Schema per tenant sits in between.

Shared schema with a `TenantId` column scales best and is what most Indian SaaS products ship, at the cost of one catastrophic failure mode: a single query missing its tenant filter leaks data across customers. Defend against that structurally, not by discipline. Resolve the tenant once per request in middleware (subdomain, header, or a claim in the JWT) into a scoped `ITenantContext`, and apply a global query filter on every tenant-owned entity so EF appends the predicate to every query automatically.

Enforce it on write too, by setting `TenantId` in `SaveChangesAsync` overrides and rejecting entities whose tenant does not match. Add a test that reflects over the model and fails if any entity implementing `ITenantOwned` lacks a filter, because a new entity added six months later is the realistic leak. Know the escape hatches and their risk: `IgnoreQueryFilters()` disables the guard, and a raw SQL query through `FromSqlRaw` bypasses filters entirely, so both should be restricted and code-reviewed.

Watch caching keys, because a cache key without the tenant id serves one tenant's data to another, and the same applies to output caching varies and to background jobs, which have no request context and must set the tenant explicitly. Where the database supports it, PostgreSQL row-level security gives you a second enforcement layer beneath the application.

public interface ITenantOwned { Guid TenantId { get; set; } }

public sealed class AppDbContext(DbContextOptions<AppDbContext> o, ITenantContext tenant)
    : DbContext(o)
{
    protected override void OnModelCreating(ModelBuilder mb)
    {
        foreach (var et in mb.Model.GetEntityTypes()
                     .Where(t => typeof(ITenantOwned).IsAssignableFrom(t.ClrType)))
        {
            mb.Entity(et.ClrType).HasIndex(nameof(ITenantOwned.TenantId));
            var p = Expression.Parameter(et.ClrType, "e");
            var body = Expression.Equal(
                Expression.Property(p, nameof(ITenantOwned.TenantId)),
                Expression.Property(Expression.Constant(this), nameof(CurrentTenantId)));
            mb.Entity(et.ClrType).HasQueryFilter(Expression.Lambda(body, p));
        }
    }

    public Guid CurrentTenantId => tenant.TenantId;

    public override Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        foreach (var e in ChangeTracker.Entries<ITenantOwned>())
        {
            if (e.State is EntityState.Added) e.Entity.TenantId = CurrentTenantId;
            else if (e.Entity.TenantId != CurrentTenantId)
                throw new InvalidOperationException("Cross-tenant write blocked");
        }
        return base.SaveChangesAsync(ct);
    }
}
💡 Pro Tip: Write a unit test that walks the EF model and fails when any ITenantOwned entity has no query filter. That single test prevents the most expensive bug a SaaS product can ship.
Q42

Design an idempotent payment endpoint in .NET that survives retries, duplicate webhooks and a crash between the database write and the message publish.

AdvancedDistributed Systems

Answer

Two independent problems hide in this question and the interviewer wants both. The first is duplicate inbound requests: a mobile client on a flaky Indian network retries a POST, or a payment gateway redelivers a webhook, and you must not charge twice. Require an `Idempotency-Key` header, and on the first request insert a row keyed by (tenant, endpoint, key) inside the same transaction as the business write, with a unique index on the key.

A concurrent duplicate then fails on the unique constraint, which you catch and translate into a 409 or, better, into returning the stored response of the original request. Storing the response body and status code lets you replay the exact original answer, which is what clients expect. The second problem is dual-write: after committing to the database you still need to publish an event to Kafka or RabbitMQ, and a crash in between leaves the two systems inconsistent.

Distributed transactions are not the answer. Use the transactional outbox: insert the event into an `OutboxMessages` table in the same transaction as the state change, then have a separate dispatcher poll unsent rows and publish them, marking them sent after the broker acknowledges. Because the dispatcher can crash after publishing but before marking, delivery is at-least-once, so consumers must be idempotent as well, typically by tracking processed message ids.

Wrap the whole unit of work in `CreateExecutionStrategy().ExecuteAsync` so it composes with connection retries. For the gateway side, always pass your own idempotency key to the provider API too, so a retry at that layer is also deduplicated.

app.MapPost("/api/v1/payments", async (
    [FromHeader(Name = "Idempotency-Key")] string? key,
    CreatePayment body, AppDbContext db, IPaymentGateway gw, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(key))
        return Results.Problem("Idempotency-Key header is required", statusCode: 400);

    var existing = await db.IdempotencyRecords
        .AsNoTracking()
        .FirstOrDefaultAsync(r => r.Key == key, ct);
    if (existing is not null)
        return Results.Content(existing.ResponseBody, "application/json", null, existing.StatusCode);

    var strategy = db.Database.CreateExecutionStrategy();

    return await strategy.ExecuteAsync(async () =>
    {
        await using var tx = await db.Database.BeginTransactionAsync(ct);

        var charge = await gw.ChargeAsync(body, idempotencyKey: key, ct);
        var payment = Payment.From(charge);
        db.Payments.Add(payment);
        db.OutboxMessages.Add(OutboxMessage.Create("payment.captured", payment));
        db.IdempotencyRecords.Add(new(key, 201, JsonSerializer.Serialize(payment)));

        try { await db.SaveChangesAsync(ct); }
        catch (DbUpdateException e) when (e.IsUniqueViolation("ix_idempotency_key"))
        {
            return Results.Conflict(new { message = "Request already in progress" });
        }

        await tx.CommitAsync(ct);
        return Results.Created($"/api/v1/payments/{payment.Id}", payment);
    });
});

Key Points

  • Unique index on the idempotency key is the real enforcement, not a lookup
  • Store and replay the original response, do not just reject duplicates
  • Outbox table written in the same transaction removes the dual-write hole
  • Delivery is at-least-once, so consumers must dedupe by message id
Q43

How do you ship an EF Core schema change with zero downtime while old and new versions of the application run simultaneously?

AdvancedOperations

Answer

During a rolling deployment both versions serve traffic at once, so any migration must be backwards compatible with the version still running. The discipline is expand and contract, executed across at least two releases. Expand: add the new nullable column, the new table, or the new index concurrently, and deploy code that writes to both old and new shapes while still reading the old one.

Backfill existing rows in batches from a background job or a script, never as one giant UPDATE that locks the table and blocks writers. Then deploy a release that reads from the new shape. Only after that release is stable everywhere do you contract: stop writing the old column, and in a later release drop it.

Renaming a column in one step is the classic outage, because EF generates a drop and an add, so the old pods write to a column that no longer exists and you lose data. Specific traps to name: adding a NOT NULL column with no default rewrites the table on many engines and blocks; adding an index without `CONCURRENTLY` on PostgreSQL takes an exclusive lock; and changing a column type can force a full rewrite. Set a short `lock_timeout` and `statement_timeout` for migration sessions so a blocked DDL fails fast rather than queueing every query behind it.

Apply migrations as a separate pipeline step with an idempotent script, never `Database.Migrate()` from N replicas racing at startup. Keep every migration forward-only in production and handle mistakes with a new compensating migration, because a down migration against live data usually destroys it.

// Release 1 (expand): additive only, old pods keep working
migrationBuilder.AddColumn<string>(
    name: "CustomerEmail", table: "Orders", nullable: true);

migrationBuilder.Sql("SET lock_timeout = '3s'; SET statement_timeout = '60s';");
migrationBuilder.Sql(
    "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_orders_customer_email " +
    "ON \"Orders\" (\"CustomerEmail\")", suppressTransaction: true);

// Application code in release 1: dual write, read the old field
order.Email = value;          // legacy column
order.CustomerEmail = value;  // new column

// Backfill in bounded batches, not one statement
int affected;
do
{
    affected = await db.Orders
        .Where(o => o.CustomerEmail == null)
        .Take(5_000)
        .ExecuteUpdateAsync(s => s.SetProperty(o => o.CustomerEmail, o => o.Email), ct);
    await Task.Delay(200, ct);
} while (affected > 0);

// Release 2: read new column. Release 3 (contract): drop the old one.
💡 Pro Tip: Treat a column rename as three deployments, never one. The migration that renames in a single step is the most common cause of data loss during a rolling deploy.
Q44

Production requests are hanging and eventually timing out, CPU is near zero and no exceptions are logged. Walk through the diagnosis on a Linux container.

AdvancedDebugging

Answer

Low CPU with hanging requests points at threads blocked rather than work being done, and the two dominant causes are thread pool starvation and a deadlocked or unbounded wait on an external resource. Confirm first with counters: `dotnet-counters monitor -p 1 System.Runtime` shows `threadpool-thread-count` climbing slowly (the pool injects roughly one thread per second past the minimum) while `threadpool-queue-length` grows without bound. That signature is thread pool starvation and it is almost always sync-over-async: `.Result`, `.Wait()`, `.GetAwaiter().GetResult()` or `Task.Run(...).Result` somewhere on the request path blocks a pool thread while the continuation it is waiting on needs a pool thread to run.

Also look for a `SemaphoreSlim.Wait()` instead of `WaitAsync`, an `HttpClient` with no timeout, and a database connection pool exhausted because contexts are not disposed, which shows up as timeouts waiting for a connection from the pool. To find the exact code, capture a dump with `dotnet-dump collect -p 1` and analyse it: `clrthreads` shows how many threads exist, `parallelstacks` or `clrstack -all` shows what they are all blocked on, and `syncblk` shows monitor contention. `dotnet-stack report` is a lighter alternative when a full dump is too large. `dotnet-trace collect --providers Microsoft-DotNETCore-SampleProfiler` over thirty seconds also reveals blocked frames. The fix is to make the path async end to end, never block on a task, pass `CancellationToken` everywhere so timeouts actually propagate, and set explicit timeouts on every outbound call. Raising `ThreadPool.SetMinThreads` is a stopgap that buys time during an incident; it does not remove the blocking call.

# 1. Signature check: queue grows, CPU flat, thread count creeping up
dotnet-counters monitor -p 1 System.Runtime Microsoft.AspNetCore.Hosting

# 2. Capture and open a dump inside the container
dotnet-dump collect -p 1 -o /tmp/hang.dmp
dotnet-dump analyze /tmp/hang.dmp
>  clrthreads
>  parallelstacks
>  syncblk
>  dumpasync -completed:false

# 3. Lighter option when a dump is impractical
dotnet-stack report -p 1

// The bug the dump will point at
public IActionResult GetLegacy() =>
    Ok(_client.GetStringAsync(url).Result);          // blocks a pool thread

// The fix: async all the way down, with a real timeout
public async Task<IActionResult> Get(CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(3));
    return Ok(await _client.GetStringAsync(url, cts.Token));
}

Key Points

  • Low CPU plus growing threadpool-queue-length equals starvation
  • Root cause is nearly always .Result, .Wait() or a sync semaphore on the path
  • dotnet-dump analyze with parallelstacks and dumpasync names the frame
  • SetMinThreads is an incident stopgap, not a fix
Q45

Design a .NET ingestion endpoint that accepts tens of thousands of events per second and writes them to a database without falling over.

AdvancedArchitecture

Answer

The shape of the answer is: accept fast, buffer with an explicit bound, batch the expensive work, and apply backpressure rather than queueing without limit. Use a minimal API endpoint (lower overhead than MVC) that does the minimum synchronous validation and hands the payload to a bounded `System.Threading.Channels` channel, then returns 202 Accepted. A bounded channel with `BoundedChannelFullMode.Wait` gives you real backpressure: when the consumer falls behind, `WriteAsync` awaits, the endpoint slows, and eventually your rate limiter or load balancer sheds load in a controlled way.

An unbounded channel converts a downstream slowdown into an out-of-memory kill, which is the failure this design exists to prevent. A `BackgroundService` drains the channel, accumulates a batch bounded by both count and time (a `PeriodicTimer` or a linked timeout so a partial batch still flushes under low traffic), and writes with a bulk path: `SqlBulkCopy` or the Npgsql binary `COPY` importer rather than one INSERT per row, since round trips, not the database, are usually the bottleneck. Serialization matters at this rate, so use `System.Text.Json` source generation and read the body as a stream rather than a string.

Instrument channel depth, batch size and flush latency as metrics; channel depth is your leading indicator that consumers are losing. Accept the durability trade-off honestly in the interview: returning 202 before persistence means an in-flight buffer is lost on a hard kill, so if events cannot be lost you must either write to a durable log such as Kafka first or acknowledge only after persistence and accept the latency. Handle graceful shutdown by completing the writer and draining the channel within the shutdown timeout.

builder.Services.AddSingleton(_ => Channel.CreateBounded<Event>(
    new BoundedChannelOptions(50_000)
    {
        FullMode = BoundedChannelFullMode.Wait,   // backpressure, never unbounded
        SingleReader = true,
        SingleWriter = false,
    }));

app.MapPost("/ingest", async (Event e, Channel<Event> ch, CancellationToken ct) =>
{
    await ch.Writer.WriteAsync(e, ct);
    return Results.Accepted();
}).RequireRateLimiting("ingest");

public sealed class BatchWriter(Channel<Event> ch, NpgsqlDataSource ds) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var batch = new List<Event>(1_000);

        await foreach (var e in ch.Reader.ReadAllAsync(ct))
        {
            batch.Add(e);
            if (batch.Count < 1_000 && ch.Reader.Count > 0) continue;

            await using var conn = await ds.OpenConnectionAsync(ct);
            await using var writer = await conn.BeginBinaryImportAsync(
                "COPY events (id, tenant, payload, at) FROM STDIN (FORMAT BINARY)", ct);

            foreach (var item in batch)
            {
                await writer.StartRowAsync(ct);
                await writer.WriteAsync(item.Id, ct);
                await writer.WriteAsync(item.Tenant, ct);
                await writer.WriteAsync(item.Payload, NpgsqlDbType.Jsonb, ct);
                await writer.WriteAsync(item.At, ct);
            }

            await writer.CompleteAsync(ct);
            batch.Clear();
        }
    }
}

Key Points

  • Bounded channel gives backpressure; unbounded gives an OOMKill
  • Batch by both count and time so low traffic still flushes
  • Bulk copy paths beat per-row inserts by an order of magnitude
  • 202 before persistence is a durability trade-off you must state explicitly

Companies Hiring .NET

TCS
Infosys
Cognizant
Accenture
HCLTech
LTIMindtree
Microsoft
Nagarro

Salary Insights

Average in India
₹7-24 LPA

Frequently Asked Questions

What does a .NET developer earn in India in 2026?

The broad band is ₹7-24 LPA. Freshers at TCS, Infosys, Cognizant and Accenture typically start around ₹3.5-6 LPA on standard hiring, with digital or premium offers reaching ₹7-9 LPA. Two to five years with solid ASP.NET Core, EF Core and cloud experience puts you at ₹9-16 LPA, and product companies or GCCs in Bengaluru, Hyderabad, Pune and Gurugram pay ₹18-30 LPA for senior engineers who can own performance, distributed design and Azure or AWS infrastructure. The largest single jump comes from moving off maintenance work on .NET Framework onto modern .NET product teams, which routinely doubles the offer.

How long does it take to prepare for a .NET interview if I already write C# at work?

Three to five weeks of focused evening study is realistic if you already ship C# daily. Spend the first week on the hosting model, configuration, DI lifetimes and middleware ordering, because those decide the first-round screening. Week two on EF Core behaviour: change tracking, N+1, split queries, concurrency tokens and migrations. Week three on production topics: caching, resilience, health checks, observability and containers. Reserve the last week for building one small service end to end and for practising the diagnostic questions out loud, since senior rounds are mostly "walk me through how you would debug this" rather than definitions.

Is .NET still worth learning in 2026, or should I pick Node.js or Go?

It is worth learning, especially in India, where the installed base is enormous and the migration from .NET Framework to modern .NET keeps demand steady across services firms, BFSI, healthcare and GCCs. Modern .NET is cross-platform, competitive with Go on throughput for typical API workloads, and unusually strong on tooling: first-class diagnostics, a real type system, EF Core and a batteries-included web stack. Node.js wins where the team wants one language across frontend and backend, and Go wins for small static binaries and infrastructure tooling. The honest differentiator is that .NET roles are less crowded than JavaScript roles at the same experience level.

What do freshers get asked versus engineers with five years of experience?

Freshers are asked about the language and the basics of the framework: OOP, collections, LINQ, the request pipeline, DI lifetimes, and simple EF Core queries, usually with a coding round on data structures. Experienced candidates get almost no definition questions. Instead you get design and failure scenarios: how you would make a payment endpoint idempotent, why cookies break after scaling to two pods, how to diagnose p99 spikes, how to migrate a legacy application without a big-bang cutover, and how you would structure the database migration for a rolling deployment. Prepare specific incidents from your own work, with numbers, because interviewers probe for real production exposure.

Should I learn .NET Framework as well, or only modern .NET?

Learn modern .NET properly first, then acquire enough .NET Framework literacy to be useful on migration work. Many Indian service contracts are precisely migration projects, and being the person who can explain why HttpContext.Current has no equivalent, what happens to Global.asax and web.config, and how to strangle an IIS application behind YARP makes you immediately valuable on those teams. You do not need to become fluent in Web Forms or WCF authoring, but you should be able to read them and know the modern replacement for each. Treat .NET Framework as a legacy dialect you can interpret, not as a stack you build new systems in.

How does .NET compare with Spring Boot for backend interviews?

The frameworks map almost concept for concept: DI containers with scoped and singleton lifetimes, filters versus middleware, EF Core versus JPA and Hibernate, minimal APIs versus Spring WebFlux and MVC controllers, Micrometer versus the built-in Meter API. Interview style differs slightly. Spring Boot panels lean on annotations, transaction propagation and the bean lifecycle, while .NET panels lean on async and await behaviour, the thread pool, garbage collection and EF Core query translation. Salary bands in India are broadly comparable, with Java holding an edge at the very top of the product-company market and .NET being less crowded in the mid range.

Introduction

The .NET platform in 2026 is a single cross-platform runtime with a November release train: even-numbered releases such as .NET 8 and .NET 10 are LTS with three years of support, odd-numbered ones such as .NET 9 are STS with eighteen months. That cadence matters in interviews because Indian services firms still run huge estates on .NET Framework 4.8 while product teams have moved to minimal APIs, Native AOT, source generators and OpenTelemetry. A .NET engineer today is expected to be fluent in both worlds: the legacy System.Web mental model and the modern Generic Host with its configuration, dependency injection and middleware pipeline.

Interviews reflect that split. Panels at TCS, Infosys, Cognizant, Accenture and LTIMindtree probe migration knowledge, Entity Framework Core behaviour and layered architecture, while product companies and GCCs push on the hosting model, DI lifetimes and captive dependencies, Kestrel and connection limits, garbage collection inside memory-capped containers, resilience with Polly, and how you actually debug a hung request in production using dotnet-counters and dotnet-dump. Nearly every senior loop includes one open design question, usually multi-tenancy, idempotent payments, or a zero-downtime database migration, where the interviewer wants to hear failure modes and not just happy paths.

This set works through 45 questions ordered from fundamentals to production engineering: 18 basic, 18 intermediate and 9 advanced. Each answer explains how the platform actually behaves at runtime, the specific configuration keys, CLI commands and API names you are expected to know, and the mistakes that turn into incidents. Most questions carry a runnable code sample. Work the basic section until the hosting model and DI container feel obvious, then spend your remaining prep time on the intermediate and advanced sections, because those are the questions that decide the compensation band you are offered.

Ready to practice .NET interviews?

Don't just read, practice these .NET questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview