ASP.NET Core MVC · Controllers & Views

Controllers

Controllers are the traffic police of an MVC app. Each public method — an action — handles one kind of request: show the product list, show one product, save a form. In this lesson ShopSmart gets its ProductsController.

Beginner 6 min Lesson 8 of 42Examples target .NET 9.
Before you start:Routing
0/42 in ASP.NET Core MVC
On this page · 6 min · Beginner

In simple words#

A controller is a receptionist: receives the visitor (request), asks the right department (service) for help, then directs them to the right room (view).

Controller
Class with action methods.
Action
A public method that handles a request.
Thin controller
Logic lives in services, not here.

Learning objectives#

  • Create a controller that follows MVC conventions
  • Write sync and async actions
  • Receive services through the constructor
  • Return views, 404s and redirects from actions

Explanation#

A controller is a class whose name ends in Controller and which (usually) inherits from Controller. ProductsController handles URLs starting with /Products.

Every public method is an action, reachable by URL. Mark helper methods [NonAction] or make them private so nobody can call them from a browser.

Actions that touch a database or another service should be async: public async Task<IActionResult> Index(). The thread is free to serve other requests while waiting for I/O.

Dependencies arrive through the constructor (here with a C# 12 primary constructor). ASP.NET Core creates a new controller for every request and injects the registered services.

Controller gives you helpers that return results: View(model), NotFound(), RedirectToAction(nameof(Details), new { id }), Content(), Json() and more. Use ControllerBase (without views) for pure APIs.

Syntax#

C# Illustrative
public class ProductsController(IProductStore store) : Controller
{
    public async Task<IActionResult> Details(int id)
    {
        var product = await store.FindAsync(id);
        return product is null ? NotFound() : View(product);
    }
}

Code example: ShopSmart’s ProductsController#

C#Controllers/ProductsController.cs Illustrative
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                   // keep the demo output clean
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();


var app = builder.Build();
app.MapDefaultControllerRoute();
app.Urls.Add("http://127.0.0.1:5106");
await app.StartAsync();


var handler = new HttpClientHandler { AllowAutoRedirect = false };  // show redirects instead of following them
using var http = new HttpClient(handler) { BaseAddress = new Uri("http://127.0.0.1:5106") };
foreach (var path in new[] { "/Products", "/Products/Details/2", "/Products/Details/99", "/Products/Latest", "/Products/FormatName" })
{
    var response = await http.GetAsync(path);
    var location = response.Headers.Location is { } url ? 
quot; ->
{url}" : "";
var body = (await response.Content.ReadAsStringAsync()).Trim(); Console.WriteLine(
quot;
{path,-22} {(int)response.StatusCode}{location} {body}".TrimEnd());
} await app.StopAsync(); // Services/ProductStore.cs public record ProductItem(int Id, string Name, decimal Price); public interface IProductStore { Task<IReadOnlyList<ProductItem>> GetAllAsync(); Task<ProductItem?> FindAsync(int id); } public class InMemoryProductStore : IProductStore { private readonly List<ProductItem> _items = [new(1, "Wireless Mouse", 799m), new(2, "USB-C Hub", 1499m), new(3, "Mechanical Keyboard", 3999m)]; public Task<IReadOnlyList<ProductItem>> GetAllAsync() => Task.FromResult<IReadOnlyList<ProductItem>>(_items); public Task<ProductItem?> FindAsync(int id) => Task.FromResult(_items.FirstOrDefault(p => p.Id == id)); } // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; public class ProductsController(IProductStore store) : Controller { // GET /Products public async Task<IActionResult> Index() => View(await store.GetAllAsync()); // GET /Products/Details/2 public async Task<IActionResult> Details(int id) { var product = await store.FindAsync(id); if (product is null) return NotFound(); return View(product); } // GET /Products/Latest → redirect to the newest product public async Task<IActionResult> Latest() { var newest = (await store.GetAllAsync()).MaxBy(p => p.Id)!; return RedirectToAction(nameof(Details), new { id = newest.Id }); } [NonAction] // public helper, but not reachable by URL public string FormatName(ProductItem p) => p.Name.ToUpperInvariant(); } @* Views/Products/Index.cshtml *@ @model IReadOnlyList<ProductItem> <ul>@foreach (var p in Model) {<li>@p.Name</li>}</ul> @* Views/Products/Details.cshtml *@ @model ProductItem <h1>@Model.Name</h1>
Expected output
/Products              200 <ul><li>Wireless Mouse</li><li>USB-C Hub</li><li>Mechanical Keyboard</li></ul>
/Products/Details/2    200 <h1>USB-C Hub</h1>
/Products/Details/99   404
/Products/Latest       302 -> /Products/Details/3
/Products/FormatName   404

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. ProductsController receives IProductStore through its primary constructor; DI supplies InMemoryProductStore.
  2. Index awaits the store and passes the list to Views/Products/Index.cshtml.
  3. Details returns NotFound() for an unknown id instead of rendering an empty page.
  4. Latest uses RedirectToAction with nameof, so renaming Details later cannot break the redirect.
  5. [NonAction] keeps FormatName public for code but invisible to URLs — /Products/FormatName is a 404.

Real-world use case#

ShopSmart has ProductsController (catalogue), CartController (add/remove items), CheckoutController (address, payment) and Admin/ProductsController (manage stock) — each with a handful of focused actions.

Common mistakes#

Fat controllers

Pricing rules, SQL and email sending inside actions make controllers huge and untestable. Move logic into services.

Public helper methods

Every public method is an action. Unintended ones become URLs. Make them private or [NonAction].

Blocking on async code

.Result and .Wait() inside actions can exhaust the thread pool under load. Use async all the way.

Best practices#

  • One controller per resource or feature
  • Async actions for I/O
  • Constructor injection for dependencies
  • nameof(Action) in redirects and links

Remember this#

XxxController + public methods = actions.

NotFound() for missing data, RedirectToAction after changes.

Inject services; never new them up in actions.

Practice exercise#

Your task: Add a Search(string q) action to ProductsController that returns the matching products (name contains q, case-insensitive) to the Index view, or redirects to Index when q is empty.

Need a hint?
string.IsNullOrWhiteSpace(q) → RedirectToAction(nameof(Index)); otherwise filter with Contains(q, StringComparison.OrdinalIgnoreCase) and return View("Index", results).
Show solution
C# Illustrative
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;


public class ProductsController(IProductStore store) : Controller
{
    public async Task<IActionResult> Index() => View(await store.GetAllAsync());


    public async Task<IActionResult> Search(string? q)
    {
        if (string.IsNullOrWhiteSpace(q)) return RedirectToAction(nameof(Index));
        var results = (await store.GetAllAsync())
            .Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
            .ToList();
        return View("Index", results);
    }
}


public record ProductItem(int Id, string Name, decimal Price);
public interface IProductStore { Task<IReadOnlyList<ProductItem>> GetAllAsync(); }

Mini quiz#

Controllers quizMultiple choice Question 1 of 3

Which class name makes a controller for URLs starting with /Orders?

Score so far: 0/0

Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.