ASP.NET Core Interview Questions and Answers – Crack Your Next Interview

ASP.NET Core Interview Questions and Answers – Crack Your Next Interview

Cracking an ASP.NET Core interview is not just about memorizing a list of interview questions—it is about understanding how the framework works and being able to apply that knowledge to real-world development scenarios. Interviewers often test your understanding of core concepts such as Dependency Injection, middleware, routing, Web APIs, Entity Framework Core, authentication and authorization, performance optimization, and asynchronous programming, along with your ability to solve practical problems. In this guide, we will cover the most important ASP.NET Core interview questions and answers, from beginner-level fundamentals to advanced and scenario-based questions, so you can build confidence and prepare effectively for your next ASP.NET Core interview.

Absolutely. Here is a practical ASP.NET Core interview question set, organized from beginner to advanced. It covers topics commonly asked for ASP.NET Core Web API, MVC, Minimal APIs, Entity Framework Core, authentication/authorization, dependency injection, middleware, performance, and .NET 10.

I have compiled 100 of the best and most popular ASP.NET Core interview questions covering the concepts, features, and real-world scenarios most frequently discussed in interviews. These questions are carefully selected to help you focus your preparation on the topics that matter most. I am confident that at least 80–85% of the questions you encounter in an ASP.NET Core interview will be related to the concepts covered in these 100 questions. Prepare these questions thoroughly, understand the concepts behind them, and you will be in a strong position to confidently face your ASP.NET Core interview.

ASP.NET Core Basics Interview Questions

ASP.NET Core basics questions are usually the first step in an interview because they help the interviewer understand how strong your fundamental knowledge of the framework is. Questions about Program.cs, middleware, the request pipeline, dependency injection, configuration, hosting, routing, and environments may seem simple, but your answers reveal whether you understand how an ASP.NET Core application actually works behind the scenes. These questions also help the interviewer judge your technical foundation, clarity of concepts, practical experience, and ability to explain technical topics, rather than simply checking whether you have memorized definitions. A strong understanding of these fundamentals will also make it much easier to answer advanced ASP.NET Core questions later in the interview.

1. What is ASP.NET Core and it’s advantages over ASP.NET Framework? Also explain difference between .NET Core, and modern .NET?

ASP.NET Core is a cross-platform, open-source, high-performance framework from Microsoft for building modern web applications, RESTful Web APIs, Minimal APIs, MVC applications, real-time applications, and other server-side web solutions. It runs on Windows, Linux, and macOS and is part of the modern unified .NET platform.

ASP.NET Core was designed as a successor to the older ASP.NET Framework, addressing several of its limitations while providing better performance, flexibility, scalability, and support for modern application development.

Advantages of ASP.NET Core over ASP.NET Framework

The biggest difference is that ASP.NET Core is a modern, cross-platform, high-performance framework designed for today’s cloud, container, API, and web application development, whereas ASP.NET Framework is the older Windows-focused web framework.

For new applications, ASP.NET Core is generally the preferred choice, while ASP.NET Framework remains relevant when maintaining existing legacy applications or applications that depend on technologies available only in the .NET Framework.

ASP.NET CoreASP.NET Framework
Cross-platform — runs on Windows, Linux, and macOSPrimarily designed for Windows
High performance and optimized for modern workloadsGenerally lower performance for modern web workloads
Open sourceParts of the framework are open source, but traditionally Windows-focused
Supports modern .NET versionsBased on the older .NET Framework
Built-in Dependency InjectionDI is not built into the framework in the same way
Lightweight and modularLarger, more monolithic framework
Excellent support for Web APIs and Minimal APIsPrimarily uses Web API, MVC, Web Forms, etc.
Designed for cloud-native applicationsNot originally designed around cloud-native development
Runs well with Docker and containersContainer support is more limited and Windows-oriented
Can be hosted using Kestrel and behind reverse proxiesCommonly hosted with IIS
Supports modern middleware-based request pipelinesUses older HTTP/application pipeline models
Actively developed as part of modern .NET.NET Framework is largely in maintenance mode
Difference between .NET Core, and modern .NET?

.NET Core was the name used for Microsoft’s cross-platform .NET platform from versions 1.0 through 3.1. Starting with .NET 5, Microsoft unified the platform and dropped the “Core” name. Therefore, .NET 5 and later—including .NET 10—are simply called .NET. ASP.NET Core, however, retained the “Core” name. So today, you should generally say “.NET 10”, not “.NET Core 10”.

2. What is Program.cs in ASP.NET Core, WebApplication, and explain the purpose of WebApplicationBuilder ?

Program.cs is the entry point of an ASP.NET Core application. Since .NET 6, it uses a minimal hosting model — meaning all the setup that used to be spread across Program.cs (with ConfigureServices and Configure methods) is now consolidated into a single file with top-level statements (no Main method or class boilerplate required).

A typical modern Program.cs looks like this:

var builder = WebApplication.CreateBuilder(args);

// Register services (dependency injection container)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

// Configure the HTTP request pipeline (middleware)
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

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

app.Run();

It handles three jobs in sequence: build configuration and register services, build the app, then configure the middleware pipeline and start listening for requests.

What is WebApplication ?

WebApplication is the object you get back after calling builder.Build() on a WebApplicationBuilder. It represents your fully configured, ready-to-run application, and it’s what you use to define the HTTP request pipeline and start the server.

var builder = WebApplication.CreateBuilder(args);
// ... register services on builder.Services ...

var app = builder.Build(); // <-- this is the WebApplication

// From here on, you configure app, not builder
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

WebApplication implements several interfaces at once, which is why it can do so much with one object:

  1. IHost — it’s the application host, so it manages the app’s lifetime (starting, running, stopping, graceful shutdown).
  2. IApplicationBuilder — this is what lets you build the middleware pipeline using app.Use…() methods (UseRouting, UseAuthentication, UseHttpsRedirection, custom middleware via app.Use(…), etc.). Middleware runs in the order you add it, for every incoming request.
  3. IEndpointRouteBuilder — this is what lets you map endpoints directly on it: app.MapGet(…), app.MapControllers(), app.MapRazorPages(), app.MapHub<t>(), etc.

What you typically do with it:

  1. Read environment info — app.Environment.IsDevelopment(), IsProduction(), etc., to conditionally add middleware.
  2. Configure the middleware pipeline.
  3. Map endpoints — minimal APIs directly, or MVC/Razor Pages routes.
  4. Access services — via app.Services (an IServiceProvider), useful for things like running startup logic or seeding a database.
  5. Run the app — app.Run() starts listening for requests and blocks until shutdown. There’s also app.RunAsync() and app.Start()/app.StopAsync() for more control.

Purpose of WebApplicationBuilder

WebApplicationBuilder (created via WebApplication.CreateBuilder(args)) is the object responsible for assembling everything the app needs before it starts running. It bundles together several things that used to be configured separately:

  1. Configuration — automatically loads settings from appsettings.json, appsettings.{Environment}.json, environment variables, command-line args, and user secrets, all merged into builder.Configuration.
  2. Dependency Injection container — builder.Services is an IServiceCollection where you register your services, repositories, DbContexts, HttpClients, etc. so they can be injected elsewhere in the app.
  3. Loggingbuilder.Logging lets you configure logging providers (console, debug, event source, etc.) out of the box. Web server setup — configures Kestrel (the built-in web server) and host settings like URLs, content root, and environment name (Development/Staging/Production).

Once you’ve configured everything on the builder, you call builder.Build(), which returns a WebApplication instance — this represents the fully configured app, and it’s what you use afterward to set up middleware (app.Use..) and endpoints (app.Map..) before calling app.Run().

In short: WebApplicationBuilder is a single unified object that replaces the old IWebHostBuilder/IHostBuilder split, making it much simpler to configure services, configuration, and logging in one place before the app is built and started.

3. What is Kestrel ?

Kestrel is the built-in, cross-platform web server used by ASP.NET Core to handle HTTP requests. It’s the default server that gets automatically configured and started for you when you call app.Run() — you don’t need to install IIS, Apache, or anything else to run an ASP.NET Core app. Key characteristics are:

  1. Cross-platform — Kestrel runs on Windows, Linux, and macOS, since it’s built on top of libuv/managed sockets (in newer versions, it uses a fully managed socket implementation) rather than any OS-specific web server technology.
  2. Fast and lightweight — it’s optimized for throughput and is one of the fastest .NET web servers, benchmarked regularly in the TechEmpower benchmarks.
  3. Included by default — when you call WebApplication.CreateBuilder(args), Kestrel is configured automatically as the web server. You don’t add it explicitly in most apps.
  4. Not always exposed directly to the internet — in production, Kestrel is often run behind a reverse proxy like:
    • IIS (on Windows)
    • Nginx or Apache (on Linux)
    • Azure App Service’s built-in proxy

The reverse proxy handles things like SSL termination, request buffering, load balancing across multiple Kestrel instances, and serving static files more efficiently — while Kestrel focuses purely on handling the ASP.NET Core application logic. That said, Kestrel is robust enough to be exposed directly to the internet these days (it has built-in support for HTTPS, HTTP/2, HTTP/3, connection limits, timeouts, etc.), so this pattern is now optional rather than mandatory.

How you configure it:

You can customize Kestrel through builder.WebHost.ConfigureKestrel(…) or in appsettings.json:

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(5000); // HTTP
    options.ListenAnyIP(5001, listenOptions =>
    {
        listenOptions.UseHttps(); // HTTPS
    });

    options.Limits.MaxConcurrentConnections = 100;
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
});

Or via configuration:

{
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://localhost:5000"
      },
      "Https": {
        "Url": "https://localhost:5001"
      }
    }
  }
}

Where it fits in the pipeline:

Client Request
      ↓
[Reverse Proxy - optional] (Nginx / IIS / Azure)
      ↓
   Kestrel  ← low-level HTTP server, receives raw requests
      ↓
Middleware Pipeline (app.Use...)
      ↓
Endpoints (app.Map... / Controllers)

In short: Kestrel is the actual server process listening on a port and translating raw HTTP traffic into requests that flow through your ASP.NET Core middleware pipeline. It’s what makes app.Run() actually able to “run” — without it, there’d be nothing accepting connections.

4. What is the ASP.NET Core request pipeline?

The request pipeline is the sequence of middleware components that every incoming HTTP request passes through before a response is generated and sent back. Each middleware can inspect, modify, short-circuit, or pass along the request — and then do the same on the way back out.

A typical pipeline in Program.cs:

var app = builder.Build();

app.UseExceptionHandler("/Error");   // catches exceptions from everything below
app.UseHsts();                       // adds HSTS header (production)

app.UseHttpsRedirection();           // redirect HTTP -> HTTPS
app.UseStaticFiles();                // serve wwwroot files, short-circuits if found

app.UseRouting();                    // determines which endpoint matches the URL

app.UseCors();                       // apply CORS policy
app.UseAuthentication();             // who are you? (sets HttpContext.User)
app.UseAuthorization();              // are you allowed? (checks policies)

app.MapControllers();                // executes the matched endpoint
app.Run();

Middleware executes in the order it’s registered, and typically in a “pipeline” shape:

For example, UseAuthentication() must come before UseAuthorization(), and both generally come after UseRouting() but before UseEndpoints().

5. What is the difference between Use, Run, and Map?

These are the three core extension methods (from IApplicationBuilder) for building the middleware pipeline. They differ in whether they call the next middleware, whether they branch, and how they match requests.

Use — chain middleware, call next()

Use adds middleware to the pipeline that can call the next middleware and continue the chain. This is the most common one.

app.Use(async (context, next) =>
{
    Console.WriteLine("Before");
    await next(); // passes control to the next middleware
    Console.WriteLine("After");
});
  • Can inspect/modify the request before next().
  • Can inspect/modify the response after next() returns.
  • Can choose not to call next() (short-circuit), though that’s more Run’s job conceptually.
  • Multiple Use calls form a chain, each wrapping the next.

Run — terminal middleware, no next

Run adds terminal middleware — it does not receive a next parameter at all, because it’s meant to end the pipeline. Whatever is registered after a Run will never execute for that branch.

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello, this is the end of the line.");
});
  • No way to call anything further — it’s a dead end by design.
  • Typically used as the last piece of a pipeline or a branch (e.g., inside Map/MapWhen).
  • If you add middleware after app.Run(), it’s simply never reached — the compiler won’t stop you, but it’s dead code.

Map — branch the pipeline by path

Map creates a separate sub-pipeline based on a matching request path. Once a request matches, it’s routed into that branch instead of continuing in the main pipeline.

app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) =>
    {
        Console.WriteLine("Inside /admin branch");
        await next();
    });

    adminApp.Run(async context =>
    {
        await context.Response.WriteAsync("Admin area");
    });
});

app.Run(async context =>
{
    await context.Response.WriteAsync("Main pipeline");
});
  • Matches based on the path segment (/admin, /api, etc.) — it strips the matched segment off PathBase for the branch.
  • Once branched, that request stays in the branch’s pipeline (it doesn’t automatically rejoin the main one).
  • There’s also MapWhen(), which branches based on any condition, not just path.

Important distinction: this is different from app.MapGet/MapControllers

Don’t confuse app.Map(…) (pipeline branching, from “IApplicationBuilder”) with app.MapGet(…), app.MapPost(…), app.MapControllers() (from IEndpointRouteBuilder) — the endpoint routing methods used after UseRouting(). Those register actual endpoints (minimal APIs, controllers) rather than branching middleware.

app.UseRouting();

app.MapGet("/hello", () => "Hi!");   // endpoint mapping, not pipeline branching
app.MapControllers();                 // endpoint mapping

6. What is Routing also explain endpoint routing. What does app.UseRouting() do ?

Routing is the process by which ASP.NET Core matches an incoming HTTP request to an endpoint — a piece of code that can handle that request (a controller action, Razor Page, minimal API delegate, gRPC service, etc.).

It looks at things like:

  • The URL path (/products/5).
  • The HTTP method (GET, POST, etc).
  • Sometimes headers, query strings, or route constraints.

…and decides which handler should execute, and extracts parameters from the URL (like id = 5) to pass into that handler.

What is Endpoint Routing ?

Before ASP.NET Core 2.2, routing and execution were tightly coupled — the router matched a route and immediately dispatched to a handler in one step. This made it hard for other middleware (like CORS, Authorization) to know in advance which endpoint would run, because that information wasn’t available until the MVC middleware itself resolved it.

Endpoint Routing (introduced in 2.2, standard since 3.0) splits this into two distinct phases:

  1. Route Matching — figure out which endpoint matches the request, and expose that as HttpContext.GetEndpoint().
  2. Route Execution — actually invoke that endpoint.

Because these are now separate, middleware placed between matching and execution can inspect the selected endpoint (and its metadata, like [Authorize] attributes) and make decisions accordingly — without having to duplicate routing logic itself.

Why this matters (good interview point):

“Endpoint routing decouples matching from execution. This lets middleware like Authorization or CORS run after the endpoint is known but before it executes, so they can read metadata off the endpoint (like [Authorize(Roles=”Admin”)]) and enforce policies correctly.”

What does app.UseRouting() do?

app.UseRouting() adds the route matching middleware to the pipeline. It:

  • Looks at the incoming request.
  • Matches it against the registered route patterns (from MapControllers(), MapGet(), MapRazorPages(), etc.)
  • Sets HttpContext.GetEndpoint() to the matched endpoint.
  • Does NOT execute the endpoint yet — it just identifies it.

The actual execution happens later — either implicitly via app.MapControllers() / app.MapGet() (in .NET 6+ minimal hosting), or explicitly via app.UseEndpoints(…) in older Program.cs / Startup.cs style.

Typical middleware order (very commonly asked):

app.UseRouting();          // 1. Match the request to an endpoint

app.UseCors();             // 2. Runs AFTER routing, BEFORE execution —
app.UseAuthentication();   //    these can inspect the matched endpoint
app.UseAuthorization();    //    (e.g., check [Authorize] metadata)

app.MapControllers();      // 3. Execute the matched endpoint

Why order matters (classic gotcha question):

If you call UseAuthorization() before UseRouting(), it will throw an exception or fail to work correctly — because at that point, no endpoint has been matched yet, so there’s no metadata (like [Authorize]) for the authorization middleware to inspect.

7. What is app.UseAuthentication() and app.UseAuthorization() ?

app.UseAuthentication() adds the authentication middleware to the pipeline. Its job is to figure out “who is making this request?”

  • It examines the incoming request (cookies, JWT bearer tokens, headers, etc).
  • Runs the configured authentication scheme(s) (Cookie, JWT Bearer, OAuth, OpenID Connect, etc).
  • If valid credentials are found, it constructs a ClaimsPrincipal and attaches it to HttpContext.User.
  • If no valid credentials exist, HttpContext.User is set to an unauthenticated ClaimsPrincipal (not null — just IsAuthenticated == false).
“Authentication answers: who are you? It populates HttpContext.User based on the credentials in the request.”
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer();

app.UseAuthentication(); // sets HttpContext.User

app.UseAuthorization() adds the authorization middleware. Its job is to figure out “are you allowed to do this?”

  • It looks at HttpContext.User (populated by authentication).
  • Checks it against the requirements of the matched endpoint — e.g., [Authorize], [Authorize(Roles = “Admin”)], or policy-based requirements.
  • If the user doesn’t meet the requirements → returns 403 Forbidden (or 401 Unauthorized if not authenticated at all).
  • If they do → request proceeds to the endpoint.
“Authorization answers: given who you are, are you permitted to access this specific resource?”
[Authorize(Roles = "Admin")]
public IActionResult DeleteUser(int id) { ... }

Why Order Matters (classic gotcha):

app.UseRouting();          // 1. Match request to endpoint (sets HttpContext.GetEndpoint())
app.UseAuthentication();   // 2. Determine WHO the user is → sets HttpContext.User
app.UseAuthorization();    // 3. Determine IF that user can access the matched endpoint
app.MapControllers();      // 4. Execute the endpoint

Key rules:

  • UseAuthentication() must come before UseAuthorization() — you can’t authorize a user you haven’t identified yet.
  • Both must come after UseRouting() — authorization needs to know which endpoint was matched (to read its [Authorize] metadata), which is only known after routing runs.
  • Both must come before MapControllers() / endpoint execution — otherwise the checks never happen before the code runs.

If you get the order wrong, ASP.NET Core will often literally throw a runtime exception telling you to fix the middleware order (there’s a diagnostic check for this).

8. What is the difference between IHostEnvironment and IWebHostEnvironment ?

IHostEnvironment is a generic interface providing information about the hosting environment for any .NET generic host — not specific to web apps. It’s part of Microsoft.Extensions.Hosting, used by console apps, worker services, and web apps alike.

public interface IHostEnvironment
{
    string EnvironmentName { get; set; }     // Development, Staging, Production
    string ApplicationName { get; set; }      // Assembly/app name
    string ContentRootPath { get; set; }      // Root folder for content files
    IFileProvider ContentRootFileProvider { get; set; }
}

Use case: Any generic host app (worker service, background service, console app) that just needs to know the environment name or content root — no concept of “web” involved.

IWebHostEnvironment is a web-specific extension of IHostEnvironment, part of Microsoft.AspNetCore.Hosting. It adds properties relevant only to web applications — specifically, serving static files.

public interface IWebHostEnvironment : IHostEnvironment
{
    string WebRootPath { get; set; }          // wwwroot folder path
    IFileProvider WebRootFileProvider { get; set; }
}

Use case: ASP.NET Core web apps that need to know where wwwroot is, to serve static files (CSS, JS, images).

“IHostEnvironment is the generic host abstraction — environment name, content root — usable in any .NET host. IWebHostEnvironment extends it with web-specific concerns, primarily the wwwroot path, since only web apps serve static files.”

9. What is IConfiguration?

IConfiguration is the core abstraction in ASP.NET Core for accessing configuration data — key/value settings your app needs at runtime — regardless of where that data actually comes from.

It’s part of Microsoft.Extensions.Configuration and is populated by the Generic Host before your app even starts handling requests.

“IConfiguration is a unified, provider-agnostic way to read settings. Your code doesn’t care whether a value came from appsettings.json, an environment variable, or a command-line argument — it just asks IConfiguration for it.”

Where the data comes from (Configuration Providers):

By default, WebApplicationBuilder wires up multiple providers, layered in this order (later ones override earlier ones):

  • appsettings.json
  • appsettings.{Environment}.json
  • User Secrets (Development only)
  • Environment variables
  • Command-line arguments
var builder = WebApplication.CreateBuilder(args);
// builder.Configuration is already an IConfiguration built from the above sources

You can also add custom sources: Azure Key Vault, a database, XML/INI files, in-memory collections, etc.

10. What is appsettings.json ? How do environment-specific configuration files work ?

appsettings.json is the default configuration file in ASP.NET Core — a JSON file at the root of the project used to store application settings (connection strings, logging levels, feature flags, custom settings, etc.). It’s automatically loaded as one of the configuration providers when you call WebApplication.CreateBuilder(args).

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=.;Database=MyDb;Trusted_Connection=True;"
  },
  "AllowedHosts": "*"
}

Alongside appsettings.json, you can have environment-specific overrides:

appsettings.json                 ← base settings, always loaded
appsettings.Development.json     ← loaded only when EnvironmentName = "Development"
appsettings.Staging.json         ← loaded only when EnvironmentName = "Staging"
appsettings.Production.json      ← loaded only when EnvironmentName = "Production"

How the environment is determined: Set via the ASPNETCORE_ENVIRONMENT (or DOTNET_ENVIRONMENT) environment variable — commonly Development, Staging, or Production. Locally, this is usually set in launchSettings.json.

Load Order & Merging (the key interview point):

WebApplication.CreateBuilder(args) wires these up in a specific order, and later sources override earlier ones on matching keys:

  1. appsettings.json
  2. appsettings.{EnvironmentName}.json
  3. User Secrets (Development only)
  4. Environment variables
  5. Command-line arguments
“It’s not a replace — it’s a layered merge. appsettings.{Environment}.json only needs to specify the keys that differ; everything else falls back to the base appsettings.json.”

Example of the merge behavior

appsettings.json:

{
  "Logging": { "LogLevel": { "Default": "Information" } },
  "ApiUrl": "https://api.prod.example.com"
}

appsettings.Development.json:

{
  "Logging": { "LogLevel": { "Default": "Debug" } }
}

Result when running in Development:

{
  "Logging": { "LogLevel": { "Default": "Debug" } },   // overridden
  "ApiUrl": "https://api.prod.example.com"               // inherited from base
}
Only LogLevel.Default was overridden — ApiUrl was untouched because Development’s file never mentioned it.

How It’s Registered (behind the scenes) – WebApplication.CreateBuilder(args) does roughly this internally:

config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
config.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
if (env.IsDevelopment())
{
    config.AddUserSecrets(...);
}
config.AddEnvironmentVariables();
config.AddCommandLine(args);
  • optional: true → the app won’t crash if, say, appsettings.Production.json doesn’t exist.
  • reloadOnChange: true → the file is watched; if it changes on disk while the app is running, IConfiguration picks up the new values (paired with IOptionsMonitor for live updates in your code).

Dependency Injection Interview Questions

Dependency Injection questions are a staple in ASP.NET Core interviews because DI isn’t just a supporting feature — it’s baked into the framework’s core architecture. Interviewers ask about it to see whether you understand why the framework is built the way it is, not just that you can inject an interface into a constructor. Your answer reveals whether you grasp concepts like loose coupling, testability, service lifetimes (Singleton/Scoped/Transient), the composition root, and how ASP.NET Core’s own internals (logging, configuration, EF Core) are wired using the same container. A clear, structured answer here signals real hands-on experience rather than memorized definitions, and it sets you up well for follow-up questions on service lifetimes, captive dependencies, and the options pattern.

11. What is Dependency Injection(DI) ? Why DI is used in ASP.NET Core ? Explain the three DI lifetimes?

Dependency Injection is a design pattern where an object’s dependencies (the other objects/services it needs to function) are provided to it from the outside, rather than the object creating them itself.

Instead of this (tight coupling):

public class OrderService
{
    private readonly EmailService _emailService = new EmailService(); // hardcoded dependency
}

You do this (loose coupling):

public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService) // injected via constructor
    {
        _emailService = emailService;
    }
}

The class depends on an abstraction (IEmailService), and something external — a DI container — decides which concrete implementation to hand it at runtime.

Why is DI used in ASP.NET Core ?

ASP.NET Core has DI built into the framework itself (not bolted on like older versions needed third-party tools such as Autofac or Ninject). Reasons it’s used:

  • Loose coupling — classes depend on interfaces, not concrete implementations, making code easier to change.
  • Testability — you can inject mock/fake implementations in unit tests instead of real services (e.g., mock a database call).
  • Maintainability — swapping an implementation (e.g., switching from SendGrid to SMTP for emails) means changing one registration line, not every class that uses it.
  • Centralized object lifetime management — the DI container manages creation and disposal of objects, so you’re not manually managing new and cleanup everywhere.
  • Framework-wide consistency — built-in services like ILogger, DbContext, IConfiguration, HttpClient are all provided via DI, so your custom services follow the same pattern.

Registration happens in Program.cs:

builder.Services.AddScoped<IEmailService, EmailService>();

Explain the three DI lifetimes

LifetimeInstance createdTypical use case
TransientA new instance every time it’s requestedLightweight, stateless services
ScopedOne instance per request (HTTP request scope)Services that need consistency within a request, e.g., DbContext
SingletonOne instance for the entire application lifetimeShared state, caching, configuration, logging
a. Transient — AddTransient<TInterface, TImplementation>()
  • A new object is created every single time it’s injected — even multiple times within the same request.
  • Best for small, stateless, cheap-to-create services.
builder.Services.AddTransient<IEmailService, EmailService>();
b. Scoped — AddScoped<TInterface, TImplementation>()
  • One instance is created per HTTP request. If the same service is requested multiple times within that request, the same instance is reused.
  • Classic example: DbContext in EF Core — you want the same context throughout a request to track changes consistently, but not share it across requests (which would cause concurrency issues).
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
c. Singleton — AddSingleton<TInterface, TImplementation>()
  • Only one instance for the entire lifetime of the application — created once, reused for every request by every user.
  • Good for: caching services, configuration objects, logging providers.
  • ⚠️ Risk: if a singleton holds a reference to a scoped service (like DbContext), you get a captive dependency bug — the short-lived service gets trapped inside the long-lived one, causing threading/data issues.
builder.Services.AddSingleton<ICacheService, CacheService>();

Follow-up traps interviewers often ask:

  • “What happens if you inject a scoped service into a singleton?” → Runtime exception (or captive dependency bug if done incorrectly via IServiceProvider).
  • “Is DbContext scoped or transient by default?” → Scoped, via AddDbContext<>().
  • “Can you manually resolve a service instead of constructor injection?” → Yes, via IServiceProvider.GetService<t>() (service locator pattern — generally discouraged).
  • “What’s the difference between DI and IoC (Inversion of Control)?” → DI is one way to implement the broader IoC principle.

12. When should you use a Singleton service ?

Use Singleton when the service should be created once for the entire application lifetime and shared across all requests and all users.

Good use cases:

  • Stateless, thread-safe utility services — e.g., a service that just does computation with no mutable shared state (ILogger-style helpers, mapping utilities).
  • In-memory caching services — e.g., IMemoryCache itself is registered as a singleton; a custom cache wrapper that holds data across requests.
  • Configuration/settings objects that are read-only after startup (e.g., wrapping IOptions values you don’t expect to change).
  • Connection pools / expensive-to-create shared resources — e.g., an HttpClient factory setup, a database connection pool manager, or a third-party SDK client that’s explicitly documented as thread-safe and meant to be reused.
  • Application-wide counters, background job schedulers, or shared state coordinators — e.g., a service tracking metrics across the app, or a coordinator for a BackgroundService.
  • Expensive initialization — if constructing the service is costly (loading large data, compiling regex, etc.), singleton avoids repeating that cost per request.

