2. Controllers, Actions, Routing
This chapter examines the path a request takes, from the browser to the action that processes it, and the response that action returns. Views will not be introduced until Chapter 4: here, actions return text or JSON directly.
2.1. Example 01: The Structure of a ASP.NET Core Application MVC
The first example is the smallest possible [ASP.NET Core MVC] application. In addition to the [Example01.csproj] and [appsettings.json] files presented in the introduction, it has two files:
2.1.1. The entry point: Program.cs
Let’s break down this code:
- Line 2: [WebApplication.CreateBuilder] creates the application’s “constructor.” It reads the configuration (the [appsettings.json] file, environment variables, etc.), sets up logging, and prepares the list of application services, [builder.Services]. The [args] parameter contains the command-line arguments;
- line 5: the services required by [MVC] to locate the application’s controllers and execute their actions are added to the list;
- line 8: the application is built. After this line, the list of services can no longer be modified;
- line 11: the routes declared by the controllers (the attributes [[HttpGet]], [[Route]]..., which we will discuss) are enabled: without this line, no action would ever be called;
- line 14: starts the [Kestrel] web server and waits for requests until the application is stopped ([Ctrl-C]). Unless otherwise specified, the server listens on port 5000.
This file contains neither a class nor a method named [Main]: it is a high-level instruction-based program. The [C#] compiler itself wraps these instructions into a method named [Main], which serves as the program’s entry point. Note also the absence of [using] directives: [WebApplication] belongs to the [Microsoft.AspNetCore.Builder] namespace, which is imported automatically (see [ImplicitUsings] in the introduction).
2.1.2. The controller: Controllers/HomeController.cs
- Line 1: the namespace containing the class [Controller] and the attributes of [MVC];
- line 3: the namespace for the classes in this file. By convention, it includes the project name ([Example01]) and the file’s directory ([Controllers]). The form [namespace X;] (without curly braces) applies to the entire file;
- line 6: A controller is a public class that inherits from the [Controller] class. By convention, its name ends with [Controller]. [ASP.NET Core] automatically finds the application’s controllers—there is no need to declare them;
- line 9: the [[HttpGet("/")]] attribute associates the [Index] method with [GET /] requests. A method associated in this way with a route is an action. A [C#] attribute is written in square brackets, immediately before what it describes: it adds information to the method, which the framework reads at startup;
- Line 13: The value returned by the action is the response sent to the browser.
2.1.3. Execution
In a terminal window open to the [01-structure] directory, type [dotnet run]. After compilation, the server starts:
The startup messages indicate the listening address ([http://localhost:5000]) and the runtime environment: [Production], the default environment. We will see that certain behaviors of [ASP.NET Core] depend on the environment ([Development] or [Production]). Let’s request URL and [http://localhost:5000/] using a browser:

then with [curl]. The [-i] option also displays the HTTP headers from the response (we’re only showing a few of them here):
A string returned by an action is sent with the header [Content-Type: text/plain]: the browser displays it as plain text, without interpreting it. This differs from [NestJS], which sent it as HTML ([text/html]). Example 03 will show how to send HTML.
2.2. Example 02: Routing
Routing associates a URL and a HTTP method (GET, POST...) with an action. Example 02 has two controllers:
The file [Program.cs] is identical to the one in Example 01: this is true for all the examples in this chapter, except for the last one. The file [Controllers/HomeController.cs]:
- line 9: the action no longer returns a string, but a [ContentResult]: a response whose content and type are specified;
- lines 11–22: a raw string [C#] ([raw string literal]), delimited by three quotation marks. It can span multiple lines and contain quotation marks without escaping them. Spaces preceding the closing ["""] are removed from each line;
- line 24: the [Content(text, type)] method of the [Controller] class generates a [ContentResult]. The [text/html] type instructs the browser to interpret the text as HTML.
The [Index] action thus returns a HTML page that contains links to the actions of the other controller:

The [Controllers/FirstController.cs] file:
Let’s break down this code:
- line 6: the [[Route("first")]] attribute, applied to the class, sets the prefix common to all the controller’s routes: its actions handle URL routes that begin with [/first];
- lines 10–11: [[HttpGet("")]]: the action [Index] handles [GET /first]. The URL for an action is the concatenation of the controller prefix and the action path;
- lines 17–18: The action [Action01] processes [GET /first/action01];
- lines 24–25: the same path, but for the POST method: [[HttpPost]] replaces [[HttpGet]]. There are also [[HttpPut]], [[HttpDelete]], [[HttpPatch]]... The name of the method [C#] ([Action01Post]) is arbitrary: only the attributes matter for routing. Furthermore, within the same class, two methods cannot have the same name with the same parameters;
- Lines 31–32: [{id}] is a route parameter: this segment of URL can take on multiple values. [:int] is a constraint: the segment must be an integer; otherwise, the route does not match. The value of the segment is passed to the action’s parameter of the same name, [id], which has already been converted to [int]: for [/first/products/17], [id] is equal to the number 17. This is the template binding ([model binding]), which the next chapter will examine in detail;
- lines 38–39: two route parameters, passed to two parameters of the action;
- Lines 44–46: [{**path}] is a "catch-all" parameter ([catch-all]): it accepts any ending for URL, including [/].
Here are the results for a few queries:
To send a POST request, use the [-X POST] option of [curl]:
A HTTP method that is not handled by any action for this path results in a 405 error ([Method Not Allowed]). The [Allow] header lists the accepted methods:
Finally, a URL that no route handles results in a 404 error. This is also the case for an identifier that does not meet the [:int] constraint: no route matches [/first/products/abc].
The 404 response from [ASP.NET Core] has no content ([Content-Length: 0]): the browser displays its own error page.

Chapter 7 will show how to replace this empty response with a HTML page.
2.3. Example 03: Responses from an action
What an action returns determines the response HTTP. Example 03 reviews the main cases. The file [Controllers/ResponsesController.cs]:
Let’s break down this code:
- lines 9–12: a string is sent as-is, with the header [Content-Type: text/plain; charset=utf-8];
- lines 16–19: the same string, declared as HTML (see Example 02): the browser interprets it;
- lines 23–26: an object is serialized as JSON, with the header [Content-Type: application/json]. [new { lastName = ..., firstName = ... }] creates an object of an anonymous type: a nameless type generated by the compiler, useful for data that is used only once. The property names of JSON are written as “camelCase” (first letter lowercase), following the convention of [JavaScript];
- lines 30–33: An array is also serialized as JSON. [["Mme", "Melle", "Mr"]] is a collection expression ([C#] 12), which here creates an array of strings;
- lines 39–41: when the action needs to select the status code, it returns a [IActionResult]: an object that describes the complete response. The methods of the [Controller] class generate many of these: [StatusCode(201, object)] produces a 201-code response ([Created]) whose content is the object serialized in JSON;
- lines 46–48: [NoContent()] returns a 204 status code ([No Content]): “no content”;
- lines 53–57: An action can be asynchronous ([async]): it then returns a task ([Task<T>]), the equivalent of a promise ([JavaScript]), and [ASP.NET Core] waits for the task to complete before responding. [await Task.Delay(500)] waits 500 ms without blocking the server: while waiting, the thread that was executing the action is returned to the server, which can use it to process other requests. This is how all actions that query a database are written;
- Lines 62–64: An error expected by the program: [NotFound(object)] returns a 404 response with the content JSON. Similarly, there are [BadRequest(...)] (400), [Unauthorized()] (401), [Forbid()] (403), [Ok(...)] (200)...;
- Lines 69–71: an unexpected error: the action throws an exception, which it does not handle. [ASP.NET Core] then responds with a 500 error ([Internal Server Error]).
Responses received:
In this exception, the response does not provide any information about the error: the details could help an attacker. They are logged in the server console (we are only reproducing the beginning here):
In a browser, the difference between [text/plain] and [text/html] is visible:


2.4. Example 04: Redirects
An action may respond with a redirect: a 301 or 302 status code and a [Location] header that tells the browser which URL to request next. The browser requests it immediately, without any user intervention. Redirects are ubiquitous in a web application: after a successful form submission, after logging in, after changing the language...
The [Controllers/RedirectsController.cs] file:
Let’s break down this code:
- lines 9–11: [RedirectPermanent(url)] generates a permanent redirect (status code 301, [Moved Permanently]): the page’s address has permanently changed. Browsers and search engines can cache the new address;
- lines 22–24: [Redirect(url)] generates a temporary redirect (status code 302, [Found]), in this case to a calculated address. [$"/page/{destination}"] is an interpolated string: the expression within curly braces is replaced by its value;
- lines 29–31: [RedirectToAction(action, values)] redirects to an action, not to a URL: [ASP.NET Core] itself generates the URL for the action [Page] based on its route ([page/{name}]) and the provided values ([name = destination]). If the action’s path ever changes, the redirection follows suit. [nameof(Page)] returns the method name (["Page"]): a typo would cause a compilation error, which is not the case with a hand-written string;
- Lines 42–44: You can redirect to another site.
[curl] demonstrates the redirect without following it:
With the option [-L], [curl] follows the redirect, just as a browser would. We then see the two successive responses:
Both actions result in the same redirect: one with a handwritten URL, the other with a URL generated by [ASP.NET Core]. In the browser, only the final page is visible; the address bar displays the new URL:

2.5. Example 05: The Request and Response Objects
Each request is represented by a [HttpContext] object, which contains the request ([Request]) and the response currently being generated ([Response]). An action accesses it directly through the properties [HttpContext], [Request], and [Response] of the [Controller] class. This is sometimes necessary, and we’ll do it in the case study (to read or set a cookie, for example), but you need to know what you’re doing.
The file [Controllers/HttpController.cs]:
Let’s break down this code:
- lines 10–18: the request: the HTTP method ([Request.Method]), the path ([Request.Path]), the parameters of URL ([Request.QueryString]), the headers ([Request.Headers]), and, via [HttpContext.Connection], the client’s address (IP). [?.] is the “null conditional” operator: if [RemoteIpAddress] equals [null], the expression evaluates to [null] instead of causing an error;
- lines 24–27: the action adds a header to the response, then returns its value normally, which [ASP.NET Core] sends. This is the most common use of [Response];
- lines 32–36: The action writes the response itself—status code, type, then the body—using [Response.WriteAsync]. It does not return anything ([Task], not [Task<T>]);
- lines 41–45: the mistake to avoid: writing part of the response and then returning a result. Once the first bytes of the body have been sent to the browser, the status code and headers—which were sent before the body—can no longer be changed.
The responses obtained:
(The [-A] option in [curl] sets the [User-Agent] header, which identifies the browser)
For the last action, the browser receives a 200 status code and the beginning of the response, rather than the requested 404. The server console reports the error:
In the rest of the document, the actions will almost always return a result ([IActionResult]), and let [ASP.NET Core] write the response. [Request] and [Response] will only be used to read the request and set cookies.
2.6. Example 06: Services and Dependency Injection
Controllers should not contain application logic; they delegate it to services. A service is a regular object that [ASP.NET Core] creates and provides to the classes that need it: this is dependency injection. Example 06 breaks down a small application into two parts: products and customers:
2.6.1. The Product
A record: a data class declared in a single line. The compiler creates the properties [Id], [Name], and [Price], a constructor that initializes them, and utility methods (comparison, display). The type [decimal] represents amounts without rounding errors.
2.6.2. The service contract: IProductsService
- Line 4: An interface describes what the service can do, without specifying how. Classes that use the service will only be aware of this interface;
- line 6: [IReadOnlyList<Product>]: a list that the caller can read but not modify;
- line 7: [Product?]: a product, or [null] if no product has this identifier (see [Nullable] in the introduction).
2.6.3. The implementation: ProductsService
- line 4: the class implements the interface: it must provide all of its methods;
- lines 6–11: the data, here in memory, in a list initialized by a collection expression;
- lines 13–15: the two methods, written using the [=>] syntax (a method whose body is a single expression). [FirstOrDefault] ([LINQ]) returns the first result that satisfies the condition, or [null] if there are none.
2.6.4. The ProductsController controller
- Lines 11–13: Dependency injection. The controller declares, in its constructor, that it needs a [IProductsService]; [ASP.NET Core] provides it with the object registered for that type (see below). The controller never calls [new ProductsService()]: it doesn’t even know this class, only the interface. The implementation could be replaced (with a version that reads a database, or with a test version) without modifying the controller;
- lines 17–19: the action delegates to the service. The list is serialized using JSON;
- lines 23–30: [{id:int}] converts the ID; a nonexistent product returns a 404 response. [Ok(object)] returns a 200 response whose content is the object serialized in JSON. [is null] checks whether a value is null.
2.6.5. The ClientsController controller
- line 9: a primary constructor ([C#] 12): the constructor parameter is written directly after the class name. It can be used throughout the class: there’s no need to copy it into a field. This is the most compact form of dependency injection; it will be used throughout the rest of this document. [ClientsController] receives the same service as [ProductsController];
- lines 12–15: it uses it to calculate the total for a shopping cart. [Sum] ([LINQ]) adds up the prices.
2.6.6. Service Registration
[ASP.NET Core] can only provide a service if it knows about it: each service is registered in [builder.Services], along with its lifetime. The file [Products/ProductsExtensions.cs]:
- line 6: a static class, which contains only static methods;
- line 8: an extension method. The keyword [this] before the first parameter allows it to be called as if it were part of that parameter’s type: [builder.Services.AddProducts()]. This is how the [ASP.NET Core] libraries add their services ([AddControllers], [AddAuthentication]...), and it is a good way to group records from a specific part of the application;
- Line 12: “When a class requests a [IProductsService], provide it with a [ProductsService].” [Singleton] is the service’s lifetime: a single instance, created on the first request and shared by all requests. There are two other lifetimes: [Scoped] (one instance per request) and [Transient] (one new instance for each request). Chapter 6 will revisit this topic;
- line 13: the method returns the list of services so that calls can be chained together.
The file [Program.cs]:
- line 1: the namespace of the extension method;
- line 8: the product service record.
Controllers, on the other hand, are not registered individually: [AddControllers] finds them all, and [ASP.NET Core] creates a new controller object for each request, injecting the services it requests into it.
2.6.7. Execution
The case study is structured exactly like this: lightweight controllers that delegate to injected services, described by interfaces and registered in [Program.cs].