
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.
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.
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 Core | ASP.NET Framework |
|---|---|
| Cross-platform — runs on Windows, Linux, and macOS | Primarily designed for Windows |
| High performance and optimized for modern workloads | Generally lower performance for modern web workloads |
| Open source | Parts of the framework are open source, but traditionally Windows-focused |
| Supports modern .NET versions | Based on the older .NET Framework |
| Built-in Dependency Injection | DI is not built into the framework in the same way |
| Lightweight and modular | Larger, more monolithic framework |
| Excellent support for Web APIs and Minimal APIs | Primarily uses Web API, MVC, Web Forms, etc. |
| Designed for cloud-native applications | Not originally designed around cloud-native development |
| Runs well with Docker and containers | Container support is more limited and Windows-oriented |
| Can be hosted using Kestrel and behind reverse proxies | Commonly hosted with IIS |
| Supports modern middleware-based request pipelines | Uses older HTTP/application pipeline models |
| Actively developed as part of modern .NET | .NET Framework is largely in maintenance mode |
.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.
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:
What you typically do with it:
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:
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:
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");
});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.");
});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");
});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 mapping6. 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:
…and decides which handler should execute, and extracts parameters from the URL (like id = 5) to pass into that handler.
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:
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):
app.UseRouting() adds the route matching middleware to the pipeline. 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 endpointWhy 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?”
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer();
app.UseAuthentication(); // sets HttpContext.Userapp.UseAuthorization() adds the authorization middleware. Its job is to figure out “are you allowed to do this?”
[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 endpointKey rules:
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).
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.
Where the data comes from (Configuration Providers):
By default, WebApplicationBuilder wires up multiple providers, layered in this order (later ones override earlier ones):
var builder = WebApplication.CreateBuilder(args);
// builder.Configuration is already an IConfiguration built from the above sourcesYou 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:
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
}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);
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.
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:
Registration happens in Program.cs:
builder.Services.AddScoped<IEmailService, EmailService>();| Lifetime | Instance created | Typical use case |
|---|---|---|
| Transient | A new instance every time it’s requested | Lightweight, stateless services |
| Scoped | One instance per request (HTTP request scope) | Services that need consistency within a request, e.g., DbContext |
| Singleton | One instance for the entire application lifetime | Shared state, caching, configuration, logging |
builder.Services.AddTransient<IEmailService, EmailService>();builder.Services.AddScoped<IOrderRepository, OrderRepository>();builder.Services.AddSingleton<ICacheService, CacheService>();Follow-up traps interviewers often ask:
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:
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:
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).
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:
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(OrderService orderService) // <- injected automatically
{
_orderService = orderService;
}
}Why it’s preferred (key interview points):
| Benefit | Explanation |
|---|---|
| Explicit dependencies | Anyone reading the constructor immediately knows what the class needs to work. |
| Guaranteed valid state | The object can’t exist without its required dependencies — no null-reference surprises later. |
| Immutability | Dependencies can be assigned to readonly fields, since they’re only set once at construction. |
| Testability | Easy to pass in mocks/fakes in unit tests without a DI container. |
| Fail-fast | If 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’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:
| Interface | Role | When used |
|---|---|---|
| IServiceCollection | Registration — a list of service descriptors (what maps to what) | During app startup/configuration |
| IServiceProvider | Resolution — actually creates instances on demand | At 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.”
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:
OrderService → finds its constructor needs IEmailSender (and maybe ILogger<OrderService>)IEmailSender → finds it maps to EmailSender → checks its constructor for dependenciesOrderServiceThis recursive process is often called building the object graph or dependency graph.
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 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:
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 ← EndpointEach middleware component has the chance to:
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")
AfterReal 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 actionASP.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.
This is the standard approach for real-world, reusable middleware. No interface is required — just follow a convention:
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.
| Aspect | Middleware | Filters |
|---|---|---|
| Level | Application-level (raw HTTP pipeline) | MVC/Action-level (inside the MVC framework) |
| Awareness of MVC | None — knows nothing about controllers, actions, model binding | Full — knows about action methods, model state, controller context |
| Scope | Runs 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 to | HttpContext only | HttpContext + ActionContext, model binding results, action arguments, action result |
| Configured via | app.Use...() in Program.cs | Attributes, or registered globally in AddControllers(options => options.Filters.Add(...)) |
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
Filters have multiple specialized stages, which middleware doesn’t have:
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>();
});
Use Middleware when:
Use Filters when:
[FromBody] arguments)[Authorize], validation, response shaping)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:
HttpContext — giving access to the request, response, user, services, etc., for this specific request._next(context) — passing control to the next middleware in the pipeline. This is what actually chains everything together._next() runs on the way “in”; code after _next() runs on the way “out” (response phase).This is a very common interview follow-up, so it’s worth being precise:
| Constructor | InvokeAsync | |
|---|---|---|
| Called | Once, at app startup | Once per HTTP request |
| Purpose | Capture _next and inject singleton dependencies | Do the actual per-request work; inject scoped/transient dependencies as method parameters |
| DI behavior | Constructor is only ever resolved once, so scoped services (like DbContext) would effectively become singletons if injected here — bug risk | Method 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
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:
next + singleton services into the constructor).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:
| Middleware | Why 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 failures | Return 401/403 immediately rather than letting the request reach a controller |
| Response caching | If a valid cached response exists, return it directly, skip regenerating it |
| Rate limiting | Return 429 immediately if the client has exceeded their limit |
| Health check endpoints | Respond 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| Concern | Guidance |
|---|---|
| Placement | Must be the first middleware (or very close to it) so it wraps everything downstream |
| Don’t leak details | In production, avoid returning stack traces / internal exception messages to the client — log them server-side, return generic messages |
| Consistent response shape | Use ProblemDetails (RFC 7807) — ASP.NET Core has built-in support via AddProblemDetails() |
| Status code mapping | Map exception types → HTTP status codes via a switch expression or a dictionary, rather than always returning 500 |
| Logging | Always log the full exception (with stack trace) server-side, even though the client gets a sanitized message |
| Environment-specific behavior | Often 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
b. RequestDelegate _next
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
f. Registration order matters
g. Alternative styles
app.Use(async (context, next) =>
{
// before
await next();
// after
});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 hereBecause 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.
If you register UseExceptionHandler after other middleware — say, after UseRouting or UseEndpoints — then:
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:
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 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:
Characteristics:
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:
Characteristics:
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.
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. |
|---|---|---|
| Purpose | Defines URL template | Restricts HTTP verb (+ optional URL template) |
| HTTP verb restriction | None (allows all verbs unless combined) | Restricts to one specific verb |
| Can carry a route? | Yes | Yes (optional parameter) |
| Typical use | On controller, to set a base path | On 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).[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:
| Constraint | Description | Example |
|---|---|---|
int | Matches an integer | {id:int} |
bool | Matches true/false | {active:bool} |
datetime | Matches a valid DateTime | {date:datetime} |
decimal | Matches a decimal | {price:decimal} |
double / float | Matches double/float | {value:double} |
long | Matches a long | {id:long} |
guid | Matches a GUID | {id:guid} |
alpha | Alphabetic 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}}$)} |
required | Value 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} 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.
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:
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:
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() 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):
"api/products/..." on every Map* call..WithTags(), .WithOpenApi(), .AddEndpointFilter(), .RequireAuthorization(), .RequireCors(), .RequireRateLimiting(), etc. once on the group, and it applies to all endpoints inside it.
var api = app.MapGroup("api");
var v1 = api.MapGroup("v1");
var products = v1.MapGroup("products"); // final prefix: api/v1/products
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 authYou 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 RequireAuthorizationThis works because endpoint metadata is combined/overridden based on specificity — more specific (endpoint-level) metadata takes precedence over group-level metadata.
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:
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:
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(...);
}
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:
| Aspect | MVC (Controller) | Web API (ApiController) |
|---|---|---|
| Purpose | Serves web pages (HTML views) to browsers | Serves data (usually JSON/XML) to any client |
| Base class | Controller (has view support) | ControllerBase (no view support, lighter) |
| Return type | Typically returns View() / ViewResult | Typically returns IActionResult / ActionResult<T> / data objects |
| Consumers | Browsers rendering UI | SPAs (Angular/React), mobile apps, other services |
| Attribute | No [ApiController] needed | Decorated with [ApiController] for API-specific behaviors (automatic model validation, binding source inference, problem-details responses) |
| Routing style | Convention-based routing common ({controller}/{action}/{id}) | Attribute routing common ([Route("api/[controller]")]) |
| Content negotiation | Usually renders Razor views | Relies 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:
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);
}
}Controller inherits from ControllerBase and adds view-related functionality on top of it.
| Aspect | ControllerBase | Controller |
|---|---|---|
| Inheritance | Base class | Inherits from ControllerBase |
| View support | ❌ No view rendering | ✅ Supports View(), PartialView(), Razor views |
| Used for | Web APIs (returning data — JSON/XML) | MVC apps (returning HTML views) |
| Extra methods | Only data/response helpers (Ok, NotFound, etc.) | All of the above plus View(), ViewBag, ViewData, TempData |
| Typical return type | IActionResult, ActionResult<T>, data | ViewResult, IActionResult |
[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
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
[Route], [HttpGet], etc.) instead of conventional routing — makes route definitions explicit and required.c. Binding source parameter inference
[FromBody], [FromRoute], [FromQuery]) based on parameter type/complexity, so you often don’t need to specify them manually.[FromBody], simple types default to [FromQuery]/[FromRoute].d. Multipart/form-data inference for IFormFile parameters
e. Problem details for error responses
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:
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..."
}[ApiController] — without it, ModelState.IsValid stays your responsibility to check manually.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.
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
}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
}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 }
}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:
| Attribute | Source | Typical Use |
|---|---|---|
[FromRoute] | URL path segment | IDs in the route, e.g. /api/products/{id} |
[FromQuery] | Query string | Filters, paging, search params, e.g. ?page=2&size=10 |
[FromBody] | Request body (JSON) | Complex objects sent in POST/PUT requests |
[FromHeader] | HTTP headers | API keys, tokens, custom metadata |
[FromForm] | Form data | File 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:
[FromBody]int, string, Guid, etc.) that match a route parameter name → inferred as [FromRoute][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.
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
}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
}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)
}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.");
...
}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);
}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:
| Method | Status Code | Meaning | Includes Body? | Includes Location Header? |
|---|---|---|---|---|
Ok() | 200 | Success | ✅ Yes (optional) | ❌ No |
Created() | 201 | Resource created (manual URI) | ✅ Yes | ✅ Yes |
CreatedAtAction() | 201 | Resource created (URI via route) | ✅ Yes | ✅ Yes |
BadRequest() | 400 | Invalid client request | ✅ Optional (error details) | ❌ No |
NotFound() | 404 | Resource doesn’t exist | ✅ Optional | ❌ No |
NoContent() | 204 | Success, 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:
Accept header specifying the format(s) it wants:
GET /api/products/1
Accept: application/json
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
}
Accept: application/json → response body: {"id":1,"name":"Phone"}Accept: application/xml → response body: <Product><Id>1</Id><Name>Phone</Name></Product> (if XML formatter is added)Default behavior in ASP.NET Core:
System.Text.Json since ASP.NET Core 3.0, previously Newtonsoft.Json).
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
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:
Accept header.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.
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:
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:
Product.[ProducesResponseType] attributes.NotFound(), BadRequest(), etc.) via implicit conversion — you don’t lose flexibility.T directly (e.g., return product;) and it automatically becomes a 200 OK with that body.Summary Table:
| Aspect | IActionResult | ActionResult<T> |
|---|---|---|
| Type | Interface | Generic 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 in | Original MVC/Web API | ASP.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():
[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.
| Operation | HTTP Verb | Correct Status Code |
|---|---|---|
| Fetch existing resource | GET | 200 OK |
| Create new resource | POST | 201 Created |
| Update existing resource | PUT | 200 OK or 204 No Content |
| Partial update | PATCH | 200 OK or 204 No Content |
| Delete resource | DELETE | 204 No Content |
| Resource not found | any | 404 Not Found |
| Invalid input | any | 400 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.
| Aspect | PUT | PATCH |
|---|---|---|
| Purpose | Replace the entire resource | Apply a partial update to the resource |
| Request body | Must contain the complete representation of the resource | Contains 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 body | Missing 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..
[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
}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, testpath — which property to modifyvalue — the new valueApproach 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 testpath — a JSON Pointer (RFC 6901) indicating which part of the document to targetvalue — 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:
| Operation | Meaning | Example |
|---|---|---|
add | Adds a value at the given path (or appends to an array with -) | { "op": "add", "path": "/stock", "value": 50 } |
remove | Removes the value at the given path | { "op": "remove", "path": "/discountCode" } |
replace | Replaces the existing value at the given path | { "op": "replace", "path": "/price", "value": 600 } |
move | Moves a value from one path to another | { "op": "move", "from": "/oldField", "path": "/newField" } |
copy | Copies a value from one path to another | { "op": "copy", "from": "/name", "path": "/displayName" } |
test | Checks 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.
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.
| Aspect | JSON Patch (RFC 6902) | Simple Partial DTO |
|---|---|---|
| Format | Array of operations (op, path, value) | Plain JSON object with only changed fields |
| Standardized? | ✅ Yes (RFC 6902) | ❌ No, custom convention |
| Complexity | More powerful (array manipulation, move, copy, test) | Simpler, easier for frontend devs to construct |
| Common in practice | Less common outside strict REST APIs | Very 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.
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.
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
dotnet new webapi generates a Minimal API project by default (not a controller-based one).b. It tests whether your knowledge is current or outdated
c. Companies are actively migrating toward it for microservices
d. It tests understanding of the underlying pipeline, not just syntax
WebApplication / WebApplicationBuilderapp.MapGet, app.MapPost, etc.)e. It reveals knowledge of trade-offs — a sign of seniority
[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
g. It often comes packaged with other modern-era topics
IExceptionHandler (rather than filters)Program.cs top-level statements (no Startup.cs)AddEndpointFilter) instead of MVC filtersh. It’s a quick way to differentiate candidates in a crowded market
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.
| Aspect | Minimal APIs | Controller-Based APIs |
|---|---|---|
| Structure | Endpoints defined as lambdas/methods mapped directly to routes | Classes inheriting ControllerBase, actions as methods |
| Routing | app.MapGet/MapPost/... fluent calls | Attribute routing ([Route], [HttpGet]) or conventional routing |
| Boilerplate | Very little — no class, no attributes required | More ceremony — class, base type, attributes |
| Filters | Endpoint filters (IEndpointFilter), lighter-weight | Action filters, full filter pipeline (Authorization, Resource, Action, Exception, Result) |
| Model binding | Explicit and simpler ( [FromBody], [FromRoute], etc., but less “magic”) | Rich automatic binding via [ApiController] inference |
| Validation | No 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/Swagger | Supported but you often annotate more explicitly (WithName, Produces, etc.) | Well-integrated via attributes and conventions |
| Performance | Slightly leaner/faster — fewer abstractions, less reflection overhead | Marginally more overhead due to MVC pipeline richness |
| Testability | Handlers are just delegates — easy to unit test in isolation | Controllers are classes — also easily testable, more familiar to MVC devs |
| Organization for large APIs | Can get messy if not organized (people use extension methods/route groups to manage) | Naturally organizes by resource via separate controller classes |
| Convention over configuration | Minimal — you’re explicit about most things | MVC 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.
Choose Minimal APIs when:
Choose Controllers when:
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");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);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:
/products) is applied automatically to all routes in the group..WithTags(), .RequireAuthorization(), .AddEndpointFilter(), .MapToApiVersion() etc. chained on the group cascades to every endpoint inside it — no need to repeat it per route.
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.
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:
| Method | Purpose |
|---|---|
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 tagNested 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 + RequireAuthorizationMapGroup() 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:
| Method | Status Code | Purpose |
|---|---|---|
Results.Ok(value) | 200 | Success with a body |
Results.Created(uri, value) | 201 | Resource created |
Results.CreatedAtRoute(...) | 201 | Created, using a named route to build Location header |
Results.Accepted(uri, value) | 202 | Accepted for async processing |
Results.NoContent() | 204 | Success, no body |
Results.BadRequest(errors) | 400 | Invalid request |
Results.Unauthorized() | 401 | Not authenticated |
Results.Forbid() | 403 | Authenticated but not allowed |
Results.NotFound() | 404 | Resource not found |
Results.Conflict() | 409 | Conflict (e.g., duplicate resource) |
Results.UnprocessableEntity() | 422 | Semantic validation error |
Results.ValidationProblem(errors) | 400 | Structured validation error response |
Results.Problem(...) | Configurable (default 500) | RFC 7807 problem details |
Results.StatusCode(code) | Custom | Any 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
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 GoneKey interview point to mention:
Prefer TypedResults over Results when possible — it:
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).
| Aspect | Middleware | Endpoint Filters |
|---|---|---|
| Scope | Applies globally to the whole request pipeline (or conditionally via app.Map/UseWhen) | Applies to a specific endpoint or group |
| Registration | app.Use...() in Program.cs, order matters globally | .AddEndpointFilter<T>() chained on a specific route/group |
| Access to route data / typed arguments | No 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 routing | Runs before routing resolves the endpoint (in general middleware) or after, depending on pipeline position | Runs after the endpoint has been matched, right around the handler invocation |
| Awareness of Minimal API semantics | Generic — 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 fit | Cross-cutting, pipeline-wide concerns: authentication, CORS, exception handling, routing, static files | Endpoint-specific or group-specific concerns: validation, argument transformation, per-route logging/auditing |
| Return value visibility | Operates on HttpContext directly; doesn’t see the handler’s return object in a typed way | Can inspect and modify the actual result object returned by the handler before it’s sent |
Conceptual analogy:
IActionFilter), but for Minimal APIs.When to use which:
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.
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:
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:
DbSet<T> properties)SaveChanges())OnModelCreatingDbContext instance, querying the same entity twice returns the same object referenceBasic 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:
| Aspect | Detail |
|---|---|
| Lifetime | Should 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-safe | A single DbContext instance must not be used concurrently across threads. |
| Unit of Work + Repository pattern | DbContext itself implements the Unit of Work pattern; DbSet<T> acts like a Repository. |
| Holds a Change Tracker | Every entity it retrieves or you attach gets a tracked “entry” with a state (Added, Modified, Deleted, Unchanged, Detached). |
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 beforeSaveChanges(), before LINQ queries, and at a few other trigger points. You can disable it and callChangeTracker.DetectChanges()manually for performance-sensitive bulk scenarios.
b. Each tracked entity’s state is evaluated
| State | Meaning | Resulting SQL |
|---|---|---|
Added | New entity, not yet in the DB | INSERT |
Modified | Existing entity with changed property values | UPDATE |
Deleted | Marked for removal | DELETE |
Unchanged | No changes detected | No 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
INSERT/UPDATE/DELETE statements into fewer round trips where the provider supports it (SQL Server, for example, batches statements).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 UnchangedDeleted entities are detached from the context entirelySaveChanges() only picks up new changesj. 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 rows46. 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:
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.Change tracking strategies:
| Strategy | How it works |
|---|---|
| Snapshot tracking (default) | EF Core stores a full snapshot of original values and diffs against it during DetectChanges() |
| Notification tracking | Entities 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;Every tracked entity has an EntityState (an enum) at any given time:
| State | Meaning | SQL generated on SaveChanges() |
|---|---|---|
Added | Entity is new; doesn’t exist in the DB yet | INSERT |
Unchanged | Entity exists in DB and no properties have changed since it was loaded/last saved | None |
Modified | Entity exists in DB, and one or more property values differ from the original snapshot | UPDATE |
Deleted | Entity exists in DB but is marked for removal | DELETE |
Detached | Entity is not tracked by this context at all — EF Core knows nothing about it | None (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:
DbContext (e.g., context.Employees.ToList()), EF Core creates a snapshot of each entity’s original values and starts tracking it.EntityState enum:Added – new entity, will be insertedUnchanged – no modifications since it was loadedModified – one or more properties changedDeleted – marked for deletionDetached – not being trackedModified.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 = 1You didn’t have to explicitly call Update() — EF Core detected the change itself via tracking.
Why it matters (interview angle):
DetectChanges()), which takes memory and CPU, especially with large result sets.DbContext implements.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:
SaveChanges() unless you explicitly attach them again..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;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 contextAn entity also becomes Detached again if you explicitly call context.Entry(entity).State = EntityState.Detached, or after the context is disposed.
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 INSERTThe 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 loadingThe 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 = ...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:
| Action | Resulting State |
|---|---|
new Employee() | Detached |
context.Add(entity) | Added |
Query result (ToList(), First(), etc.) | Unchanged |
| Modify a property on a tracked entity | Modified |
context.Remove(entity) | Deleted |
SaveChanges() completes | Added/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.
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:
Downside:
// 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();
Related data is loaded automatically, on-demand, the moment you access a navigation property — not when the initial query runs.
To enable it:
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:
Downside (important interview point):
.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!
}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:
if block, based on business logic).Comparison Table:
| Strategy | When data loads | Query count | Main risk |
|---|---|---|---|
Eager (Include) | Upfront, with main query | 1 (or more with split query) | Over-fetching, cartesian explosion |
| Lazy | On property access (automatic) | 1 + N (per access) | N+1 problem, hidden queries |
| Explicit | On-demand, but manually triggered | 1 + however many you explicitly call | Still 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.
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.
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
└── ClientThe 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
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.
context.Database.EnsureCreated();What it does:
Key characteristics:
__EFMigrationsHistory).EnsureCreated() will not update the existing database. You’d have to drop and recreate it.EnsureCreated().When to use:
// Typical test setup
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb")
.Options;
using var context = new AppDbContext(options);
context.Database.EnsureCreated(); // fast, one-time schema creationdotnet ef migrations add InitialCreate
dotnet ef database updateWhat it does:
__EFMigrationsHistory table in the database that tracks which migrations have already been applied.Key characteristics:
Up() and Down() methods — enabling rollback.context.Database.Migrate()) or via CLI/scripts — suitable for CI/CD pipelines.dotnet ef migrations script) for DBA review or production deployment without needing the EF tooling on the production server.// Typical production startup
context.Database.Migrate(); // applies any pending migrationsSide-by-side comparison:
| Aspect | EnsureCreated() | Migrations |
|---|---|---|
| Schema versioning | None | Full 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 | ✅ Yes | Possible but often overkill |
| Can coexist on same DB | ❌ Mutually exclusive | ❌ Mutually exclusive |
| CLI tooling required | No | Yes (dotnet ef / Package Manager Console) |
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:
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 JOINb. 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 postsc. 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 SQLd. 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:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) and watch for repeated similar-looking SELECT statementscontext.ChangeTracker.LazyLoadingEnabled = false in development to make lazy-load accidents throw/fail loudly instead of silently queryingforeachKey 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 conflictb.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 | Pessimistic | |
|---|---|---|
| Approach | Allow concurrent access, check at save time | Lock the row so others can’t touch it |
| Performance | Better — no locks held | Worse under contention — blocks other users |
| Best for | Web apps (disconnected, high concurrency, low actual conflict rate) | Short transactions where conflicts are frequent/expected |
| EF Core support | Built-in via concurrency tokens | Not 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:
SaveChanges() call → transaction is automatic, no code needed.SaveChanges() calls, or mixing with raw SQL → use BeginTransaction() / Commit() / Rollback() explicitly.EnableRetryOnFailure → wrap explicit transactions in CreateExecutionStrategy().Execute(...).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 ?
| Authentication | Authorization | |
|---|---|---|
| Question answered | “Who are you?” | “What are you allowed to do?” |
| Purpose | Verifies identity | Grants/denies access to resources |
| Happens | First | After authentication |
| Middleware | UseAuthentication() | UseAuthorization() |
| HTTP status on failure | 401 Unauthorized | 403 Forbidden |
| Based on | Credentials, tokens, cookies | Roles, claims, policies |
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 UseAuthorizationIf authentication fails (bad/missing credentials), the server responds with 401 Unauthorized — “I don’t know who you are.”
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 UseAuthenticationIf 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 permissionsGetting 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:
"Department")"Engineering")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
}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:
| Concept | Role |
|---|---|
| Claim | Raw piece of user data (who they are / what they have) |
| Requirement | A rule about claims/data that must be satisfied |
| Policy | A 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:
ClaimTypes.Role.ClaimsPrincipal.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);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:
| Aspect | Detail |
|---|---|
| Basis | Claim of type Role |
| Attribute | [Authorize(Roles = "...")] |
| Multiple roles in one attribute | OR condition |
Stacked [Authorize] attributes | AND condition |
| Code check | User.IsInRole("RoleName") |
| Storage | Roles can come from a database, Identity, JWT tokens, Windows groups, etc. |
Department = "Engineering"), more flexible.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:
| Component | Purpose |
|---|---|
| Requirement | A statement of what must be true (implements IAuthorizationRequirement) |
| Handler | Contains the logic that evaluates whether a requirement is met (AuthorizationHandler<T>) |
| Policy | A 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:
Program.cs / a startup extension), not scattered across controllers.Quick comparison:
| Model | Granularity | Flexibility | Typical Use |
|---|---|---|---|
| Role-based | Coarse | Low | Simple hierarchies (Admin/User) |
| Claims-based | Medium | Medium | Attribute checks (Department, Age) |
| Policy-based | Fine | High | Complex, 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 ClaimsIdentity → ClaimsPrincipal:
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);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:
ClaimTypes.Role), so role-based authorization is really a narrow form of claims-based authorization.AddPolicy + RequireClaim (or a custom handler) — there’s no built-in [Authorize(Claim=...)] attribute.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:
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.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:
| Table | Purpose |
|---|---|
Users | User accounts |
Roles | Named role groupings |
Permissions | Fine-grained actions (e.g., Orders.Edit) |
RolePermissions | Maps roles → permissions |
UserRoles | Maps 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:
| Model | Granularity | Basis | Typical check |
|---|---|---|---|
| Role-based | Coarse | Role claim | [Authorize(Roles = "Admin")] |
| Claims-based | Medium | Any claim | RequireClaim("Department", "Engineering") |
| Policy-based | Fine | Requirements/handlers | [Authorize(Policy = "MinimumAge18")] |
| Permission-based | Finest | Permission claims + custom handler | [Authorize(Policy = "Orders.Edit")] |
Key points:
IAuthorizationPolicyProvider so you don’t have to manually register hundreds of policies.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:
Authorization: Bearer <token> header on every subsequent request.Three parts (separated by dots: header.payload.signature):
| Part | Content |
|---|---|
| Header | Metadata — token type (JWT) and signing algorithm (e.g., HS256) |
| Payload | Claims — user data like sub (user ID), role, exp (expiry), custom claims |
| Signature | Header + 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.
You can also visit my series on JSON Web Token (JWT) in ASP.NET Core. There are 3 tutorials to master JWT:
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/rolesIn 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:
sub, name, email, iat, exp) that the client application can verify and trust.How it Works (Simplified Flow):
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:
| Role | Description |
|---|---|
| Resource Owner | The user who owns the data (e.g., you) |
| Client | The application requesting access (e.g., the photo-printing app) |
| Authorization Server | Issues access tokens after authenticating the resource owner (e.g., Google’s auth server) |
| Resource Server | Hosts the protected resources/APIs (e.g., Google Photos API) |
Key Concepts:
read:photos, write:contacts).Common OAuth 2.0 Grant Types (Flows):
| Grant Type | Use Case |
|---|---|
| Authorization Code | Most secure; used by web apps with a backend (server-side) |
| Authorization Code + PKCE | Recommended for SPAs and mobile apps (no client secret) |
| Client Credentials | Machine-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
| Aspect | OAuth 2.0 | OpenID Connect |
|---|---|---|
| Purpose | Authorization | Authentication (+ Authorization) |
| Token | Access Token | ID Token + Access Token |
| Tells you | What you can access | Who the user is |
| Token format | Opaque or JWT | JWT (ID Token is always a JWT) |
Common Claims in an ID Token
sub — unique identifier for the useriss — issuer (the identity provider)aud — audience (the client app)exp — expiration timeiat — issued at timename, 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 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:
| Capability | Description |
|---|---|
| Authentication | Issues ID Tokens to verify user identity (OIDC) |
| Authorization | Issues Access Tokens to allow API access (OAuth 2.0) |
| Single Sign-On (SSO) | One login session works across multiple client apps |
| Federation | Supports external login providers (Google, Azure AD, etc.) |
| Token Management | Handles refresh tokens, token expiry, revocation |
| Custom User Store | Integrates with ASP.NET Core Identity, EF Core, or custom databases |
Key Building Blocks (Configuration Concepts):
openid, profile, api1.read)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
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.
An API Resource represents the actual API/application you want to protect — a logical grouping of one or more related APIs that validate tokens.
OrdersApi, PaymentsApi).aud (audience) / scope claims.new ApiResource("orders-api", "Orders API")
{
Scopes = { "orders.read", "orders.write" }
}An API Scope represents a specific permission or capability that a client can request — a granular piece of access within an API Resource.
scope claim.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"orders.read).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.
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
}The Client ID is a public, unique identifier for the client application registered with the IdentityServer.
ClientId = "webapp-client"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).
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 Resource | Claims Included |
|---|---|
openid | sub (subject/user ID) — required for any OIDC request |
profile | name, family_name, given_name, picture, birthdate, etc. |
email | email, email_verified |
address | address |
phone | phone_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:
openid profile roles.sub, name, role, etc.).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
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.”openid is requested (and the flow completes), the token response includes an ID Token (a JWT) alongside any access token.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:
| Claim | Description |
|---|---|
name | Full display name |
given_name | First name |
family_name | Last name |
middle_name | Middle name |
nickname | Casual/preferred name |
preferred_username | Preferred username/handle |
profile | URL to the user’s profile page |
picture | URL to the user’s profile photo |
website | User’s website URL |
gender | Gender |
birthdate | Date of birth |
zoneinfo | Time zone |
locale | Locale/language preference |
updated_at | When 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?”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:
sub (not email or name) as the key to look up/link the local user record, since it’s immutable and unique.openid scope request contains sub, even if no other claims are requested.sub 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)
header.payload.signature) is sent to the client.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
tempkey.rsa or tempkey.jwk in the app’s root directory).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 mismatchc. 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.
| Approach | Description |
|---|---|
| X.509 Certificate | Load from certificate store or file (AddSigningCredential(certificate)) |
| Azure Key Vault | Store keys securely, integrate via AddSigningCredential with a Key Vault-backed provider |
| AWS KMS / HSM | Hardware-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.
The Access Token is the credential a client presents to a Resource Server (API) to access protected resources.
Authorization: Bearer <token> header).exp), issuer (iss), audience (aud), and sometimes user claims.Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...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.
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:
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:
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:
| Mechanism | Storage Location | Shared Across Instances? | Caches What |
|---|---|---|---|
IMemoryCache | Local server memory | ❌ No | Any object |
IDistributedCache | External (Redis/SQL) | ✅ Yes | Any object (serialized) |
| Response Caching | HTTP layer (client/proxy/server) | Depends on layer | Full HTTP response |
| Output Caching | Server-side (pluggable store) | ✅ Yes (with Redis backing) | Full HTTP response, more flexible |
Why Interviewers Ask This:
IMemoryCache in a load-balanced multi-instance deployment, causing inconsistent data across servers.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 Case | Why Redis Fits |
|---|---|
| Distributed caching | Multiple app instances (load-balanced/scaled-out) need to share the same cached data consistently |
| Session state | Store user sessions centrally so any server instance can handle any request (AddStackExchangeRedisCache for IDistributedCache) |
| Rate limiting / counters | Atomic increment operations (INCR) across distributed requests |
| Pub/Sub messaging | Real-time notifications, SignalR backplane for scaled-out WebSocket connections |
| Short-lived, high-throughput data | Leaderboards, temporary tokens, distributed locks — Redis is extremely fast (in-memory) |
When NOT to Use Redis:
IMemoryCache is simpler and faster (no network hop).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:
| Benefit | Explanation |
|---|---|
| Reduces latency | Reading from memory (or Redis) is far faster than querying a database, calling an external API, or running heavy computation |
| Reduces database/backend load | Fewer repeated queries hit the database, freeing it up to handle other work — critical under high traffic |
| Reduces network calls | Avoids redundant calls to slow external services/APIs |
| Improves scalability | Since less work is repeated per request, the same server resources can handle more concurrent users |
| Reduces CPU usage | Avoids 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:
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.
| Reason | Explanation |
|---|---|
| Prevent abuse/DoS attacks | Stops a single client from overwhelming the server with excessive requests, intentionally or accidentally |
| Ensure fair usage | Prevents one client from monopolizing shared resources, so others get fair access |
| Protect backend resources | Shields databases/downstream services from being overloaded by request spikes |
| Cost control | Limits usage of paid/metered resources (e.g., third-party API calls, compute-heavy endpoints) |
| Enforce business/pricing tiers | Different rate limits for free vs. paid API tiers |
When a request is rejected due to rate limiting, the API should return:
429 Too Many RequestsThis 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:
429, not 403 or 503) shows familiarity with HTTP semantics, not just “it returns an error.”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.
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.
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.
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.
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:
Retry-After header to tell the client when to try againRetry-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 GMTRetry-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?
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.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:
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();Reduce repeated expensive work using IMemoryCache (single instance) or IDistributedCache/Redis (multi-instance), plus Output Caching for full response caching.
| Technique | Why |
|---|---|
AsNoTracking() | Skips change-tracking overhead for read-only queries |
| Avoid N+1 queries | Use .Include() or projections instead of lazy-loading in loops |
| Select only needed columns | Use .Select() projections instead of pulling full entities |
| Compiled queries | Cache query execution plans for hot-path queries |
| Connection pooling | Enabled by default with EF Core’s DbContext pooling (AddDbContextPool) |
Reduces payload size over the network.
Keep the middleware pipeline lean — remove unused middleware, order it efficiently (put cheap/rejecting middleware like rate limiting early).
Minimal APIs have less overhead than full MVC controllers for simple endpoints, due to a lighter execution pipeline.
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();Use IHttpClientFactory (avoids socket exhaustion from creating raw HttpClient instances) and configure connection pooling/timeouts properly.
Prevent overload from excessive/abusive traffic (covered earlier) — protects the API from degrading under unexpected spikes.
Kestrel limits (max concurrent connections, request body size).Use Application Insights, dotnet-trace, MiniProfiler, or BenchmarkDotNet to identify actual bottlenecks — optimize based on data, not guesses.
Why Interviewers Ask This:
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:
| Synchronous | Asynchronous | |
|---|---|---|
| Thread during I/O wait | Blocked, unusable | Released, reusable |
| Requests handled per available thread | 1 at a time (thread tied up) | Many (thread reused while others wait) |
| Behavior under high load | Thread pool exhaustion → requests queue/timeout | Scales 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.
| Feature | Task | ValueTask | void |
|---|
| Represents async operation | Yes | Yes | No |
| Can be awaited | Yes | Yes | No |
| Can return a result | Task<T> | ValueTask<T> | No |
Supports exception propagation through await | Yes | Yes | No |
Can be used with async | Yes | Yes | Yes |
| Recommended for ASP.NET Core actions | Yes | Sometimes | Generally No |
| Typical use | Most async operations | Performance-sensitive operations that often complete synchronously | Synchronous methods / event handlers |
TaskTask 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.
ValueTaskValueTask 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.
voidvoid 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 itThis 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.
Common techniques include:
AsNoTracking() for suitable read-only EF Core queries.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=20The client can then request the next page:
GET /api/products?page=2&pageSize=20With 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.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 → ClientThis 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.
For simple APIs, offset pagination is common:
GET /api/products?page=5&pageSize=50For 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=50Pagination 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.
IHostedServiceIHostedService 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.
BackgroundServiceBackgroundService 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>();| Feature | IHostedService | BackgroundService |
|---|---|---|
| Type | Interface | Abstract base class |
| Main methods | StartAsync() / StopAsync() | ExecuteAsync() |
| Long-running tasks | You implement the pattern yourself | Built-in pattern |
| Control | More control | More convenient |
| Typical use | Custom lifecycle/start-stop logic | Continuous background processing |
Use BackgroundService when you need a continuously running worker, such as:
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 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 profiling97. 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 clients98. 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 order100. 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 FirstThese 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!