Key requirement: thread safety:

Because a singleton instance is shared across concurrent requests, it must be thread-safe. Any mutable state inside it needs proper synchronization (locks, ConcurrentDictionary, immutable data structures, etc.), or you risk race conditions.

When NOT to use Singleton:

  • If the service depends on Scoped services (like DbContext), don’t make it a singleton — this causes the classic “captive dependency” problem, where the scoped service gets trapped inside a singleton and effectively becomes a singleton too, often causing bugs (e.g., a DbContext used across threads/requests incorrectly).
  • If the service holds per-request or per-user state — use Scoped instead.
  • If it has cheap-to-create, non-thread-safe logic with no shared state — Transient is simpler and safer.

Interview-ready summary:

Use Singleton for stateless or thread-safe shared services that are expensive to create or need to maintain state across the entire app lifetime — like caching, configuration, or connection pooling — while making sure they never directly depend on Scoped services like DbContext.

13. Why is DbContext normally registered as Scoped ?

a. It maps naturally to a single unit of work:

A web request typically represents one logical “unit of work” — read some data, maybe modify it, save changes, done. DbContext is designed around this same idea: it tracks changes to entities, batches them, and commits them together via SaveChanges(). Scoping it to the request means each request gets a clean, isolated unit of work that starts and ends with the request.

b. DbContext is not thread-safe

A single DbContext instance cannot be used by multiple threads concurrently — EF Core will throw exceptions if you try. If it were a Singleton, every concurrent request would share the same instance and could corrupt or crash things under load. Scoped guarantees each request gets its own instance, so there’s no cross-request interference.

c. Change tracking needs a clear boundary

DbContext keeps an internal cache of entities it’s tracking (the change tracker). If it lived for the whole app lifetime (Singleton), this cache would grow unbounded and get stale — you’d risk serving outdated data or accidentally saving changes that belong to a completely different request/user.

d. Avoids “captive dependency” problems

If DbContext were Scoped but got injected into a Singleton service, it would become a captive dependency — trapped inside the singleton for the app’s whole life, defeating its purpose and likely causing threading/staleness bugs. Keeping DbContext itself Scoped, and being careful about what depends on it, keeps this boundary clean.

e. Matches connection lifetime expectations

Scoped lifetime aligns well with how the underlying database connection should be used — opened for the duration of the request/unit of work, then released back to the pool. Not held open indefinitely (Singleton), and not needlessly reopened many times within the same logical operation (which could happen if it were Transient and injected into multiple places within one request).

DbContext is Scoped because it represents a single unit of work tied to a request: it’s not thread-safe, tracks entity state that shouldn’t leak across requests, and needs a clear start/end boundary — Singleton would cause thread-safety and stale-data issues, while Transient would fragment change tracking within a single request if injected into multiple services.

14. What is constructor injection ? Can ASP.NET Core perform property injection ?

Constructor injection is a dependency injection technique where a class receives its dependencies as parameters through its constructor, rather than creating them itself or having them set via properties/methods.

public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IEmailSender _emailSender;

    // Dependencies are "injected" through the constructor
    public OrderService(ILogger<OrderService> logger, IEmailSender emailSender)
    {
        _logger = logger;
        _emailSender = emailSender;
    }

    public void PlaceOrder()
    {
        _logger.LogInformation("Order placed");
        _emailSender.Send("Order confirmed");
    }
}

Instead of OrderService doing new EmailSender() internally, it declares “I need an an IEmailSender to function” — and something external (the DI container) supplies them when creating the object.

How it works in ASP.NET Core:

a. You register the dependency and its implementation in Program.cs:

builder.Services.AddScoped<IEmailSender, EmailSender>();
builder.Services.AddScoped<OrderService>();

b. When you ask the container to create an OrderService (e.g., because it’s injected into a controller), it:

  • Looks at the constructor – See it needs ILogger<OrderService> and IEmailSender
  • Resolves those from the container (recursively resolving their dependencies too)
  • Passes them into the constructor
  • Returns you a fully-constructed OrderService.
public class OrdersController : ControllerBase
{
    private readonly OrderService _orderService;

    public OrdersController(OrderService orderService) // <- injected automatically
    {
        _orderService = orderService;
    }
}

Why it’s preferred (key interview points):

BenefitExplanation
Explicit dependenciesAnyone reading the constructor immediately knows what the class needs to work.
Guaranteed valid stateThe object can’t exist without its required dependencies — no null-reference surprises later.
ImmutabilityDependencies can be assigned to readonly fields, since they’re only set once at construction.
TestabilityEasy to pass in mocks/fakes in unit tests without a DI container.
Fail-fastIf a dependency isn’t registered, you get an exception immediately at startup (or first resolution) rather than a silent null property later.

ASP.NET Core and Property Injection

ASP.NET Core’s built-in dependency injection (DI) container does not support property injection out of the box. It only supports constructor injection.

The built-in IServiceProvider/IServiceCollection container resolves dependencies exclusively by looking at a class’s constructor parameters. If you have a public property decorated with something like [Inject] (as you might see in other frameworks or in Blazor components), the core DI container will simply ignore it — it won’t populate that property automatically.

public class MyService
{
    // This will NOT be automatically injected by the built-in container
    public ILogger<MyService> Logger { get; set; }

    // Only this works with built-in DI
    public MyService(ILogger<MyService> logger)
    {
        Logger = logger;
    }
}

Blazor components are a notable exception — they use the [Inject] attribute for property injection, but this is handled by Blazor’s component activation pipeline, not the general-purpose ASP.NET Core DI container.

public class MyComponent : ComponentBase
{
    [Inject]
    public IMyService MyService { get; set; }
}

15. What is IServiceProvider ? What is service resolution ?

IServiceProvider is the core interface in .NET’s dependency injection system that represents a container capable of resolving (creating/retrieving) service instances. It’s the fundamental abstraction that all of ASP.NET Core’s DI is built on top of.

It’s surprisingly simple — just one method:

public interface IServiceProvider
{
    object? GetService(Type serviceType);
}

That’s it. Given a Type, it returns an instance of that type (or null if it’s not registered). Everything else — AddScoped, AddSingleton, constructor injection, etc. — is built as convenience layers on top of this single method.

Relationship to IServiceCollection:

These two work together but serve different roles:

InterfaceRoleWhen used
IServiceCollectionRegistration — a list of service descriptors (what maps to what)During app startup/configuration
IServiceProviderResolution — actually creates instances on demandAt runtime, when something needs a dependency
// IServiceCollection: registering
var services = new ServiceCollection();
services.AddScoped<IEmailSender, EmailSender>();

// Build the container -> get an IServiceProvider
IServiceProvider provider = services.BuildServiceProvider();

// IServiceProvider: resolving
var emailSender = provider.GetService<IEmailSender>();

In Program.cs, builder.Services is an IServiceCollection. When you call builder.Build(), ASP.NET Core internally calls BuildServiceProvider() to produce the IServiceProvider that powers the app for its whole lifetime.

Interview-ready summary:

“IServiceProvider is the interface representing the DI container itself — it has a single method, GetService(Type), that resolves and returns an instance of a requested type. While IServiceCollection is used at startup to register services, IServiceProvider is used at runtime to resolve them. ASP.NET Core builds an IServiceProvider from the IServiceCollection when the app starts, and the framework uses it internally to satisfy constructor injection. You’d interact with it directly mainly for manual resolution or when you need to create a scope — for example, to consume a scoped service from within a singleton via IServiceScopeFactory.”

What is service resolution ?

Service resolution is the runtime process of the DI container producing an actual instance of a requested type, based on what was registered earlier. It works recursively — the container inspects the requested type’s constructor, resolves each dependency (and their dependencies, and so on), then builds the object graph bottom-up. The instance returned — and whether it’s newly created or reused — depends on the service’s registered lifetime: transient, scoped, or singleton. Resolution typically happens automatically when ASP.NET Core constructs controllers or other framework-managed types, but can also be done manually via IServiceProvider.GetService or GetRequiredService.

// 1. Registration (startup) — just metadata, nothing is created yet
builder.Services.AddScoped<IEmailSender, EmailSender>();
builder.Services.AddScoped<OrderService>();

// 2. Resolution (runtime) — container actually builds the object graph
var orderService = provider.GetRequiredService<OrderService>();

When step 2 runs, the container:

  1. Looks up OrderService → finds its constructor needs IEmailSender (and maybe ILogger<OrderService>)
  2. Resolves IEmailSender → finds it maps to EmailSender → checks its constructor for dependencies
  3. Keeps resolving recursively until every leaf dependency is satisfied
  4. Builds the objects bottom-up and passes them into constructors
  5. Returns the fully-constructed OrderService

This recursive process is often called building the object graph or dependency graph.

Where resolution happens automatically vs. manually

Automatic (most common): The framework resolves for you.

public class OrdersController : ControllerBase
{
    // ASP.NET Core resolves OrderService (and everything it needs)
    // automatically when creating this controller for an incoming request
    public OrdersController(OrderService orderService) { }
}

Manual: You explicitly ask the IServiceProvider to resolve something.

var service = provider.GetRequiredService<OrderService>();

Middleware Interview Questions

Middleware questions come up a lot in ASP.NET Core interviews because they test whether you actually understand the framework rather than just knowing syntax. Here’s why interviewers lean on them:

a. It reveals whether you understand the request pipeline, not just controllers

A lot of developers can write a controller action but have never thought about everything that happens before and after it. Middleware questions expose whether you understand the full lifecycle of a request — which is foundational to debugging real production issues (weird headers, auth failures, CORS errors, etc. almost always trace back to middleware order).

b. Order-of-execution bugs are extremely common in real jobs

Bugs like “authorization isn’t working,” “CORS is broken,” or “exceptions aren’t being caught” are very often just middleware registered in the wrong order. Interviewers ask this because it’s a real, recurring source of production bugs — not an academic gotcha.

c. It tests architectural thinking (separation of concerns)

Knowing why logging/auth/error-handling belong in middleware rather than in every controller shows you understand clean architecture principles — separating cross-cutting concerns from business logic. This is a signal of engineering maturity, not just framework trivia.

d. It’s a natural way to probe DI lifetime understanding too

The classic “middleware constructor runs once, InvokeAsync runs per-request” gotcha tests whether you understand singleton vs. scoped/transient lifetimes — connecting two topics (DI + middleware) in one question. It’s an efficient way for an interviewer to check multiple concepts at once.

e. Custom middleware = a proxy for “can you extend the framework”

Asking you to write custom middleware checks if you can work with the framework’s conventions (the InvokeAsync(HttpContext, RequestDelegate) pattern) rather than fighting against them — a good signal of whether you’ll be productive quickly on a real codebase.

f. It often leads into follow-up questions

Middleware is a great “hub” topic — a good interviewer will pivot from it into:

  • Exception handling strategies.
  • (UseExceptionHandler vs. try/catch) Authentication/authorization pipeline CORS configuration.
  • Short-circuiting and performance implications Filters vs. middleware (a classic follow-up: “when would you use an action filter instead of middleware?”).

16. What is middleware ? How does middleware execute ? How do you create custom middleware ?

Middleware is a piece of code that runs on every request (or requests matching some condition), sitting between the raw incoming HTTP request and your endpoint/controller — able to inspect, modify, short-circuit, or pass along the request/response.

ASP.NET Core builds the pipeline by chaining middleware components together, one after another:

Request  →  Middleware A  →  Middleware B  →  Middleware C  →  Endpoint
Response ←  Middleware A  ←  Middleware B  ←  Middleware C  ←  Endpoint

Each middleware component has the chance to:

  • Do something before passing control to the next component (on the way in)
  • Decide whether to call the next component at all (or short-circuit)
  • Do something after the next component finishes (on the way out, with the response)
var app = builder.Build();

app.Use(async (context, next) =>
{
    Console.WriteLine("Before"); // runs on the way in
    await next(context);         // calls the next middleware
    Console.WriteLine("After");  // runs on the way out
});

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello World");
});

app.Run();

For a request, the output would be:

Before
(response generated: "Hello World")
After

Real world examples : Every ASP.NET Core app is built from middleware — you’ve likely used these without necessarily calling them “middleware”:

app.UseHttpsRedirection();   // redirects HTTP -> HTTPS
app.UseStaticFiles();        // serves files from wwwroot
app.UseRouting();            // figures out which endpoint matches the URL
app.UseAuthentication();     // identifies who the user is
app.UseAuthorization();      // checks if they're allowed to access the resource
app.UseCors();               // handles cross-origin request rules
app.MapControllers();        // the "final" middleware that invokes your controller action

How Middleware Executes in ASP.NET Core ?

ASP.NET Core middleware executes as an ordered pipeline of request delegates. Each component can perform logic before and after calling the next middleware via next(), forming a nested chain. Order of registration determines execution order for the request path and the reverse order for the response path. Middleware can also short-circuit the pipeline by not calling next(), which is how things like authentication or static file middleware avoid unnecessary processing.

How do you create custom middleware ?

This is the standard approach for real-world, reusable middleware. No interface is required — just follow a convention:

  • Constructor takes RequestDelegate next.
  • Public method named Invoke or InvokeAsync that takes HttpContext and returns Task
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("Handling request: {Path}", context.Request.Path);

        await _next(context); // pass control to next middleware

        _logger.LogInformation("Finished handling request. Status: {StatusCode}", 
            context.Response.StatusCode);
    }
}

Register it — typically via an extension method for cleanliness:

public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestLoggingMiddleware>();
    }
}

In the Program class:

// Program.cs
app.UseRequestLogging();

17. What is the difference between middleware and filters ?

Both Middleware and Filters let you hook into the request pipeline, but they operate at different levels and have different levels of context awareness.

Core Difference
AspectMiddlewareFilters
LevelApplication-level (raw HTTP pipeline)MVC/Action-level (inside the MVC framework)
Awareness of MVCNone — knows nothing about controllers, actions, model bindingFull — knows about action methods, model state, controller context
ScopeRuns for every request (unless branched/short-circuited)Runs only for requests that reach the MVC/Razor Pages pipeline, and can be scoped per-controller/action
Access toHttpContext onlyHttpContext + ActionContext, model binding results, action arguments, action result
Configured viaapp.Use...() in Program.csAttributes, or registered globally in AddControllers(options => options.Filters.Add(...))
Where They Sit in the Pipeline

Filters actually execute inside one particular middleware — the MVC/endpoint-invocation middleware (UseRouting + UseEndpoints, effectively). So the relationship looks like:

Request
  → Middleware 1
    → Middleware 2 (e.g., Routing)
      → Middleware 3 (Endpoint execution)
        → Authorization Filter
          → Resource Filter
            → Model Binding
              → Action Filter (before)
                → Action Method executes
              → Action Filter (after)
            → Exception Filter (if needed)
          → Result Filter (before)
            → Result executes (e.g., serialize to JSON)
          → Result Filter (after)
      ← Middleware 3
    ← Middleware 2
  ← Middleware 1
Response
Filter Types (MVC-specific granularity)

Filters have multiple specialized stages, which middleware doesn’t have:

  1. Authorization Filters — run first, decide if user is allowed
  2. Resource Filters — run before model binding (good for caching)
  3. Action Filters — run immediately before/after the action method
  4. Exception Filters — handle exceptions thrown by action methods
  5. Result Filters — run before/after the action result is executed (e.g., before the view/JSON is written to response)
public class LogActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        // Before the action method runs — has access to action arguments
        Console.WriteLine($"Executing {context.ActionDescriptor.DisplayName}");
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        // After the action method runs — has access to the result
        Console.WriteLine($"Executed. Result: {context.Result}");
    }
}

Registration options:

[ServiceFilter(typeof(LogActionFilter))]  // on a specific action/controller
public IActionResult Get() => Ok();

// OR globally:
builder.Services.AddControllers(options =>
{
    options.Filters.Add<LogActionFilter>();
});
When to Use Which

Use Middleware when:

  • Logic applies to all requests regardless of framework (e.g., static files, HTTPS redirection, CORS, logging every request, custom headers)
  • You need to short-circuit before reaching MVC at all
  • You don’t need action-specific context (route values, model state, action arguments)

Use Filters when:

  • You need MVC-specific context (which action is being called, model binding results, [FromBody] arguments)
  • You want granular control tied to specific controllers/actions (e.g., [Authorize], validation, response shaping)
  • You want cross-cutting logic reusable via attributes on select endpoints only

Interview-ready summary

Middleware operates at the raw ASP.NET Core HTTP pipeline level and runs for every request, with no knowledge of MVC concepts like actions or model binding. Filters run inside the MVC pipeline — specifically inside the endpoint-execution middleware — and have five types (Authorization, Resource, Action, Exception, Result) that hook into specific stages of action execution, giving them access to richer context like action arguments and results. Use middleware for cross-cutting, framework-agnostic concerns; use filters when you need MVC-specific context or want behavior scoped to particular controllers/actions.

18. What is the purpose of Invoke() / InvokeAsync() ?

This is the method that does the actual work of the middleware — it’s the piece the framework calls for every incoming request.

public class CustomMiddleware
{
    private readonly RequestDelegate _next;

    public CustomMiddleware(RequestDelegate next)
    {
        _next = next; // stored once, at startup
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // 1. Logic before passing control forward
        // 2. Call the next component in the pipeline
        await _next(context);
        // 3. Logic after the rest of the pipeline completes
    }
}

InvokeAsync (or Invoke) is:

  1. The per-request entry point — called by the ASP.NET Core runtime for every HTTP request that reaches this middleware.
  2. Where you receive HttpContext — giving access to the request, response, user, services, etc., for this specific request.
  3. Where you call _next(context) — passing control to the next middleware in the pipeline. This is what actually chains everything together.
  4. Where before/after logic lives — code before _next() runs on the way “in”; code after _next() runs on the way “out” (response phase).

Why It’s Separate from the Constructor

This is a very common interview follow-up, so it’s worth being precise:

ConstructorInvokeAsync
CalledOnce, at app startupOnce per HTTP request
PurposeCapture _next and inject singleton dependenciesDo the actual per-request work; inject scoped/transient dependencies as method parameters
DI behaviorConstructor is only ever resolved once, so scoped services (like DbContext) would effectively become singletons if injected here — bug riskMethod parameters are resolved fresh by DI per call, so scoped services are safe here
public class AuditMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<AuditMiddleware> _logger; // OK: singleton-safe

    public AuditMiddleware(RequestDelegate next, ILogger<AuditMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    // AppDbContext is scoped — injected here, resolved fresh each request
    public async Task InvokeAsync(HttpContext context, AppDbContext db)
    {
        var user = context.User.Identity?.Name;
        db.AuditLogs.Add(new AuditLog { User = user, Path = context.Request.Path });
        await db.SaveChangesAsync();

        await _next(context);
    }
}

Invoke vs InvokeAsync

  • Functionally identical — the framework uses reflection to find either name.
  • Convention: use InvokeAsync if the method is asynchronous (returns Task), which is almost always the case since you’re typically awaiting _next(context) or I/O. Invoke (synchronous) is rare in practice.

What Happens Internally:

Under the hood, UseMiddleware<T>() uses reflection to build a RequestDelegate that:

  1. Instantiates your middleware class once (passing next + singleton services into the constructor).
  2. Wraps a call to InvokeAsync, resolving any additional parameters from HttpContext.RequestServices (the per-request DI scope) each time it’s invoked.

This is effectively how the whole middleware pipeline is just a chain of RequestDelegate (Func<HttpContext, Task>) instances, each closing over the “next” one.

19. What is short-circuiting in middleware ?

Short-circuiting is when a middleware component ends the pipeline early by not invoking next(), meaning subsequent middleware and the endpoint are never reached. It’s used intentionally for things like serving static files, rejecting unauthenticated requests, or returning cached responses — avoiding unnecessary work downstream. The middleware that short-circuits is responsible for writing a complete response itself, since nothing further in the pipeline will run.

app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Api-Key"))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("API key missing");
        return; // next() is NOT called — pipeline short-circuits here
    }

    await next(context); // only reached if the check passes
});

Everything registered after this middleware — including later middleware and the MVC/endpoint layer — is skipped entirely for this request.

Common Real-World Examples:

MiddlewareWhy it short-circuits
Static Files (UseStaticFiles)If the requested path matches a file on disk, it serves the file and stops — no need to hit routing/MVC
Authentication/Authorization failuresReturn 401/403 immediately rather than letting the request reach a controller
Response cachingIf a valid cached response exists, return it directly, skip regenerating it
Rate limitingReturn 429 immediately if the client has exceeded their limit
Health check endpointsRespond immediately without going through the full MVC pipeline
app.Run()By definition, terminal middleware — it never calls next because there’s nothing to call

20. How would you implement global exception handling middleware ?

This is the modern, framework-supported way — cleaner, testable, and supports multiple chained handlers.

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Unhandled exception");

        var (statusCode, title) = exception switch
        {
            KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found"),
            ValidationException  => (StatusCodes.Status400BadRequest, "Validation Error"),
            _ => (StatusCodes.Status500InternalServerError, "Server Error")
        };

        httpContext.Response.StatusCode = statusCode;

        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = exception.Message,
            Instance = httpContext.TraceIdentifier
        }, cancellationToken);

        return true; // true = handled, stop looking for other handlers
    }
}

Registration in Program.cs:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails(); // enables standard ProblemDetails responses

var app = builder.Build();

app.UseExceptionHandler(); // uses the registered IExceptionHandler(s)

app.UseHttpsRedirection();
app.UseRouting();
app.MapControllers();

app.Run();

You can register multiple handlers — they’re tried in order until one returns true:

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // fallback/catch-all last
Key Design Considerations (common follow-ups)
ConcernGuidance
PlacementMust be the first middleware (or very close to it) so it wraps everything downstream
Don’t leak detailsIn production, avoid returning stack traces / internal exception messages to the client — log them server-side, return generic messages
Consistent response shapeUse ProblemDetails (RFC 7807) — ASP.NET Core has built-in support via AddProblemDetails()
Status code mappingMap exception types → HTTP status codes via a switch expression or a dictionary, rather than always returning 500
LoggingAlways log the full exception (with stack trace) server-side, even though the client gets a sanitized message
Environment-specific behaviorOften combined with app.UseDeveloperExceptionPage() in Development and the custom handler in Production:

21. How can middleware access request and response information ?

In ASP.NET Core, middleware doesn’t get separate req/res parameters. Everything is bundled into a single HttpContext object that’s passed through the pipeline.

public class MyMiddleware
{
    private readonly RequestDelegate _next;

    public MyMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Access request
        var path = context.Request.Path;
        var method = context.Request.Method;
        var headers = context.Request.Headers;

        // Do something before calling the next middleware
        Console.WriteLine($"{method} {path}");

        await _next(context); // pass control to next middleware

        // Do something after downstream middleware has run
        Console.WriteLine($"Response status: {context.Response.StatusCode}");
    }
}

Key pieces

a. HttpContext

  • The single object that wraps everything about the current request/response cycle.
  • context.RequestHttpRequest object (path, method, headers, query string, body stream, cookies, etc.)
  • context.ResponseHttpResponse object (status code, headers, body stream, cookies)
  • Also exposes context.User (claims principal), context.Items (per-request key/value bag for passing data between middleware), context.Connection, etc.

b. RequestDelegate _next

  • Represents “the rest of the pipeline.” Calling await _next(context) invokes the next middleware.
  • Because it’s await ed, code after that call runs after the downstream pipeline completes — this is how you read/modify the response on the way back out (e.g., logging status codes, injecting headers based on the final response).

c. Reading the request

   var token = context.Request.Headers["Authorization"];
   var body = await new StreamReader(context.Request.Body).ReadToEndAsync();

d. Modifying the response

context.Response.StatusCode = 401;
context.Response.Headers["X-Custom"] = "value";
await context.Response.WriteAsync("Unauthorized");

Note: once context.Response.Body has started being written to (headers flushed), you can no longer change the status code or headers — a common gotcha interviewers like to probe.

e. Short-circuiting

  • A middleware can choose not to call _next(context) at all, ending the pipeline early (e.g., auth failure, rate limiting, serving a cached response).

f. Registration order matters

  • Middleware is wired up in Program.cs/Startup.cs via app.Use…() calls, and executes in that exact order on the way in, then unwinds in reverse order on the way out (it’s effectively a nested/recursive chain, not a flat list).

g. Alternative styles

  • Besides the class-based convention above, ASP.NET Core also supports inline delegate middleware:
app.Use(async (context, next) =>
{
    // before
    await next();
    // after
});
  • And IMiddleware (factory-based) for DI-friendly, per-request instantiated middleware.

Interview-worthy comparison point:

ASP.NET Core uses one context object with two-way (in/out) access and an awaitable pipeline, so a single middleware naturally handles both the “before” and “after” phases in one method — no separate req/res params.

22. Why Exception-Handling Middleware Goes Early in the Pipeline ?

The key reason: middleware can only catch exceptions thrown by components that run after it in the pipeline.

ASP.NET Core middleware forms a nested call chain via await _next(context). If middleware A calls middleware B which calls middleware C, and C throws, that exception propagates back up through B and then A — but only if A and B are “wrapping” C, i.e., positioned before it in registration order.

app.UseExceptionHandler("/Error");   // registered early
app.UseHsts();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(...);               // exceptions can occur here

Because UseExceptionHandler is registered near the top, it wraps everything that comes after it (routing, auth, endpoint execution, etc.) in a try/catch. When any of those downstream components throw, the exception bubbles back up through the call stack and lands inside the exception-handling middleware’s catch block.

What happens if you place it late

If you register UseExceptionHandler after other middleware — say, after UseRouting or UseEndpoints — then:

  • Any exception thrown by middleware before it (routing, auth, custom middleware, etc.) will never reach it, because the exception handler isn’t wrapping those components.
  • The exception would instead propagate all the way up to the server (Kestrel) or the built-in developer exception page (if enabled), resulting in an unhandled 500 error with no custom error page/logging — defeating the purpose of having centralized error handling.
One-line interview answer: Because middleware in ASP.NET Core only catches exceptions from components later in the pipeline (due to how the nested next() delegate chain unwinds), exception-handling middleware must be registered early so it wraps — and can catch errors from — everything that runs after it.

Routing Interview Questions

Routing is one of the highest-yield topics interviewers probe because it sits at the intersection of “how does a request become a method call” — a question that tests whether you understand the framework’s core mechanics rather than just memorized syntax. Here’s how to think about its importance and what’s commonly asked.

Why it’s a big deal conceptually:

Routing is the mechanism that maps an incoming URL + HTTP method to a specific piece of executable code (an endpoint — typically a controller action or minimal API delegate). Without it, the framework has no way of knowing what code should handle GET /api/products/5 vs POST /api/products. It’s the bridge between the raw HTTP request and your application logic.

Interviewers care about it because:

  • It reveals whether you understand the request pipeline as a whole (routing is a middleware itself — UseRouting()/UseEndpoints() or the newer minimal hosting model).
  • It’s where real-world bugs happen — ambiguous routes, wrong HTTP verb handling, route ordering issues, constraint mismatches.
  • It connects to API design — RESTful conventions, versioning, attribute vs conventional routing.

23. What is routing in ASP.NET Core ? What is attribute routing ? What is conventional routing ?

Routing is the mechanism that matches an incoming HTTP request to an executable endpoint (a controller action, Razor Page, or minimal API delegate) based on the request’s URL and HTTP method, and extracts route values (like IDs) from the URL to make them available to that endpoint.

In other words: when a request comes in like GET /products/5, routing is what decides which method in your code should handle it, and it also extracts 5 as a value (e.g., id = 5) that gets passed into that method.

routing is implemented as endpoint routing, split into two middleware calls:

app.UseRouting();      // matches the request to an endpoint
// ... other middleware (auth, etc.) can inspect the matched endpoint here ...
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers(); // executes the matched endpoint
});

