C# · STEP 3 · ROUTING

C# · ASP.NET Core / Entity Framework Core: setup and routing

Scaffold an ASP.NET Core 10 Web API with controllers, read the generated Program.cs, then write controller actions reached by conventional routes and by attribute routes, reading URL params, query strings and JSON bodies.

Step 3 of 5ASP.NET Core / Entity Framework Corev10.0© Arthur Bernier Jrrewritten · verified10 code blocks1168 words

What you will learn#

  • What is an Opinionated Framework?
  • How to start up a new project
  • How to create a controller and actions
  • How to connect controller actions to routes

Prerequisites#

  • .NET 10 SDK installed from dotnet.microsoft.com/download (dotnet --version prints 10.0.x)
  • Postgres installed (not used until the models lesson; any supported version works)
  • VS Code with the C# Dev Kit extension, or any editor
  • Postman or curl for making requests

What is an opinionated framework?#

Most web frameworks for building full-stack websites or APIs/microservices fall into one of two categories.

  • Minimalist: Like Express, these frameworks provide the basic features to create a web server, leaving it up to you to decide which libraries to use for databases, encryption, authentication, etc., and how to structure your files and folders.

  • Opinionated: These frameworks usually come with CLI tools that spin up new projects with a pre-defined structure, conventions and all the libraries for full functionality already built in.

The main web frameworks across many languages:

LanguageMinimalistOpinionated
JavaScriptExpress, Koa, HonoNestJS, AdonisJS
PythonFlask, FastAPIDjango
RubySinatraRuby on Rails
PHPSlimLaravel, Symfony
Gochi, net/http, Echo—
RustAxum, Actix WebLoco
JavaJavalinSpring Boot
C#ASP.NET Core minimal APIsASP.NET Core MVC
DartshelfServerpod
KotlinKtor, JavalinSpring Boot
SwiftHummingbirdVapor

ASP.NET Core is both: the same framework gives you minimal APIs (app.MapGet("/", () => "hi") in one file, Express-style) and MVC controllers (classes, attributes, conventions). We use controllers here because they map most directly onto Rails, Django and Spring; routing and binding work the same way in minimal APIs.

Setting Up the Project#

  • To create a new Web API project with controllers:
SH
dotnet new webapi --use-controllers -o firstdotnetcd firstdotnet

WHY --use-controllers: since .NET 8, plain dotnet new webapi generates a minimal API (one Program.cs, no Controllers folder). The flag gives you the controller layout this lesson uses.

  • Look at what was generated: firstdotnet.csproj (the project file — package references go here), Program.cs (startup), Controllers/WeatherForecastController.cs (a sample controller), appsettings.json / appsettings.Development.json (configuration) and Properties/launchSettings.json (dev-server ports and environment).

  • Start the server with dotnet run. The terminal prints Now listening on: http://localhost:5xxx — the port is picked per project and lives in launchSettings.json. Open that URL plus /weatherforecast and you get JSON. In Development the template also serves an OpenAPI description of your routes at /openapi/v1.json (the Swagger UI from older templates is no longer bundled; Postman can import that file).

  • Ctrl+C stops the server. dotnet watch runs it with hot reload so you do not have to restart after every edit.

Creating a Controller#

A controller is a class whose methods (actions) are triggered by routes: combinations of URL and HTTP verb. The starting point of the application is Program.cs. There is no Startup.cs any more — the builder pattern does both jobs in one top-level file: register services on builder.Services, build the app, describe the request pipeline on app.

Make sure to add the MapControllerRoute call for pattern routing below:

Program.cs

C#
var builder = WebApplication.CreateBuilder(args); // Add services to the container.builder.Services.AddControllers();   // collects all our controllers for routingbuilder.Services.AddOpenApi();       // generates /openapi/v1.json var app = builder.Build(); // Configure the HTTP request pipeline.if (app.Environment.IsDevelopment()){    app.MapOpenApi();} app.UseHttpsRedirection(); app.UseAuthorization(); // Attribute routing: controllers that carry [Route] / [HttpGet] attributesapp.MapControllers(); // Pattern (conventional) routing: URL segments pick the controller and actionapp.MapControllerRoute(    name: "default",    pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run();

Note there is no using block and no namespace — the Web SDK turns on implicit usings, so WebApplication and friends are already in scope.

