ASP.NET CORE MVC - Interview Questions
Part 1: Core Pipeline & DI. 12 senior/staff questions total.
Crash Answer:
12 steps: Kestrel → Routing matches URL→Endpoint → Auth → Authorization → Controller Factory → DI creates controller → Model Binding fills id=5 → Validation → Action Filters → Action runs → ViewResult → Razor compiles.cshtml → Tag Helpers run → HTML to browser.
Staff Explanation:
1. Kestrel: TCP connection, parses HTTP. Creates HttpContext.
2. UseRouting(): Matches /products/5 to [HttpGet("products/{id:int}")]. Sets HttpContext.GetEndpoint(). If no match, 404 here.
3. UseAuthentication() / UseAuthorization(): Reads endpoint.Metadata for [Authorize]. If present, checks HttpContext.User. This is why Auth must be after Routing.
4. Endpoint Execution: ControllerActionInvoker uses DI to create ProductsController. If controller constructor needs DbContext, scope is created now.
5. Model Binding: RouteValueDictionary has id="5". Binds to int id param. Type conversion happens. If "abc", ModelState.IsValid=false.
6. Action Filters: OnActionExecuting runs. If it sets context.Result, action skipped.
7. Action: return View(product); returns ViewResult.
8. Result Execution: ViewResult.ExecuteResultAsync finds Views/Products/Details.cshtml. If not found, searches Shared. Razor compiles to C# if not precompiled.
9. Tag Helpers: <input asp-for="Name" /> executes C#, outputs <input name="Name" id="Name" value="iPhone" />.
10. Response: HTML bytes → Kestrel → TCP. Browser never sees C#, asp-for, or @model.
What Interviewer Wants:
They test if you know middleware vs filters, routing order, and that Razor is compiled. If you say "MVC handles it", you fail. If you mention GetEndpoint() and metadata, you pass staff.
Crash Answer:
Singleton holding Scoped service. Singleton lives forever, Scoped dies after request. Singleton holds disposed Scoped = crash.
Staff Explanation:
The Bug:
// Program.cs
builder.Services.AddSingleton<IEmailService, EmailService>(); // Lives for app lifetime
builder.Services.AddScoped<AppDbContext>(); // New per HTTP request, disposed end of request
public class EmailService : IEmailService {
private readonly AppDbContext _db; // Captured from FIRST request's scope
public EmailService(AppDbContext db) => _db = db; // DI injects request #1 DbContext
public async Task SendAsync(int userId) {
// Request #1: Works. _db is valid.
// Request #1 ends: Scope disposed → _db.Dispose() called.
// Request #2: _db is same instance but disposed.
var user = await _db.Users.FindAsync(userId); // ObjectDisposedException
}
}
Why It Happens: DI has 3 containers. Root container = Singletons. Scoped container = per request. When Singleton is created at startup, it resolves dependencies from Root container. Root container doesn't have DbContext, so it creates a Scoped container just for this, resolves DbContext, then disposes the temp scope. But Singleton holds reference. Or worse: if scope not disposed, Singleton holds DbContext from request #1 forever = data leak.
Fix 1 - Make Service Scoped:
builder.Services.AddScoped<IEmailService, EmailService>(); // Now same lifetime as DbContext
Trade-off: If EmailService is expensive to create, you lose performance.
Fix 2 - IServiceScopeFactory:
public class EmailService : IEmailService {
private readonly IServiceScopeFactory _scopeFactory;
public EmailService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task SendAsync(int userId) {
using var scope = _scopeFactory.CreateScope(); // New scope per call
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); // Fresh DbContext
var user = await db.Users.FindAsync(userId); // Safe
} // scope.Dispose() → db.Dispose()
}
Trade-off: Manual scope management. Allocates scope per call.
What Interviewer Wants:
They want you to draw the lifetime diagram. If you just say "use AddScoped", you're mid-level. If you explain Root vs Scoped containers and show IServiceScopeFactory, you're staff. Bonus: Mention that this caused data leak at Stripe in 2021.
Crash Answer:
1. Auto 400 on invalid ModelState. 2. [FromBody] inferred. 3. ProblemDetails errors. 4. Attribute routing required. Use [ApiController] for APIs, Controller for Views.
Staff Explanation:
| Feature | [ApiController] | Controller |
|---|---|---|
| ModelState | Auto-checks. If invalid, returns 400 ProblemDetails before action runs | Manual: if(!ModelState.IsValid) return View(vm); |
| Parameter Binding | Complex types = [FromBody]. string/int = [FromQuery]. IFormFile = [FromForm] | All from Form/Route/Query by default. [FromBody] needed for JSON |
| Error Response | ProblemDetails RFC7807 JSON | HTML error page or your exception filter |
| Routing | Attribute routing required. Conventional ignored | Both work |
Code Example:
// API - No ModelState check needed
[ApiController][Route("api/users")]
public class UsersController : ControllerBase {
[HttpPost]
public IActionResult Create(UserVM vm) { // [FromBody] inferred
// If vm.Name is null + [Required], never gets here. 400 auto-returned.
_db.Add(vm); return Ok();
}
}
// MVC - Manual check required
public class UsersController : Controller {
[HttpPost]
public IActionResult Create(UserVM vm) { // Binds from form
if (!ModelState.IsValid) return View(vm); // YOU must check
_db.Add(vm); return RedirectToAction("Index");
}
}
What Interviewer Wants:
"I use [ApiController] for REST APIs because it reduces boilerplate and returns standard ProblemDetails. I use Controller for MVC Views because I need ViewResult and manual ModelState handling for forms." If you say "ApiController is for API", you fail. Explain the 4 behaviors.
Crash Answer:
Hacker posts extra fields like isAdmin=true. If you bind to Entity, hacker becomes admin. Prevent: 1. Input ViewModel 2. whitelist 3. [BindNever].
Staff Explanation:
The Attack:
public class User { public int Id {get;set;} public string Name {get;set;} public bool IsAdmin {get;set;} }
// VULNERABLE
[HttpPost] public IActionResult Create(User user) {
_db.Add(user); _db.SaveChanges(); // Hacker POSTs: {name:"John", isAdmin:true}
}
Fix 1: Input ViewModel - Best
public class UserCreateVM { public string Name {get;set;} } // No IsAdmin
[HttpPost] public IActionResult Create(UserCreateVM vm) {
var user = new User { Name = vm.Name, IsAdmin = false }; // You control IsAdmin
_db.Add(user); _db.SaveChanges();
}
Pro: Compile-time safety. Hacker JSON ignored. Clear contract.
Con: Extra class to maintain.
Fix 2: Whitelist
[HttpPost] public IActionResult Create([Bind("Name")] User user) {
user.IsAdmin = false; // Still set it manually
_db.Add(user); _db.SaveChanges();
}
Pro: No extra class.
Con: Stringly typed. If you add User.Email and forget, Email not bound. Easy to forget.
Fix 3: [BindNever] on Entity
public class User {
public int Id {get;set;}
public string Name {get;set;}
[BindNever] public bool IsAdmin {get;set;}
}
Pro: Entity documents security.
Con: Pollutes domain model with MVC concerns. Breaks separation of concerns.
What Interviewer Wants:
"I use Input ViewModels by default because they're compile-safe and separate concerns. I use for quick admin screens. I never use [BindNever] on domain entities." If you only know, you're junior.
Crash Answer:
Middleware = app-level, runs for every request including static files. Filters = MVC-level, only run if route matches controller. Use middleware for logging/CORS, filters for auth/validation.
Staff Explanation:
| Middleware | Filters | |
|---|---|---|
| Scope | Entire app pipeline | MVC only |
| Runs For | All requests: /api, /css/site.css, /images/logo.png | Only matched controller actions |
| Has Access To | HttpContext only | HttpContext + ActionContext + Controller instance |
| Order | You control in Program.cs | Fixed: Auth→Resource→Action→Exception→Result |
| Short-circuit | Don't call next() | Set context.Result |
Use Middleware When:
// Logs EVERY request, even 404s and static files
app.Use(async (ctx, next) => {
var sw = Stopwatch.StartNew();
await next();
_log.LogInformation("{Path} took {Ms}ms", ctx.Request.Path, sw.ElapsedMilliseconds);
});
// CORS, Gzip, StaticFiles, ExceptionHandler = Middleware
Use Filters When:
// Only runs for actions, has access to action params
public class ValidateModelFilter : IAsyncActionFilter {
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next) {
if (!ctx.ModelState.IsValid) {
ctx.Result = new BadRequestObjectResult(ctx.ModelState); // Short-circuit
return;
}
await next(); // Run action
}
}
// [Authorize], [HttpPost], Caching = Filters
What Interviewer Wants:
"Middleware for cross-cutting concerns that need HttpContext only. Filters when I need ActionContext or want to run only for MVC." If you say "both are same", instant reject.
Crash Answer:
asp-for uses ModelExpression to walk the expression tree. User.Address.City becomes name="User.Address.City" id="User_Address_City".
Staff Explanation:
Step 1: Razor Compilation
<input asp-for="User.Address.City" />
Compiles to C#:
var inputTagHelper = new InputTagHelper(...);
inputTagHelper.For = ModelExpressionProvider.CreateModelExpression(ViewData, "User.Address.City");
await inputTagHelper.ProcessAsync(context, output);
Step 2: ModelExpression Walk
ModelExpression holds: 1. Model: The View's @model instance 2. Name: "User.Address.City" 3. Metadata: Property info from reflection 4. ModelExplorer: Walks Model→User→Address→City
Step 3: HTML Generation
// InputTagHelper.ProcessAsync
output.TagName = "input";
output.Attributes.SetAttribute("name", "User.Address.City"); // From ModelExpression.Name
output.Attributes.SetAttribute("id", "User_Address_City"); // Name.Replace(".", "_")
output.Attributes.SetAttribute("value", Model?.User?.Address?.City?? ""); // Walks object
// Validation attributes
output.Attributes.SetAttribute("data-val", "true");
output.Attributes.SetAttribute("data-val-required", "The City field is required.");
Why This Matters: Browser gets plain HTML. No asp-for. Model binding uses same name="User.Address.City" to set vm.User.Address.City. Validation uses same path. If you rename property, compile error = refactor-safe.
What Interviewer Wants:
They want you to say "ModelExpression" and "expression tree". If you say "magic", you fail. Bonus: Explain why [Bind("User.Address.City")] works - same path.
Crash Answer:
Build-time =.cshtml compiled to DLL at build. Faster startup, catches errors early. Runtime = compiled on first request. Slower first hit, but can edit.cshtml on server.
Staff Explanation:
Runtime Compilation (Default in Debug):
// Views/Home/Index.cshtml exists as file on disk
// First request to /Home:
// 1. Razor finds.cshtml
// 2. Compiles to Views_Home_Index class using Roslyn
// 3. Loads DLL, caches
// 4. Executes
// Time: 200-500ms first hit. Next hits: 0ms (cached)
Pro: Edit.cshtml on prod server, refresh = new view. No rebuild.
Con: First user pays 500ms. Typo in.cshtml = 500 error in prod, not caught at build.
Build-time Compilation (Default in Release):
//.csproj
<PropertyGroup>
<RazorCompileOnBuild>true</RazorCompileOnBuild>
<RazorCompileOnPublish>true</RazorCompileOnPublish>
</PropertyGroup>
// Build output: App.Views.dll contains Views_Home_Index class
// No.cshtml files deployed. First request: 0ms.
Pro: Zero startup cost. Compile errors fail build, not runtime. Smaller deploy.
Con: Can't edit.cshtml on server. Must rebuild + redeploy for view change.
What Interviewer Wants:
"I use build-time for prod because startup time matters and I want compile errors at CI, not in prod. I use runtime in dev for fast iteration." If you don't know RazorCompileOnPublish, you've never deployed MVC.
Crash Answer:
IActionResult = any result: View, Json, Redirect, StatusCode. ActionResult<T> = typed, returns T or StatusCodeResult. Use ActionResult<T> for APIs, IActionResult for MVC.
Staff Explanation:
// IActionResult - Can return anything
public IActionResult Get(int id) {
if (id == 0) return BadRequest(); // StatusCodeResult
if (id == 1) return View(); // ViewResult
if (id == 2) return Json(new { id }); // JsonResult
return NotFound(); // NotFoundResult
}
// Problem: No compile-time check. Can return View from API.
// ActionResult<Product> - Typed
public ActionResult<Product> Get(int id) {
var p = _db.Products.Find(id);
if (p == null) return NotFound(); // OK: ActionResult<T> implicit from StatusCodeResult
return p; // OK: ActionResult<T> implicit from T
// return View(); // COMPILE ERROR - ViewResult not ActionResult<Product>
}
Why ActionResult<T>: 1. Swagger generates correct response type. 2. Compile-time safety - can't return View from API. 3. Unit test: Assert.IsType<Product>(result.Value).
What Interviewer Wants:
"I use ActionResult<T> for APIs because Swagger and tests need the type. I use IActionResult for MVC actions that return View or Redirect." If you say "they're the same", you fail.
Crash Answer:
ViewBag/ViewData = request only. TempData = survives one redirect via cookie/session. Session = survives multiple requests until timeout.
Staff Explanation:
| ViewBag/ViewData | TempData | Session | |
|---|---|---|---|
| Storage | ViewDataDictionary, memory | Cookie or Session, serialized | Server memory or Distributed Cache |
| Lifetime | Current request only | Until read once, then deleted | 20min default or until abandoned |
| Survives Redirect | No | Yes, exactly once | Yes, multiple |
| Use Case | Pass data Controller→View | POST-Redirect-GET success message | Shopping cart, user prefs |
// ViewBag - Controller to View, same request
public IActionResult Index() {
ViewBag.Title = "Home"; // ViewData["Title"] = "Home"
return View(); // View: @ViewBag.Title
}
// TempData - Survives redirect
[HttpPost] public IActionResult Create(UserVM vm) {
_db.Add(vm); _db.SaveChanges();
TempData["Msg"] = "User created!"; // Stored in cookie
return RedirectToAction("Index"); // New request
}
public IActionResult Index() {
ViewBag.Msg = TempData["Msg"]; // Read once, then deleted from cookie
return View();
}
// Session - Multiple requests
HttpContext.Session.SetString("CartId", "abc123"); // Lives 20min
var cartId = HttpContext.Session.GetString("CartId"); // Any later request
What Interviewer Wants:
"ViewBag for current request. TempData for POST-Redirect-GET pattern. Session for user state across requests. I avoid Session for scalability - prefer JWT or DB." If you use Session for everything, you fail system design.
Crash Answer:
When default binding can't handle format. Ex: Query ?ids=1,2,3 to List<int>. Write IModelBinder.
Staff Explanation:
Problem: API client sends GET /api/users?ids=1,2,3. Default binder expects ?ids=1&ids=2&ids=3.
public class CsvModelBinder : IModelBinder {
public Task BindModelAsync(ModelBindingContext ctx) {
var value = ctx.ValueProvider.GetValue(ctx.ModelName).FirstValue;
if (string.IsNullOrEmpty(value)) {
ctx.Result = ModelBindingResult.Success(new List<int>());
return Task.CompletedTask;
}
try {
var ids = value.Split(',').Select(int.Parse).ToList();
ctx.Result = ModelBindingResult.Success(ids);
} catch {
ctx.ModelState.AddModelError(ctx.ModelName, "Invalid CSV format");
ctx.Result = ModelBindingResult.Failed();
}
return Task.CompletedTask;
}
}
// Usage 1: Attribute
public IActionResult Get([ModelBinder(typeof(CsvModelBinder))] List<int> ids) { }
// Usage 2: Global - Program.cs
builder.Services.AddControllers(opt => {
opt.ModelBinderProviders.Insert(0, new CsvModelBinderProvider());
});
public class CsvModelBinderProvider : IModelBinderProvider {
public IModelBinder GetBinder(ModelBinderProviderContext ctx) {
if (ctx.Metadata.ModelType == typeof(List<int>))
return new CsvModelBinder();
return null;
}
}
What Interviewer Wants:
They want a real example you wrote. If you say "I haven't needed one", say "I'd use it for custom formats like Unix timestamp to DateTime, or encrypted IDs". If you can't code IModelBinder, you're not staff.
Crash Answer:
Exception Middleware runs first because it wraps entire pipeline. Then MVC Exception Filter. Use middleware for global, filter for MVC-specific.
Staff Explanation:
Pipeline:
app.UseExceptionHandler("/Error"); // Middleware - Catches ALL
app.UseRouting();
app.UseEndpoints(endpoints => {
endpoints.MapControllers(); // MVC - Exception Filter runs here
});
Exception Middleware:
app.UseExceptionHandler(errorApp => {
errorApp.Run(async ctx => {
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
_log.LogError(ex, "Unhandled");
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Server error" });
});
});
Catches: Everything - routing errors, middleware errors, MVC errors, static file errors.
Exception Filter:
public class ApiExceptionFilter : IAsyncExceptionFilter {
public Task OnExceptionAsync(ExceptionContext ctx) {
if (ctx.Exception is NotFoundException) {
ctx.Result = new NotFoundObjectResult(new { error = ctx.Exception.Message });
ctx.ExceptionHandled = true; // Stop propagation
}
return Task.CompletedTask;
}
}
// Register: builder.Services.AddControllers(opt => opt.Filters.Add<ApiExceptionFilter>());
Catches: Only exceptions from MVC actions/filters. Not routing, not middleware.
Order: Exception in action → Exception Filter runs first. If ctx.ExceptionHandled=false, exception bubbles to Exception Middleware. If handled, middleware never sees it.
What Interviewer Wants:
"Middleware for global 500 handler, logging, generic response. Filter for MVC-specific like converting NotFoundException to 404 ProblemDetails. Filter runs first if exception from action." If you say middleware always first, you miss the nuance.
Crash Answer:
async void can't be awaited. Exception has nowhere to go = process crash. async Task returns Task, framework awaits it, catches exception, returns 500.
Staff Explanation:
async Task - Safe:
public async Task<IActionResult> Get(int id) {
await Task.Delay(100);
throw new Exception("Boom"); // Exception stored in Task
}
// MVC framework: await action(); try/catch → returns 500 ProblemDetails
async void - Crashes:
public async void Get(int id) {
await Task.Delay(100);
throw new Exception("Boom"); // No Task to store exception
}
// Exception goes to SynchronizationContext. No context in ASP.NET Core = unobserved
// Result: Environment.FailFast → process terminated. No 500, app dies.
Why: async void increments AsyncVoidMethodBuilder. On exception, it posts to SynchronizationContext. ASP.NET Core has no SyncContext on ThreadPool threads. Post goes nowhere..NET runtime sees unobserved exception on background thread → terminates process per policy.
Only Exception: Event handlers like button_Click because UI frameworks have SyncContext. But in ASP.NET Core, NEVER use async void.
What Interviewer Wants:
"I never use async void in ASP.NET Core. Exceptions crash the process because there's no SynchronizationContext. I always return Task or Task<T> so the framework can await and handle." If you say "async void is fine", you've never debugged a prod crash.
No comments yet. Be the first to share your thoughts!