In minimal hosting, this is often implicit — you just call things like app.MapGet(…) or app.MapControllers() directly, and the framework wires up routing/endpoints for you.

There are two ways to define routes: conventional routing and attribute routing.

Conventional Routing

Conventional routing defines route patterns centrally, usually once, and lets those patterns apply broadly across controllers based on naming conventions (controller name, action name).

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

How it works:

  • The URL is matched against the pattern’s placeholders: {controller}, {action}, {id}. {controller=Home} and {action=Index} provide default values — if the URL segment is missing, it defaults to Home/Index.
  • {id?} marks id as optional.
  • So /Products/Details/5 maps to ProductsController.Details(int id) with id = 5. A request to just / maps to HomeController.Index() via the defaults.

Characteristics:

  • One central place to define URL structure/shape for a whole app.
  • Relies on a consistent naming convention across controllers — if you deviate, the “convention” breaks down and you need attribute routing anyway.
  • More common in traditional MVC apps (server-rendered views) with predictable, uniform URL patterns.
  • Less precise control per-action — harder to express things like versioned APIs or unconventional URL shapes cleanly.

Attribute Routing

Attribute routing defines routes directly on controllers and actions using attributes, giving fine-grained, explicit control over each endpoint’s URL.

[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]                  // GET api/products
    public IActionResult GetAll() { ... }

    [HttpGet("{id:int}")]      // GET api/products/5
    public IActionResult Get(int id) { ... }

    [HttpPost]                 // POST api/products
    public IActionResult Create(Product product) { ... }
}

How it works:

  • [Route(“api/[controller]”)] on the class sets a base template; [controller] is a token that resolves to the controller’s name (Products).
  • [HttpGet], [HttpPost], [HttpPut], [HttpDelete] attributes on individual actions both specify the HTTP verb and can append to/override the route template.
  • Route constraints ({id:int}), optional segments, and custom templates can be set per-action with full precision.

Characteristics:

  • Explicit and self-documenting — you can see the exact route right next to the action that handles it.
  • Standard for Web APIs, since REST conventions often need precise control (e.g., GET /api/products/{id} vs GET /api/products/{id}/reviews).
  • Doesn’t rely on naming convention magic — each route is declared exactly as intended.
  • Easier to manage in large APIs with many endpoints that don’t follow a single uniform pattern.

24. What is the difference between [Route], [HttpGet], [HttpPost], etc ?

[Route] defines the URL pattern/template that maps to an action or controller. It doesn’t say anything about which HTTP verb (GET, POST, etc.) is allowed — it just sets up the path.

[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [Route("{id}")]
    public IActionResult GetById(int id) { ... }
}

By default, [Route] alone (without an HTTP verb attribute) responds to any HTTP method unless you explicitly restrict it.

[HttpGet], [HttpPost], [HttpPut], [HttpDelete], [HttpPatch]

These are HTTP verb attributes — they restrict an action to a specific HTTP method, and they can also optionally carry a route template.

[HttpGet]
public IActionResult GetAll() { ... }

[HttpGet("{id}")]
public IActionResult GetById(int id) { ... }

[HttpPost]
public IActionResult Create([FromBody] Product product) { ... }

Key Differences:

Aspect[Route][HttpGet]/[HttpPost]/etc.
PurposeDefines URL templateRestricts HTTP verb (+ optional URL template)
HTTP verb restrictionNone (allows all verbs unless combined)Restricts to one specific verb
Can carry a route?YesYes (optional parameter)
Typical useOn controller, to set a base pathOn actions, for verb + route together

How they typically combine:

[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]                     // GET api/products
    public IActionResult GetAll() { ... }

    [HttpGet("{id}")]             // GET api/products/5
    public IActionResult GetById(int id) { ... }

    [HttpPost]                    // POST api/products
    public IActionResult Create([FromBody] Product p) { ... }

    [HttpPut("{id}")]             // PUT api/products/5
    public IActionResult Update(int id, [FromBody] Product p) { ... }

    [HttpDelete("{id}")]          // DELETE api/products/5
    public IActionResult Delete(int id) { ... }
}
  • [Route] on the controller sets the base path (api/products).
  • Each [Http*] attribute appends its own segment and locks the action to that verb.

Why not just use [Route] everywhere? You could write:

[Route("api/products")]
[AcceptVerbs("GET")]
public IActionResult GetAll() { ... }

But [HttpGet], [HttpPost], etc. are more concise, self-documenting, and are the idiomatic convention in ASP.NET Core Web API — they make it immediately clear (to readers and to Swagger/OpenAPI tooling) what verb an action responds to.

Interview one-liner:

[Route] defines the URL pattern an action responds to, while [HttpGet], [HttpPost], etc. restrict an action to a specific HTTP verb — and can also define a route themselves. In practice, they’re used together: [Route] at the controller level for a common prefix, and verb attributes at the action level for both routing and verb constraint.

25. What are route constraints ? Explain {id:int}. How do you create a custom route constraint ?

Route constraints restrict whether a route matches based on the value of a route parameter — not just its presence, but its type, format, or range. They act as a filter so a URL segment is only accepted if it satisfies a rule.

Without a constraint, {id} matches any string:

GET api/products/5       ✅ matches
GET api/products/abc     ✅ also matches (id = "abc")

With a constraint, you can force “id” to be numeric only, so “abc” won’t match that route at all — it’ll either 404 or fall through to another matching route/action.

Syntax:

Constraints are applied using a colon after the parameter name:

{parameterName:constraint}

Common Built-in Constraints:

ConstraintDescriptionExample
intMatches an integer{id:int}
boolMatches true/false{active:bool}
datetimeMatches a valid DateTime{date:datetime}
decimalMatches a decimal{price:decimal}
double / floatMatches double/float{value:double}
longMatches a long{id:long}
guidMatches a GUID{id:guid}
alphaAlphabetic characters only{name:alpha}
minlength(n)Minimum string length{name:minlength(3)}
maxlength(n)Maximum string length{name:maxlength(10)}
length(n) / length(min,max)Exact or range length{code:length(5,10)}
min(n) / max(n)Numeric min/max value{id:min(1)}
range(min,max)Value within a range{age:range(18,65)}
regex(pattern)Matches a regex pattern{code:regex(^\d{{4}}$)}
requiredValue must be provided{name:required}

Example:

[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    // Matches only if id is an integer
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) { ... }

    // Matches only if id is a GUID
    [HttpGet("{id:guid}")]
    public IActionResult GetByGuid(Guid id) { ... }

    // Combine multiple constraints
    [HttpGet("{id:int:min(1)}")]
    public IActionResult GetPositive(int id) { ... }
}

Optional parameters with constraints:

You can combine a constraint with ? to make it optional:

[HttpGet("{id:int?}")]
public IActionResult Get(int? id) { ... }

{id:int} Explained

{id:int} is a route parameter named id constrained to match only integer values — if the URL segment can’t be parsed as an int, the route doesn’t match, which helps with routing disambiguation and early filtering before the action even executes.

Creating a Custom Route Constraint in ASP.NET Core

To create a custom route constraint, you implement the IRouteConstraint interface and register it in the routing options. This lets you define matching logic beyond the built-in constraints (int, guid, alpha, etc.).

Step 1: Implement IRouteConstraint:

public class EvenNumberConstraint : IRouteConstraint
{
    public bool Match(
        HttpContext httpContext,
        IRouter route,
        string routeKey,
        RouteValueDictionary values,
        RouteDirection routeDirection)
    {
        if (values.TryGetValue(routeKey, out var value) && value != null)
        {
            if (int.TryParse(value.ToString(), out int number))
            {
                return number % 2 == 0;
            }
        }
        return false;
    }
}

Key points about the Match method:

  • Returns true if the value satisfies the constraint (route matches), false otherwise.
  • routeKey is the parameter name (e.g., “id”). values is the dictionary of route values for the current match attempt.
  • routeDirection tells you whether this is being evaluated for an incoming request (IncomingRequest) or for URL generation (UrlGeneration) — useful if constraint logic should differ.

Step 2: Register the constraint

In Program.cs:

builder.Services.Configure<RouteOptions>(options =>
{
    options.ConstraintMap.Add("even", typeof(EvenNumberConstraint));
});

This maps the string key “even” to your constraint class — that key is what you’ll use in route templates.

Step 3: Use it in a route

[HttpGet("{id:even}")]
public IActionResult GetEven(int id)
{
    return Ok($"{id} is even");
}
GET /api/products/4	✅ Yes
GET /api/products/5	❌ No (odd)
GET /api/products/abc	❌ No (not even a number)

Important considerations:

  • Constraints run on every routing attempt — keep the logic fast and lightweight. Avoid database calls or expensive operations inside Match.
  • Constraints affect routing, not validation — a failed match usually results in a 404 (or falls through to another matching route), not a meaningful validation error. Don’t use custom constraints as a substitute for model validation.
  • Constraints can be chained — e.g. {id:int:even} if you want to combine built-in and custom constraints.
  • Alternative approach: IActionConstraint — if what you actually need is to select between overloaded actions (rather than restrict URL matching), IActionConstraint may be more appropriate; it’s evaluated later in the pipeline, after routing has matched a template, and can be closer to per-action selection logic.

26. What is route grouping ? What is MapGroup() in Minimal APIs ? How can you apply authorization to an entire route group ?

Route grouping lets you organize a set of related endpoints under a common prefix and shared configuration (metadata, filters, authorization, etc.) — instead of repeating the same settings on every single endpoint.

It’s the Minimal API equivalent of what [Route(“api/[controller]”)] + controller-level attributes give you in MVC/Web API — a way to apply cross-cutting concerns (prefix, auth, filters, tags, versioning) once, at the group level, rather than per-endpoint.

MapGroup()

MapGroup() is the method (introduced in .NET 7) used to create a route group in Minimal APIs. It returns a RouteGroupBuilder, which itself implements IEndpointRouteBuilder — so you can chain .MapGet(), .MapPost(), etc. on it just like you would on the app directly, and any group-level configuration cascades down to all endpoints in the group.

var app = builder.Build();

var products = app.MapGroup("api/products");

products.MapGet("/", GetAllProducts);
products.MapGet("/{id:int}", GetProductById);
products.MapPost("/", CreateProduct);
products.MapPut("/{id:int}", UpdateProduct);
products.MapDelete("/{id:int}", DeleteProduct);

app.Run();

Here, every endpoint automatically gets the api/products prefix:

  • GET api/products/
  • GET api/products/{id}
  • POST api/products/

Why use it (benefits):

  1. Shared prefix — avoid repeating "api/products/..." on every Map* call.
  2. Shared metadata/filters — apply .WithTags(), .WithOpenApi(), .AddEndpointFilter(), .RequireAuthorization(), .RequireCors(), .RequireRateLimiting(), etc. once on the group, and it applies to all endpoints inside it.
  3. Nested groups — groups can be nested for hierarchical organization:
   var api = app.MapGroup("api");
   var v1 = api.MapGroup("v1");
   var products = v1.MapGroup("products"); // final prefix: api/v1/products
  1. Cleaner Program.cs — especially useful for organizing large Minimal API apps without needing full MVC controllers.
  2. Can be extracted into extension methods for modular endpoint registration (a common pattern replacing “one giant Program.cs”).

Applying Authorization to an Entire Route Group

Use .RequireAuthorization() on the group returned by MapGroup() — it cascades to every endpoint registered on that group.

var products = app.MapGroup("api/products")
                   .RequireAuthorization(); // applies to ALL endpoints below

products.MapGet("/", GetAllProducts);
products.MapPost("/", CreateProduct);   // also requires auth
products.MapDelete("/{id:int}", DeleteProduct); // also requires auth

You can also target a specific policy or role:

var admin = app.MapGroup("api/admin")
                .RequireAuthorization("AdminOnly"); // named policy

var orders = app.MapGroup("api/orders")
                 .RequireAuthorization(policy => policy.RequireRole("Manager"));

Overriding at the endpoint level:

Metadata applied at the group level can be overridden per-endpoint if needed. For example, to make one endpoint in an otherwise-protected group public:

var products = app.MapGroup("api/products")
                   .RequireAuthorization();

products.MapGet("/", GetAllProducts);

products.MapGet("/public-info", GetPublicInfo)
        .AllowAnonymous(); // overrides the group's RequireAuthorization

This works because endpoint metadata is combined/overridden based on specificity — more specific (endpoint-level) metadata takes precedence over group-level metadata.

ASP.NET Core Web API Interview Questions

Why prepare specifically around ASP.NET Core Web API interview questions:

It’s usually the actual job, not a formality – For most .NET backend roles, Web API work is the day-to-day — building endpoints, wiring up auth, handling data access, structuring services. Interviewers ask these questions because they map almost 1:1 to what you’ll be doing in week one, unlike, say, algorithmic trivia that rarely shows up in daily work.

It’s a fast way to filter “used it” from “understands it” – Web API has enough moving parts (routing, model binding, filters, middleware, DI, auth) that surface-level familiarity breaks down quickly under follow-up questions. A structured interview on this topic lets an interviewer distinguish, in 20–30 minutes, someone who’s copy-pasted from tutorials from someone who’s actually debugged production issues.

It reveals whether you understand the pipeline, not just isolated features – Almost every ASP.NET Core interview thread (like the one we just went through) is designed to walk you from a narrow starting point ([Route] vs [HttpGet]) into the broader request lifecycle. Preparing deliberately means you’re not just memorizing 50 disconnected facts — you’re building a mental model of how a request flows from routing → model binding → filters → action → response. That mental model is reusable and lets you improvise on questions you’ve never seen before.

Depth matters more than breadth at senior levels – Junior interviews often stop at “what is X.” Mid/senior interviews probe trade-offs and failure modes — e.g., “constraints don’t validate, they just affect matching, so a mismatch gives you a 404 not a helpful error.” If you haven’t prepared with that framing in mind, you’ll answer correctly but sound junior anyway.

Practical outcome: it directly affects your interview performance – Concretely, structured prep helps you:

  • Answer confidently without long pauses (fluency signals real experience)
  • Anticipate the natural follow-up question (interviewers often chain, like we did: [Route] → constraints → custom constraints → groups)
  • Avoid contradicting yourself when asked “why not just do X instead” (a very common interview technique)
  • Talk about real-world gotchas unprompted, which is the strongest signal of hands-on experience

I have written complete series on ASP.NET Core Web API. It contains 5 tutorials to master this area:

27. What is ASP.NET Core Web API ? What is the difference between MVC and Web API ?

ASP.NET Core Web API is a framework built on top of ASP.NET Core for building HTTP-based services (RESTful APIs) that can be consumed by a wide range of clients — browsers, mobile apps, desktop apps, IoT devices, or other servers. Instead of returning HTML views, it returns data — typically in JSON (or XML) — over HTTP.

Key points to mention in an interview:

  • It’s cross-platform (runs on Windows, Linux, macOS) since it’s built on .NET Core. It’s designed around HTTP verbs — GET, POST, PUT, DELETE, PATCH — mapped to CRUD operations.
  • Controllers typically inherit from ControllerBase (not Controller), since they don’t need view-rendering capabilities.
  • It supports content negotiation (returning JSON or XML based on the Accept header), model binding, model validation, filters, middleware, dependency injection, etc. — same underlying pipeline as ASP.NET Core MVC.
  • In modern ASP.NET Core (post 3.0), MVC and Web API have been unified into a single framework — there’s no separate “Web API” package anymore. You just build a Web API project using the same Microsoft.AspNetCore.Mvc namespace.

Example minimal controller:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll() => Ok(products);

    [HttpGet("{id}")]
    public IActionResult GetById(int id) => Ok(product);

    [HttpPost]
    public IActionResult Create(Product product) => CreatedAtAction(...);
}

Difference between MVC and Web API

This is a bit of a “trick” interview question in the ASP.NET Core world, because historically (in classic ASP.NET / pre-Core), MVC and Web API were separate frameworks. In ASP.NET Core, they’ve been merged into one framework (ASP.NET Core MVC), so technically there is no separate “Web API framework” anymore — it’s all part of the same pipeline.

That said, interviewers usually want you to explain the conceptual difference in purpose/usage:

AspectMVC (Controller)Web API (ApiController)
PurposeServes web pages (HTML views) to browsersServes data (usually JSON/XML) to any client
Base classController (has view support)ControllerBase (no view support, lighter)
Return typeTypically returns View() / ViewResultTypically returns IActionResult / ActionResult<T> / data objects
ConsumersBrowsers rendering UISPAs (Angular/React), mobile apps, other services
AttributeNo [ApiController] neededDecorated with [ApiController] for API-specific behaviors (automatic model validation, binding source inference, problem-details responses)
Routing styleConvention-based routing common ({controller}/{action}/{id})Attribute routing common ([Route("api/[controller]")])
Content negotiationUsually renders Razor viewsRelies on formatters (JSON/XML) based on Accept header

28. What is ControllerBase ? What is the difference between Controller and ControllerBase ? What does [ApiController] do ?

ControllerBase is the base class for building API controllers in ASP.NET Core. It lives in the Microsoft.AspNetCore.Mvc namespace and provides all the core functionality needed to handle HTTP requests and produce responses — without any support for rendering views (HTML).

It gives you access to things like:

  • HttpContext, Request, Response
  • ModelState (for validation)
  • Helper methods for building responses: Ok(), NotFound(), BadRequest(), CreatedAtAction(), NoContent(), StatusCode(), etc.
  • User (for accessing the authenticated user/claims)
  • Url (IUrlHelper for generating links)

Example:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {
        var product = _repo.Find(id);
        if (product == null)
            return NotFound();

        return Ok(product);
    }
}

Difference between Controller and ControllerBase

Controller inherits from ControllerBase and adds view-related functionality on top of it.

AspectControllerBaseController
InheritanceBase classInherits from ControllerBase
View support❌ No view rendering✅ Supports View(), PartialView(), Razor views
Used forWeb APIs (returning data — JSON/XML)MVC apps (returning HTML views)
Extra methodsOnly data/response helpers (Ok, NotFound, etc.)All of the above plus View(), ViewBag, ViewData, TempData
Typical return typeIActionResult, ActionResult<T>, dataViewResult, IActionResult

What does [ApiController] do ?

[ApiController] is an attribute you put on a controller class to opt into a set of API-specific conventions and behaviors that make building REST APIs easier and more consistent. It’s typically combined with ControllerBase.

Specifically, it enables:

a. Automatic HTTP 400 responses on model validation errors

  • If ModelState.IsValid is false, the framework automatically returns a 400 Bad Request with a ProblemDetails response — you don’t need to manually check ModelState.IsValid in every action.

b. Attribute routing requirement

  • Forces the use of attribute-based routing ([Route], [HttpGet], etc.) instead of conventional routing — makes route definitions explicit and required.

c. Binding source parameter inference

  • Automatically infers where action parameters come from ([FromBody], [FromRoute], [FromQuery]) based on parameter type/complexity, so you often don’t need to specify them manually.
  • Example: complex types default to [FromBody], simple types default to [FromQuery]/[FromRoute].

d. Multipart/form-data inference for IFormFile parameters

  • Automatically recognizes file upload parameters.

e. Problem details for error responses

  • Standardizes error responses using the ProblemDetails format (RFC 7807), improving API consistency.

29. What happens when model validation fails with [ApiController] ?

When a controller is decorated with [ApiController], ASP.NET Core automatically handles invalid model state for you — you don’t need to write if (!ModelState.IsValid) return BadRequest(ModelState); manually in every action.

What happens under the hood:

  • Model binding runs first (mapping request data — body, route, query — to your action parameters/model).
  • If binding produces validation errors (e.g., a required field is missing, a data annotation like [Required], [Range], [StringLength] fails), the framework sets ModelState.IsValid = false.
  • Before your action method even executes, a built-in filter — ModelStateInvalidFilter — runs as part of the action-invocation pipeline and short-circuits the request.
  • It automatically returns an HTTP 400 Bad Request response, formatted as a ValidationProblemDetails object (an extension of ProblemDetails, per RFC 7807), containing the validation errors.

Example:

public class ProductDto
{
    [Required]
    public string Name { get; set; }

    [Range(1, 1000)]
    public decimal Price { get; set; }
}

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(ProductDto product)
    {
        // This code is NEVER reached if validation fails —
        // the [ApiController] filter already returned 400.
        return Ok(product);
    }
}

If you POST { “Price”: 5000 } (missing Name, invalid Price), the response is automatically:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["The Name field is required."],
    "Price": ["The field Price must be between 1 and 1000."]
  },
  "traceId": "00-abc123..."
}
Key points for the interview
  • This automatic behavior is only enabled because of [ApiController] — without it, ModelState.IsValid stays your responsibility to check manually.
  • The action method body never executes when validation fails — it’s short-circuited earlier in the pipeline.
  • The response format follows RFC 7807 (Problem Details for HTTP APIs), giving a consistent, machine-readable error structure across your whole API.
  • You can customize this behavior by configuring ApiBehaviorOptions.InvalidModelStateResponseFactory in Program.cs/Startup.cs:
  builder.Services.Configure<ApiBehaviorOptions>(options =>
  {
      options.InvalidModelStateResponseFactory = context =>
      {
          // custom response logic
          return new BadRequestObjectResult(context.ModelState);
      };
  });

30. What are FromBody, FromRoute, FromQuery, and FromHeader ?

These attributes tell ASP.NET Core where in the HTTP request to look for a value when binding it to an action method’s parameter or model property. This process is called model binding.

[FromRoute]

Binds a parameter from the route/URL segment (values captured by the route template).

[HttpGet("{id}")]
public IActionResult GetById([FromRoute] int id)
{
    // URL: GET /api/products/5  → id = 5
}

[FromQuery]

Binds a parameter from the query string.

[HttpGet]
public IActionResult Search([FromQuery] string name, [FromQuery] int page)
{
    // URL: GET /api/products?name=phone&page=2
}

[FromBody]

Binds a parameter from the request body (typically JSON), deserialized into a complex object. Only one parameter per action can use [FromBody].

[HttpPost]
public IActionResult Create([FromBody] Product product)
{
    // Body: { "name": "Phone", "price": 500 }
}

[FromHeader]

Binds a parameter from an HTTP request header.

[HttpGet]
public IActionResult Get([FromHeader(Name = "X-Api-Key")] string apiKey)
{
    // Reads the "X-Api-Key" header value
}

Summary Table:

AttributeSourceTypical Use
[FromRoute]URL path segmentIDs in the route, e.g. /api/products/{id}
[FromQuery]Query stringFilters, paging, search params, e.g. ?page=2&size=10
[FromBody]Request body (JSON)Complex objects sent in POST/PUT requests
[FromHeader]HTTP headersAPI keys, tokens, custom metadata
[FromForm]Form dataFile uploads, form submissions

Do you need to specify them explicitly?

With [ApiController], ASP.NET Core does automatic binding source inference, so in many common cases you don’t need to write the attribute explicitly:

  • Complex types (classes) → inferred as [FromBody]
  • Simple types (int, string, Guid, etc.) that match a route parameter name → inferred as [FromRoute]
  • Simple types that don’t match a route parameter → inferred as [FromQuery]
  • IFormFile / IFormFileCollection → inferred as [FromForm]

You still need to specify [FromHeader] explicitly — it’s never inferred automatically.

31. What is the difference between Ok(), Created(), CreatedAtAction(), BadRequest(), NotFound(), and NoContent() ?

These are all helper methods available in ControllerBase (and Controller) that return IActionResult types, each mapping to a specific HTTP status code. Using them makes your API responses explicit and RESTful, rather than manually setting status codes everywhere.

Ok() → 200 OK

Indicates the request succeeded. Optionally returns data in the response body.

[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    var product = _repo.Find(id);
    return Ok(product); // 200 + product in body
}

Created() → 201 Created

Indicates a new resource was successfully created. Requires you to manually provide the URI of the new resource plus the resource itself.

[HttpPost]
public IActionResult Create(Product product)
{
    _repo.Add(product);
    string uri = $"/api/products/{product.Id}";
    return Created(uri, product); // 201 + Location header + body
}

CreatedAtAction() → 201 Created

Same as Created(), but instead of manually building the URI string, you point to an action method (and route values), and the framework generates the correct URL for the Location header using routing.

[HttpPost]
public IActionResult Create(Product product)
{
    _repo.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    // 201 + Location: /api/products/{id} (auto-generated via route)
}
Difference between Created() and CreatedAtAction(): Created() needs a hardcoded/manual URI; CreatedAtAction() generates the URI dynamically based on route info of another action — safer against typos and route changes.

BadRequest() → 400 Bad Request

Indicates the client sent an invalid request (e.g., failed validation, bad input).

[HttpPost]
public IActionResult Create(Product product)
{
    if (product.Price <= 0)
        return BadRequest("Price must be greater than zero.");

    ...
}

NotFound() → 404 Not Found

Indicates the requested resource doesn’t exist.

[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    var product = _repo.Find(id);
    if (product == null)
        return NotFound(); // 404

    return Ok(product);
}

NoContent() → 204 No Content

Indicates the request succeeded, but there’s no data to return in the response body — common for PUT/DELETE operations.

[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
    var product = _repo.Find(id);
    if (product == null)
        return NotFound();

    _repo.Remove(product);
    return NoContent(); // 204, empty body
}

Summary Table:

MethodStatus CodeMeaningIncludes Body?Includes Location Header?
Ok()200Success✅ Yes (optional)❌ No
Created()201Resource created (manual URI)✅ Yes✅ Yes
CreatedAtAction()201Resource created (URI via route)✅ Yes✅ Yes
BadRequest()400Invalid client request✅ Optional (error details)❌ No
NotFound()404Resource doesn’t exist✅ Optional❌ No
NoContent()204Success, nothing to return❌ No❌ No

32. What is content negotiation ?

Content negotiation is the mechanism by which a Web API decides what format to send the response back in (e.g., JSON, XML, plain text), based on what the client says it can accept, rather than the server always returning a fixed format.

It works using the standard HTTP Accept request header, and the corresponding Content-Type response header.

How it works:

  1. The client sends a request with an Accept header specifying the format(s) it wants:
   GET /api/products/1
   Accept: application/json
  1. ASP.NET Core’s content negotiation system looks at the registered output formatters and picks the one matching the requested media type.
  2. The response is serialized accordingly, and the Content-Type header reflects the chosen format:
   Content-Type: application/json

If the client instead sends:

Accept: application/xml

…and XML formatters are configured, the same endpoint returns XML instead — without changing any controller code.

Example:

[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    var product = _repo.Find(id);
    return Ok(product); // format decided by content negotiation
}
  • Request with Accept: application/json → response body: {"id":1,"name":"Phone"}
  • Request with Accept: application/xml → response body: <Product><Id>1</Id><Name>Phone</Name></Product> (if XML formatter is added)

Default behavior in ASP.NET Core:

  • JSON is the default output formatter (System.Text.Json since ASP.NET Core 3.0, previously Newtonsoft.Json).
  • XML is NOT included by default — you must explicitly add it:
  builder.Services.AddControllers()
      .AddXmlSerializerFormatters();
  • If the Accept header requests a format the server doesn’t support, ASP.NET Core by default ignores the header and returns the default formatter’s output (JSON) rather than a 406 Not Acceptable — unless you explicitly enable strict negotiation:
  builder.Services.AddControllers(options =>
  {
      options.ReturnHttpNotAcceptable = true; // returns 406 if format unsupported
  });

Input vs Output formatters:

Content negotiation usually refers to output (response), but there’s a related concept for input:

  • Output formatters — serialize the response based on Accept header.
  • Input formatters — deserialize the incoming request body based on Content-Type header (e.g., telling the server the request body is JSON).

33. Explain IActionResult and ActionResult<T>

Both are return types used in Web API action methods to represent an HTTP response, but they differ in flexibility, type-safety, and how well they play with Swagger/OpenAPI documentation.

