Skip to content
wannacode

MVC: model, view, controller

Problem

Everything in Program.cs

The catalog from the previous lesson works. Now the shop wants a page for every product, a cart, orders, customer sign-in and a page for the stock room. With MapGet, all of this goes into Program.cs, and each handler fetches the data itself, decides itself what to show and glues the HTML together itself:

app.MapGet("/products/{id:int}", (int id) =>
{
Product product = products.FirstOrDefault(p => p.Id == id);
if (product == null) return Results.NotFound();
string html = "<h1>" + product.Name + "</h1><p>€" + product.Price + "</p>";
if (product.Category == "coffee") html += "<p>Ground fresh in the shop.</p>";
return Results.Content(html, "text/html; charset=utf-8");
});
Output
<h1>Ethiopia Yirgacheffe</h1><p>€14.90</p><p>Ground fresh in the shop.</p>

With fifty handlers like this, the file grows to thousands of lines. A designer who wants to change a heading has to dig through C# code. If the price is calculated in two places, sooner or later the two places will disagree.

The idea

Three parts with separate jobs

Web applications have long been split into three parts. The split is called MVC (Model-View-Controller):

  • Model: the data and the rules about it. The Product class, the list of products, the total of an order. The model knows nothing about HTML or HTTP.
  • View: how the data looks to a person. An HTML template with blanks for the values. In ASP.NET Core, views are written in Razor: HTML with C# inside (the next lesson).
  • Controller: takes the request, asks the model and chooses what to return. Its methods are called actions.

Each part has its own folder in the project:

Shop/
├── Controllers/
│ ├── HomeController.cs
│ └── ProductsController.cs
├── Models/
│ └── Product.cs
├── Views/
│ ├── Home/
│ │ └── Index.cshtml
│ └── Products/
│ ├── Index.cshtml
│ └── Details.cshtml
├── wwwroot/ ← css, js and images, served as they are
├── appsettings.json ← settings (logging levels, database address)
└── Program.cs

dotnet new mvc -n Shop makes a project with this layout.

More precisely, if you are curious
  • Splitting by job is called separation of concerns. The benefit is not just tidiness: a model that does not know about HTTP can be tested with an ordinary unit test, without a server.
  • The folders are a convention. Views really do have to be in Views/<controller>/, because that is where View() looks for them. Controllers and models can be anywhere, but everyone expects them in Controllers/ and Models/.
  • In the examples in this lesson everything is in one file, so the code can be checked in one go. In a real project, each class is in its own file.

See it

Controller and actions

The Program.cs of an MVC application says two things: "I use controllers" (AddControllersWithViews) and "this is how the address gets to the controller" (MapControllerRoute):

using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.Urls.Add("http://localhost:6222");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
await app.StartAsync();

A controller is a class that inherits from Controller. Each of its public methods is an action:

public class HomeController : Controller
{
public IActionResult Index() => Content("Coffee and tea: home page");
}
public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public IActionResult Index()
{
List<string> names = products.All().Select(p => p.Name).ToList();
return Content(string.Join(", ", names));
}
public IActionResult Details(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Content(product.Name + ": €" + product.Price);
}
}

Content returns text, and NotFound returns 404. Both are action results (IActionResult); more about them in a moment. The => after the method is a short way to write a body of one expression: Index() => Content(...) is the same as { return Content(...); }.

The client asks for several addresses:

var http = new HttpClient();
foreach (string path in new string[] { "/", "/Products", "/Products/Details/2", "/Products/Details/9", "/products/details/3" })
{
HttpResponseMessage response = await http.GetAsync("http://localhost:6222" + path);
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());
}
await app.StopAsync();
Output
/ -> 200 Coffee and tea: home page
/Products -> 200 Ethiopia Yirgacheffe, Colombia Supremo, Sencha, Earl Grey
/Products/Details/2 -> 200 Colombia Supremo: €12.50
/Products/Details/9 -> 404
/products/details/3 -> 200 Sencha: €8.40

The pattern {controller=Home}/{action=Index}/{id?} takes the path apart:

  • /Products/Details/2 is the controller Products (the class ProductsController), the action Details, and id = 2. The method's int id parameter gets 2 by itself, by its name.
  • / has none of the parts, so the values after = are used: Home and Index.
  • The ? after id means it can be missing. Uppercase and lowercase in the path do not matter.
More precisely, if you are curious
  • By convention the class name ends in Controller, and in the address you write it without that suffix.
  • In older projects (up to .NET 5) the setup was in a separate Startup class with two methods: ConfigureServices registers the services, and Configure sets up how requests are handled. You will meet it in many articles and in old projects:
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}

You start it like this:

