Understanding exactly what happens between a browser request arriving and HTML being sent back is essential for diagnosing problems, adding features in the right place, and understanding where performance bottlenecks occur. The MVC request lifecycle is a well-defined pipeline: routing matches the URL, the controller is activated, action filters run, model binding populates parameters, the action executes, and the view engine renders the response. Each stage has extension points — filters, custom model binders, custom view engines — that you will use throughout this series.
Complete Request Lifecycle
// ── Stage 1: HTTP Request Arrives ─────────────────────────────────────────
// Browser sends: GET /posts/details/42 HTTP/1.1
// ── Stage 2: Middleware Pipeline ──────────────────────────────────────────
// ExceptionHandler → HTTPS → StaticFiles → Routing → Auth → MVC Endpoint
// ── Stage 3: Route Matching ───────────────────────────────────────────────
// Template: {controller=Home}/{action=Index}/{id?}
// URL: /posts/details/42
// Matched: controller=Posts, action=Details, id=42
// ── Stage 4: Controller Activation ────────────────────────────────────────
// DI container creates PostsController, injecting all constructor dependencies
// PostsController(IPostService service, ILogger<PostsController> logger)
// ── Stage 5: Action Filters — OnActionExecuting ───────────────────────────
// [Authorize] — checks authentication/authorisation
// [ValidateAntiForgeryToken] — validates CSRF token for POST requests
// Custom [AuditAction] — logs the action invocation
// ── Stage 6: Model Binding ────────────────────────────────────────────────
// Route value id=42 → bound to action parameter int id
// Query string, form data, JSON body also bound here
// ModelState.IsValid checked after binding + DataAnnotations validation
public async Task<IActionResult> Details(int id) // id=42 bound here
{
// ── Stage 7: Action Execution ─────────────────────────────────────────
if (!ModelState.IsValid)
return BadRequest(ModelState);
var post = await _service.GetByIdAsync(id);
if (post is null) return NotFound();
var vm = new PostDetailsViewModel(post);
// ── Stage 8: Action Result Selection ─────────────────────────────────
return View(vm); // returns ViewResult — not yet rendered!
}
// ── Stage 9: Action Filters — OnActionExecuted ───────────────────────────
// Custom filters can inspect and modify the ViewResult before rendering
// ── Stage 10: View Engine (Razor) Executes ────────────────────────────────
// ViewResult.ExecuteResultAsync() is called
// View engine locates Views/Posts/Details.cshtml
// Razor template compiled (cached after first request)
// Template executed with the view model → HTML string produced
// ── Stage 11: Response Written ────────────────────────────────────────────
// HTML written to response body
// Response passes back through middleware (in reverse)
// Browser receives HTML
return View(vm) does not immediately render the HTML. It creates a ViewResult object that contains the view name and the model. The actual Razor execution (finding the .cshtml file, compiling it if needed, running the C# code in the template, producing HTML) happens when the action result is executed after the action method returns. This design allows action filters to intercept and modify the ViewResult before rendering — for example, replacing one view with another for A/B testing, or adding ViewBag data to all views./p:RazorCompileOnPublish=true, eliminating the first-request compilation delay. This is particularly valuable for production deployments where the first user to load each page should not experience a slower response. Precompiled views also catch template compilation errors at publish time rather than at runtime.ModelState.IsValid after calling any method that might modify the model — model binding and DataAnnotations validation run before the action body, but custom validation code in the action body is your responsibility. Always validate before acting on user input. Also note that ModelState.IsValid is true by default when a GET request has no model bound — always check it only for state-changing actions (POST, PUT, DELETE).View Location Convention
// ── View() searches for .cshtml files in this order: ─────────────────────
// return View() → Views/{Controller}/{Action}.cshtml
// Views/Shared/{Action}.cshtml
// return View("Details") → Views/{Controller}/Details.cshtml
// Views/Shared/Details.cshtml
// return View("~/Views/Posts/Details.cshtml", vm) → explicit path
// PostsController.Details() → Views/Posts/Details.cshtml
// PostsController.Index() → Views/Posts/Index.cshtml
// Shared views (used by multiple controllers) → Views/Shared/
// ── Common ActionResult types ─────────────────────────────────────────────
return View(vm); // renders a Razor view
return PartialView("_PostCard", post); // renders partial view (no layout)
return RedirectToAction("Index"); // 302 redirect to same controller
return RedirectToAction("Details", new { id }); // 302 with route values
return RedirectToRoute("blog-post", new { slug }); // 302 using named route
return NotFound(); // 404
return Forbid(); // 403
return Json(vm); // JSON response from MVC controller
Common Mistakes
Mistake 1 — Wrong view name in return View() (view not found exception)
❌ Wrong — typo causes “The view ‘Deatils’ was not found”:
return View("Deatils", vm); // typo — exception at runtime
✅ Correct — use return View(vm) (no name) to follow the convention; or use nameof(Details).
Mistake 2 — Checking ModelState.IsValid on GET requests (always true, misleading)
❌ Wrong — GET requests have empty ModelState; IsValid is always true with no data bound.
✅ Correct — only check ModelState.IsValid in POST/PUT/DELETE action methods.