IActionResult

An interface representing any action result. It’s the common return type shared by all the helper methods (Ok(), NotFound(), BadRequest(), NoContent(), etc.), since they all return classes implementing IActionResult (OkObjectResult, NotFoundResult, etc.).

[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    var product = _repo.Find(id);
    if (product == null)
        return NotFound();       // NotFoundResult

    return Ok(product);          // OkObjectResult
}

Characteristics:

  • Flexible — lets you return any combination of result types (Ok, NotFound, BadRequest, etc.) from the same method.
  • Not strongly typed — the compiler doesn’t know what the actual success payload type is; tools like Swagger can’t automatically infer the response model unless you add [ProducesResponseType(typeof(Product), 200)] manually.

ActionResult<T>

A generic class introduced in ASP.NET Core 2.1 that combines the flexibility of IActionResult with strong typing of the actual data being returned. It supports implicit conversion from both your data type T and standard action results.

[HttpGet("{id}")]
public ActionResult<Product> GetById(int id)
{
    var product = _repo.Find(id);
    if (product == null)
        return NotFound();       // implicit conversion to ActionResult<Product>

    return product;              // implicit conversion, wraps in Ok(product) automatically
    // return Ok(product);       // also valid
}

Characteristics:

  • Strongly typed — the method signature tells you (and tooling) exactly what successful responses look like: Product.
  • Better Swagger/OpenAPI integration — since the return type is known, tools can automatically generate accurate response schemas without needing extra [ProducesResponseType] attributes.
  • Still supports returning other status results (NotFound(), BadRequest(), etc.) via implicit conversion — you don’t lose flexibility.
  • You can return T directly (e.g., return product;) and it automatically becomes a 200 OK with that body.

Summary Table:

AspectIActionResultActionResult<T>
TypeInterfaceGeneric class
Strong typing of success payload❌ No✅ Yes
Can return multiple result types (Ok, NotFound, etc.)✅ Yes✅ Yes
Can return raw data directly (return product;)❌ No (must wrap in Ok(product))✅ Yes (implicit conversion)
Swagger/OpenAPI schema accuracy⚠️ Needs [ProducesResponseType] for accuracy✅ Better out-of-the-box
Introduced inOriginal MVC/Web APIASP.NET Core 2.1+

34. What HTTP status code should be returned after successfully creating a resource ?

201 Created is the correct status code after successfully creating a new resource via a POST request.

Why 201, not 200?

In ASP.NET Core, use CreatedAtAction() (preferred) or Created():

  • 200 OK means “the request succeeded” — generic, doesn’t say anything about a new resource being created.
  • 201 Created specifically means “a new resource was created as a result of this request,” and per HTTP spec, the response should ideally include:
    • A Location header pointing to the URI of the newly created resource.
    • Optionally, the created resource itself in the response body.
[HttpPost]
public IActionResult Create(Product product)
{
    _repo.Add(product);

    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    // Status: 201 Created
    // Location: /api/products/{id}
    // Body: the created product
}

Response headers/body look like:

HTTP/1.1 201 Created
Location: /api/products/5
Content-Type: application/json

{ "id": 5, "name": "Phone", "price": 500 }

Common mistake in interviews:

Returning Ok(product) (200) after a POST — it works, but it’s not RESTfully correct. Interviewers often ask this specifically to check if you know the difference between “successful operation” (200) vs “resource created” (201) semantics.

Quick reference for common scenarios
OperationHTTP VerbCorrect Status Code
Fetch existing resourceGET200 OK
Create new resourcePOST201 Created
Update existing resourcePUT200 OK or 204 No Content
Partial updatePATCH200 OK or 204 No Content
Delete resourceDELETE204 No Content
Resource not foundany404 Not Found
Invalid inputany400 Bad Request

35. What is the difference between PUT and PATCH ? How do you implement PATCH in ASP.NET Core ?

Both are HTTP verbs used to update an existing resource, but they differ in scope of the update.

AspectPUTPATCH
PurposeReplace the entire resourceApply a partial update to the resource
Request bodyMust contain the complete representation of the resourceContains only the fields that need to change
Idempotent?✅ Yes — sending the same request multiple times produces the same result⚠️ Typically yes in practice, but not guaranteed by spec depending on how the patch is defined
Missing fields in bodyMissing fields are typically overwritten with null/default (since it’s a full replace)Missing fields are left untouched
Typical use case“Replace this entire product with this new version”“Just update the price of this product”

Example scenario – Given a resource:

{ "id": 1, "name": "Phone", "price": 500, "stock": 20 }

PUT request body (must send the whole object):

{ "id": 1, "name": "Phone", "price": 600, "stock": 20 }

If you forgot to include stock, it might get reset to 0/null — because PUT expects the complete resource.

PATCH request body (only what changes):

{ "price": 600 }

Only price is updated; name and stock remain untouched..

Implementing PUT in ASP.NET Core (for comparison)

[HttpPut("{id}")]
public IActionResult Update(int id, Product updatedProduct)
{
    var product = _repo.Find(id);
    if (product == null) return NotFound();

    product.Name = updatedProduct.Name;
    product.Price = updatedProduct.Price;
    product.Stock = updatedProduct.Stock;

    return NoContent(); // 204
}

Implementing PATCH in ASP.NET Core

There are two common approaches:

Approach 1: JSON Patch (RFC 6902) — the “standard” way:

Uses Microsoft.AspNetCore.JsonPatch with a JsonPatchDocument<T>. The client sends a series of operations (add, replace, remove) rather than raw field values.

Setup:

dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson

builder.Services.AddControllers().AddNewtonsoftJson();

Controller:

[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] JsonPatchDocument<Product> patchDoc)
{
    if (patchDoc == null) return BadRequest();

    var product = _repo.Find(id);
    if (product == null) return NotFound();

    patchDoc.ApplyTo(product, ModelState);

    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    return NoContent();
}

Client request body (JSON Patch format):

[
  { "op": "replace", "path": "/price", "value": 600 }
]
  • op — the operation: replace, add, remove, copy, move, test
  • path — which property to modify
  • value — the new value

Approach 2: Simple DTO-based partial update (more common in practice)

Instead of the formal JSON Patch spec, many real-world APIs just accept a partial DTO where only provided fields are updated (often using nullable properties to distinguish “not provided” from “set to default”).

public class ProductPatchDto
{
    public string? Name { get; set; }
    public decimal? Price { get; set; }
    public int? Stock { get; set; }
}

[HttpPatch("{id}")]
public IActionResult Patch(int id, ProductPatchDto dto)
{
    var product = _repo.Find(id);
    if (product == null) return NotFound();

    if (dto.Name != null) product.Name = dto.Name;
    if (dto.Price.HasValue) product.Price = dto.Price.Value;
    if (dto.Stock.HasValue) product.Stock = dto.Stock.Value;

    return NoContent();
}

This approach is simpler to consume from typical frontend clients (just send changed fields as plain JSON) but doesn’t follow the formal JSON Patch RFC.

36. What is JSON Patch ?

JSON Patch is a standardized format (defined in RFC 6902) for describing partial modifications to a JSON document. Instead of sending the entire updated resource, the client sends a list of operations that describe exactly what should change — making it ideal for implementing HTTP PATCH requests.

Structure:

A JSON Patch document is a JSON array of operation objects. Each operation has:

  • op — the operation type: add, remove, replace, move, copy, or test
  • path — a JSON Pointer (RFC 6901) indicating which part of the document to target
  • value — the new value (required for add, replace, test; not used for remove)
[
  { "op": "replace", "path": "/price", "value": 600 },
  { "op": "add", "path": "/tags/-", "value": "new-arrival" },
  { "op": "remove", "path": "/discontinued" }
]

The six operations:

OperationMeaningExample
addAdds a value at the given path (or appends to an array with -){ "op": "add", "path": "/stock", "value": 50 }
removeRemoves the value at the given path{ "op": "remove", "path": "/discountCode" }
replaceReplaces the existing value at the given path{ "op": "replace", "path": "/price", "value": 600 }
moveMoves a value from one path to another{ "op": "move", "from": "/oldField", "path": "/newField" }
copyCopies a value from one path to another{ "op": "copy", "from": "/name", "path": "/displayName" }
testChecks that a value at a path equals the given value (used for conditional/optimistic operations){ "op": "test", "path": "/price", "value": 500 }

Example in context – Given this resource:

{ "id": 1, "name": "Phone", "price": 500, "stock": 20 }

This JSON Patch request:

[
  { "op": "replace", "path": "/price", "value": 600 },
  { "op": "replace", "path": "/stock", "value": 25 }
]

…results in:

{ "id": 1, "name": "Phone", "price": 600, "stock": 25 }

Only the specified fields change — everything else stays as-is.

JSON Patch in ASP.NET Core

ASP.NET Core supports JSON Patch through the Microsoft.AspNetCore.JsonPatch package and the JsonPatchDocument<T> type:

[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] JsonPatchDocument<Product> patchDoc)
{
    var product = _repo.Find(id);
    if (product == null) return NotFound();

    patchDoc.ApplyTo(product, ModelState);

    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    return NoContent();
}

ApplyTo() executes each operation from the patch document against the target object.

JSON Patch vs. Simple Partial DTO

AspectJSON Patch (RFC 6902)Simple Partial DTO
FormatArray of operations (op, path, value)Plain JSON object with only changed fields
Standardized?✅ Yes (RFC 6902)❌ No, custom convention
ComplexityMore powerful (array manipulation, move, copy, test)Simpler, easier for frontend devs to construct
Common in practiceLess common outside strict REST APIsVery common in real-world apps

37. How do you return XML from an ASP.NET Core Web API and how do you consume XML in an ASP.NET Core API ? How do you implement global API exception handling ?

By default, ASP.NET Core Web API only supports JSON. To support XML, you register XML formatters via AddXmlSerializerFormatters() (or AddXmlDataContractSerializerFormatters() for more complex types) in Program.cs. Once registered, it works both ways: for output, content negotiation returns XML when the client sends Accept: application/xml; for input, the framework deserializes the request body into your model when Content-Type: application/xml is set — all without changing any controller code, since the formatter selection happens transparently based on HTTP headers.

Global API Exception Handling in ASP.NET Core

Instead of try/catch in every action, ASP.NET Core supports centralized exception handling. The classic approach is app.UseExceptionHandler() middleware, which catches unhandled exceptions from the whole pipeline and returns a ProblemDetails response. Since .NET 8, the recommended approach is implementing IExceptionHandler — a DI-friendly abstraction where you can register multiple handlers that run in order. For custom exception-to-status-code mapping — like a NotFoundException becoming 404 — I’d write custom middleware or an IExceptionHandler that inspects the exception type. Exception filters (IExceptionFilter) are an older, MVC-only alternative that won’t catch exceptions outside the MVC pipeline, so middleware-based approaches are generally preferred.

Minimal APIs Interview Questions

ASP.NET CORE Minimal APIs matter in interviews because they’re now the default template in modern .NET, they’re heavily used in microservices and cloud-native architectures, and they test whether a candidate understands the underlying ASP.NET Core pipeline rather than relying purely on MVC conventions. Being asked about them is really a proxy for: ‘Is this candidate’s knowledge current, and do they understand trade-offs between lightweight and full MVC-based APIs?

Here’s why interviewers increasingly focus on Minimal APIs and why you need to be prepared:

a. Minimal APIs are now the default template in .NET

  • Since .NET 6+, dotnet new webapi generates a Minimal API project by default (not a controller-based one).
  • Any interviewer working with modern .NET codebases (.NET 6/7/8/9) will likely be using or evaluating Minimal APIs in real projects — so it’s now considered baseline knowledge, not a niche topic.

b. It tests whether your knowledge is current or outdated

  • Many candidates learned ASP.NET Core only through Controller-based Web API (older tutorials, older jobs).
  • Asking about Minimal APIs immediately reveals whether you’ve kept up with the framework’s evolution — a strong signal of whether you follow the ecosystem or are stuck on legacy patterns.

c. Companies are actively migrating toward it for microservices

  • Minimal APIs are lightweight, have less boilerplate, and start faster — making them attractive for microservices, serverless functions, and containerized workloads (fewer resources, smaller footprint).
  • If a company is building microservices or cloud-native systems, they specifically want engineers comfortable with this style.

d. It tests understanding of the underlying pipeline, not just syntax

  • Minimal APIs strip away the MVC abstraction (controllers, filters, model binding conventions), forcing you to understand things more directly:
    • WebApplication / WebApplicationBuilder
    • Endpoint routing (app.MapGet, app.MapPost, etc.)
    • Middleware ordering
    • Dependency injection at the endpoint level
  • Interviewers use it to test whether you understand the framework, rather than just memorized Controller patterns.

e. It reveals knowledge of trade-offs — a sign of seniority

  • Knowing when to use Minimal APIs vs Controllers (e.g., simple CRUD/microservices vs large enterprise apps needing filters, model binding conventions, versioning, [ApiController] conventions) shows architectural judgment, not just syntax memorization — which is exactly what separates mid-level from senior candidates.

f. Performance-conscious teams specifically look for it

  • Minimal APIs have less overhead than MVC controllers (no controller activation, fewer filters by default), which matters for teams optimizing for high-throughput APIs.
  • Being able to discuss this shows you understand performance implications of architectural choices — a common senior-level interview theme.

g. It often comes packaged with other modern-era topics

  • Once you know Minimal APIs, interviewers will likely chain into related modern .NET topics that are also now common:
    • IExceptionHandler (rather than filters)
    • Program.cs top-level statements (no Startup.cs)
    • Native AOT compilation support
    • Endpoint filters (AddEndpointFilter) instead of MVC filters
  • These are all part of the same “modern .NET” cluster interviewers use to gauge how current your skills are.

h. It’s a quick way to differentiate candidates in a crowded market

  • Since ASP.NET Core roles get a huge number of applicants, interviewers use Minimal API knowledge as a filter — it’s a low-effort way to separate candidates who only know “textbook MVC” from those with genuine hands-on modern experience.

38. What are Minimal APIs and how are Minimal APIs different from controller-based APIs ? When would you choose Minimal APIs over controllers?

Minimal APIs (introduced in .NET 6) let you build HTTP APIs with minimal ceremony — no controllers, no boilerplate, no attribute routing classes. You define routes and handlers directly against the WebApplication instance.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products/{id}", (int id, IProductService svc) => svc.GetById(id));
app.MapPost("/products", (Product p, IProductService svc) => svc.Add(p));

app.Run();

That’s a complete, working API — no Program.cs split, no [ApiController], no separate controller file.

Key Differences from Controller-Based APIs

AspectMinimal APIsController-Based APIs
StructureEndpoints defined as lambdas/methods mapped directly to routesClasses inheriting ControllerBase, actions as methods
Routingapp.MapGet/MapPost/... fluent callsAttribute routing ([Route], [HttpGet]) or conventional routing
BoilerplateVery little — no class, no attributes requiredMore ceremony — class, base type, attributes
FiltersEndpoint filters (IEndpointFilter), lighter-weightAction filters, full filter pipeline (Authorization, Resource, Action, Exception, Result)
Model bindingExplicit and simpler ( [FromBody], [FromRoute], etc., but less “magic”)Rich automatic binding via [ApiController] inference
ValidationNo built-in automatic model validation (must do manually or via libraries like FluentValidation, or newer .NET 7+ improvements)[ApiController] gives automatic 400 on invalid ModelState
Documentation/SwaggerSupported but you often annotate more explicitly (WithName, Produces, etc.)Well-integrated via attributes and conventions
PerformanceSlightly leaner/faster — fewer abstractions, less reflection overheadMarginally more overhead due to MVC pipeline richness
TestabilityHandlers are just delegates — easy to unit test in isolationControllers are classes — also easily testable, more familiar to MVC devs
Organization for large APIsCan get messy if not organized (people use extension methods/route groups to manage)Naturally organizes by resource via separate controller classes
Convention over configurationMinimal — you’re explicit about most thingsMVC provides many conventions out of the box

Notably, both ultimately run on the same underlying routing and hosting infrastructure (EndpointRouteBuilder) — Minimal APIs aren’t a separate framework, just a lighter-weight way to register endpoints.

When to Choose Minimal APIs vs Controllers

Choose Minimal APIs when:

  • Building microservices or small, focused APIs with few endpoints.
  • Building serverless functions or lightweight backends (e.g., Azure Functions-style workloads).
  • where startup time and memory footprint matter.
  • You want fast prototyping or simple CRUD endpoints without ceremony.
  • The team is comfortable with a more functional/lambda-based style.
  • You don’t need the full MVC filter pipeline or complex model binding conventions.

Choose Controllers when:

  • Building large, complex APIs with many endpoints, where organizing by controller/resource keeps things maintainable.
  • You need the full MVC feature set: model binding conventions, automatic validation via [ApiController], rich filter pipeline, versioning conventions, etc.
  • The team is already experienced with MVC patterns and conventions.
  • You need better built-in support for things like API versioning, conventions-based Swagger/OpenAPI generation, or complex content negotiation.
Interview soundbite: Minimal APIs reduce ceremony and are ideal for small services, microservices, and lightweight endpoints, while controller-based APIs offer richer conventions, filters, and structure for larger, more complex applications. Both use the same underlying ASP.NET Core routing and hosting model — the choice is about developer experience and project scale, not fundamentally different runtime capability.

39. Explain what is MapGet(), MapPost() and MapGroup()

MapGet() and MapPost() are extension methods on IEndpointRouteBuilder (implemented by WebApplication) used to register Minimal API endpoints for specific HTTP verbs.

app.MapGet("/products", () => "Get all products");
app.MapPost("/products", (Product p) => "Product created");
  • MapGet(pattern, handler) — registers an endpoint that responds to HTTP GET requests at the given route pattern.
  • MapPost(pattern, handler) — registers an endpoint that responds to HTTP POST requests.

There are corresponding siblings too: MapPut(), MapDelete(), MapPatch(), and the generic MapMethods() (for custom/multiple verbs).

app.MapGet("/products/{id:int}", (int id, IProductService svc) =>
{
    var product = svc.GetById(id);
    return product is not null ? Results.Ok(product) : Results.NotFound();
})
.WithName("GetProductById")
.Produces<Product>(200)
.Produces(404);
  • The route pattern can include route constraints ({id:int}), optional segments, etc.
  • The handler delegate supports full dependency injection (parameters are resolved from DI, route values, query string, or body automatically based on type/binding rules).
  • Both return an IEndpointConventionBuilder/RouteHandlerBuilder, letting you chain metadata: .WithName(), .WithTags(), .Produces(), .RequireAuthorization(), .AddEndpointFilter(), etc.

MapGroup()

Introduced in .NET 7, MapGroup() lets you group related endpoints under a common route prefix and apply shared configuration (filters, auth, metadata) to all of them at once — solving the “Minimal APIs get messy at scale” problem.

var products = app.MapGroup("/products")
                   .WithTags("Products")
                   .RequireAuthorization();

products.MapGet("/", () => "Get all products");
products.MapGet("/{id}", (int id) => $"Get product {id}");
products.MapPost("/", (Product p) => "Created");

This is equivalent to registering /products, /products/{id}, etc., but:

  • The prefix (/products) is applied automatically to all routes in the group.
  • Any .WithTags(), .RequireAuthorization(), .AddEndpointFilter(), .MapToApiVersion() etc. chained on the group cascades to every endpoint inside it — no need to repeat it per route.
  • Groups can be nested (a group within a group) for further sub-organization.
  • You can also extract group definitions into extension methods per feature/resource, giving you something like a “controller-lite” structure:
public static class ProductEndpoints
{
    public static void MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/products").WithTags("Products");
        group.MapGet("/", GetAll);
        group.MapGet("/{id}", GetById);
        group.MapPost("/", Create);
    }
}

// Program.cs
app.MapProductEndpoints();

40. How do you apply authorization to Minimal API endpoints ?

In Minimal APIs, you apply authorization using the RequireAuthorization() extension method on the endpoint, instead of using [Authorize] attributes (which is how you do it in MVC controllers).

var app = builder.Build();

app.MapGet("/secure-data", () => "This is protected")
   .RequireAuthorization();

This requires the user to simply be authenticated (no specific policy).

Applying a specific policy:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));
});

app.MapGet("/admin", () => "Admin area")
   .RequireAuthorization("AdminOnly");

Requiring specific roles or claims inline:

app.MapGet("/manager", () => "Manager data")
   .RequireAuthorization(policy => policy.RequireRole("Manager"));

Applying authorization to a group of endpoints:

Instead of chaining RequireAuthorization() on every route, you can group endpoints and apply it once:

var group = app.MapGroup("/api/orders")
                .RequireAuthorization();

group.MapGet("/", GetOrders);
group.MapPost("/", CreateOrder);

All endpoints inside that group inherit the authorization requirement.

Allowing anonymous access to specific endpoints in a protected group.

If a group is secured but one endpoint should be public:

group.MapGet("/public-info", GetPublicInfo)
     .AllowAnonymous();

Prerequisites:

For RequireAuthorization() to work, you still need the standard middleware pipeline set up:

app.UseAuthentication();
app.UseAuthorization();

And the authorization services registered:

builder.Services.AddAuthorization();

Key interview point to mention – The main conceptual shift from MVC is: authorization is applied fluently via extension methods on the endpoint (or route group) rather than declaratively via attributes on a controller/action. This fits the Minimal API philosophy of composing behavior through method chaining rather than decorating classes.

41. How do you inject a service into a Minimal API endpoint ? How do you perform validation in Minimal APIs?

Minimal APIs support parameter-based dependency injection — you simply add the service as a parameter to the route handler delegate, and the framework resolves it from the DI container automatically.

Basic Example:

builder.Services.AddScoped<IOrderService, OrderService>();

var app = builder.Build();

app.MapGet("/orders", (IOrderService orderService) =>
{
    return orderService.GetAllOrders();
});

No attributes are needed — the runtime inspects the parameter types and resolves them from the service container, similar to constructor injection in MVC controllers.

Explicit binding with [FromServices]:

Usually unnecessary since Minimal APIs infer services automatically, but you can be explicit (useful in ambiguous cases or with certain complex types):

app.MapGet("/orders", ([FromServices] IOrderService orderService) =>
{
    return orderService.GetAllOrders();
});

Mixing services with route/query parameters and request body:

app.MapPost("/orders/{customerId}", (
    int customerId,
    [FromBody] OrderDto order,
    IOrderService orderService,
    ILogger<Program> logger) =>
{
    logger.LogInformation("Creating order for {CustomerId}", customerId);
    return orderService.CreateOrder(customerId, order);
});

The framework figures out parameter sources using conventions: route values, query strings, body, services, etc.

Validation in Minimal APIs

Unlike MVC controllers, Minimal APIs do not have built-in automatic model validation (no automatic ModelState.IsValid behavior tied to [ApiController]). You have to handle validation explicitly.

app.MapPost("/orders", (OrderDto order) =>
{
    if (string.IsNullOrWhiteSpace(order.ProductName) || order.Quantity <= 0)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            { "Quantity", new[] { "Quantity must be greater than zero." } }
        });
    }

    // process order
    return Results.Created($"/orders/{order.Id}", order);
});

We can also use Data Annotations manually:

public class OrderDto
{
    [Required]
    public string ProductName { get; set; }

    [Range(1, int.MaxValue)]
    public int Quantity { get; set; }
}

Since these aren’t auto-validated, you validate manually via Validator.TryValidateObject:

app.MapPost("/orders", (OrderDto order) =>
{
    var context = new ValidationContext(order);
    var results = new List<ValidationResult>();

    bool isValid = Validator.TryValidateObject(order, context, results, true);

    if (!isValid)
    {
        var errors = results.ToDictionary(
            r => r.MemberNames.FirstOrDefault() ?? "",
            r => new[] { r.ErrorMessage }
        );
        return Results.ValidationProblem(errors);
    }

    return Results.Created($"/orders/{order.Id}", order);
});

42. How do you define common metadata for a group of endpoints ?

In Minimal APIs, you use MapGroup() to create a route group, and then chain metadata-related extension methods onto the group itself. Any metadata applied to the group is automatically inherited by every endpoint mapped within it.

Basic example:

var group = app.MapGroup("/api/orders")
                .WithTags("Orders")
                .RequireAuthorization()
                .WithOpenApi();

group.MapGet("/", GetOrders);
group.MapPost("/", CreateOrder);
group.MapDelete("/{id}", DeleteOrder);

Here, all three endpoints automatically get:

  • The /api/orders prefix
  • The “Orders” OpenAPI tag
  • The authorization requirement
  • OpenAPI metadata generation
Common metadata methods you can apply to a group
MethodPurpose
RequireAuthorization()Applies authorization to all endpoints in the group
AllowAnonymous()Allows anonymous access for all (can be overridden per endpoint)
WithTags("TagName")Groups endpoints under a tag in Swagger/OpenAPI
WithOpenApi()Adds OpenAPI metadata generation
WithMetadata(...)Attaches arbitrary custom metadata objects
WithSummary() / WithDescription()Adds OpenAPI documentation text
Produces<T>()Declares expected response type/content
AddEndpointFilter<T>()Applies a filter (e.g., validation, logging) to every endpoint in the group
CacheOutput()Applies output caching policy to the group
RequireRateLimiting("policy")Applies rate limiting to the group
RequireCors("policyName")Applies CORS policy to the group

Example combining several:

var group = app.MapGroup("/api/products")
    .RequireAuthorization("AdminOnly")
    .WithTags("Products")
    .AddEndpointFilter<ValidationFilter<ProductDto>>()
    .RequireRateLimiting("fixed")
    .WithOpenApi();

group.MapGet("/", GetProducts);
group.MapPost("/", CreateProduct);

Overriding metadata for a specific endpoint within the group:

Individual endpoints can still customize or override group-level settings:

group.MapGet("/public", GetPublicProducts)
     .AllowAnonymous();   // overrides the group's RequireAuthorization()

group.MapPost("/", CreateProduct)
     .WithTags("Products", "Write-Operations"); // adds additional tag

Nested groups:

Groups can also be nested, and metadata compounds down the hierarchy:

var api = app.MapGroup("/api");
var orders = api.MapGroup("/orders").RequireAuthorization();

orders.MapGet("/", GetOrders); // inherits /api/orders + RequireAuthorization
Key interview point to mention

MapGroup() returns a RouteGroupBuilder, which implements the same IEndpointConventionBuilder interface as individual route mappings (MapGet, MapPost, etc.). That’s why all the same fluent extension methods (RequireAuthorization, WithTags, AddEndpointFilter, etc.) work identically whether applied to a single endpoint or an entire group — it’s a unified builder abstraction, not a special case for groups.

43. How do you return different HTTP status codes from a Minimal API ?

Minimal APIs use the static Results class (or the TypedResults class for a strongly-typed variant) to construct responses with specific HTTP status codes, instead of returning IActionResult types like in MVC controllers.

Common Results methods and their status codes:

MethodStatus CodePurpose
Results.Ok(value)200Success with a body
Results.Created(uri, value)201Resource created
Results.CreatedAtRoute(...)201Created, using a named route to build Location header
Results.Accepted(uri, value)202Accepted for async processing
Results.NoContent()204Success, no body
Results.BadRequest(errors)400Invalid request
Results.Unauthorized()401Not authenticated
Results.Forbid()403Authenticated but not allowed
Results.NotFound()404Resource not found
Results.Conflict()409Conflict (e.g., duplicate resource)
Results.UnprocessableEntity()422Semantic validation error
Results.ValidationProblem(errors)400Structured validation error response
Results.Problem(...)Configurable (default 500)RFC 7807 problem details
Results.StatusCode(code)CustomAny arbitrary status code

Basic example:

app.MapGet("/orders/{id}", (int id, IOrderService service) =>
{
    var order = service.GetById(id);

    if (order is null)
        return Results.NotFound();

    return Results.Ok(order);
});