IHost host = Host.CreateDefaultBuilder()
.ConfigureLogging(logging => logging.ClearProviders())
.ConfigureWebHostDefaults(web => web.UseStartup<Startup>().UseUrls("http://localhost:6229"))
.Build();
await host.StartAsync();

Today's templates put everything in Program.cs, as in this lesson. The job is the same: builder.Services is ConfigureServices, and app.Map... is Configure.

  • appsettings.json holds the settings you change without compiling: the logging levels, the database address, the allowed hosts.

Try it

Which address goes where

The route is the same, {controller=Home}/{action=Index}/{id?}, but the application has only this controller:

public class OrdersController : Controller
{
public IActionResult Index() => Content("all orders");
public IActionResult Details(int id) => Content("order " + id);
}
var http = new HttpClient();
foreach (string path in new string[] { "/Orders", "/Orders/Details/7", "/Orders/Details", "/" })
{
HttpResponseMessage response = await http.GetAsync("http://localhost:6231" + path);
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{path} -> {(int)response.StatusCode} {body}".TrimEnd());
}
Answer

The idea

Attributes: the route on the method itself

The conventional pattern is handy for pages. Sometimes you want an exact address, or a different action for each HTTP method. Then you write the route above the class and above the method, in square brackets:

[Route("catalog")]
public class CatalogController : Controller
{
private static readonly ProductService products = new ProductService();
[HttpGet("")]
public IActionResult List() => Content(string.Join(", ", products.All().Select(p => p.Id + " " + p.Name)));
[HttpGet("{id:int}")]
public IActionResult Show(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Content(product.Name + ": €" + product.Price);
}
}

[Route("catalog")] and [HttpGet("{id:int}")] are attributes. An attribute is a note on a class, a method or a parameter. By itself it does nothing, but the framework reads it and acts on it. Here ASP.NET Core understands: "this controller is at /catalog, and Show answers a GET to /catalog/<number>".

There is an attribute for each HTTP method: [HttpGet] reads, [HttpPost] creates, [HttpPut] changes, [HttpDelete] deletes. Changing a price:

[HttpPut("{id:int}")]
public IActionResult ChangePrice(int id, decimal price)
{
Product product = products.Find(id);
if (product == null) return NotFound();
product.Price = price;
return NoContent();
}

NoContent() is 204: "done, but I have nothing to show you". The client sends requests with different methods. Send is a helper method that sends an HttpRequestMessage (the request as an object: method, address, body) and prints the response:

var form = new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Matcha", ["price"] = "18" });
await Send(HttpMethod.Get, "/catalog");
await Send(HttpMethod.Post, "/catalog", form);
await Send(HttpMethod.Put, "/catalog/2?price=11.90");
await Send(HttpMethod.Get, "/catalog/2");
await Send(HttpMethod.Delete, "/catalog/4");
await Send(HttpMethod.Delete, "/catalog/4");
await Send(HttpMethod.Get, "/catalog");
await Send(HttpMethod.Delete, "/catalog");
Output
GET /catalog -> 200 1 Ethiopia Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 4 Earl Grey
POST /catalog -> 200 added 5 Matcha
PUT /catalog/2?price=11.90 -> 204
GET /catalog/2 -> 200 Colombia Supremo: €11.90
DELETE /catalog/4 -> 204
DELETE /catalog/4 -> 404
GET /catalog -> 200 1 Ethiopia Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 5 Matcha
DELETE /catalog -> 405
  • price=11.90 comes from the address, and on the POST, name and price come from the body of the form. In both cases the parameters are filled in by name.
  • The last request is a DELETE to /catalog. That address exists, but only for GET and POST, so the response is 405, not 404.
  • The list is static so that it is the same for all requests. Why this is not the good way, we will see two cards from now.
More precisely, if you are curious
  • Attributes in C# are classes that inherit from Attribute. [HttpGet("{id:int}")] creates an HttpGetAttribute object with the argument "{id:int}". The framework finds them with reflection while it sets up the routes.
  • [Route("api/[controller]")] puts the controller's name in the address: for ProductsController it is api/Products. That is how the APIs in the WebAPI lesson are made.
  • Conventional routes and attribute routes can live together in one application. A controller with [Route] can no longer be reached through the conventional pattern.

The idea

What an action returns

An action returns an IActionResult: a description of the response, which ASP.NET Core turns into a status, headers and a body. The methods of Controller make the ready-made kinds:

Method What it sends Status
View(model) HTML from a Razor view 200
Content(text) plain text 200
Json(obj) the object as JSON 200
File(bytes, type, name) a file to download 200
RedirectToAction("Index") "go to another address" 302
NotFound() / BadRequest() / NoContent() an empty response 404 / 400 / 204
public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public IActionResult Hours() => Content("We are open every day, 8 a.m. to 8 p.m.");
public IActionResult Info(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return Json(product);
}
public IActionResult Report()
{
IEnumerable<string> lines = products.All().Select(p => p.Id + ";" + p.Name + ";" + p.Price);
byte[] bytes = Encoding.UTF8.GetBytes(string.Join("\n", lines) + "\n");
return File(bytes, "text/csv", "products.csv");
}
[HttpPost]
public IActionResult Create(string name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest();
// The product is saved here (task 2 at the end of the lesson).
return RedirectToAction("Index");
}
}

