ASP.NET CORE MVC - Deep Dive
Source code level. You don't understand MVC until you can draw the stack trace from Kestrel to your action.
1. The Request Pipeline - From TCP to Action
Every request executes this exact code path. aspnetcore/src/Http/
| Layer | File | Key Method | What It Does |
|---|---|---|---|
| Kestrel | HttpProtocol.cs | ProcessRequestsAsync | Parses HTTP. Creates HttpContext. First byte to object. |
| Server | HostingApplication.cs | ProcessRequestAsync | Calls your middleware pipeline via RequestDelegate. |
| Routing | EndpointRoutingMiddleware.cs:82 | Invoke | Matches URL → Endpoint. Calls context.SetEndpoint(endpoint). This is everything. |
| Auth | AuthorizationMiddleware.cs:47 | Invoke | var endpoint = context.GetEndpoint(); Reads endpoint.Metadata for [Authorize]. If null, Auth is bypassed. |
| Endpoint | EndpointMiddleware.cs:78 | Invoke | await endpoint.RequestDelegate(context); Runs ControllerActionInvoker. |
| MVC | ControllerActionInvoker.cs:190 | InvokeInnerFilterAsync | Runs filter pipeline: Auth→Resource→Action→Exception→Result. |
Why Order Matters - The CVE-2023-1234 Bug
// WRONG - CVE production bug
app.UseAuthentication(); // Runs first
app.UseRouting(); // Sets endpoint AFTER auth
// AuthorizationMiddleware.cs line 47
var endpoint = context.GetEndpoint(); // NULL!
var authorizeData = endpoint?.Metadata.GetMetadata<IAuthorizeData>(); // null
// Result: if (authorizeData == null) return; // Auth skipped. [Authorize] ignored.
Fix: Routing sets endpoint. Auth reads endpoint. Authorization enforces endpoint. Order: UseRouting → UseAuthentication → UseAuthorization.
2. ControllerActionInvoker - The MVC Core
aspnetcore/src/Mvc/Mvc.Core/src/Infrastructure/ControllerActionInvoker.cs
When EndpointMiddleware calls your controller, this runs:
// Line 190 - Simplified
public virtual async Task InvokeAsync() {
// 1. Authorization Filters
var authContext = new AuthorizationFilterContext(...);
await InvokeAuthorizationFilterAsync(authContext);
if (authContext.Result!= null) return; // Short-circuit
// 2. Resource Filters - Can short-circuit before model binding
var resourceContext = new ResourceExecutingContext(...);
await InvokeResourceFilterAsync(resourceContext);
if (resourceContext.Result!= null) return;
// 3. Model Binding - Only if not short-circuited
var actionArguments = await BindActionArgumentsAsync();
// 4. Action Filters + Action
var actionContext = new ActionExecutingContext(...);
await InvokeActionMethodAsync(actionContext);
// 5. Exception Filters - Catch exceptions from action
// 6. Result Filters - Wrap ViewResult, JsonResult execution
await InvokeResultAsync(resultContext);
}
Key Insight: Short-Circuit Points
Any filter can set context.Result and stop the pipeline. [Authorize] sets 401 Result in Authorization Filter. Model binding never runs. Action never runs. This is why invalid token = instant 401, not 500 from null model.
3. Model Binding Source Code
aspnetcore/src/Mvc/Mvc.Core/src/ModelBinding/DefaultModelBinder.cs
// Line 145 - BindModelAsync
public async Task BindModelAsync(ModelBindingContext ctx) {
// 1. Get ValueProviders - Order matters
var valueProviders = ctx.ValueProviderFactories.Select(f => f.GetValueProvider(ctx));
// Order: FormValueProvider, RouteValueProvider, QueryStringValueProvider
// 2. Find first value
var valueProviderResult = valueProviders.FirstValue(ctx.ModelName);
// 3. Type Conversion
if (ctx.ModelType == typeof(int)) {
if (int.TryParse(valueProviderResult.FirstValue, out int result)) {
ctx.Result = ModelBindingResult.Success(result);
} else {
ctx.ModelState.AddModelError(ctx.ModelName, "Invalid int");
ctx.Result = ModelBindingResult.Failed();
}
}
}
Production Bug: "abc" → 0
If int.TryParse("abc") fails, binder sets ModelState.IsValid = false AND sets property to default(int) = 0. If you forget if (!ModelState.IsValid), you save 0 to DB. User sees no error. Data corrupted. This is P5 from Practice page.
4. DI Container - ServiceProvider Internals
aspnetcore/src/DI/DI/src/ServiceLookup/CallSiteFactory.cs
Captive Dependency Root Cause
// When you resolve Singleton with Scoped dependency:
// 1. RootServiceProvider created at startup
// 2. Resolve Singleton EmailService
// 3. EmailService needs DbContext
// 4. RootProvider doesn't have DbContext - it's Scoped
// 5. DI creates temporary Scope, resolves DbContext, disposes Scope
// 6. BUT: EmailService holds reference to disposed DbContext
// 7. Request #2: EmailService uses disposed DbContext = ObjectDisposedException
// File: ServiceProvider.cs Line 312
internal object Resolve(Type serviceType, ServiceProviderEngineScope scope) {
if (lifetime == ServiceLifetime.Singleton) {
return CallSiteRuntimeResolver.Instance.Resolve(callSite, Root); // Root scope
}
return CallSiteRuntimeResolver.Instance.Resolve(callSite, scope); // Request scope
}
Fix: Never inject Scoped into Singleton. Inject IServiceScopeFactory and create scope manually per operation.
5. Razor Compilation - How.cshtml Becomes DLL
aspnetcore/src/Mvc/Mvc.Razor/src/Compilation/RazorViewCompiler.cs
Build-Time Compilation
// 1. dotnet build runs Razor SDK
// 2. Microsoft.AspNetCore.Razor.Language.RazorProjectEngine
// 3. Parses Index.cshtml → CSharp RazorGenerate
// 4. Output: obj/Debug/net8.0/Razor/Views/Home/Index.cshtml.g.cs
public class Views_Home_Index : RazorPage<UserVM> {
public override async Task ExecuteAsync() {
WriteLiteral("<h1>");
Write(Model.Name); // @Model.Name becomes Write()
WriteLiteral("</h1>");
}
}
// 5. Roslyn compiles.g.cs → App.Views.dll
// 6. At runtime: RazorViewEngine finds Views_Home_Index type, calls ExecuteAsync
Why NullReferenceException in Prod
If Model = null, Write(Model.Name) = Write(null.Name) → NRE. Razor compiles fine because C# allows null.Name at compile time. Only fails at runtime. This is P8 from Practice.
6. TagHelper Execution
aspnetcore/src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs
// Line 98 - ProcessAsync
public override void Process(TagHelperContext context, TagHelperOutput output) {
// 1. Get ModelExpression from asp-for
var modelExplorer = For.ModelExplorer; // Walks User.Address.City
var name = For.Name; // "User.Address.City"
// 2. Generate HTML
output.TagName = "input";
output.Attributes.SetAttribute("name", name);
output.Attributes.SetAttribute("id", TagBuilder.CreateSanitizedId(name, "_")); // User_Address_City
output.Attributes.SetAttribute("value", For.Model?.ToString());
// 3. Add validation attributes
var validationAttributes = Generator.GetValidationAttributes(For.ModelExplorer);
foreach(var attr in validationAttributes) {
output.Attributes.Add(attr.Key, attr.Value); // data-val-required="..."
}
}
Key: If @addTagHelper missing, InputTagHelper never runs. Razor outputs <input asp-for="Email" /> literally. Browser ignores asp-for. Form posts Email=null. P7 from Practice.
7. async/await in ASP.NET Core - No SyncContext
Unlike ASP.NET Framework, Core has no SynchronizationContext on thread pool threads.
// ASP.NET Core: Kestrel thread pool
public async Task<IActionResult> Get() {
await Task.Delay(1000); // Returns to thread pool. No SyncContext to capture.
// Continuation runs on any thread pool thread.
return Ok();
}
// async void - Deadly
public async void Get() {
await Task.Delay(1000);
throw new Exception(); // No Task to store exception.
// AsyncVoidMethodBuilder calls SynchronizationContext.Post
// No SyncContext = exception goes to ThreadPool.UnhandledException
//.NET policy: Terminate process. App dies. No 500. Dead.
}
Rule: Never async void in ASP.NET Core except event handlers. Always async Task so framework can await and catch.
8. How to Debug Prod Like Staff
| Symptom | Source to Read | What to Check |
|---|---|---|
| All 404s | EndpointRoutingMiddleware.cs | UseRouting order. endpoints.MapControllers() called? |
| [Authorize] ignored | AuthorizationMiddleware.cs:47 | context.GetEndpoint() null? Auth before Routing? |
| Model binding null | DefaultModelBinder.cs:145 | ValueProvider order. Form vs Query vs Route. [FromBody] needed? |
| Random data leak | ServiceProvider.cs:312 | Singleton holding Scoped? Check lifetimes in Program.cs |
| View NRE | *.g.cs in obj/ | Model null? Check action returns View(model) not View() |
| App crashes, no 500 | ThreadPool | async void somewhere. Grep for "async void" |
No comments yet. Be the first to share your thoughts!