Returning different results based on logic:

app.MapPost("/orders", (OrderDto dto, IOrderService service) =>
{
    if (dto.Quantity <= 0)
        return Results.BadRequest("Quantity must be greater than zero.");

    var created = service.CreateOrder(dto);
    return Results.Created($"/orders/{created.Id}", created);
});

Using TypedResults (strongly-typed, better for OpenAPI + testability):

TypedResults is preferred in .NET 7+ because it improves OpenAPI schema generation and makes unit testing easier (you get compile-time type checking instead of the generic IResult).

app.MapGet("/orders/{id}", Results<Ok<Order>, NotFound> (int id, IOrderService service) =>
{
    var order = service.GetById(id);

    return order is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(order);
});

Here, the Results, NotFound> return type explicitly documents all possible response types for the endpoint — this metadata flows automatically into OpenAPI/Swagger without needing .Produces() calls.

Multiple possible outcomes example:

app.MapPost("/orders", Results<Created<Order>, BadRequest<string>> (OrderDto dto, IOrderService service) =>
{
    if (dto.Quantity <= 0)
        return TypedResults.BadRequest("Invalid quantity.");

    var order = service.CreateOrder(dto);
    return TypedResults.Created($"/orders/{order.Id}", order);
});

Custom status code:

app.MapGet("/legacy", () => Results.StatusCode(410)); // 410 Gone

Key interview point to mention:

Prefer TypedResults over Results when possible — it:

  • Provides compile-time safety over which result types an endpoint can return.
  • Automatically generates accurate OpenAPI metadata (status codes + response types) without manual .Produces() annotations.
  • Makes endpoints easier to unit test since you assert on concrete types instead of the generic IResult interface.

Good soundbite: “Results gives you IResult; TypedResults gives you the same functionality but with concrete types, which pays off in better OpenAPI docs and more testable, compile-time–checked endpoints.”

44. What are endpoint filters and how are endpoint filters different from middleware ?

Endpoint filters (IEndpointFilter) are a Minimal API feature (introduced in .NET 7) that let you run logic before and/or after a specific endpoint handler executes — without cluttering the handler itself. They give you access to the strongly-typed route handler arguments, which middleware cannot see.

Basic example:

public class LoggingFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        Console.WriteLine($"Handling request: {context.HttpContext.Request.Path}");

        var result = await next(context); // call the next filter / the handler itself

        Console.WriteLine($"Response generated: {result}");

        return result;
    }
}

app.MapGet(“/orders/{id}”, (int id) => $”Order {id}”)
.AddEndpointFilter();

Applying multiple filters (they run in order, like a pipeline):

app.MapPost("/orders", (OrderDto order) => Results.Created("/orders/1", order))
   .AddEndpointFilter<ValidationFilter<OrderDto>>()
   .AddEndpointFilter<LoggingFilter>();

Inline filter (lambda-based, for quick logic):

app.MapGet("/orders/{id}", (int id) => $"Order {id}")
   .AddEndpointFilter(async (context, next) =>
   {
       var id = context.GetArgument<int>(0);
       if (id <= 0)
           return Results.BadRequest("Invalid ID");

       return await next(context);
   });

Short-circuiting:

Just like middleware, a filter can choose not to call next(), short-circuiting the pipeline and returning immediately (e.g., failed validation, unauthorized access).

Key Differences: Endpoint Filters vs Middleware

AspectMiddlewareEndpoint Filters
ScopeApplies globally to the whole request pipeline (or conditionally via app.Map/UseWhen)Applies to a specific endpoint or group
Registrationapp.Use...() in Program.cs, order matters globally.AddEndpointFilter<T>() chained on a specific route/group
Access to route data / typed argumentsNo direct access to strongly-typed handler parameters (works with raw HttpContext)Full access to handler arguments via context.Arguments / context.GetArgument<T>()
Runs relative to routingRuns before routing resolves the endpoint (in general middleware) or after, depending on pipeline positionRuns after the endpoint has been matched, right around the handler invocation
Awareness of Minimal API semanticsGeneric — works the same regardless of framework (MVC, Minimal API, gRPC, etc.)Minimal API–specific — tightly coupled to endpoint handler signature and return value
Use case fitCross-cutting, pipeline-wide concerns: authentication, CORS, exception handling, routing, static filesEndpoint-specific or group-specific concerns: validation, argument transformation, per-route logging/auditing
Return value visibilityOperates on HttpContext directly; doesn’t see the handler’s return object in a typed wayCan inspect and modify the actual result object returned by the handler before it’s sent

Conceptual analogy:

  • Middleware = concentric layers wrapping the entire request pipeline (onion model) — it doesn’t know or care which endpoint eventually handles the request.
  • Endpoint filters = a pipeline scoped to one endpoint’s invocation — closer to “method interceptors” or “action filters” in MVC (similar to IActionFilter), but for Minimal APIs.

When to use which:

  • Use middleware for concerns that apply broadly across many/most routes regardless of what they do: authentication, HTTPS redirection, CORS, global exception handling, response compression.
  • Use endpoint filters for concerns tied to the specific inputs/outputs of certain endpoints: request validation against a specific DTO, argument logging, transforming a specific endpoint’s result, applying business rules only relevant to a subset of routes.

Entity Framework Core Interview Questions

EF Core is Microsoft’s modern, open-source, cross-platform Object-Relational Mapper (ORM) for .NET. It lets you work with a database using .NET objects (classes) instead of writing raw SQL — you query and manipulate data using C# and LINQ, and EF Core translates that into SQL behind the scenes.

EF Core questions are less about memorizing method names and more a filter for practical, production-grade .NET experience — efficient querying, understanding abstractions instead of just using them, and making sensible architecture calls. That’s exactly why they show up so often in interviews.

Why EF Core Interview Questions Matter:

EF Core comes up constantly in .NET interviews because it sits at the intersection of a few things employers actually care about in day-to-day work:

  • It’s the default data access story for .NET
  • It reveals whether you understand what’s happening under the hood
  • Performance and scaling questions live here
  • It tests architectural judgment
  • Migrations = real-world team workflow

45. what is DbContext ? What happens internally when SaveChanges() is called ?

DbContext is the primary class in EF Core that represents a session with the database. It’s the bridge between your C# objects and the underlying database, responsible for:

  • Querying the database (via DbSet<T> properties)
  • Tracking changes made to entity instances in memory
  • Persisting changes back to the database (SaveChanges())
  • Managing the connection lifecycle
  • Configuring the model (relationships, keys, constraints) via OnModelCreating
  • Caching a first-level “identity map” — within one DbContext instance, querying the same entity twice returns the same object reference

Basic shape:

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasOne(o => o.Customer)
            .WithMany(c => c.Orders)
            .HasForeignKey(o => o.CustomerId);
    }
}

Key characteristics:

AspectDetail
LifetimeShould be short-lived — typically scoped to a single unit of work (one HTTP request in a web app). Registered as Scoped in DI by default via AddDbContext<T>().
Not thread-safeA single DbContext instance must not be used concurrently across threads.
Unit of Work + Repository patternDbContext itself implements the Unit of Work pattern; DbSet<T> acts like a Repository.
Holds a Change TrackerEvery entity it retrieves or you attach gets a tracked “entry” with a state (Added, Modified, Deleted, Unchanged, Detached).

What Happens Internally When SaveChanges() Is Called?

This is the meatier part of the question — here’s the internal sequence:

a. DetectChanges() runs

EF Core walks through every tracked entity and compares its current property values against the original snapshot values captured when the entity was first queried/attached. This is how it figures out what actually changed without you telling it explicitly.

Note: With ChangeTracker.AutoDetectChangesEnabled, this normally happens automatically before SaveChanges(), before LINQ queries, and at a few other trigger points. You can disable it and call ChangeTracker.DetectChanges() manually for performance-sensitive bulk scenarios.

b. Each tracked entity’s state is evaluated

StateMeaningResulting SQL
AddedNew entity, not yet in the DBINSERT
ModifiedExisting entity with changed property valuesUPDATE
DeletedMarked for removalDELETE
UnchangedNo changes detectedNo SQL generated

c. A change set / execution plan is built

EF Core groups the pending Added/Modified/Deleted entries and determines the correct order of operations — respecting foreign key dependencies. For example, a parent entity must be inserted before a dependent child entity that references its generated key (this matters a lot with auto-generated identity/primary keys).

d. A database transaction is started (implicitly)

If you didn’t start one manually, EF Core wraps all the generated SQL statements from this SaveChanges() call in an implicit transaction. This guarantees atomicity — either all changes commit, or none do, if any statement fails.

e. SQL statements are generated and sent to the database

  • By default, EF Core batches multiple INSERT/UPDATE/DELETE statements into fewer round trips where the provider supports it (SQL Server, for example, batches statements).
  • Parameterized SQL is used (protects against SQL injection and enables plan caching).

f. Generated keys are propagated back

For Added entities with database-generated keys (identity columns), EF Core reads back the newly generated primary key values from the database and updates the in-memory entity objects with them.

g. Concurrency tokens are checked (if configured)

If the entity has a concurrency token (e.g., [Timestamp]/RowVersion column, or a property marked IsConcurrencyToken), the generated UPDATE/DELETE includes a WHERE clause checking the original value. If zero rows are affected (meaning someone else already changed/deleted the row), EF Core throws a DbUpdateConcurrencyException.

h. Transaction commits (or rolls back on failure)

If everything succeeds, the transaction commits. If any statement fails (constraint violation, concurrency conflict, connection issue), the transaction rolls back and EF Core throws (DbUpdateException or DbUpdateConcurrencyException).

i. Change tracker state resets

After a successful save:

  • Added entities become Unchanged (now that they exist in the DB)
  • Modified entities become Unchanged
  • Deleted entities are detached from the context entirely
  • The “original values” snapshot is refreshed to match current values, so the next SaveChanges() only picks up new changes

j. SaveChanges() returns an int

Specifically, the number of rows affected in the database (or state entries written) — useful as a lightweight way to confirm something actually happened.

Visual summary:

SaveChanges() called
        ↓
DetectChanges() — diff current vs. original snapshot values
        ↓
Determine entity states (Added / Modified / Deleted)
        ↓
Order operations respecting FK dependencies
        ↓
Begin implicit transaction (if none active)
        ↓
Generate + execute parameterized SQL (batched where possible)
        ↓
Read back DB-generated keys → update tracked entities
        ↓
Check concurrency tokens (throw if conflict)
        ↓
Commit transaction (or rollback on error)
        ↓
Reset entity states to Unchanged / detach Deleted entities
        ↓
Return count of affected rows

46. What is the Change Tracker ? What are entity states in EF Core ?

The Change Tracker (ChangeTracker) is a component owned by every DbContext instance that monitors the state of entity instances the context knows about — whether they were retrieved from the database, added, or manually attached. Its job is to figure out what changed so that SaveChanges() knows exactly which INSERT/UPDATE/DELETE statements to generate.

You can access it directly:

var entries = context.ChangeTracker.Entries();

foreach (var entry in entries)
{
    Console.WriteLine($"{entry.Entity.GetType().Name} — {entry.State}");
}

How it works internally:

  • When an entity is queried, EF Core takes a snapshot of its original property values at that moment and starts tracking it.
  • As you mutate properties on that entity in memory, the object itself changes — but the original snapshot stays frozen.
  • When DetectChanges() runs (automatically before SaveChanges(), before queries, etc.), EF Core compares current values vs. original snapshot values property-by-property to determine what’s actually different.
  • Based on that diff, it assigns/updates the entity’s state.

Change tracking strategies:

StrategyHow it works
Snapshot tracking (default)EF Core stores a full snapshot of original values and diffs against it during DetectChanges()
Notification trackingEntities implement INotifyPropertyChanged; changes are detected immediately as they happen, no need to call DetectChanges() — more efficient for large object graphs

Accessing/manipulating tracked entries directly:

var entry = context.Entry(order);

Console.WriteLine(entry.State); // e.g. Modified

// Check specific property changes
var originalQty = entry.Property(o => o.Quantity).OriginalValue;
var currentQty = entry.Property(o => o.Quantity).CurrentValue;
bool isModified = entry.Property(o => o.Quantity).IsModified;

Entity States in EF Core

Every tracked entity has an EntityState (an enum) at any given time:

StateMeaningSQL generated on SaveChanges()
AddedEntity is new; doesn’t exist in the DB yetINSERT
UnchangedEntity exists in DB and no properties have changed since it was loaded/last savedNone
ModifiedEntity exists in DB, and one or more property values differ from the original snapshotUPDATE
DeletedEntity exists in DB but is marked for removalDELETE
DetachedEntity is not tracked by this context at all — EF Core knows nothing about itNone (until attached/added)

How entities transition between states:

// Detached → Added
var newOrder = new Order { ProductName = "Widget" };
context.Orders.Add(newOrder);           // state: Added

// Query result → Unchanged (automatically tracked)
var order = context.Orders.First();     // state: Unchanged

// Unchanged → Modified (automatic, via change detection)
order.Quantity = 99;                    // still Unchanged in memory...
context.ChangeTracker.DetectChanges();  // ...now becomes Modified

// Unchanged/Modified → Deleted
context.Orders.Remove(order);           // state: Deleted

// After successful SaveChanges()
// Added    → Unchanged
// Modified → Unchanged
// Deleted  → Detached (removed from tracker entirely)

47. What is DbSet<t> ?

DbSet<T> is a class in EF Core that represents a collection of entities of a given type that can be queried and manipulated against the database — conceptually, it maps to a table (or a queryable view/set of rows) in the underlying database.

It’s exposed as a property on your DbContext, and it’s the main entry point for interacting with a specific entity type.

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }
}

What DbSet<T> gives you:

a. Querying (it implements IQueryable<T>)

var bigOrders = context.Orders
    .Where(o => o.Quantity > 10)
    .OrderBy(o => o.ProductName)
    .ToList();

Because DbSet<T> implements IQueryable<T>, LINQ expressions built against it are translated into SQL and executed on the database — not run in memory (this matters a lot; see IQueryable vs IEnumerable below).

b. Adding entities

context.Orders.Add(new Order { ProductName = "Widget", Quantity = 5 });
context.Orders.AddRange(order1, order2, order3);

Marks the entity as Added in the Change Tracker — it will generate an INSERT on SaveChanges().

c. Removing entities

context.Orders.Remove(order);
context.Orders.RemoveRange(order1, order2);

Marks the entity as Deleted — generates a DELETE on SaveChanges().

d. Updating entities

context.Orders.Update(order);

Marks the entire entity graph as Modified (used mainly in disconnected scenarios where the entity wasn’t already tracked).

e. Finding by primary key

var order = context.Orders.Find(5);
// or async
var order = await context.Orders.FindAsync(5);

Find/FindAsync first checks the Change Tracker’s local cache (identity map) before hitting the database — if the entity with that key is already tracked in memory, it’s returned without a database round trip.

48. What is change tracking ? What is AsNoTracking()?

Change Tracking is the mechanism by which EF Core’s DbContext monitors entities that have been loaded (via queries) or attached to it, so it knows what changes have been made in memory and can generate the correct SQL (INSERT, UPDATE, DELETE) when SaveChanges() is called.

How it works:

  1. When you query entities using a DbContext (e.g., context.Employees.ToList()), EF Core creates a snapshot of each entity’s original values and starts tracking it.
  2. Each tracked entity has an internal state, represented by the EntityState enum:
    • Added – new entity, will be inserted
    • Unchanged – no modifications since it was loaded
    • Modified – one or more properties changed
    • Deleted – marked for deletion
    • Detached – not being tracked
  3. When you modify a property on a tracked entity, EF Core compares the current value to the snapshot and marks the entity (and the specific properties) as Modified.
  4. When SaveChanges() is called, EF Core inspects the tracked entities’ states and generates the appropriate SQL statements only for what actually changed.
var employee = context.Employees.First(e => e.Id == 1); // tracked
employee.Salary = 50000; // EF detects this change automatically

context.SaveChanges(); // generates UPDATE ... SET Salary = 50000 WHERE Id = 1

You didn’t have to explicitly call Update() — EF Core detected the change itself via tracking.

Why it matters (interview angle):

  • It enables the “just modify the object” programming model instead of manually writing UPDATE statements.
  • It has a performance cost: EF Core keeps snapshots and does change detection (DetectChanges()), which takes memory and CPU, especially with large result sets.
  • It’s central to concepts like the Unit of Work pattern that DbContext implements.

AsNoTracking()

AsNoTracking() is a query extension method that tells EF Core not to track the entities returned by that query. The entities are returned as plain objects with no snapshot kept, and no EntityState is maintained for them.

var employees = context.Employees
    .AsNoTracking()
    .Where(e => e.DepartmentId == 3)
    .ToList();

Key characteristics:

  • Read-only intent: Since there’s no tracking, changes to these entities won’t be detected or persisted by SaveChanges() unless you explicitly attach them again.
  • Performance benefit: Skips the overhead of creating snapshots and change-detection, making queries faster and more memory-efficient — especially valuable for:
    • Reporting queries
    • API GET endpoints that just return data
    • Large result sets
  • Can be applied per-query (.AsNoTracking()) or set as a context-wide default:
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

There’s also AsNoTrackingWithIdentityResolution(), which avoids full tracking overhead but still ensures that the same logical entity (same key) maps to the same object instance within that query’s results — useful when a query has duplicate/related rows for the same entity.

49. Explain Added, Modified, Deleted, Unchanged, and Detached

Every entity that EF Core knows about (tracked entities) has a state, represented by the EntityState enum. This state tells SaveChanges() exactly what SQL operation to generate for that entity.

public enum EntityState
{
    Detached,
    Unchanged,
    Deleted,
    Modified,
    Added
}

You can inspect it at any time:

var state = context.Entry(employee).State;

a. Detached

The entity exists as a .NET object, but the DbContext is not tracking it at all. EF knows nothing about it — no snapshot, no state management.

var employee = new Employee { Name = "John" }; // Detached — just created, not added to context

An entity also becomes Detached again if you explicitly call context.Entry(entity).State = EntityState.Detached, or after the context is disposed.

b. Added

The entity is new and tracked, and will be inserted into the database on the next SaveChanges(). Its primary key (if database-generated) is typically not yet set to a real value.

context.Employees.Add(employee); // state becomes Added
context.SaveChanges(); // generates INSERT

c. Unchanged

The entity is tracked, and its current property values match the original snapshot EF Core took when it was loaded. No SQL is generated for this entity on SaveChanges().

var employee = context.Employees.First(); // state is Unchanged right after loading

d. Modified

The entity is tracked, and at least one property value differs from its original snapshot. EF Core tracks changes at the property level, not just entity level — so it knows exactly which columns to include in the UPDATE statement.

employee.Salary = 60000; // state becomes Modified
context.SaveChanges(); // generates UPDATE ... SET Salary = 60000 WHERE Id = ...

e. Deleted

The entity is tracked and marked for removal. On SaveChanges(), EF Core generates a DELETE statement using the entity’s key.

context.Employees.Remove(employee); // state becomes Deleted
context.SaveChanges(); // generates DELETE FROM Employees WHERE Id = ...

State transition summary:

ActionResulting State
new Employee()Detached
context.Add(entity)Added
Query result (ToList(), First(), etc.)Unchanged
Modify a property on a tracked entityModified
context.Remove(entity)Deleted
SaveChanges() completesAdded/Modified → Unchanged; Deleted → Detached

50. What is eager loading, lazy loading and explicit loading ?

EF Core offers three strategies for loading related (navigation property) data. This is a very common interview question because it tests both API knowledge and performance judgment.

Eager Loading

Related data is loaded upfront, as part of the same query, using Include() (and ThenInclude() for deeper levels).

var employees = context.Employees
    .Include(e => e.Department)
    .ThenInclude(d => d.Location)
    .Include(e => e.Projects)
    .ToList();

This generates a single SQL query (typically using JOINs) that pulls employees along with their departments, locations, and projects all at once.

When to use:

  • You know upfront you’ll need the related data.
  • Avoids the N+1 query problem (see below).

Downside:

  • Over-fetching if you don’t actually need the related data every time.
  • Multiple Include() calls with collections can cause a cartesian explosion — the result set balloons because of multiple JOINs multiplying row counts. EF Core mitigates this using “split queries” (see below).
// Split query — runs separate SQL queries instead of one big JOIN, avoiding cartesian explosion
var employees = context.Employees
    .Include(e => e.Projects)
    .Include(e => e.Certifications)
    .AsSplitQuery()
    .ToList();

Lazy Loading

Related data is loaded automatically, on-demand, the moment you access a navigation property — not when the initial query runs.

To enable it:

  • Install Microsoft.EntityFrameworkCore.Proxies
  • Enable it in DbContext configuration: optionsBuilder.UseLazyLoadingProxies()
  • Mark navigation properties as virtual
public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public virtual Department Department { get; set; } // must be virtual
}

var employee = context.Employees.First(); // only Employee is loaded
var deptName = employee.Department.Name; // triggers a SEPARATE query here, behind the scenes

When to use:

  • Rarely recommended for anything but small, exploratory scenarios.

Downside (important interview point):

  • Causes the infamous N+1 query problem — if you loop over 100 employees and access .Department on each, that’s 1 query for employees + 100 separate queries for departments.
foreach (var emp in employees) // 1 query already ran
{
    Console.WriteLine(emp.Department.Name); // N additional queries!
}
  • Requires proxies and virtual properties, which some consider an anti-pattern (breaks POCO purity, has gotchas with sealed classes/structs).
  • Easy to accidentally trigger from serialization frameworks (e.g., a JSON serializer touching every navigation property).

Explicit Loading

You load the initial entity without related data, then explicitly and deliberately trigger loading of specific navigation properties when you choose to, using context.Entry().

var employee = context.Employees.First(); // just Employee, nothing else

// Explicitly load a reference navigation property
context.Entry(employee).Reference(e => e.Department).Load();

// Explicitly load a collection navigation property
context.Entry(employee).Collection(e => e.Projects).Load();

// You can even filter what gets loaded
context.Entry(employee).Collection(e => e.Projects)
    .Query()
    .Where(p => p.IsActive)
    .Load();

When to use:

  • You need related data only conditionally (e.g., inside an if block, based on business logic).
  • You want the control of lazy loading’s “load only if needed” without the hidden magic and N+1 risk of automatic lazy loading — the loading call is visible in the code.

Comparison Table:

StrategyWhen data loadsQuery countMain risk
Eager (Include)Upfront, with main query1 (or more with split query)Over-fetching, cartesian explosion
LazyOn property access (automatic)1 + N (per access)N+1 problem, hidden queries
ExplicitOn-demand, but manually triggered1 + however many you explicitly callStill N+1 if called in a loop without care

51. What is the difference between Include() and ThenInclude() ?

Both are used for eager loading of related data, but they operate at different levels of the object graph.

Include()

Loads a navigation property directly on the entity you’re querying (first level of relation).

var employees = context.Employees
    .Include(e => e.Department)
    .ToList();

This loads each Employee along with its direct Department.

You can also chain multiple Include() calls to load several sibling (first-level) navigation properties independently:

var employees = context.Employees
    .Include(e => e.Department)
    .Include(e => e.Projects)
    .ToList();

This loads Department and Projects, both directly related to Employee.

ThenInclude()

Loads a navigation property on an entity that was just included — i.e., it goes one level deeper into the object graph, continuing from the previous Include()/ThenInclude().

var employees = context.Employees
    .Include(e => e.Department)
    .ThenInclude(d => d.Location)
    .ToList();

This loads Employee → Department → Location. Without ThenInclude(), there’s no way to express “go one level deeper” — Include() alone only knows about the root entity’s navigation properties.

Combining them — branching the graph:

You can mix multiple Include/ThenInclude chains to load a complex graph. Each new Include() call starts a new branch from the root entity:

var employees = context.Employees
    .Include(e => e.Department)
        .ThenInclude(d => d.Location)
    .Include(e => e.Projects)
        .ThenInclude(p => p.Client)
    .ToList();

This produces:

Employee
 ├── Department
 │     └── Location
 └── Projects
       └── Client

The second Include(e => e.Projects) resets the “current level” back to the root Employee, so the interviewer should know that ThenInclude() always continues from the immediately preceding Include/ThenInclude in that chain — not from wherever the chain “ended up.”

Multiple levels deep:

You can chain ThenInclude() multiple times to go arbitrarily deep:

var employees = context.Employees
    .Include(e => e.Department)
        .ThenInclude(d => d.Location)
            .ThenInclude(l => l.Country)
    .ToList();

Employee → Department → Location → Country

Key distinction (interview soundbite) – Include() specifies a navigation property on the root entity type being queried. ThenInclude() specifies a navigation property on the entity that was just loaded by the previous Include or ThenInclude call — it lets you traverse multiple levels of relationships in a single eager-loading query.

A gotcha worth mentioning:

If you need to include a navigation property on a collection’s element type (e.g., a collection navigation on each item inside another collection), ThenInclude() handles that too — EF Core infers you’re operating on the element type of the enclosing collection:

context.Departments
    .Include(d => d.Employees)
        .ThenInclude(e => e.Projects)
    .ToList();

This loads all Employees for each Department, and all Projects for each of those Employees — correctly handling the one-to-many-to-many nesting.

52. What is the difference between EnsureCreated() and EF Core Migrations ?

Both can be used to get a database schema in place from your EF Core model, but they’re designed for very different scenarios and are not compatible with each other.

Database.EnsureCreated()

context.Database.EnsureCreated();

What it does:

  • Checks if the database exists. If not, creates it.
  • Creates the schema (tables, columns, keys, indexes) directly from the current state of your EF Core model — in one shot.
  • If the database already exists, it does nothing (doesn’t check if the schema matches the model).

Key characteristics:

  • Does not use or create a migrations history table (__EFMigrationsHistory).
  • Does not support incremental schema changes — there’s no concept of “diffing” old vs new model versions.
  • If you change your model (add a property, new entity, etc.) after the database was created, EnsureCreated() will not update the existing database. You’d have to drop and recreate it.
  • Cannot be mixed with Migrations on the same database — they use fundamentally different tracking mechanisms, and EF Core will throw an error if you try to apply migrations to a database created via EnsureCreated().

When to use:

  • Unit/integration tests (especially with in-memory or SQLite in-memory providers) where you just need a throwaway schema quickly.
  • Quick prototypes, demos, or POCs where schema evolution doesn’t matter.
  • Scenarios where you never intend to evolve the schema over time (rare in real apps).
// Typical test setup
var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseInMemoryDatabase("TestDb")
    .Options;

using var context = new AppDbContext(options);
context.Database.EnsureCreated(); // fast, one-time schema creation

EF Core Migrations

dotnet ef migrations add InitialCreate
dotnet ef database update

