MVC & Razor Pages
Key Points
- MVC organizes around
Controllerclasses with action methods, view templates, and routes by attribute or convention. - Razor Pages organizes by page (one PageModel + Razor view per URL). Often clearer for traditional CRUD UI.
- For JSON APIs in 2026: prefer Minimal APIs. For server-rendered HTML: choose Razor Pages over MVC for UI-centric apps; choose MVC for complex action conventions or existing MVC codebases.
- Conventions matter:
[ApiController]opts in to model validation, ProblemDetails on errors, automatic 400 on invalid model, etc. - Razor Pages handlers:
OnGet,OnPost,OnPostHandlerfor named handlers (?handler=Foo).
Concepts (deep dive)
MVC controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController(IRepo repo) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id)
{
var o = await repo.GetAsync(id);
return o is not null ? Ok(o) : NotFound();
}
[HttpPost]
public async Task<ActionResult<Order>> Create([FromBody] CreateOrderRequest req)
{
var saved = await repo.AddAsync(req);
return CreatedAtAction(nameof(Get), new { id = saved.Id }, saved);
}
}
[ApiController] enables: - Automatic model state validation → 400 with ProblemDetails on invalid input. - Inferred parameter binding (complex from body, simple from route/query). - ProblemDetails for error status codes. - Multipart/form-data binding.
Razor Pages
public class IndexModel(IRepo repo) : PageModel
{
public List<Order> Orders { get; private set; } = new();
public async Task OnGetAsync()
{
Orders = await repo.GetAllAsync();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
await repo.DeleteAsync(id);
return RedirectToPage();
}
}
@page "{id:int?}"
@model IndexModel
<h1>Orders</h1>
<table>
@foreach (var o in Model.Orders) { ... }
</table>
@page "{id:int?}" declares the route. OnGet, OnGetAsync, OnPost, OnPostHandlerName handle requests.
MVC vs Razor Pages — when to choose
| Aspect | MVC | Razor Pages |
|---|---|---|
| Organization | By controller (collection of actions) | By page (single URL = single file pair) |
| Best for | API + complex flows | UI/CRUD apps |
| Routing | Attribute or convention | File-based (@page) |
| Filters | Same [Filter] attributes work | Same |
| Razor views | Yes | Yes (the view is the page) |
| Controllers | Required | Not used (PageModel replaces) |
For JSON APIs, neither — use Minimal APIs. For traditional server-rendered apps, Razor Pages is usually cleaner.
Routing for MVC
// Conventional routing
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Attribute routing (preferred for APIs)
[Route("api/v1/[controller]")]
public class OrdersController { }
[Route] with [controller] token auto-uses the type name minus "Controller". Combine with [HttpGet("{id}")], etc.
Action results
return Ok(value); // 200 with body
return Created(uri, value); // 201 with Location header
return NoContent(); // 204
return NotFound(); // 404
return BadRequest("error"); // 400 with body
return Forbid(); // 403
return Problem(detail: "..."); // 500 ProblemDetails
return File(stream, "image/png"); // file download
return RedirectToAction("Other"); // 302
return View(model); // Razor view (MVC controllers)
return Page(); // Razor Pages
ActionResult<T> is the typed wrapper — lets you return either a T or any IActionResult.
[ApiController] automatic model validation
Without [ApiController]:
[HttpPost]
public IActionResult Create(Order order)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
// ...
}
With [ApiController], the framework auto-returns 400 + ProblemDetails when ModelState.IsValid is false — no boilerplate.
Conventions and [Bind]
[Bind] whitelists which properties bind from input — defends against over-posting (where attacker sends extra fields like IsAdmin=true).
Better pattern in 2026: use a DTO/request type that contains only bindable fields:
public record CreateCustomerRequest(string Name, string Email);
public IActionResult Create(CreateCustomerRequest req) { /* map to Customer */ }
Filters
[ApiController]
[ServiceFilter(typeof(LogActionFilter))]
public class OrdersController : ControllerBase
{
[HttpGet]
[ResponseCache(Duration = 60)]
public IActionResult Get() => Ok(/* ... */);
}
MVC filters: action, result, exception, authorization, resource. Apply via attributes (global, controller, or action). See Filters & Action Pipeline.
View location and partial views
Views/
├── Shared/
│ ├── _Layout.cshtml
│ └── _Validation.cshtml
├── Orders/
│ ├── Index.cshtml
│ └── Edit.cshtml
└── _ViewImports.cshtml
MVC's view discovery walks: Views/{Controller}/{Action}.cshtml → Views/Shared/{Action}.cshtml. Override with View("Name") or full path.
_ViewImports.cshtml and tag helpers
Globally-scoped imports and tag-helper registrations. Reduces boilerplate at the top of each view.
Code: correct vs wrong
❌ Wrong: forgetting [ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
public IActionResult Create(Order o)
{
// ❌ ModelState.IsValid not checked; clients get 200 with corrupted model
}
}
✅ Correct
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost] public IActionResult Create(Order o) { /* automatic 400 on invalid */ }
}
❌ Wrong: binding the entity directly
✅ Correct: DTO
public record UpdateCustomerRequest(string Name, string Email);
public IActionResult Update(int id, UpdateCustomerRequest req) { ... }
❌ Wrong: returning raw exception details to client
✅ Correct
catch (Exception ex)
{
Logger.LogError(ex, "create failed");
return Problem(detail: "Could not create order.", statusCode: 500);
}
Design patterns for this topic
Pattern 1 — "DTO per use case"
- Intent: prevent over-posting; explicit contracts.
Pattern 2 — "[ApiController] for every API controller"
- Intent: automatic validation + ProblemDetails.
Pattern 3 — "Razor Pages for traditional CRUD UIs"
- Intent: less ceremony than MVC for page-centric apps.
Pattern 4 — "Action filters for repeated cross-cutting concerns"
- Intent: auditing, slow-query logging, tenant context.
Pattern 5 — "PageModel binding via [BindProperty]"
- Intent: Razor Pages property-based input.
- Code sketch:
public class EditModel : PageModel
{
[BindProperty] public Order Order { get; set; } = new();
public async Task<IActionResult> OnPostAsync() { /* save Order */ }
}
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| MVC | Mature; flexible | Boilerplate for simple APIs |
| Razor Pages | Page-centric clarity | Less convention reuse for APIs |
[ApiController] | Auto-validation | Requires you accept the conventions |
| ActionFilters | Cross-cutting | Hidden behavior |
When to use / when to avoid
- Use MVC for complex apps with action conventions (legacy or hybrid).
- Use Razor Pages for new server-rendered UIs.
- Use Minimal APIs for new JSON APIs.
- Avoid binding entities directly — use DTOs.
- Avoid writing
if (!ModelState.IsValid)boilerplate —[ApiController]does it.
Interview Q&A
Q1. Difference between MVC and Razor Pages? MVC organizes by controller (collection of actions). Razor Pages organizes by page (single URL = file pair). Razor Pages is cleaner for page-centric UIs.
Q2. What does [ApiController] do? Enables automatic model validation, inferred parameter binding, ProblemDetails for error status codes, and multipart/form-data binding.
Q3. What's over-posting? Client sends extra properties (e.g., IsAdmin=true) that bind to your entity. Defense: use a DTO with only the expected fields, or [Bind("X,Y")].
Q4. How do you return a 201 Created? return Created(uri, value) or CreatedAtAction(nameof(Get), new {id}, saved) — the latter generates the URI from a route.
Q5. What's ActionResult<T>? A wrapper that lets an action return either a T (auto-wrapped in 200 OK) or any IActionResult. Better OpenAPI.
Q6. How does Razor Pages handle multiple POSTs on a page? Named handlers: OnPostDeleteAsync, OnPostUpdateAsync. Submit to ?handler=Delete or via <form asp-page-handler="Delete">.
Q7. Where does _Layout.cshtml live? Views/Shared/_Layout.cshtml (MVC) or Pages/Shared/_Layout.cshtml (Razor Pages). Set in _ViewStart.cshtml.
Q8. When would you use [FromForm] vs [FromBody]? [FromForm] for application/x-www-form-urlencoded or multipart/form-data. [FromBody] for JSON (default for complex types in [ApiController]).
Q9. What's the difference between an action filter and a result filter? Action filter wraps action method execution. Result filter wraps the result execution (e.g., view rendering). Both have before/after hooks.
Q10. Should you use convention-based or attribute-based routing? Attribute for APIs (clear, per-action). Convention for traditional MVC sites with predictable URLs.
Gotchas / common mistakes
- ⚠️ Forgetting
[ApiController]— manual validation needed. - ⚠️ Binding entities directly — over-posting risk.
- ⚠️ Mixing
IActionResultandTask<IActionResult>— async actions must returnTask. - ⚠️ Returning
View()from an API controller — looks for a Razor view. - ⚠️ PageModel state across requests — requests are independent; persist via session or DB.