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");});<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
Productclass, 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.csdotnet 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 whereView()looks for them. Controllers and models can be anywhere, but everyone expects them inControllers/andModels/. - 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();/ -> 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.40The pattern {controller=Home}/{action=Index}/{id?} takes the path apart:
/Products/Details/2is the controllerProducts(the classProductsController), the actionDetails, andid= 2. The method'sint idparameter gets 2 by itself, by its name./has none of the parts, so the values after=are used:HomeandIndex.- The
?afteridmeans 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
Startupclass with two methods:ConfigureServicesregisters the services, andConfiguresets 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.jsonholds 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());}/Orders goes to Index, because the action is missing and Index is used. In /Orders/Details the id is missing, and the ? allows that, so int id gets the default value, 0. / looks for a HomeController, and there is none: 404.
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");GET /catalog -> 200 1 Ethiopia Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 4 Earl GreyPOST /catalog -> 200 added 5 MatchaPUT /catalog/2?price=11.90 -> 204GET /catalog/2 -> 200 Colombia Supremo: €11.90DELETE /catalog/4 -> 204DELETE /catalog/4 -> 404GET /catalog -> 200 1 Ethiopia Yirgacheffe, 2 Colombia Supremo, 3 Sencha, 5 MatchaDELETE /catalog -> 405price=11.90comes from the address, and on the POST,nameandpricecome 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
staticso 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 anHttpGetAttributeobject 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: forProductsControllerit isapi/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:
We are open every day, 8 a.m. to 8 p.m.{"id":3,"name":"Sencha","price":8.40,"category":"tea"}Info/9: 404text/csv, products.csv1;Ethiopia Yirgacheffe;14.902;Colombia Supremo;12.503;Sencha;8.404;Earl Grey;6.90Create: 302, Location: /ProductsJsonwrites the properties with a lowercase first letter (name,price), as is usual in JSON.Fileadds aContent-Dispositionheader 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 headerLocation: /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é" becomesCafé. 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 withConfigureHttpJsonOptions: all letters stay, and the characters that are dangerous for HTML (<,>) are encoded. For controllers the setting isAddJsonOptions:
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é. The browser shows them correctly, but in console output they are unreadable.AddWebEncoderswithUnicodeRanges.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. RedirectToActionis 302 (a temporary redirect). For "the address has changed for good" there isRedirectPermanent, 301.Redirect("https://…")redirects to any address.- Besides an
IActionResult, an action can also return a plain object, such as aProduct. 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");
}RedirectToAction("Index") sends a 302 with the address of Index. The browser goes there with GET. If the user presses "Reload", only showing the list is repeated, not the save. This is the POST, redirect, GET pattern, covered in more detail in the next lesson.
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");}new ProductsController and new ProductService4 productsnew ProductsController and new ProductService4 productsWith 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
AddSingletonthere are two more service lifetimes.AddScopedgives a new object for each request, but the same one within a request. This is how the database connection (DbContext) is usually registered.AddTransientgives 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
- A GET /Products/Details/1 request. The route {controller}/{action}/{id} splits it into three parts.
- Products is the ProductsController, Details is the action, and 1 becomes the id parameter.
- The controller does not create the service itself: it gets it in the constructor (DI).
- The action asks the model: products.Find(1) returns the product.
- There is a product, so it is not NotFound().
- View(product) passes the product to the Details.cshtml view.
- The view makes HTML, which goes back to the browser.
The browser asks for /Products/Details/1:
- The route takes the path apart: controller
Products, actionDetails,id= 1. - ASP.NET Core creates a
ProductsControllerand passes it the registeredIProductService. Details(1)asks the model:products.Find(1)returns the product. If there is no such product, the action returnsNotFound()and the trip ends with 404.View(product)looks for the viewViews/Products/Details.cshtml(the folder is the controller's name, the file is the action's name) and passes it the product.- 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:
<h1>Ethiopia Yirgacheffe</h1><p>Price: €14.90</p><p>Category: coffee</p>/Products/Details/99 -> 404More precisely, if you are curious
- The program is a project with three files. Besides
Program.cswith the classes andDetails.cshtml, it has the.csprojthatdotnet new mvcmakes. The views are compiled together with the C# code atdotnet build, so a mistake in@Model.Nmeis 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.
AddSingleton<ProductService>() registers the class under its own name. But the controller asks for IProductService, and the framework does not know which class to give it. The fix is AddSingleton<IProductService, ProductService>(): first the interface that is asked for, then the class that implements it.
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:
/Products -> 200 1. Ethiopia Yirgacheffe: €14.902. Colombia Supremo: €12.503. Sencha: €8.404. Earl Grey: €6.90/Products/Details/3 -> 200 Sencha (tea): €8.40/Products/Details/42 -> 4042. 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:
POST /Products/Create -> 302, Location: /ProductsPOST without a name -> 400Ethiopia Yirgacheffe, Colombia Supremo, Sencha, Earl Grey, Matcha3. 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:
/Products/Category/tea -> 200 Sencha, Earl Grey | total €15.30/Products/Category/coffee -> 200 Ethiopia Yirgacheffe, Colombia Supremo | total €27.40/Products/Category/cocoa -> 404Then make Index and Details with views instead of Content. How is in the next lesson.
Test