What it does:

  • Generates a versioned migration file (C# code) representing the diff between your current model and the last known model snapshot.
  • Maintains a __EFMigrationsHistory table in the database that tracks which migrations have already been applied.
  • Allows incremental, repeatable, and reversible schema changes over the lifetime of an application.

Key characteristics:

  • Every schema change (add column, rename table, add index, etc.) is captured in its own migration file with Up() and Down() methods — enabling rollback.
  • Can be applied programmatically (context.Database.Migrate()) or via CLI/scripts — suitable for CI/CD pipelines.
  • Can generate raw SQL scripts (dotnet ef migrations script) for DBA review or production deployment without needing the EF tooling on the production server.
  • Supports team collaboration — migration files are checked into source control, so schema history is versioned alongside code.
// Typical production startup
context.Database.Migrate(); // applies any pending migrations

Side-by-side comparison:

AspectEnsureCreated()Migrations
Schema versioningNoneFull history via migration files
Incremental changes❌ Not supported✅ Supported
Rollback capability❌ No✅ Yes (Down() method / previous migration)
Tracks applied changes in DB❌ No history table__EFMigrationsHistory table
Suitable for production❌ Generally no✅ Yes
Suitable for tests/prototypes✅ YesPossible but often overkill
Can coexist on same DB❌ Mutually exclusive❌ Mutually exclusive
CLI tooling requiredNoYes (dotnet ef / Package Manager Console)
Interview soundbite – EnsureCreated() is a one-shot, all-or-nothing schema creation useful for tests and prototypes — it has no concept of incremental change, so if my model evolves, it won’t update an existing database. Migrations, on the other hand, give me a versioned, reviewable, and reversible history of schema changes, tracked in an __EFMigrationsHistory table, which is what any real production application needs. The two are mutually exclusive — you pick one strategy per database.

Common follow-up: “What about EnsureDeleted()?”

Worth mentioning briefly — context.Database.EnsureDeleted() drops the database entirely if it exists. It’s often paired with EnsureCreated() in test setups to guarantee a clean slate before each test run:

context.Database.EnsureDeleted();
context.Database.EnsureCreated();

53. What is the N+1 query problem ?

In EF Core, it happens when you fetch a set of entities, then access a navigation property on each one in a loop — and EF Core (with lazy loading enabled) fires off a separate query for each entity to resolve that navigation property.

Example:

var blogs = context.Blogs.ToList();  // 1 query — fetches all blogs

foreach (var blog in blogs)
{
    Console.WriteLine(blog.Posts.Count);  // N queries — one per blog to lazy-load Posts
}

If there are 50 blogs, this fires 1 + 50 = 51 queries instead of one efficient query (or two well-batched ones).

Why it happens in EF Core specifically:

  • Lazy loading is opt-in but easy to enable — via UseLazyLoadingProxies() and making navigation properties virtual. Once enabled, simply touching a navigation property (blog.Posts) triggers a new round-trip to the database, invisibly.
  • Even without lazy loading, developers sometimes manually call .Entry(blog).Collection(b => b.Posts).Load() inside a loop — same effect, just explicit instead of hidden.

How to fix it:

a. Eager loading with Include() / ThenInclude() — the most common EF Core fix:

var blogs = context.Blogs
    .Include(b => b.Posts)
    .ToList();  // 1 query total, using a SQL JOIN

b. Explicit loading, but batched, not per-entity:

var blogIds = blogs.Select(b => b.Id).ToList();
var posts = context.Posts
    .Where(p => blogIds.Contains(p.BlogId))
    .ToList();  // 1 query for all related posts

c. Projection with Select() — often the most efficient, since you only pull the columns you need:

var result = context.Blogs
    .Select(b => new {
        b.Name,
        PostCount = b.Posts.Count()
    })
    .ToList();  // 1 query, translated entirely to SQL

d. Split queries (EF Core 5+) — when Include with multiple collections causes a cartesian explosion instead of N+1, you can tell EF to issue separate-but-batched queries instead of one giant join:

var blogs = context.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToList();

How to catch it:

  • Enable EF Core logging: optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) and watch for repeated similar-looking SELECT statements
  • Use context.ChangeTracker.LazyLoadingEnabled = false in development to make lazy-load accidents throw/fail loudly instead of silently querying
  • Tools like MiniProfiler or Application Insights can visually flag repeated queries in a request
  • As a rule of thumb: if lazy loading proxies are enabled, be very suspicious of any navigation-property access inside a foreach

Key takeaway for EF Core: default to Include() for known access patterns, prefer Select() projections when you don’t need full entities, and treat lazy loading as something to use cautiously (or disable entirely) rather than as the default.

54. What is optimistic concurrency ?

It’s a strategy for handling simultaneous edits to the same data without locking rows — instead of preventing others from reading/editing a record while you have it open, EF Core lets everyone read and edit freely, but checks at save time whether the data changed since you loaded it. If it did, the update is rejected instead of silently overwriting someone else’s changes.

It’s called “optimistic” because it assumes conflicts are rare — so it doesn’t pay the cost of locking upfront, only checks when it actually matters (on save).

The problem it solves:

// User A loads a Blog (Name = "Tech News", RowVersion = 1)
// User B loads the same Blog (Name = "Tech News", RowVersion = 1)

// User A changes Name to "Tech Daily" and saves → RowVersion becomes 2
// User B changes Name to "Tech World" and saves...

Without concurrency control, User B’s save would silently overwrite User A’s change — even though User B never saw it. Optimistic concurrency catches this instead of letting it happen silently.

How it works in EF Core:

You mark a property as a concurrency token. On every UPDATE/DELETE, EF Core includes that property’s original value in the WHERE clause. If no rows match (because someone else already changed it), EF Core throws a DbUpdateConcurrencyException.

a. Using a dedicated RowVersion / Timestamp column (most common, SQL Server):

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }  // auto-updated by the DB on every write
}

Generated SQL looks roughly like:

UPDATE Blogs 
SET Name = 'Tech World' 
WHERE Id = 1 AND RowVersion = 0x0000000000000001
-- if 0 rows affected → concurrency conflict

b.Using Fluent API on any property:

modelBuilder.Entity<Blog>()
    .Property(b => b.Name)
    .IsConcurrencyToken();

Now any property you designate (not just a special RowVersion column) gets included in the WHERE clause — if the value in the DB differs from what was originally loaded, the update fails.

c. Handling the conflict:

try
{
    context.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
    foreach (var entry in ex.Entries)
    {
        var databaseValues = entry.GetDatabaseValues();
        
        if (databaseValues == null)
        {
            // Row was deleted by someone else
        }
        else
        {
            var currentValues = entry.CurrentValues;
            var originalValues = entry.OriginalValues;

            // Decide: overwrite with client's values, 
            // keep the database's values, 
            // or merge/prompt the user
            entry.OriginalValues.SetValues(databaseValues); // common: refresh original values, then retry
        }
    }

    context.SaveChanges(); // retry after resolving
}

Optimistic vs. Pessimistic concurrency

OptimisticPessimistic
ApproachAllow concurrent access, check at save timeLock the row so others can’t touch it
PerformanceBetter — no locks heldWorse under contention — blocks other users
Best forWeb apps (disconnected, high concurrency, low actual conflict rate)Short transactions where conflicts are frequent/expected
EF Core supportBuilt-in via concurrency tokensNot natively supported — requires raw SQL / transaction-level locking hints

EF Core supports optimistic concurrency natively and it’s the default recommended approach for typical web apps — pessimistic locking isn’t really a first-class EF Core feature and would need manual transaction/locking code. Use a [Timestamp] RowVersion column for the simplest, most reliable setup.

55. What is a transaction in EF Core?

A transaction is a way to group multiple database operations together so they either all succeed or all fail as one unit — there’s no in-between state where only some of the changes got applied.

The default behavior (implicit transaction):

You often don’t need to think about this because SaveChanges() already wraps itself in a transaction automatically. If you modify multiple entities and call SaveChanges() once, EF Core executes all the resulting SQL statements inside a single transaction — if any statement fails, everything rolls back.

context.Blogs.Add(new Blog { Name = "Tech News" });
context.Posts.Add(new Post { Title = "Hello World", BlogId = 1 });

context.SaveChanges(); 
// Both inserts happen in ONE implicit transaction.
// If the second insert fails, the first is rolled back too.

When you need an explicit transaction:

The implicit one only covers a single SaveChanges() call. If you need to span multiple SaveChanges() calls, or mix EF Core with raw SQL, you need to manage the transaction explicitly.

using var transaction = context.Database.BeginTransaction();

try
{
    context.Blogs.Add(new Blog { Name = "Tech News" });
    context.SaveChanges(); // SaveChanges #1

    context.Posts.Add(new Post { Title = "Hello World", BlogId = 1 });
    context.SaveChanges(); // SaveChanges #2

    transaction.Commit(); // only now are changes actually persisted
}
catch (Exception)
{
    transaction.Rollback(); // undo everything if anything failed
    throw;
}

Or more concisely with EnsureTransaction / the built-in execution strategy wrapper:

using var transaction = context.Database.BeginTransaction();
try
{
    // multiple operations, possibly raw SQL too
    context.Database.ExecuteSqlRaw("UPDATE Blogs SET Name = 'X' WHERE Id = 1");
    context.SaveChanges();
    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

Transactions across multiple DbContext instances:

If you need one transaction to span two separate DbContexts (e.g., two different bounded contexts), EF Core supports sharing a connection/transaction:

using var connection = new SqlConnection(connectionString);
connection.Open();
using var transaction = connection.BeginTransaction();

using (var context1 = new BlogContext(new DbContextOptionsBuilder<BlogContext>()
        .UseSqlServer(connection).Options))
{
    context1.Database.UseTransaction(transaction);
    // ... operations
}

using (var context2 = new OrderContext(new DbContextOptionsBuilder<OrderContext>()
        .UseSqlServer(connection).Options))
{
    context2.Database.UseTransaction(transaction);
    // ... operations
}

transaction.Commit();

Working with retry-resilient providers (e.g., EnableRetryOnFailure):

If you’ve enabled an execution strategy (like automatic retries for transient SQL Server failures), you can’t just call BeginTransaction() directly — the whole block, including retries, needs to be wrapped:

var strategy = context.Database.CreateExecutionStrategy();

strategy.Execute(() =>
{
    using var transaction = context.Database.BeginTransaction();
    
    context.Blogs.Add(new Blog { Name = "Tech News" });
    context.SaveChanges();

    context.Posts.Add(new Post { Title = "Hello World" });
    context.SaveChanges();

    transaction.Commit();
});

This is necessary because if a transient failure happens mid-transaction and a naive retry just re-runs the code, you could end up trying to begin a transaction inside an already-failed one — CreateExecutionStrategy() handles this correctly.

Savepoints (EF Core 5+):

Within a transaction, you can mark savepoints to roll back to a partial point without discarding the whole transaction:

using var transaction = context.Database.BeginTransaction();

context.Blogs.Add(new Blog { Name = "A" });
context.SaveChanges();
transaction.CreateSavepoint("AfterFirstBlog");

context.Blogs.Add(new Blog { Name = "B" });
context.SaveChanges();

// something went wrong with "B" specifically
transaction.RollbackToSavepoint("AfterFirstBlog"); // "A" is kept, "B" is undone

transaction.Commit();

Key takeaway:

  • Single SaveChanges() call → transaction is automatic, no code needed.
  • Multiple SaveChanges() calls, or mixing with raw SQL → use BeginTransaction() / Commit() / Rollback() explicitly.
  • Using EnableRetryOnFailure → wrap explicit transactions in CreateExecutionStrategy().Execute(...).

Authentication & Authorization Interview Questions

Authentication and Authorization are two of the most heavily tested topics in ASP.NET Core interviews. Authentication answers “who are you?” — verifying identity via credentials, tokens, or external providers — while Authorization answers “what are you allowed to do?”, determining access after identity is confirmed. Interviewers typically go beyond definitions to probe the mechanics: middleware pipeline order (UseAuthentication() before UseAuthorization()), JWT vs. cookie-based auth, ASP.NET Core Identity, and role-based vs. policy-based vs. claims-based authorization. Scenario questions are common too — like restricting an endpoint by a specific claim/role combo, or debugging why a valid user gets a 403 — testing whether you can apply the concepts, not just recite them.

These topics carry weight because security mistakes are among the costliest a developer can make — a broken authorization check can mean data breaches or full system compromise, unlike a minor UI bug. Interviewers use Auth questions as a proxy for engineering maturity and “secure by default” thinking, so candidates who explain the full lifecycle clearly — login, token issuance, claims validation, fine-grained authorization — tend to stand out, while textbook-only answers invite deeper follow-ups that expose gaps. For mid-level and senior roles especially, this is often a make-or-break area, so it’s worth mastering rather than just memorizing.

56. What is the difference between authentication and authorization ?

AuthenticationAuthorization
Question answered“Who are you?”“What are you allowed to do?”
PurposeVerifies identityGrants/denies access to resources
HappensFirstAfter authentication
MiddlewareUseAuthentication()UseAuthorization()
HTTP status on failure401 Unauthorized403 Forbidden
Based onCredentials, tokens, cookiesRoles, claims, policies

Authentication

The process of verifying who the user is — checking credentials (username/password), a token (JWT), a cookie, or an external identity provider (Google, Microsoft, etc.), and establishing a ClaimsPrincipal representing the user.

// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            // ...
        };
    });

app.UseAuthentication(); // must come BEFORE UseAuthorization

If authentication fails (bad/missing credentials), the server responds with 401 Unauthorized — “I don’t know who you are.”

Authorization

Once the user’s identity is established, authorization decides what that user is permitted to do — based on roles, claims, or custom policies.

[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult DeleteUser(int id) { ... }

// Policy-based
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("MinimumAge", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c => c.Type == "Age") &&
            int.Parse(context.User.FindFirst("Age").Value) >= 18));
});

app.UseAuthorization(); // must come AFTER UseAuthentication

If the user is authenticated but lacks the required role/claim/policy, the server responds with 403 Forbidden — “I know who you are, but you’re not allowed to do this.”

Key relationship

Authentication always happens before Authorization in the pipeline — you can’t check what someone can do until you know who they are. That’s also why middleware order matters:

app.UseAuthentication(); // 1. Establish identity
app.UseAuthorization();  // 2. Check permissions

Getting this order wrong (or reversing it) is a classic interview trick question, since misconfigured middleware order can silently allow unauthorized access or unexpectedly block valid users — a good example of why this distinction matters more in practice than it might seem in theory.

57. What is a Claims and Policies in ASP.NET Core authorization

A Claim is a key-value pair that represents information about a user, issued by a trusted party (typically during authentication). It’s part of the ClaimsIdentity/ClaimsPrincipal model.

// Examples of claims
new Claim(ClaimTypes.Name, "John Doe")
new Claim(ClaimTypes.Email, "john@example.com")
new Claim("EmployeeId", "12345")
new Claim("Department", "Engineering")

Each claim has:

  • Type – what the claim represents (e.g., "Department")
  • Value – the actual data (e.g., "Engineering")
  • Issuer (optional) – who issued the claim

Claims are typically added during authentication (login) and bundled into a ClaimsIdentity, which becomes part of the ClaimsPrincipal (User object) accessible throughout the request.

var claims = new List<Claim>
{
    new Claim(ClaimTypes.Name, "jdoe"),
    new Claim("Department", "Engineering")
};
var identity = new ClaimsIdentity(claims, "MyAuthScheme");
var principal = new ClaimsPrincipal(identity);

You can check claims directly in code:

if (User.HasClaim("Department", "Engineering"))
{
    // allow access
}

Policy

A Policy is a named, reusable set of authorization requirements — often built using one or more claims — registered centrally so it can be applied via attributes instead of writing manual checks everywhere.

a. Registering a policy (in Program.cs):

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("EngineeringOnly", policy =>
        policy.RequireClaim("Department", "Engineering"));

    options.AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

b. Applying it to a controller or endpoint:

[Authorize(Policy = "EngineeringOnly")]
public IActionResult SecretPage()
{
    return View();
}

Policies can be simple (RequireClaim, RequireRole, RequireAuthenticatedUser) or complex, using a custom IAuthorizationRequirement and AuthorizationHandler<T> for logic beyond a simple claim check (e.g., “user must be over 18,” verified via a date-of-birth claim).

How they relate:

ConceptRole
ClaimRaw piece of user data (who they are / what they have)
RequirementA rule about claims/data that must be satisfied
PolicyA named bundle of one or more requirements
[Authorize(Policy = "...")]Enforces the policy on an endpoint

In short: claims describe the user, and policies define the rules used to decide whether those claims are sufficient to authorize access.

58. What is role-based authorization ?

Role-based authorization restricts access to resources based on the role(s) a user belongs to (e.g., Admin, Manager, User) rather than checking individual permissions or claims one by one. It’s one of the simplest and most common authorization models.

How it works:

  • A role is essentially just a special claim of type ClaimTypes.Role.
  • When a user is authenticated, their roles are added to the ClaimsPrincipal.
  • Authorization checks whether the current user belongs to a required role before granting access.
var claims = new List<Claim>
{
    new Claim(ClaimTypes.Name, "jdoe"),
    new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "MyAuthScheme");
var principal = new ClaimsPrincipal(identity);

Applying role-based authorization

a. Using the [Authorize] attribute:

[Authorize(Roles = "Admin")]
public IActionResult AdminPanel()
{
    return View();
}

b. Multiple roles (OR logic) — user needs any one of the listed roles:

[Authorize(Roles = "Admin,Manager")]
public IActionResult ManageUsers()
{
    return View();
}

c. Multiple [Authorize] attributes (AND logic) — user must satisfy all of them:

[Authorize(Roles = "Admin")]
[Authorize(Roles = "HR")]
public IActionResult SensitiveAction()
{
    return View();
}

d. Checking roles in code:

if (User.IsInRole("Admin"))
{
    // perform admin-only logic
}

e. Role-based policies (useful when combining with other requirements):

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));
});

[Authorize(Policy = "AdminOnly")]
public IActionResult SecretSettings()
{
    return View();
}

Key points:

AspectDetail
BasisClaim of type Role
Attribute[Authorize(Roles = "...")]
Multiple roles in one attributeOR condition
Stacked [Authorize] attributesAND condition
Code checkUser.IsInRole("RoleName")
StorageRoles can come from a database, Identity, JWT tokens, Windows groups, etc.

Role-based vs. Claim-based vs. Policy-based

  • Role-based → coarse-grained, checks a single “Role” claim (good for simple hierarchies like Admin/User).
  • Claim-based → checks any claim type/value (e.g., Department = "Engineering"), more flexible.
  • Policy-based → the most flexible; can combine roles, claims, and custom logic into a single named rule — and is the recommended approach in ASP.NET Core going forward, since roles and claims can both be expressed as policies.

59. What is policy-based authorization ?

Policy-based authorization is the most flexible and recommended authorization model in ASP.NET Core. Instead of scattering role or claim checks throughout your code, you define named policies centrally — each policy is a set of one or more requirements that must be satisfied — and then apply that policy declaratively wherever needed.

It’s the underlying model that role-based and claims-based authorization are actually built on top of.

Core building blocks:

ComponentPurpose
RequirementA statement of what must be true (implements IAuthorizationRequirement)
HandlerContains the logic that evaluates whether a requirement is met (AuthorizationHandler<T>)
PolicyA named collection of one or more requirements

a. Simple policies (built-in requirements)

You don’t always need custom requirements — simple policies can use built-in methods:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("EngineeringOnly", policy =>
        policy.RequireClaim("Department", "Engineering"));

    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("MustBeAuthenticated", policy =>
        policy.RequireAuthenticatedUser());
});

b. Custom policies (custom requirement + handler)

For logic that goes beyond a single claim check — e.g., “user must be at least 18 years old.”

Step 1 – Define the requirement:

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}

Step 2 – Create the handler:

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dobClaim = context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);
        if (dobClaim == null)
            return Task.CompletedTask;

        var dob = DateTime.Parse(dobClaim.Value);
        var age = DateTime.Today.Year - dob.Year;

        if (age >= requirement.MinimumAge)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

Step 3 – Register the handler and policy:

builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("MinimumAge18", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

Step 4 – Apply it:

[Authorize(Policy = "MinimumAge18")]
public IActionResult AdultContent()
{
    return View();
}

c. Combining multiple requirements

A policy can require several things at once — all requirements must succeed:

options.AddPolicy("SeniorEngineer", policy =>
{
    policy.RequireRole("Engineer");
    policy.RequireClaim("Level", "Senior");
    policy.Requirements.Add(new MinimumAgeRequirement(21));
});

d. Imperative checks (outside attributes)

Using IAuthorizationService when you need to check a policy in code, e.g., inside a Razor Page or controller action:

public class DocumentController : Controller
{
    private readonly IAuthorizationService _authorizationService;

    public DocumentController(IAuthorizationService authorizationService)
    {
        _authorizationService = authorizationService;
    }

    public async Task<IActionResult> Edit(Document doc)
    {
        var result = await _authorizationService.AuthorizeAsync(User, doc, "EditPolicy");
        if (!result.Succeeded)
            return Forbid();

        return View(doc);
    }
}

This example also shows resource-based authorization — passing the actual resource (doc) so the handler can check ownership or other resource-specific rules, not just claims on the user.

Why policy-based is preferred:

  • Centralized – all authorization rules live in one place (Program.cs / a startup extension), not scattered across controllers.
  • Reusable – one policy can be applied to many endpoints.
  • Composable – combine roles, claims, and custom logic in a single policy.
  • Testable – handlers are plain classes that can be unit tested independently.
  • Extensible – supports resource-based authorization for per-object rules (e.g., “only the document owner can edit”).

Quick comparison:

ModelGranularityFlexibilityTypical Use
Role-basedCoarseLowSimple hierarchies (Admin/User)
Claims-basedMediumMediumAttribute checks (Department, Age)
Policy-basedFineHighComplex, reusable, combined rules — recommended default

60. What is claims-based authorization ?

Claims-based authorization restricts access based on the presence (and optionally the value) of specific claims in the user’s ClaimsPrincipal — rather than relying purely on roles. A claim is a key-value pair issued by a trusted party (e.g., "Department" = "Engineering", "EmployeeId" = "12345"), so this model lets you authorize based on any attribute of the user, not just a fixed role name.

It sits conceptually between role-based (single, coarse Role claim) and full policy-based authorization (which claims-based checks are actually built on top of).

How claims get into the user:

Claims are added during authentication and bundled into a ClaimsIdentityClaimsPrincipal:

var claims = new List<Claim>
{
    new Claim(ClaimTypes.Name, "jdoe"),
    new Claim("Department", "Engineering"),
    new Claim("EmployeeId", "12345"),
    new Claim(ClaimTypes.DateOfBirth, "2000-05-10")
};
var identity = new ClaimsIdentity(claims, "MyAuthScheme");
var principal = new ClaimsPrincipal(identity);

Applying claims-based authorization

Unlike roles, there’s no [Authorize(Claim = "...")] shortcut attribute — claims checks are expressed through policies.

a. Define a policy that requires a claim:

builder.Services.AddAuthorization(options =>
{
    // Just checks the claim type exists (any value)
    options.AddPolicy("HasEmployeeId", policy =>
        policy.RequireClaim("EmployeeId"));

    // Checks claim type AND specific value(s)
    options.AddPolicy("EngineeringOnly", policy =>
        policy.RequireClaim("Department", "Engineering"));

    // Multiple allowed values (OR)
    options.AddPolicy("EngineeringOrIT", policy =>
        policy.RequireClaim("Department", "Engineering", "IT"));
});

b. Apply it to a controller/endpoint:

[Authorize(Policy = "EngineeringOnly")]
public IActionResult TeamDashboard()
{
    return View();
}

c. Check claims directly in code:

if (User.HasClaim("Department", "Engineering"))
{
    // allow access
}

var employeeId = User.FindFirst("EmployeeId")?.Value;

d. Custom claims logic (beyond a simple match) — use a custom requirement/handler when you need to evaluate a claim’s value with logic (e.g., parsing a date, comparing numbers):

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    public MinimumAgeRequirement(int age) => MinimumAge = age;
}

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dobClaim = context.User.FindFirst(ClaimTypes.DateOfBirth);
        if (dobClaim != null)
        {
            var age = DateTime.Today.Year - DateTime.Parse(dobClaim.Value).Year;
            if (age >= requirement.MinimumAge)
                context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

Key points:

  • A role is just a special-cased claim (ClaimTypes.Role), so role-based authorization is really a narrow form of claims-based authorization.
  • Claims-based checks are always wired through AddPolicy + RequireClaim (or a custom handler) — there’s no built-in [Authorize(Claim=...)] attribute.
  • Best suited when authorization depends on user attributes beyond a simple role (department, subscription tier, verified email, age, permission flags, etc.).
  • Can be combined with roles and custom requirements in the same policy for fine-grained control.

61. What is permission-based authorization ?

Permission-based authorization restricts access based on fine-grained permissions (e.g., "Users.Create", "Orders.Delete", "Reports.View") assigned to a user — rather than broad roles like Admin or Manager. It’s the most granular authorization model: instead of asking “what role is this user?”, it asks “can this user perform this specific action?”

ASP.NET Core has no built-in permission system — it’s typically implemented on top of claims-based and policy-based authorization.

Why permission-based over role-based?

Roles become a problem at scale:

  • A Manager role might need 30 different permissions, and hardcoding [Authorize(Roles = "Manager")] everywhere makes it hard to give one manager extra access without creating a new role.
  • Permissions decouple “what a user can do” from “what a user is called” — roles become just a convenient bundle of permissions, not the authorization unit itself.

Common implementation approach

a. Model permissions as claims

var claims = new List<Claim>
{
    new Claim(ClaimTypes.Name, "jdoe"),
    new Claim("Permission", "Orders.View"),
    new Claim("Permission", "Orders.Edit"),
    new Claim("Permission", "Reports.View")
};
var identity = new ClaimsIdentity(claims, "MyAuthScheme");

Permissions are usually stored in a database (Roles → Permissions mapping table), and converted into claims when the user logs in / token is issued.

b. Define a custom requirement

public class PermissionRequirement : IAuthorizationRequirement
{
    public string Permission { get; }
    public PermissionRequirement(string permission) => Permission = permission;
}

c. Create the handler

public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, PermissionRequirement requirement)
    {
        if (context.User.HasClaim("Permission", requirement.Permission))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

d. Register a policy per permission — or generate them dynamically

Manually:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Orders.Edit", policy =>
        policy.Requirements.Add(new PermissionRequirement("Orders.Edit")));
});

Dynamically (better for many permissions), using an IAuthorizationPolicyProvider:

public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
    public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }

    public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
    {
        FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        var policy = new AuthorizationPolicyBuilder()
            .AddRequirements(new PermissionRequirement(policyName))
            .Build();
        return Task.FromResult(policy);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync() =>
        FallbackPolicyProvider.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() =>
        FallbackPolicyProvider.GetFallbackPolicyAsync();
}
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionHandler>();

This lets you use any permission string directly as a policy name without pre-registering each one:

[Authorize(Policy = "Orders.Edit")]
public IActionResult EditOrder(int id) { ... }

[Authorize(Policy = "Reports.View")]
public IActionResult ViewReports() { ... }

e. Check permissions imperatively

var result = await _authorizationService.AuthorizeAsync(User, "Orders.Delete");
if (!result.Succeeded)
    return Forbid();

Or directly via claims:

if (User.HasClaim("Permission", "Orders.Delete"))
{
    // allow
}

Typical database design:

TablePurpose
UsersUser accounts
RolesNamed role groupings
PermissionsFine-grained actions (e.g., Orders.Edit)
RolePermissionsMaps roles → permissions
UserRolesMaps users → roles

At login, the app resolves the user’s roles → collects all associated permissions → issues them as Permission claims (or embeds them in a JWT).

Comparison across all four models:

ModelGranularityBasisTypical check
Role-basedCoarseRole claim[Authorize(Roles = "Admin")]
Claims-basedMediumAny claimRequireClaim("Department", "Engineering")
Policy-basedFineRequirements/handlers[Authorize(Policy = "MinimumAge18")]
Permission-basedFinestPermission claims + custom handler[Authorize(Policy = "Orders.Edit")]

Key points:

  • Not a distinct ASP.NET Core feature — it’s claims + policies applied at action-level granularity.
  • Best for systems needing fine-grained access control (e.g., admin dashboards, multi-tenant SaaS apps) where roles alone would multiply out of control.
  • Often combined with a dynamic IAuthorizationPolicyProvider so you don’t have to manually register hundreds of policies.
  • Roles can still exist as a UI/management convenience (grouping permissions), while actual authorization decisions are made on permissions.

62. How does JWT authentication work and explain it’s three parts ?