The client has AllowAutoRedirect = false, so we can see the redirect itself. Otherwise HttpClient goes to the new address by itself, like a browser:

Output
We are open every day, 8 a.m. to 8 p.m.
{"id":3,"name":"Sencha","price":8.40,"category":"tea"}
Info/9: 404
text/csv, products.csv
1;Ethiopia Yirgacheffe;14.90
2;Colombia Supremo;12.50
3;Sencha;8.40
4;Earl Grey;6.90
Create: 302, Location: /Products
  • Json writes the properties with a lowercase first letter (name, price), as is usual in JSON.
  • File adds a Content-Disposition header with the file name. The browser sees it and offers "Save as products.csv". This is how you serve reports as PDF or Excel: File(bytes, "application/pdf", "MonthlyReport.pdf").
  • RedirectToAction("Index") answers with 302 and the header Location: /Products. The address is built from the route, so you do not write it by hand.
More precisely, if you are curious
  • ASP.NET Core writes JSON with System.Text.Json. With the default settings, Json(...) replaces every character outside ASCII with a code: "Café" becomes Café. That is valid JSON and every program reads it correctly, but a person cannot read it. So the program sets it explicitly, as in the previous lesson with ConfigureHttpJsonOptions: all letters stay, and the characters that are dangerous for HTML (<, >) are encoded. For controllers the setting is AddJsonOptions:
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddControllersWithViews()
.AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));
var app = builder.Build();
app.Urls.Add("http://localhost:6224");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
await app.StartAsync();
  • The same goes for Razor views: they encode letters like é as &#xE9;. The browser shows them correctly, but in console output they are unreadable. AddWebEncoders with UnicodeRanges.All (in the DI card) leaves the letters as they are. Neither setting is required for a real site; they are there to keep the output readable.
  • RedirectToAction is 302 (a temporary redirect). For "the address has changed for good" there is RedirectPermanent, 301. Redirect("https://…") redirects to any address.
  • Besides an IActionResult, an action can also return a plain object, such as a Product. Then it is sent as JSON. This is common in APIs.

Try it

After the save

The form for a new product was sent with POST. The product is saved, and the user should go to the list. With which method?

What does the action return so the browser goes to the list?

[HttpPost]
public IActionResult Create(string name)
{
    if (string.IsNullOrWhiteSpace(name)) return BadRequest();
    return ("Index");
}

The idea

The service comes from outside

So far the controller made its own new ProductService(). But ASP.NET Core creates a new controller for every request:

public class ProductsController : Controller
{
private readonly ProductService products = new ProductService();
public ProductsController()
{
Console.WriteLine("new ProductsController and new ProductService");
}
public IActionResult Index() => Content(products.All().Count + " products");
}
Output
new ProductsController and new ProductService
4 products
new ProductsController and new ProductService
4 products

With every controller, a new list of products is born too. A product added on one request is gone on the next. static solves this, but then every controller is tied to exactly this class. When the products move to a database, you will have to change every controller.

The better way: the controller says what it needs, not where to get it. What it needs is an interface:

public interface IProductService
{
Product Find(int id);
List<Product> All();
}
public class ProductService : IProductService
{
private readonly List<Product> products = new List<Product>
{
new Product { Id = 1, Name = "Ethiopia Yirgacheffe", Price = 14.90m, Category = "coffee" },
new Product { Id = 2, Name = "Colombia Supremo", Price = 12.50m, Category = "coffee" },
new Product { Id = 3, Name = "Sencha", Price = 8.40m, Category = "tea" },
new Product { Id = 4, Name = "Earl Grey", Price = 6.90m, Category = "tea" },
};
public Product Find(int id) => products.FirstOrDefault(p => p.Id == id);
public List<Product> All() => products;
}

In Program.cs you say once which class stands behind the interface:

builder.Services.AddControllersWithViews();
builder.Services.AddWebEncoders(o => o.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
builder.Services.AddSingleton<IProductService, ProductService>();

AddSingleton<IProductService, ProductService>() means: "when someone asks for an IProductService, give them a ProductService object, the same one for the whole application". The controller asks for it as a constructor parameter, and ASP.NET Core passes it in. This is called dependency injection (DI). The controller is on the next card.

More precisely, if you are curious
  • Besides AddSingleton there are two more service lifetimes. AddScoped gives a new object for each request, but the same one within a request. This is how the database connection (DbContext) is usually registered. AddTransient gives a new object every time one is asked for.
  • Swapping is one line: AddSingleton<IProductService, DatabaseProductService>(). In a test, you pass the controller your own test class with a few products, with no server and no database.
  • The list in a singleton is used by many requests at the same time. In a real application, access to it has to be protected. How, we will see in the WebAPI lesson.

See it

One request from start to finish

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Details(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return View(product);
}
}
Step by step, in words
  1. A GET /Products/Details/1 request. The route {controller}/{action}/{id} splits it into three parts.
  2. Products is the ProductsController, Details is the action, and 1 becomes the id parameter.
  3. The controller does not create the service itself: it gets it in the constructor (DI).
  4. The action asks the model: products.Find(1) returns the product.
  5. There is a product, so it is not NotFound().
  6. View(product) passes the product to the Details.cshtml view.
  7. The view makes HTML, which goes back to the browser.

The browser asks for /Products/Details/1:

  1. The route takes the path apart: controller Products, action Details, id = 1.
  2. ASP.NET Core creates a ProductsController and passes it the registered IProductService.
  3. Details(1) asks the model: products.Find(1) returns the product. If there is no such product, the action returns NotFound() and the trip ends with 404.
  4. View(product) looks for the view Views/Products/Details.cshtml (the folder is the controller's name, the file is the action's name) and passes it the product.
  5. Razor fills in the blanks, and the HTML goes into the response.

The view Views/Products/Details.cshtml:

@model Product
<h1>@Model.Name</h1>
<p>Price: €@Model.Price</p>
<p>Category: @Model.Category</p>

@model Product says what kind of object the view gets, and @Model.Name inserts its property. The program opens the two addresses:

Output
<h1>Ethiopia Yirgacheffe</h1>
<p>Price: €14.90</p>
<p>Category: coffee</p>
/Products/Details/99 -> 404
More precisely, if you are curious
  • The program is a project with three files. Besides Program.cs with the classes and Details.cshtml, it has the .csproj that dotnet new mvc makes. The views are compiled together with the C# code at dotnet build, so a mistake in @Model.Nme is caught right then.
  • Filters are code that runs before or after actions, without you writing it in each one. [Authorize] above a controller lets in only signed-in users, and an error filter turns any exception into a "Something went wrong" page.
  • Areas split a large application into parts with their own controllers and views, for example Areas/Admin/ for the shop's admin section.

Try it

The service cannot be found

The application starts, but every request to /Products/Details/1 returns 500. The console says Unable to resolve service for type 'IProductService' while attempting to activate 'ProductsController'. Which line is the bug on?

Pick the line with the bug.

Exercise

The catalog as MVC

Solve these in your editor, in a project from dotnet new mvc or in one file as in the lesson. The products are the four from the lesson, and the service is registered with AddSingleton<IProductService, ProductService>() and passed into the controller's constructor.

1. Catalog. A ProductsController with an Index that returns all products, one per line, and a Details(int id) that returns the name, the category and the price, or 404. The client asks for /Products, /Products/Details/3 and /Products/Details/42:

Output
/Products -> 200 1. Ethiopia Yirgacheffe: €14.90
2. Colombia Supremo: €12.50
3. Sencha: €8.40
4. Earl Grey: €6.90
/Products/Details/3 -> 200 Sencha (tea): €8.40
/Products/Details/42 -> 404

2. Redirect after saving. Add a method void Add(Product product) to IProductService that gives the product the next free Id. The action [HttpPost] Create(string name, decimal price, string category) returns 400 without a name, and otherwise saves the product and redirects to Index. The client has AllowAutoRedirect = false, and then opens the address from Location:

Output
POST /Products/Create -> 302, Location: /Products
POST without a name -> 400
Ethiopia Yirgacheffe, Colombia Supremo, Sencha, Earl Grey, Matcha

3. Products by category. The action Category(string id) returns the names of the products in the category and the sum of their prices, or 404 if there are none. With the conventional route, the category comes in as id. If the category had letters outside basic Latin, HttpClient would encode them in the address by itself:

Output
/Products/Category/tea -> 200 Sencha, Earl Grey | total €15.30
/Products/Category/coffee -> 200 Ethiopia Yirgacheffe, Colombia Supremo | total €27.40
/Products/Category/cocoa -> 404

Then make Index and Details with views instead of Content. How is in the next lesson.

Test

Check yourself

1. Which part of MVC decides what happens on a request and which view to show?
2. The route is {controller=Home}/{action=Index}/{id?}. Which action handles /Products/Details/5?
3. Why does a successful POST return RedirectToAction and not a page directly?
4. The controller asks for an IProductService in its constructor. Where does it get it from?
5. An action with [HttpPost] receives a GET request at the same address. What is the response?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·