Pattern Routing#
  • This line sets up a style of routing called conventional (or pattern) routing:
C#
app.MapControllerRoute(    name: "default",    pattern: "{controller=Home}/{action=Index}/{id?}");

So when a request comes in, the server assumes the first part of the URL is the controller name (first → FirstController; the Controller suffix is implied), the second is the method of the controller, and optionally the third part is saved in a route value called id that an action can receive as a parameter. =Home and =Index are the defaults when a segment is missing; ? makes id optional.

In the Controllers folder create a FirstController.cs file with the following.

Controllers/FirstController.cs

C#
using Microsoft.AspNetCore.Mvc; namespace firstdotnet.Controllers; public class FirstController : Controller{    // This route would match /first/first    public Dictionary<string, string> First()    {        return new Dictionary<string, string> { ["Result"] = "You Made a Route" };    }}

Whatever object an action returns is serialised to JSON by System.Text.Json, so the dictionary comes out as {"Result":"You Made a Route"}. The namespace firstdotnet.Controllers; line with a semicolon is a file-scoped namespace — it covers the whole file without wrapping everything in braces.

  • We can now test our app by running dotnet run and visiting http://localhost:5xxx/first/first (use the port printed in your terminal).

Getting the URL Param#

We just need to give the action a parameter with the same name as the route token (id) and model binding fills it in.

C#
// This route would match /first/second/{id}public Dictionary<string, string> Second(string id){    return new Dictionary<string, string> { ["PARAM"] = id };}
  • Restart your server (or let dotnet watch do it) and visit http://localhost:5xxx/first/second/something in the browser.

Getting a URL Query#

  • Add this to your controller
C#
// This would match /first/third?query={anything}public Dictionary<string, string> Third(string? query){    return new Dictionary<string, string> { ["query"] = query ?? "no query" };}
  • Restart your server and go to http://localhost:5xxx/first/third?query=itworks

Model binding looks for a value named query in the route values first, then the query string, then form fields. The parameter is string? because the query may be missing, and ?? supplies the fallback.

Attribute Routing#

Conventional routing is what MVC websites use; the docs recommend attribute routing for REST APIs — URL and verb declared right on the class and method. Create another controller class, SecondController.cs:

Controllers/SecondController.cs

C#
using Microsoft.AspNetCore.Mvc; namespace firstdotnet.Controllers; [ApiController][Route("theurl")] // defines the URL prefix for every action in the controllerpublic class SecondController : ControllerBase{    [HttpGet] // GET request to /theurl    public Dictionary<string, string> First()    {        return new Dictionary<string, string> { ["Result"] = "You Made a Route" };    }}

Two differences from FirstController: it derives from ControllerBase (Controller minus the view-rendering helpers an API never uses), and [ApiController] switches on API conventions — attribute routing becomes required, validation errors return a 400 automatically, and complex parameters are read from the request body without you saying so.

  • Restart your server and go to http://localhost:5xxx/theurl

Adding a Param to an Attribute Route#

Add this to SecondController

C#
[HttpGet("{urlParam}")] // GET request to /theurl/{urlParam}public Dictionary<string, string> Second(string urlParam){    return new Dictionary<string, string> { ["Result"] = urlParam };}
  • Restart your server and go to http://localhost:5xxx/theurl/anotherparam in the browser

The method's template is appended to the controller's: theurl + {urlParam}. You can constrain it — [HttpGet("{id:int}")] only matches digits and binds an int, so a non-numeric id is a 404 at the router instead of an exception in your code.

Getting the Request Body#
C#
using System.Text.Json; // add at the top of the file [HttpPost] // POST request to /theurlpublic JsonElement Third([FromBody] JsonElement body){    return body; // echo the JSON back}
  • Restart your server and use Postman (or curl) to make a POST request to /theurl with a JSON body and the Content-Type: application/json header; the same JSON comes back.
SH
curl -X POST http://localhost:5xxx/theurl \  -H "Content-Type: application/json" \  -d '{"hello": "body"}'

JsonElement is "any JSON value" from System.Text.Json. Tomorrow the body binds to a model class instead (Turtle turtle), which is what a real API wants: the framework validates the shape and you get typed properties.

  • Time to head to lab

Resources to Learn More#


See also