JWT authentication is a stateless authentication mechanism where, after a user logs in with valid credentials, the server generates a signed JSON Web Token (JWT) containing the user’s identity and claims, and sends it back to the client. The client then includes this token in the Authorization: Bearer <token> header on every subsequent request, and the server simply verifies the token’s signature and expiration to authenticate the user — without needing to query a database or maintain session state. Since the token itself carries all the necessary user information and is cryptographically signed (though not encrypted) to prevent tampering, JWT authentication scales well for distributed systems and APIs, such as those built with ASP.NET Core, where multiple servers can validate tokens independently without shared session storage.

How it works:

  1. User logs in with credentials (username/password).
  2. Server validates credentials and generates a JWT (JSON Web Token), signed with a secret/private key.
  3. Server sends the token back to the client.
  4. Client stores the token (e.g., in memory, localStorage) and sends it in the Authorization: Bearer <token> header on every subsequent request.
  5. Server verifies the token’s signature and expiry on each request — no database/session lookup needed — and extracts claims to identify/authorize the user.

Three parts (separated by dots: header.payload.signature):

PartContent
HeaderMetadata — token type (JWT) and signing algorithm (e.g., HS256)
PayloadClaims — user data like sub (user ID), role, exp (expiry), custom claims
SignatureHeader + payload, hashed with a secret key — used to verify the token hasn’t been tampered with

Example: eyJhbGciOi... (header) .eyJzdWIiOi... (payload) .SflKxwRJ... (signature)

Since the signature guarantees integrity (not secrecy — payload is just Base64-encoded, not encrypted), JWTs enable stateless authentication.

63. Why must UseAuthentication() come before UseAuthorization() ?

Because authorization depends on the result of authentication. Middleware runs in the order it’s registered, and:

  • UseAuthentication() identifies who the user is — it reads the request (e.g., cookie, JWT token), validates it, and populates HttpContext.User with a ClaimsPrincipal.
  • UseAuthorization() decides what that user is allowed to do — it checks roles/claims/policies against HttpContext.User.

If UseAuthorization() ran first, HttpContext.User would still be an empty/unauthenticated principal (no claims set), so every authorization check would fail — even for legitimately authenticated users — because there’d be no identity to evaluate yet.

app.UseAuthentication(); // Sets HttpContext.User
app.UseAuthorization();  // Checks HttpContext.User against policies/roles

In short: Authorization needs a populated User to check against — and only authentication middleware populates it. Wrong order → authorization always fails.

64. What is OpenID Connect ?

OpenID Connect is an authentication protocol built on top of OAuth 2.0. While OAuth 2.0 is designed for authorization (granting access to resources), OIDC adds an identity layer on top of it, allowing clients to verify the identity of a user and obtain basic profile information.

Key Points:

  • Authentication, not just authorization: OAuth 2.0 tells you “this app has permission to access X,” but it doesn’t tell you who the user is. OIDC solves this by introducing the concept of an ID Token.
  • ID Token: A JSON Web Token (JWT) issued by the identity provider (IdP) after successful authentication. It contains claims about the user (e.g., sub, name, email, iat, exp) that the client application can verify and trust.
  • Standardized flow: Uses the same flows as OAuth 2.0 (Authorization Code, Implicit, etc.), but the token response includes both an access token (for authorization) and an ID token (for authentication).

How it Works (Simplified Flow):

  1. The client redirects the user to the Identity Provider (e.g., IdentityServer, Azure AD, Google).
  2. The user authenticates (logs in) with the IdP.
  3. The IdP redirects back to the client with an authorization code.
  4. The client exchanges the code for an access token and an ID token.
  5. The client validates the ID token’s signature, issuer, audience, and expiry.
  6. The client extracts user claims from the ID token to establish the user’s identity.

Example in ASP.NET Core:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://your-identity-provider.com";
    options.ClientId = "your-client-id";
    options.ClientSecret = "your-client-secret";
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.Scope.Add("openid");
    options.Scope.Add("profile");
});

65. What is OAuth 2.0 ? What is the difference between OAuth 2.0 and OpenID Connect ?

OAuth 2.0 is an authorization framework (protocol) that allows a third-party application to obtain limited access to a user’s resources on another service, without exposing the user’s credentials (like username/password) to that third-party application.

Key Idea:

Instead of giving your password to an app, you grant it a token that allows limited, specific access — for a limited time — to your data hosted on another service.

Example: You want a photo-printing app to access your Google Photos. Instead of giving the app your Google password, Google issues an access token that only allows access to your photos, nothing else (not your emails, contacts, etc.).

Key Roles in OAuth 2.0:

RoleDescription
Resource OwnerThe user who owns the data (e.g., you)
ClientThe application requesting access (e.g., the photo-printing app)
Authorization ServerIssues access tokens after authenticating the resource owner (e.g., Google’s auth server)
Resource ServerHosts the protected resources/APIs (e.g., Google Photos API)

Key Concepts:

  • Access Token: A credential (often a JWT or opaque string) used by the client to access protected resources. It has a limited lifetime and scope.
  • Refresh Token: Used to obtain a new access token once the current one expires, without requiring the user to log in again.
  • Scope: Defines the level/boundary of access requested (e.g., read:photos, write:contacts).
  • Grant Type: The method by which the client obtains an access token.

Common OAuth 2.0 Grant Types (Flows):

Grant TypeUse Case
Authorization CodeMost secure; used by web apps with a backend (server-side)
Authorization Code + PKCERecommended for SPAs and mobile apps (no client secret)
Client CredentialsMachine-to-machine (M2M) communication, no user involved
Implicit (deprecated)Was used for SPAs, now replaced by Auth Code + PKCE
Resource Owner Password Credentials (deprecated)Client collects username/password directly — insecure, avoid

OIDC vs OAuth 2.0

AspectOAuth 2.0OpenID Connect
PurposeAuthorizationAuthentication (+ Authorization)
TokenAccess TokenID Token + Access Token
Tells youWhat you can accessWho the user is
Token formatOpaque or JWTJWT (ID Token is always a JWT)

Common Claims in an ID Token

  • sub — unique identifier for the user
  • iss — issuer (the identity provider)
  • aud — audience (the client app)
  • exp — expiration time
  • iat — issued at time
  • name, email, email_verified — profile information (if requested via scopes)

In short: OIDC = OAuth 2.0 + Identity. It’s the standard way to implement “Login with Google/Microsoft/Facebook” style authentication in modern applications, including ASP.NET Core apps using AddOpenIdConnect() or integrating with IdentityServer/Duende IdentityServer.

Duende IdentityServer

Duende IdentityServer questions are important to prepare for in ASP.NET Core interviews because authentication and authorization are core concerns in almost every real-world enterprise application, and Duende IdentityServer is the de facto standard for implementing centralized identity management, SSO, and API security in the .NET ecosystem — so interviewers use it to gauge whether you truly understand OAuth 2.0 and OpenID Connect concepts in practice, not just in theory. Many companies have existing systems built on IdentityServer4 or are migrating to Duende IdentityServer, so employers want to confirm you can configure clients, scopes, resources, and grant types, handle token issuance and validation, integrate custom user stores, and troubleshoot common issues like token expiration, CORS, or redirect URI mismatches — all of which reflect real production challenges. Additionally, since security vulnerabilities in authentication systems can have severe consequences, interviewers use these questions to assess whether a candidate has the judgment and depth needed to design and maintain a secure, standards-compliant identity solution rather than just copy-pasting configuration code, making this topic a strong signal of a candidate’s practical, production-ready expertise versus surface-level knowledge.

66. What is Duende Software IdentityServer ?

Duende IdentityServer is a commercial, open-source OpenID Connect and OAuth 2.0 framework for .NET, used to build a centralized authentication and authorization server (also called a Security Token Service / STS) in ASP.NET Core applications. It is the officially supported successor to IdentityServer4, developed by Duende Software after the original creators discontinued free maintenance of IdentityServer4.

Why It Exists:

In enterprise applications, you often have multiple client applications (web apps, SPAs, mobile apps) and multiple APIs that all need to authenticate users and authorize access consistently. Instead of each app implementing its own login and token logic, Duende IdentityServer centralizes this into one identity provider that all apps trust.

What It Does:

CapabilityDescription
AuthenticationIssues ID Tokens to verify user identity (OIDC)
AuthorizationIssues Access Tokens to allow API access (OAuth 2.0)
Single Sign-On (SSO)One login session works across multiple client apps
FederationSupports external login providers (Google, Azure AD, etc.)
Token ManagementHandles refresh tokens, token expiry, revocation
Custom User StoreIntegrates with ASP.NET Core Identity, EF Core, or custom databases

Key Building Blocks (Configuration Concepts):

  • Clients — the applications allowed to request tokens (e.g., a web app, a mobile app)
  • Scopes — what the client is allowed to request (openid, profile, api1.read)
  • Resources — APIs or identity data being protected
  • Grant Types — flow used to get a token (Authorization Code + PKCE is the modern standard)

Example: Minimal Setup in ASP.NET Core

builder.Services.AddIdentityServer()
    .AddInMemoryClients(Config.Clients)
    .AddInMemoryApiScopes(Config.ApiScopes)
    .AddInMemoryIdentityResources(Config.IdentityResources)
    .AddTestUsers(Config.Users); // or AddAspNetIdentity<ApplicationUser>()

app.UseIdentityServer();

A client app then consumes it like this:

.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://your-duende-identityserver.com";
    options.ClientId = "webapp-client";
    options.ResponseType = "code";
});

Why Interviewers Ask This

  1. Tests real-world knowledge — most companies don’t build raw OAuth/OIDC flows manually; they use a framework like this, so interviewers want to know if you’ve actually implemented it.
  2. Checks migration awareness — since IdentityServer4 is deprecated (EOL Nov 2022), interviewers want to know if you’re aware of the shift to Duende IdentityServer and its licensing change (free for dev/small-scale, paid for production at scale).
  3. Assesses architecture understanding — configuring Clients, Scopes, and Resources correctly requires understanding the full OAuth2/OIDC flow, not just theory.
  4. Security depth — token validation, secure redirect URIs, and grant type selection are common real-world pitfalls; this question filters candidates who understand why, not just how.

67. What is an API scope ? What is an API resource ?

These two concepts are often confused but serve different purposes in modeling what a client can access.

API Resource:

An API Resource represents the actual API/application you want to protect — a logical grouping of one or more related APIs that validate tokens.

  • Think of it as the “owner” or “container” for one or more scopes.
  • It typically corresponds to a physical API (e.g., OrdersApi, PaymentsApi).
  • A single API Resource can expose multiple scopes.
  • Access tokens are validated against the API Resource (via its name or associated scopes), and the API checks the token’s aud (audience) / scope claims.
new ApiResource("orders-api", "Orders API")
{
    Scopes = { "orders.read", "orders.write" }
}

API Scope

An API Scope represents a specific permission or capability that a client can request — a granular piece of access within an API Resource.

  • It’s what actually appears in the access token’s scope claim.
  • Clients request specific scopes; the API checks if the token contains the required scope before allowing an operation.
  • Naming convention is often resource.action, like orders.read, orders.write, payments.refund.
new ApiScope("orders.read", "Read access to Orders"),
new ApiScope("orders.write", "Write access to Orders")

How They Relate:

API Resource: "orders-api"
   ├── Scope: "orders.read"
   └── Scope: "orders.write"
  • A client requests one or more scopes (orders.read).
  • The Authorization Server (e.g., Duende IdentityServer) checks if the client is allowed those scopes, then issues an access token containing them.
  • The API Resource (orders-api) validates the incoming token — checking that the token’s audience/scopes match what it expects.

Example in Duende IdentityServer Config

public static IEnumerable<ApiScope> ApiScopes =>
    new List<ApiScope>
    {
        new ApiScope("orders.read", "Read Orders"),
        new ApiScope("orders.write", "Write Orders")
    };

public static IEnumerable<ApiResource> ApiResources =>
    new List<ApiResource>
    {
        new ApiResource("orders-api", "Orders API")
        {
            Scopes = { "orders.read", "orders.write" }
        }
    };

68. What is a client in IdentityServer ? What is a client ID and client secret ?

A Client in IdentityServer (Duende IdentityServer) represents an application that is registered to request tokens from the IdentityServer — i.e., anything that wants to authenticate a user or get access to an API on behalf of a user (or itself) must be configured as a Client.

  • Examples of clients: a web app, a mobile app, a SPA, a backend service (M2M).
  • Each client has its own configuration defining what it’s allowed to do — which grant types it can use, which scopes it can request, where it can redirect to after login, token lifetimes, etc.

Example:

new Client
{
    ClientId = "webapp-client",
    ClientSecrets = { new Secret("supersecret".Sha256()) },
    AllowedGrantTypes = GrantTypes.Code,
    RedirectUris = { "https://myapp.com/signin-oidc" },
    AllowedScopes = { "openid", "profile", "orders.read" },
    RequirePkce = true
}

Client ID

The Client ID is a public, unique identifier for the client application registered with the IdentityServer.

  • Think of it like a username for the application (not secret — it’s often visible in browser URLs, JS code, mobile app binaries).
  • IdentityServer uses it to look up the client’s configuration (allowed scopes, grant types, redirect URIs, etc.) when a token request comes in.
ClientId = "webapp-client"

Client Secret

The Client Secret is a confidential credential (like a password) that proves the client’s identity to the IdentityServer — used only by confidential clients (server-side apps that can safely store secrets).

  • It’s exchanged along with the Client ID when requesting a token, so IdentityServer can verify the request is genuinely coming from that registered client (not an impersonator).
  • Never used in public clients like SPAs or mobile apps, since those can’t securely store a secret (anyone could extract it from client-side code) — that’s why PKCE replaces the need for a client secret in public clients.
ClientSecrets = { new Secret("supersecret".Sha256()) }

Think of Client ID as a username and Client Secret as a password — but for an application, not a human user. Just like a user proves who they are with a username/password, a confidential client app proves its identity to IdentityServer with a Client ID/Client Secret pair before it’s trusted to receive tokens.

69. What is an identity resource ?

An Identity Resource in IdentityServer (OIDC) represents a set of claims about a user’s identity — such as their name, email, or profile info — that a client can request access to via scopes. While API Resources/Scopes control access to APIs, Identity Resources control access to user identity data returned in the ID Token.

Why It Exists

When a client authenticates a user via OpenID Connect, it doesn’t just want a plain “yes, logged in” — it usually wants some information about the user (name, email, etc.). Identity Resources define exactly which claims get bundled together and exposed as a requestable scope.

Standard (Built-in) Identity Resources

IdentityServer ships with standard OIDC identity resources out of the box:

Identity ResourceClaims Included
openidsub (subject/user ID) — required for any OIDC request
profilename, family_name, given_name, picture, birthdate, etc.
emailemail, email_verified
addressaddress
phonephone_number, phone_number_verified

Custom Identity Resource Example

You can also define your own, e.g., to expose a custom role or department claim:

public static IEnumerable<IdentityResource> IdentityResources =>
    new List<IdentityResource>
    {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
        new IdentityResource(
            name: "roles",
            displayName: "User Roles",
            claimTypes: new[] { "role" })
    };

How It’s Used:

  1. Client requests scopes including an identity resource: openid profile roles.
  2. User logs in and consents.
  3. IdentityServer issues an ID Token containing the claims tied to those identity resources (sub, name, role, etc.).
  4. Client reads these claims to know who the user is.
AllowedScopes = { "openid", "profile", "roles", "orders.read" }

(Notice orders.read here is an API Scope, while openid, profile, roles are Identity Resources — both can be requested together in the same token request.)

70. What does the openid scope do ?

The openid scope is the switch that turns on OpenID Connect authentication — it’s mandatory for any login flow, triggers the issuance of an ID Token, and adds the sub claim (unique user identifier) to that token. Without it, you’re doing plain OAuth 2.0 authorization with no guaranteed identity information.

What It Actually Does

  1. Activates OIDC behavior — without openid, the request is treated as plain OAuth 2.0 (authorization only). Including it tells IdentityServer: “This is an OpenID Connect request — I need to know who the user is, not just what they can access.”
  2. Triggers ID Token issuance — when openid is requested (and the flow completes), the token response includes an ID Token (a JWT) alongside any access token.
  3. Includes the sub claim — the openid scope maps to the IdentityResources.OpenId identity resource, which contains exactly one claim: sub (subject identifier — a unique, stable ID for the authenticated user).

71. What does the profile scope do ?

The profile scope is a standard Identity Resource that bundles general user profile claims (name, given_name, picture, etc.) into the ID Token — but it only returns claims that are actually populated for the user via your IProfileService or identity store.

What It Adds

When a client includes profile in its scope request (alongside the mandatory openid), IdentityServer includes these claims in the ID Token (or makes them available via the /connect/userinfo endpoint), if the user has values for them:

ClaimDescription
nameFull display name
given_nameFirst name
family_nameLast name
middle_nameMiddle name
nicknameCasual/preferred name
preferred_usernamePreferred username/handle
profileURL to the user’s profile page
pictureURL to the user’s profile photo
websiteUser’s website URL
genderGender
birthdateDate of birth
zoneinfoTime zone
localeLocale/language preference
updated_atWhen profile info was last updated

72. What is the sub claim ?

The sub (subject) claim is the unique, stable identifier for the authenticated user — it’s the single most important claim in OpenID Connect, and the only claim guaranteed to be present whenever the openid scope is requested.

What It Represents:

  • sub answers the question: “Which user is this, exactly?”
  • It’s typically a unique ID from the identity provider’s user store (e.g., a GUID or primary key), not the user’s email or username — because emails/usernames can change, but sub must remain constant for the lifetime of that user’s account.
{
  "sub": "8f14e45f-ceea-467a-9575-9a61a1c8b1c5",
  "name": "John Doe",
  "email": "john@example.com"
}

Why It Matters:

  1. Primary key for identity — your application should use sub (not email or name) as the key to look up/link the local user record, since it’s immutable and unique.
  2. Guaranteed presence — every ID Token issued for an openid scope request contains sub, even if no other claims are requested.
  3. Uniqueness scopesub is guaranteed unique within the issuer (iss) — i.e., unique per identity provider. The combination of iss + sub is what truly guarantees global uniqueness (important when supporting multiple identity providers/federation).

73. What is a signing credential ?

A Signing Credential is the cryptographic key (and algorithm) that IdentityServer (or any OIDC/OAuth token issuer) uses to digitally sign the tokens it issues — primarily the ID Token and, optionally, the Access Token (when using JWT format). It’s what allows clients and APIs to verify that a token is genuine and hasn’t been tampered with.

Why It’s Needed

Tokens (JWTs) are just Base64-encoded JSON — anyone can read or even forge one if there’s no way to verify authenticity. The signing credential solves this: IdentityServer signs the token with a private key, and consumers (clients/APIs) verify the signature using the corresponding public key — proving the token really came from that trusted issuer and wasn’t altered in transit.

How It Works (Simplified)

  1. IdentityServer creates a token (header + payload).
  2. It signs the token using its private key → produces the token’s signature.
  3. The signed JWT (header.payload.signature) is sent to the client.
  4. The client/API fetches IdentityServer’s public key (via the discovery/JWKS endpoint) and verifies the signature.
  5. If the signature is valid → token is trusted, unmodified, and genuinely issued by that server.
JWT = Base64(Header) + "." + Base64(Payload) + "." + Signature
Signature = Sign(Header + Payload, PrivateKey)

74. What is AddDeveloperSigningCredential() ? Why should developer signing credentials not be used in production ?

AddDeveloperSigningCredential() is a convenience method in Duende IdentityServer used to quickly generate a temporary RSA signing key for local development and testing, so you don’t have to set up a real X.509 certificate just to get IdentityServer running.

builder.Services.AddIdentityServer()
    .AddDeveloperSigningCredential();

What It Actually Does

  1. On first run, it generates an RSA key pair and serializes it to a file on disk (typically tempkey.rsa or tempkey.jwk in the app’s root directory).
  2. On subsequent runs, it reuses the same key from that file (so tokens remain valid across restarts during development, as long as the file persists).
  3. This key is used to sign ID Tokens and JWT access tokens, just like a “real” signing credential would.

Why It Must NOT Be Used in Production

a. Key Is Not Securely Managed

The key is stored as a plain file on the local disk of the server — no encryption, no access control, no integration with a proper secrets/key management system (like Azure Key Vault, AWS KMS, or an HSM). Anyone with file system access can read the private key and forge valid tokens.

b. Not Portable Across Multiple Instances/Servers

In production, you typically run multiple instances of your app (load-balanced, containerized, auto-scaled). Since the key file is generated locally per instance, each instance would have a different signing key — meaning a token issued by Instance A would fail validation when checked against Instance B’s public key. This breaks authentication in any horizontally-scaled deployment.

Instance A (Key A) → issues token → 
Instance B (Key B) → tries to validate → ❌ Signature mismatch

c. Key Regeneration Invalidates Existing Tokens

If the key file is lost, deleted, or the app is redeployed to a fresh container/environment without persisting that file (very common in containerized/cloud deployments), a new key is generated — instantly invalidating all previously issued tokens. Users get logged out unexpectedly; APIs start rejecting valid-looking tokens.

d. No Key Rotation Support

Production systems need key rotation (periodically retiring old keys while still honoring recently-issued tokens signed with them, using multiple keys in the JWKS). AddDeveloperSigningCredential() has no concept of rotation — it’s a single, static key with no lifecycle management.

e. Security/Compliance Red Flag

Using a developer-only feature in production is a clear signal of misconfiguration — it may fail security audits/pen tests outright, since the key isn’t protected by any real key management or rotation policy.

What to Use Instead in Production

ApproachDescription
X.509 CertificateLoad from certificate store or file (AddSigningCredential(certificate))
Azure Key VaultStore keys securely, integrate via AddSigningCredential with a Key Vault-backed provider
AWS KMS / HSMHardware-backed key management for high-security environments
Automatic Key Management (Duende)Duende IdentityServer’s built-in feature to automatically generate, rotate, and publish signing keys securely — recommended modern approach

75. What is tempkey.jwk ?

tempkey.jwk is the physical JSON file where AddDeveloperSigningCredential() stores its auto-generated RSA private/public key pair on disk — convenient for local development, but a common source of production bugs (like users getting logged out on every deploy) since it’s not portable, persistent, or securely managed across multiple instances.

What’s Inside It:

The file contains a serialized JWK (JSON Web Key) — a standard JSON format for representing a cryptographic key. It holds the RSA private key (and its corresponding public key components) that IdentityServer uses to sign tokens.

{
  "kty": "RSA",
  "kid": "a3f8c9e2b1d4...",
  "use": "sig",
  "alg": "RS256",
  "n": "xGOr-H7A-PWc7uxi...",   // modulus (part of public key)
  "e": "AQAB",                  // exponent (part of public key)
  "d": "X4cTteJY_gn4FYPsXB8r...", // private exponent — THE SECRET
  "p": "...", "q": "...", "dp": "...", "dq": "...", "qi": "..."  // other RSA private key components
}
  • kty — key type (RSA)
  • kid — key ID (used to match the right key when multiple keys exist)
  • alg — signing algorithm (RS256)
  • n, e — the public key components (safe to share)
  • d, p, q, etc. — the private key components (must stay secret — this is what signs tokens)

76. What is the difference between an Access token and Refresh token ?

Both are issued by the Authorization Server, but they serve completely different purposes in the OAuth 2.0 flow — one is for using an API, the other is for getting new access without re-login.

Access Token

The Access Token is the credential a client presents to a Resource Server (API) to access protected resources.

  • Purpose: Authorization — proves the client has permission to call the API with certain scopes.
  • Lifetime: Short-lived (typically minutes to a few hours) — by design, to limit damage if it’s leaked/stolen.
  • Sent to: Resource Server / API (in the Authorization: Bearer <token> header).
  • Format: Can be a JWT (self-contained, verifiable via signature) or an opaque/reference token (API must call back to the Authorization Server to validate it via introspection).
  • Contains: Scopes, expiry (exp), issuer (iss), audience (aud), and sometimes user claims.
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...

Refresh Token

The Refresh Token is a long-lived credential used only to obtain a new Access Token (and often a new Refresh Token) once the current Access Token expires — without requiring the user to log in again.

  • Purpose: Session continuity — silent renewal of access.
  • Lifetime: Long-lived (days, weeks, or even until explicitly revoked).
  • Sent to: Only the Authorization Server’s token endpoint — never sent to APIs/Resource Servers.
  • Format: Almost always opaque (a random string) — not meant to be decoded/inspected by clients.
  • Higher sensitivity: Since it can generate new access indefinitely, it must be stored very securely (e.g., HTTP-only secure cookie or server-side storage — never in browser localStorage for SPAs).
POST /connect/token
grant_type=refresh_token
&refresh_token=8xLOxBtZp8...
&client_id=webapp-client
&client_secret=...

Why Have Both?

This two-token design balances security and user experience:

  • Short-lived Access Tokens minimize the attack window if intercepted.
  • Long-lived Refresh Tokens avoid forcing users to log in every few minutes, while still letting the Authorization Server revoke access at any time (e.g., on logout, password change, or suspicious activity) by invalidating the refresh token — which immediately stops any further access token renewal.

ASP.NET CORE Performance based Interview Questions

ASP.NET Core performance-based interview questions matter because performance problems are among the most common and costly issues in production systems, and these questions reveal whether a candidate truly understands why the framework behaves the way it does — not just how to write functionally correct code. Topics like avoiding blocking async calls, caching (in-memory/distributed), efficient EF Core usage (AsNoTracking(), avoiding N+1 queries), and memory/GC awareness reflect real bottlenecks that only surface under actual load. A candidate who can reason about these trade-offs likely has hands-on experience diagnosing slow endpoints or memory issues in a live system — which is why employers use these questions as a strong signal of production readiness, not just theoretical knowledge.

77. What is caching ? What caching mechanisms are available in ASP.NET Core ?

Caching is the practice of storing frequently accessed or expensive-to-compute data in a temporary, fast-access storage layer, so subsequent requests for that same data can be served quickly — without repeating the expensive operation (e.g., a database query, an API call, or heavy computation).

Why It Matters:

  • Reduces latency (faster response times)
  • Reduces load on databases/downstream services
  • Improves scalability — the same server can handle more requests
  • Trade-off: cached data can become stale, so cache invalidation/expiration strategy matters

Caching Mechanisms in ASP.NET Core

a. In-Memory Caching (IMemoryCache)

Stores data in the web server’s own process memory. Fast, but not shared across multiple server instances.

builder.Services.AddMemoryCache();

public class ProductService
{
    private readonly IMemoryCache _cache;
    public ProductService(IMemoryCache cache) => _cache = cache;

    public Product GetProduct(int id)
    {
        return _cache.GetOrCreate($"product_{id}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            return _repository.GetById(id); // expensive call, only runs on cache miss
        });
    }
}

Best for: single-server apps, or data that’s okay to differ slightly between instances.

b. Distributed Caching (IDistributedCache)

Stores data in an external, shared cache (e.g., Redis, SQL Server) — consistent across multiple server instances (critical for load-balanced/scaled-out apps).

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

public async Task<string> GetDataAsync(string key)
{
    var cached = await _distributedCache.GetStringAsync(key);
    if (cached != null) return cached;

    var data = await FetchExpensiveDataAsync();
    await _distributedCache.SetStringAsync(key, data,
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
    return data;
}

Best for: multi-server/cloud deployments where all instances must see the same cached data.

c. Response Caching (Response Caching Middleware)

Caches the entire HTTP response (based on headers like Cache-Control), so identical requests are served directly — often from the client/browser or a proxy — without hitting the server logic at all.

builder.Services.AddResponseCaching();
app.UseResponseCaching();

[HttpGet]
[ResponseCache(Duration = 60)]
public IActionResult GetProducts() => Ok(_products);

Best for: public, non-personalized GET endpoints (e.g., product listings).

d. Output Caching (ASP.NET Core 7+)

A newer, more flexible server-side caching mechanism than Response Caching — supports tag-based invalidation, policies, and varying by query string/headers, without relying solely on HTTP cache headers.

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Expire60", b => b.Expire(TimeSpan.FromSeconds(60)));
});
app.UseOutputCache();

app.MapGet("/products", GetProducts).CacheOutput("Expire60");

Best for: modern apps needing fine-grained control (tag invalidation, per-endpoint policies) — generally preferred over Response Caching in .NET 7+.

Quick Comparison:

MechanismStorage LocationShared Across Instances?Caches What
IMemoryCacheLocal server memory❌ NoAny object
IDistributedCacheExternal (Redis/SQL)✅ YesAny object (serialized)
Response CachingHTTP layer (client/proxy/server)Depends on layerFull HTTP response
Output CachingServer-side (pluggable store)✅ Yes (with Redis backing)Full HTTP response, more flexible

Why Interviewers Ask This:

  1. Tests whether you know when to use which — a very common real-world mistake is using IMemoryCache in a load-balanced multi-instance deployment, causing inconsistent data across servers.
  2. Checks awareness of cache invalidation challenges — “there are only two hard problems in computer science: cache invalidation and naming things.”
  3. Follow-up likely: “How would you handle cache invalidation when the underlying data changes?” → Explicitly remove/update the cache entry on write (Cache-Aside pattern), or use short expiration + tag-based invalidation (Output Caching supports this natively).

78. When should you use Redis ?

Redis is an in-memory, distributed data store — use it when you need fast, shared state across multiple servers/instances, which IMemoryCache alone can’t provide.

Key Scenarios:

Use CaseWhy Redis Fits
Distributed cachingMultiple app instances (load-balanced/scaled-out) need to share the same cached data consistently
Session stateStore user sessions centrally so any server instance can handle any request (AddStackExchangeRedisCache for IDistributedCache)
Rate limiting / countersAtomic increment operations (INCR) across distributed requests
Pub/Sub messagingReal-time notifications, SignalR backplane for scaled-out WebSocket connections
Short-lived, high-throughput dataLeaderboards, temporary tokens, distributed locks — Redis is extremely fast (in-memory)

When NOT to Use Redis:

  • Single-instance appsIMemoryCache is simpler and faster (no network hop).
  • Long-term persistent storage — Redis is primarily in-memory; not a replacement for a relational/durable database (though it supports optional persistence).
  • Complex relational queries — Redis is a key-value store, not suited for relational joins/reporting.

Why Interviewers Ask This:

Tests whether you understand the actual problem Redis solves — shared, fast state across a distributed system — rather than just knowing “Redis = caching.” A common follow-up: “Why not just use IMemoryCache everywhere?” → Because in a scaled-out deployment, each instance would have its own separate cache, causing inconsistent data (e.g., a user’s session existing on Server A but not Server B).

In short: Use Redis when you need fast, shared state across multiple app instances — distributed caching, centralized session storage, SignalR backplane, or atomic counters — not for single-server apps or as a primary relational database.

79. What is cache invalidation ? What is cache stampede ?

Cache invalidation is explicitly clearing/updating stale cache data when the source changes (via TTL, cache-aside, or write-through). Cache stampede is when many concurrent requests simultaneously regenerate the same expired cache entry, overwhelming the backend — prevented via locking, background refresh, or staggered expiration.

80. How can caching improves performance ?

Caching improves performance by avoiding repeated, expensive work — serving data from a fast storage layer instead of recomputing or re-fetching it every time.

Key Ways It Helps:

BenefitExplanation
Reduces latencyReading from memory (or Redis) is far faster than querying a database, calling an external API, or running heavy computation
Reduces database/backend loadFewer repeated queries hit the database, freeing it up to handle other work — critical under high traffic
Reduces network callsAvoids redundant calls to slow external services/APIs
Improves scalabilitySince less work is repeated per request, the same server resources can handle more concurrent users
Reduces CPU usageAvoids re-running expensive computations (e.g., complex aggregations, report generation)

Example:

// Without caching: every request hits the database
public Product GetProduct(int id) => _repository.GetById(id); // DB call every time

// With caching: DB is hit once, then served from memory
public Product GetProduct(int id) =>
    _cache.GetOrCreate($"product_{id}", entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        return _repository.GetById(id); // only runs on cache miss
    });

If this endpoint gets 10,000 requests in 10 minutes for the same product, without caching that’s 10,000 database queries; with caching, it’s just 1 database query — the rest are served instantly from memory.

Where It Matters Most:

  • Read-heavy, rarely-changing data (product catalogs, configuration, reference data) — biggest win
  • Expensive computations (reports, aggregations) — cache the result, not just raw data
  • External API calls — especially rate-limited or slow third-party services

The Trade-off:

Caching trades freshness for speed — cached data can become stale until it’s invalidated/expired, so it’s best suited for data that doesn’t need to be instantly up-to-date on every single request.

81. What is rate limiting ? Why would you use rate limiting in an API ? What HTTP status code is normally returned when rate limiting rejects a request?

Rate limiting is a technique used to control how many requests a client (identified by IP, user, API key, etc.) can make to an API within a given time window — rejecting or delaying requests that exceed the allowed threshold.

Why Use Rate Limiting in an API?

ReasonExplanation
Prevent abuse/DoS attacksStops a single client from overwhelming the server with excessive requests, intentionally or accidentally
Ensure fair usagePrevents one client from monopolizing shared resources, so others get fair access
Protect backend resourcesShields databases/downstream services from being overloaded by request spikes
Cost controlLimits usage of paid/metered resources (e.g., third-party API calls, compute-heavy endpoints)
Enforce business/pricing tiersDifferent rate limits for free vs. paid API tiers

HTTP Status Code

When a request is rejected due to rate limiting, the API should return:

429 Too Many Requests

This is the standard status code (RFC 6585) indicating the client has sent too many requests in a given time period. It’s common to also include a Retry-After header telling the client how long to wait before retrying.

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{
  "error": "Rate limit exceeded. Try again in 30 seconds."
}

Example in ASP.NET Core (Built-in Rate Limiting Middleware, .NET 7+)

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 0; // no queueing, reject immediately over limit
    });

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

app.UseRateLimiter();

app.MapGet("/api/data", () => "response")
   .RequireRateLimiting("fixed");

Why Interviewers Ask This:

  1. Tests API design/security awareness — rate limiting is a fundamental protection mechanism that many junior developers overlook until an incident forces it.
  2. Standards knowledge — knowing the correct status code (429, not 403 or 503) shows familiarity with HTTP semantics, not just “it returns an error.”
  3. Follow-up likely: “What’s the difference between rate limiting and throttling?” → Rate limiting typically rejects excess requests outright (429), while throttling may delay/queue them to smooth out load instead of rejecting immediately — ASP.NET Core’s rate limiter supports both behaviors depending on configuration (e.g., QueueLimit > 0 allows queuing instead of immediate rejection).

82. What rate limiter algorithms are available in ASP.NET Core?

ASP.NET Core’s built-in Rate Limiting Middleware (introduced in .NET 7, via System.Threading.RateLimiting) provides four algorithms, each suited to different traffic patterns.

1. Fixed Window Limiter

Allows a fixed number of requests within a fixed time window (e.g., 100 requests per minute). Once the window resets, the count resets to zero.

options.AddFixedWindowLimiter("fixed", opt =>
{
    opt.PermitLimit = 100;
    opt.Window = TimeSpan.FromMinutes(1);
});

Drawback: Can allow bursts at window boundaries — e.g., 100 requests at 11:59:59 and another 100 at 12:00:00 = 200 requests in 1 second.

2. Sliding Window Limiter

Similar to Fixed Window, but divides the window into smaller segments, and the limit slides continuously — smoothing out the boundary-burst problem of Fixed Window.

options.AddSlidingWindowLimiter("sliding", opt =>
{
    opt.PermitLimit = 100;
    opt.Window = TimeSpan.FromMinutes(1);
    opt.SegmentsPerWindow = 4; // divides window into 4 segments of 15 sec each
});

Benefit: More accurate, fairer rate limiting than Fixed Window, at the cost of slightly more overhead/complexity.

3. Token Bucket Limiter

Maintains a “bucket” of tokens that refill at a steady rate up to a maximum capacity. Each request consumes a token; if the bucket is empty, the request is rejected. Allows controlled bursts as long as tokens are available.

options.AddTokenBucketLimiter("token", opt =>
{
    opt.TokenLimit = 100;
    opt.TokensPerPeriod = 20;
    opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
    opt.QueueLimit = 0;
});

Best for: APIs that want to allow occasional bursts of traffic while maintaining a steady average rate over time.

4. Concurrency Limiter

Limits the number of simultaneous/concurrent requests being processed, rather than counting requests over time. Once a request finishes, a “slot” frees up for the next one.

options.AddConcurrencyLimiter("concurrency", opt =>
{
    opt.PermitLimit = 10; // max 10 concurrent requests
    opt.QueueLimit = 5;   // up to 5 requests can queue and wait
});

Best for: Protecting resource-intensive endpoints (e.g., heavy computation, file processing) where the concern is simultaneous load, not total request count over time.

83. What is OnRejected ? What is Retry-After ?

OnRejected is a callback delegate in ASP.NET Core’s Rate Limiting Middleware that gets executed whenever a request is rejected because it exceeded the configured rate limit. It lets you customize what happens on rejection — instead of just relying on the default 429 response, you can add custom logging, headers, or a custom response body.

Why It’s Useful:

By default, a rejected request just returns a bare 429 Too Many Requests with no body. OnRejected lets you:

  • Log rejected requests (for monitoring/alerting on abuse patterns)
  • Return a custom error message/JSON body
  • Add a Retry-After header to tell the client when to try again
  • Track metrics (e.g., increment a counter for rate-limit violations)

Retry-After is a standard HTTP response header (defined in RFC 7231/9110) that tells the client how long to wait before making another request. It’s most commonly used alongside 429 Too Many Requests (rate limiting) and 503 Service Unavailable (temporary outages/maintenance).

Format:

It can be specified in two ways:

Retry-After: 30

→ Wait 30 seconds before retrying.

Retry-After: Wed, 21 Oct 2026 07:28:00 GMT

Retry-After: Wed, 21 Oct 2026 07:28:00 GMT

84. Where should rate limiting middleware be placed in the request pipeline ?

Rate limiting middleware should be placed early in the pipeline — after exception handling/HTTPS redirection, but before routing, authentication, and authorization — so excessive requests are rejected cheaply before consuming server resources, and sensitive endpoints like login remain protected from brute-force abuse.

var app = builder.Build();

app.UseExceptionHandler("/error");   // 1. Catch unhandled exceptions first
app.UseHttpsRedirection();            // 2. Enforce HTTPS early

app.UseRateLimiter();                 // 3. Rate limit BEFORE expensive work

app.UseRouting();                     // 4. Routing
app.UseAuthentication();              // 5. AuthN
app.UseAuthorization();               // 6. AuthZ

app.MapControllers()
   .RequireRateLimiting("fixed");     // can also apply per-endpoint

app.Run();

Why Place It Early?

  1. Reject cheaply, before doing real work — the whole point of rate limiting is to protect resources. If it runs after authentication, database calls, or business logic, the server has already spent CPU/DB/network resources on a request it’s about to reject anyway — defeating the purpose.
  2. Protects against unauthenticated abuse too — if placed after UseAuthentication(), an attacker flooding the login endpoint or public unauthenticated endpoints wouldn’t be rate-limited until after auth logic runs, which itself can be exploited (e.g., brute-force attacks, credential stuffing) for the very endpoints that most need protecting.
  3. Consistent with .NET’s benchmark ordering guidance — Microsoft’s own recommended middleware order places rate limiting near the top, alongside exception handling and HTTPS redirection, and before routing/auth.

85. How do you improve ASP.NET Core API performance ?

This is a broad, common interview question testing whether you know performance optimization across the full stack — not just one trick. Structure your answer around key areas:

1. Asynchronous Programming

Avoid blocking calls (.Result, .Wait()) that tie up thread pool threads. Use async/await throughout the call chain for I/O-bound operations (DB calls, HTTP calls, file I/O).

// ❌ Blocks a thread pool thread
var data = _repository.GetDataAsync().Result;

// ✅ Frees the thread while waiting
var data = await _repository.GetDataAsync();

2. Caching

Reduce repeated expensive work using IMemoryCache (single instance) or IDistributedCache/Redis (multi-instance), plus Output Caching for full response caching.

3. Efficient Database Access (EF Core)

TechniqueWhy
AsNoTracking()Skips change-tracking overhead for read-only queries
Avoid N+1 queriesUse .Include() or projections instead of lazy-loading in loops
Select only needed columnsUse .Select() projections instead of pulling full entities
Compiled queriesCache query execution plans for hot-path queries
Connection poolingEnabled by default with EF Core’s DbContext pooling (AddDbContextPool)

4. Response Compression

Reduces payload size over the network.

5. Minimize Middleware Pipeline Overhead

Keep the middleware pipeline lean — remove unused middleware, order it efficiently (put cheap/rejecting middleware like rate limiting early).

6. Use Minimal APIs Where Appropriate

Minimal APIs have less overhead than full MVC controllers for simple endpoints, due to a lighter execution pipeline.

8. Pagination & Limiting Payload Size

Never return unbounded result sets — paginate large collections to reduce response size and DB load.

var results = await _context.Products
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

9. HTTP Client Best Practices

Use IHttpClientFactory (avoids socket exhaustion from creating raw HttpClient instances) and configure connection pooling/timeouts properly.

10. Rate Limiting & Load Shedding

Prevent overload from excessive/abusive traffic (covered earlier) — protects the API from degrading under unexpected spikes.

11. Kestrel & Hosting Configuration

  • Tune Kestrel limits (max concurrent connections, request body size).
  • Run behind a reverse proxy (e.g., YARP, Nginx) with proper load balancing.
  • Enable HTTP/2 or HTTP/3 for multiplexed connections where applicable.

12. Monitoring & Profiling

Use Application Insights, dotnet-trace, MiniProfiler, or BenchmarkDotNet to identify actual bottlenecks — optimize based on data, not guesses.

Why Interviewers Ask This:

  1. Tests breadth of practical knowledge — performance issues can come from many layers (code, database, network, infrastructure); a strong candidate can reason across all of them.
  2. Real-world signal — this question separates candidates who’ve actually debugged production performance issues from those who’ve only worked on small, low-traffic apps.
  3. Follow-up likely: “What tools would you use to actually find the bottleneck before optimizing?”Always measure first — use profiling/APM tools (Application Insights, dotnet-trace, BenchmarkDotNet) to identify the real bottleneck rather than guessing and optimizing the wrong thing.

86. Why is asynchronous programming important ?

Asynchronous programming is important because it allows the server to handle many concurrent requests efficiently, without wasting threads sitting idle while waiting on slow I/O operations (database calls, HTTP requests, file access).

The Core Problem It Solves:

ASP.NET Core uses a limited thread pool to handle incoming requests. If a request performs a blocking (synchronous) I/O call, the thread handling it sits idle, doing nothing but waiting — yet it’s still “busy” from the thread pool’s perspective, unavailable to serve other incoming requests.

// ❌ Synchronous/blocking: thread is stuck waiting for the DB
public IActionResult GetProduct(int id)
{
    var product = _repository.GetById(id); // blocks the thread
    return Ok(product);
}

With async/await, the thread is released back to the thread pool while waiting for the I/O operation to complete, and only resumes execution when the result is ready — meaning that same thread can serve other requests in the meantime.

// ✅ Asynchronous: thread is freed during the wait
public async Task<IActionResult> GetProduct(int id)
{
    var product = await _repository.GetByIdAsync(id); // thread returns to pool while waiting
    return Ok(product);
}

Why This Matters for Scalability:

SynchronousAsynchronous
Thread during I/O waitBlocked, unusableReleased, reusable
Requests handled per available thread1 at a time (thread tied up)Many (thread reused while others wait)
Behavior under high loadThread pool exhaustion → requests queue/timeoutScales much better — same threads serve far more concurrent requests

Under high traffic, synchronous I/O-bound code can exhaust the thread pool — all threads get stuck waiting on slow database/network calls, and new incoming requests have no thread available to even start processing, causing timeouts and cascading failures. Asynchronous code avoids this by freeing threads during waits, letting a small number of threads efficiently serve a much larger number of concurrent requests.

87. What is the difference between Task, ValueTask, and void ?

Task, ValueTask, and void are all related to how a method represents its completion, but they serve very different purposes. This is particularly important when writing asynchronous code in ASP.NET Core.

FeatureTaskValueTaskvoid
Represents async operationYesYesNo
Can be awaitedYesYesNo
Can return a resultTask<T>ValueTask<T>No
Supports exception propagation through awaitYesYesNo
Can be used with asyncYesYesYes
Recommended for ASP.NET Core actionsYesSometimesGenerally No
Typical useMost async operationsPerformance-sensitive operations that often complete synchronouslySynchronous methods / event handlers

1. Task

Task represents an asynchronous operation that will complete in the future. It is the most common return type for asynchronous methods in ASP.NET Core.

public async Task GetDataAsync()
{
    await Task.Delay(1000);
}

When a value needs to be returned, use Task<T>:

public async Task<string> GetNameAsync()
{
    await Task.Delay(1000);
    return "John";
}

The caller can use await to asynchronously wait for completion:

string name = await GetNameAsync();

Interview point: Task is the default choice for most asynchronous methods in ASP.NET Core.

2. ValueTask

ValueTask also represents an asynchronous operation, but it is designed for scenarios where an operation frequently completes synchronously.

public ValueTask<string> GetNameAsync()
{
    return ValueTask.FromResult("John");
}

The caller can still use await:

string name = await GetNameAsync();

The main advantage of ValueTask is that it can sometimes avoid allocating a new Task when the result is already available.

However, ValueTask is not automatically better than Task. It has additional usage restrictions and can make code more complicated. Therefore, use it primarily when performance measurements show that avoiding Task allocations is beneficial.

3. void

void means that the method does not return a value and does not represent an asynchronous operation.

public void SaveData()
{
    // Synchronous operation
}

You can technically declare an async void method:

public async void ProcessDataAsync()
{
    await Task.Delay(1000);
}

But async void should generally be avoided in ASP.NET Core application code.

Unlike Task, an async void method cannot be awaited by its caller:

ProcessDataAsync();
// Caller cannot await it

This makes it difficult to determine when the operation has finished and makes exception handling more difficult.

async void is mainly appropriate for event handlers, where the event pattern requires a void return type.

88. What causes thread-pool starvation ?

Thread-Pool starvation occurs when Thread Pool threads are occupied or blocked for too long, leaving insufficient threads to process incoming work. Common causes include synchronous blocking of asynchronous operations using .Result or .Wait(), Thread.Sleep, synchronous I/O, excessive CPU-bound work, and lock contention. In ASP.NET Core, it can result in increased latency, low throughput, and request timeouts. The primary solution is to use asynchronous, non-blocking APIs and avoid blocking Thread Pool threads.

89. How can database queries affect API performance ?

Database queries directly affect API performance because database operations are often a significant part of an API request’s response time. Slow queries, missing indexes, excessive data retrieval, N+1 queries, unnecessary round trips, and large result sets can increase latency and resource consumption. In ASP.NET Core, we can improve performance by using asynchronous database operations, proper indexing, projections, pagination, AsNoTracking() for suitable read-only queries, avoiding N+1 queries, and caching frequently accessed data.

How to improve database-related API performance?

Common techniques include:

  • Use indexes appropriately.
  • Use async database operations.
  • Select only the required columns.
  • Use pagination for large datasets.
  • Avoid the N+1 query problem.
  • Reduce unnecessary database round trips.
  • Use AsNoTracking() for suitable read-only EF Core queries.
  • Analyze slow queries using execution plans and database monitoring.
  • Use caching where appropriate.
  • Keep database transactions as short as practical.
  • Avoid returning unnecessarily large datasets.

90. How can you reduce unnecessary database calls ?

To reduce unnecessary database calls, retrieve only the data required, avoid N+1 queries, batch operations, reuse data within a request, implement caching, use pagination and projections, and use AsNoTracking() for read-only EF Core queries. The goal is to minimize database round trips while ensuring that the queries that remain are efficient and properly indexed.

91. What is Response Compression in ASP.NET Core?

Response compression in ASP.NET Core is a middleware-based feature that compresses HTTP responses before sending them to clients. It reduces response size, saves bandwidth, and can improve network performance. ASP.NET Core supports compression algorithms such as Brotli and Gzip, and the middleware selects an appropriate encoding based on the client’s Accept-Encoding header.

92. What is Connection Pooling vs Object Pooling

Connection pooling reuses database connections to avoid the overhead of repeatedly establishing physical connections. Object pooling reuses application objects to reduce object allocation and initialization overhead. Connection pooling is primarily handled by the database provider, while object pooling can be explicitly implemented using mechanisms such as ASP.NET Core’s ObjectPool<T>.

93. How can you optimize large API responses ?

Large API responses can be optimized by using pagination, filtering, projection, DTOs, response compression, caching, and streaming for very large datasets. You should avoid returning unnecessary fields or deeply nested object graphs and retrieve only the data required by the client. The goal is to reduce database work, serialization overhead, memory consumption, and network bandwidth while keeping the API responsive.

94. What is pagination ? Why should APIs use pagination for large datasets ?

Pagination is the technique of dividing a large dataset into smaller, manageable portions called pages, instead of returning all records in a single API response.

For example, instead of returning 100,000 products at once, an API might return 20 or 50 products per request.

GET /api/products?page=1&pageSize=20

The client can then request the next page:

GET /api/products?page=2&pageSize=20

Example in ASP.NET Core

With Entity Framework Core:

var products = await db.Products
    .OrderBy(p => p.Id)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Here:

  • page specifies the requested page.
  • pageSize specifies how many records to return.
  • Skip() ignores records belonging to previous pages.
  • Take() retrieves only the records required for the current page.

Why Should APIs Use Pagination?

1. Reduces Response Size

Instead of sending thousands of records, the API sends only a small subset.

Without pagination:
Database → 100,000 records → API → Client

With pagination:
Database → 50 records → API → Client

This reduces network bandwidth and response time.

2. Reduces Memory Usage

Loading a huge dataset into application memory can consume significant resources. Pagination keeps the amount of data processed at a time relatively small.

3. Improves Database Performance

The database doesn’t need to return the entire dataset for every request.

4. Improves API Response Time

Smaller responses generally mean faster database processing, serialization, network transfer, and client-side processing.

5. Improves User Experience

Clients can display the first page immediately rather than waiting for a massive response.

6. Prevents Resource Exhaustion

Without pagination, a poorly designed API could allow a request to retrieve millions of records, potentially consuming excessive CPU, memory, database connections, and network bandwidth.

Offset vs. Cursor Pagination

For simple APIs, offset pagination is common:

GET /api/products?page=5&pageSize=50

For very large or frequently changing datasets, cursor/keyset pagination can be more efficient because it avoids increasingly expensive large offsets.

For example:

GET /api/products?afterId=500&pageSize=50

Interview Answer

Pagination is the process of dividing a large dataset into smaller pages and returning only a limited number of records per API request. APIs should use pagination for large datasets because it reduces database workload, response size, network bandwidth, serialization time, and memory consumption. It also improves response time, scalability, and user experience. For very large datasets, cursor or keyset pagination can be preferable to traditional offset-based pagination.

In short: Pagination prevents an API from trying to return the entire database in a single response.

95. What is the difference between BackgroundService and IHostedService?

Both IHostedService and BackgroundService are used to run background tasks in ASP.NET Core applications. The key difference is that IHostedService is an interface, while BackgroundService is an abstract base class that implements IHostedService and provides a convenient structure for long-running background work.

IHostedService

IHostedService defines two methods:

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

You implement these methods yourself:

public class MyHostedService : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Start background work
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        // Cleanup
        return Task.CompletedTask;
    }
}

It gives you complete control over how the service starts, runs, and stops.

BackgroundService

BackgroundService is an abstract class that implements IHostedService and provides an ExecuteAsync() method specifically designed for long-running background operations.

public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Do background work

            await Task.Delay(
                TimeSpan.FromSeconds(10),
                stoppingToken);
        }
    }
}

Register it with dependency injection:

builder.Services.AddHostedService<MyBackgroundService>();

Key Differences

FeatureIHostedServiceBackgroundService
TypeInterfaceAbstract base class
Main methodsStartAsync() / StopAsync()ExecuteAsync()
Long-running tasksYou implement the pattern yourselfBuilt-in pattern
ControlMore controlMore convenient
Typical useCustom lifecycle/start-stop logicContinuous background processing

When should you use each?

Use BackgroundService when you need a continuously running worker, such as:

  • Processing messages from a queue
  • Sending scheduled notifications
  • Consuming events
  • Periodically processing data
  • Running background cleanup jobs

Use IHostedService when you need more direct control over the application startup and shutdown lifecycle, or when your background task doesn’t fit the ExecuteAsync() pattern.

Scenario-Based Interview Questions

Scenario-Based Interview Questions in ASP.NET Core interviews are designed to test how well you can apply your knowledge to real-world development and production problems, rather than simply recall definitions. Instead of asking “What is dependency injection?”, an interviewer may ask, “Your API is becoming slow as traffic increases. How would you identify and fix the performance bottleneck?” You may be given scenarios involving API performance, database optimization, caching, authentication and authorization, dependency injection, middleware, exception handling, concurrency, background services, logging, scalability, and security. These questions evaluate your ability to analyze a problem, identify its root cause, choose an appropriate ASP.NET Core feature or architecture, and explain why your solution is suitable.

96. Scenario 1 — Slow API – Your API takes 5 seconds to respond. CPU usage is low, but database usage is high. How would you investigate and fix it?

Your answer should include the areas:

SQL query analysis
EF Core generated SQL
indexes
AsNoTracking()
projection
pagination
N+1 queries
database profiling

97. Scenario 2 — High CPU – An API suddenly reaches 90% CPU because one user is sending thousands of requests. What would you do?

Expected answer:

rate limiting
authentication
per-user/IP partitioning
caching
monitoring
potentially blocking abusive clients

98. Scenario 3: Singleton Problem – A Singleton service depends on DbContext. Is this safe?

No. DbContext is normally scoped, and injecting a scoped service into a singleton creates a lifetime mismatch.

99. Scenario 4 : Authentication Works but Authorization Fails – A user has a valid JWT but receives 403 Forbidden. What would you investigate?

Check:

authentication succeeded
required role/claim
authorization policy
claim type
issuer
audience
middleware order

100. Scenario 5 : Duplicate Database Records – Two requests arrive simultaneously and create the same record. How would you prevent duplicates?

Possible solutions:

database unique constraint
transaction
concurrency handling
idempotency keys
appropriate application-level locking where necessary
Top 20 Questions to Prepare First
Conclusion

These ASP.NET Core interview questions cover the most important concepts, practical scenarios, and real-world problems that you are highly likely to encounter in an ASP.NET Core interview. Make sure you understand not just the answers, but also the reasoning behind them, because interviewers often ask follow-up and scenario-based questions to test your practical knowledge. Don’t just read these questions—practice answering them aloud, revisit the topics you find difficult, and make sure you can explain each concept confidently with real-world examples. If you can master these questions and understand the concepts behind them, you will be much better prepared to face your ASP.NET Core interview with confidence. Now it’s your turn: start revising, keep practicing, and walk into your interview fully prepared to succeed. My very best wishes for your ASP.NET Core interview—go confidently, give it your best, and I hope you qualify with flying colors!

SHARE THIS ARTICLE

  • linkedin
  • reddit
yogihosting

ABOUT THE AUTHOR

I hope you enjoyed reading this tutorial. If it helped you then consider buying a cup of coffee for me. This will help me in writing more such good tutorials for the readers. Thank you. Buy Me A Coffee donate

Leave a Reply

Your email address will not be published. Required fields are marked *