Skip to main content
Published / updated

Routing and Controllers

Before you start

You need: dependency injection (Article 04).

Time: about 50 minutes, plus the practice.

Learning objective

Design a route table with no ambiguity, and choose the correct result type and status code for every action outcome.

Topics

  • How routing works
  • Attribute routing
  • Route constraints
  • Conventional routing
  • Controllers and [ApiController]
  • Action results
  • Status codes per outcome
  • Route ambiguity and precedence
  • Diagnosing a 404

How routing works

Routing runs in two stages, and the split explains the pipeline order from article 2.

app.UseRouting(); // matches the URL to an endpoint, stores it on HttpContext

app.UseAuthentication();
app.UseAuthorization(); // reads the endpoint's [Authorize] metadata

app.MapControllers(); // executes the selected endpoint

UseRouting selects; MapControllers executes. Everything between them can inspect the chosen endpoint — which is why UseAuthorization must sit after UseRouting to see the action's [Authorize] attributes, and why CORS policies attached to an endpoint work only in that gap.

Attribute routing

The default for APIs.

[ApiController]
[Route("api/[controller]")]
public class StudentsController : ControllerBase
{
[HttpGet] // GET /api/students
public async Task<IActionResult> Search() { }

[HttpGet("{publicId:guid}")] // GET /api/students/{publicId}
public async Task<IActionResult> GetById(Guid publicId) { }

[HttpPost] // POST /api/students
public async Task<IActionResult> Create(StudentCreateRequest request) { }

[HttpPut("{publicId:guid}")] // PUT /api/students/{publicId}
public async Task<IActionResult> Update(Guid publicId, StudentUpdateRequest request) { }

[HttpDelete("{publicId:guid}")] // DELETE /api/students/{publicId}
public async Task<IActionResult> Delete(Guid publicId) { }

[HttpGet("{publicId:guid}/results")] // GET /api/students/{publicId}/results
public async Task<IActionResult> GetResults(Guid publicId) { }
}

[controller] is replaced by the class name minus the Controller suffix — StudentsController becomes students. Renaming the class silently changes the URL, so prefer the literal on a public API:

[Route("api/students")]

A route starting with / or ~/ ignores the controller-level prefix:

[HttpGet("~/api/health")] // /api/health, not /api/students/health

Versioning

[Route("api/v{version:apiVersion}/students")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class StudentsController : ControllerBase

Version from the start on anything external. Adding versioning after clients exist means either breaking them or maintaining an unversioned route forever.

Route constraints

[HttpGet("{id:int}")] // integers only
[HttpGet("{publicId:guid}")] // GUIDs only
[HttpGet("{code:alpha}")] // letters only
[HttpGet("{year:int:range(2000,2100)}")] // integer in a range
[HttpGet("{term:minlength(2)}")] // at least 2 characters
[HttpGet("{slug:regex(^[a-z0-9-]+$)}")] // pattern
[HttpGet("{page:int?}")] // optional
[HttpGet("{page:int=1}")] // with a default

A constraint does two jobs: it rejects a non-matching URL with 404 before your code runs, and it disambiguates otherwise-identical routes.

[HttpGet("{id:int}")] // /api/students/42
public async Task<IActionResult> GetById(int id) { }

[HttpGet("{rollNumber}")] // /api/students/NCA-2024-0012
public async Task<IActionResult> GetByRollNumber(string rollNumber) { }

Without :int on the first, both routes match /api/students/42 and startup fails with an ambiguity error.

Prefer {publicId:guid} over {id:int} in public URLs. Sequential integers let anyone enumerate records by incrementing — the route constraint is not the security control, but a GUID removes the guessability, and the tenant check in the query does the rest.

Conventional routing

Used by MVC web applications.

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
name: "students",
pattern: "students/{action=Index}/{id?}",
defaults: new { controller = "Students" });
public class StudentsController : Controller
{
public IActionResult Index() { } // /students
public IActionResult Details(int id) { } // /students/details/42
public IActionResult Create() { } // /students/create
}

Routes are matched in registration order, and the first match wins — so a general pattern registered before a specific one swallows it. Register specific routes first.

Attribute routing and conventional routing coexist, but a controller cannot use both. An [Route] attribute on a controller opts it out of conventional routing entirely.

Controllers

[ApiController]
[Route("api/students")]
[Produces("application/json")]
public class StudentsController : ControllerBase
{
private readonly IStudentService _studentService;
private readonly ILogger<StudentsController> _logger;

public StudentsController(IStudentService studentService, ILogger<StudentsController> logger)
{
_studentService = studentService;
_logger = logger;
}
}

ControllerBase for APIs; Controller adds view support for MVC. Using Controller in an API pulls in view machinery you never use.

What [ApiController] does

[ApiController]

Four behaviours, and they change how you write actions:

Automatic 400 on invalid model state. You never write if (!ModelState.IsValid) return BadRequest(ModelState); — the framework does it before the action runs, returning a ValidationProblemDetails body listing each failing field.

Binding source inference. Complex types bind from the body, simple types from the route or query — so [FromBody] is usually unnecessary.

ProblemDetails for error status codes, giving a consistent RFC 7807 error shape.

Attribute routing required. A controller with [ApiController] and no route attribute fails at startup.

Use it on every API controller. Not using it means writing model-state checks in every action and forgetting them in one.

Action results

[HttpGet("{publicId:guid}")]
[ProducesResponseType(typeof(StudentDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<StudentDetailDto>> GetById(Guid publicId, CancellationToken ct)
{
var schoolId = User.GetSchoolId();

var student = await _studentService.GetByPublicIdAsync(schoolId, publicId, ct);

if (student is null)
{
return NotFound();
}

return Ok(student);
}

ActionResult<T> is the best return type: the framework knows the success shape for Swagger and for [ProducesResponseType], and you can still return any status.

Return typeUse
ActionResult<T>Preferred — typed success plus any status
IActionResultWhen the success type varies
TAlways 200 — no way to return 404
Task<...>Always, for anything doing I/O
HelperStatus
Ok(value)200
Created(uri, value)201
CreatedAtAction(name, routeValues, value)201 with a Location header
NoContent()204
BadRequest(error)400
Unauthorized()401
Forbid()403
NotFound()404
Conflict(error)409
UnprocessableEntity(error)422
StatusCode(500, value)Any
ValidationProblem(ModelState)400 with field errors
Problem(detail, statusCode)ProblemDetails
File(bytes, contentType, name)A download

Status codes per outcome

[HttpPost]
[ProducesResponseType(typeof(StudentDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<StudentDto>> Create(
StudentCreateRequest request, CancellationToken ct)
{
var schoolId = User.GetSchoolId();

try
{
var created = await _studentService.CreateAsync(schoolId, request, ct);

return CreatedAtAction(
nameof(GetById),
new { publicId = created.PublicId },
created);
}
catch (DuplicateRollNumberException ex)
{
return Conflict(new ProblemDetails
{
Title = "Duplicate roll number",
Detail = ex.Message,
Status = StatusCodes.Status409Conflict
});
}
}

CreatedAtAction sets the Location header to the new resource's URL, which is what 201 is meant to carry. It resolves the URL from the action name, so nameof(GetById) stays correct if the route changes.

[HttpPut("{publicId:guid}")]
public async Task<IActionResult> Update(
Guid publicId, StudentUpdateRequest request, CancellationToken ct)
{
var updated = await _studentService.UpdateAsync(User.GetSchoolId(), publicId, request, ct);

if (!updated)
{
return NotFound();
}

return NoContent();
}

[HttpDelete("{publicId:guid}")]
public async Task<IActionResult> Delete(Guid publicId, CancellationToken ct)
{
var deleted = await _studentService.DeactivateAsync(User.GetSchoolId(), publicId, ct);

if (!deleted)
{
return NotFound();
}

return NoContent();
}

204 for a successful update or delete with no body, not 200 with an empty object. A client calling .json() on a 200 with no body gets a parse error.

401 versus 403

return Unauthorized(); // 401 — we do not know who you are
return Forbid(); // 403 — we know exactly who you are, and no

401 means authenticate. 403 means authenticated and not permitted — logging in again changes nothing.

Return 404 rather than 403 for another tenant's record. A 403 confirms the record exists; a 404 reveals nothing:

var student = await _studentService.GetByPublicIdAsync(schoolId, publicId, ct);

if (student is null)
{
return NotFound(); // does not exist, or belongs to another school — same answer
}

Route ambiguity

[HttpGet("{id}")]
public IActionResult GetById(string id) { }

[HttpGet("{rollNumber}")]
public IActionResult GetByRoll(string rollNumber) { }
AmbiguousMatchException: The request matched multiple endpoints.

Both templates are identical once the parameter names are erased. Fix with a constraint or a distinct segment:

[HttpGet("{id:int}")]
[HttpGet("by-roll/{rollNumber}")]

Precedence, when several routes match:

  1. Literal segments beat parameters — students/active beats students/{id}
  2. Constrained parameters beat unconstrained — {id:int} beats {id}
  3. Parameters beat catch-alls — {id} beats {**path}
[HttpGet("active")] // matched for /api/students/active
[HttpGet("{id:int}")] // matched for /api/students/42

Literal wins, so the order of the attributes does not matter here — unlike conventional routing, where registration order decides.

Generating URLs

var url = Url.Action(nameof(GetById), "Students", new { publicId });
var absolute = Url.Action(nameof(GetById), "Students", new { publicId }, Request.Scheme);

return RedirectToAction(nameof(Index));
[HttpGet("{publicId:guid}", Name = "GetStudentById")]
public async Task<IActionResult> GetById(Guid publicId) { }

var url = Url.Link("GetStudentById", new { publicId });

Never hardcode a URL. Url.Action and Url.Link build it from the route table, so a route change updates every generated link.

Diagnosing a 404

Work in this order.

1. List every registered endpoint.

if (app.Environment.IsDevelopment())
{
app.MapGet("/routes", (IEnumerable<EndpointDataSource> sources) =>
string.Join("\n", sources
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.Select(e => $"{string.Join(",", e.Metadata
.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? new[] { "*" })} " +
$"/{e.RoutePattern.RawText}")));
}

That endpoint answers most routing questions in seconds. If your route is not in the list, it was never registered.

2. Check the obvious causes.

CauseCheck
Route not registeredMissing MapControllers()
Wrong HTTP verb[HttpGet] on an endpoint you are POSTing to
Constraint rejects the value{id:int} with a GUID in the URL
Missing [ApiController] routeA controller with [ApiController] and no [Route] fails at startup
Controller not publicOr missing the Controller suffix with conventional routing
Case in a literal segmentRoute matching is case-insensitive; a case-sensitive file system is not
Trailing slash or a nested Web.configBehind IIS

3. Enable routing logs.

{
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Routing": "Debug"
}
}
}

The log shows candidate endpoints and why each was rejected, which is decisive when a constraint is silently failing.

Errors you will hit

MessageCauseFix
AmbiguousMatchExceptionTwo actions match the same routeMake the templates distinct
404 on a route you can see in the codeMissing [ApiController]/[Route], or wrong HTTP verbCheck both
id arrives as 0Route parameter name does not match the argumentMatch them exactly
405 Method Not AllowedRight URL, wrong verbCheck [HttpGet] versus [HttpPost]
A breakpoint in the action never hitsRouting or binding failed firstCheck the route and the payload
InvalidOperationException: No route matches the supplied valuesCreatedAtAction names an action that does not existUse nameof

A breakpoint that never hits usually means the request never reached your action. That is a routing or binding problem, not a logic one.

Common mistakes

  • [controller] in the route, so renaming the class breaks the URL
  • No constraint, causing ambiguous routes
  • Sequential integer ids in public URLs
  • Returning T instead of ActionResult<T>, so 404 is impossible
  • 200 with an empty body where 204 belongs
  • 403 for another tenant's record, confirming it exists
  • Confusing 401 and 403
  • No CancellationToken parameter
  • Hardcoded URLs instead of Url.Action
  • Missing [ApiController], then forgetting a model-state check
  • No versioning on a public API
  • Registering a general conventional route before a specific one

Practice

  1. Build StudentsController with all five CRUD actions and correct status codes.
  2. Add a /routes diagnostic endpoint and list everything registered.
  3. Create two routes that differ only in parameter name. Record the startup exception, then fix it with a constraint.
  4. Add {id:int} and by-roll/{rollNumber} routes and confirm each matches only its own shape.
  5. Request /api/students/not-a-guid against {publicId:guid}. Confirm 404 before the action runs.
  6. Return T instead of ActionResult<T> and try to return 404. Record the compile error.
  7. Return Ok() with no body for a delete. Call it from JavaScript with .json() and record the error. Change to NoContent().
  8. Use CreatedAtAction on POST and confirm the Location header in the Network tab.
  9. Return Forbid() for another school's record, then NotFound(). Explain which leaks information.
  10. Remove [ApiController], POST an invalid body, and confirm the action runs with invalid state.
  11. Add it back and confirm the automatic 400 with a field-level errors object.
  12. Enable Microsoft.AspNetCore.Routing debug logging and read why a mistyped URL was rejected.
  13. Generate a URL with Url.Action, then change the route template and confirm the generated URL follows.

Exercises 7 and 9 correspond to two real client-facing defects.

You can now

  • Design an unambiguous route table
  • Match route parameter names to action arguments
  • Choose the correct IActionResult for each outcome
  • Return CreatedAtAction with a route that resolves
  • Diagnose a 404 or 405 from the route table

Review questions

  1. Why must UseRouting come before UseAuthorization?
  2. What four behaviours does [ApiController] add?
  3. Why return 404 rather than 403 for another tenant's record?
  4. Why is ActionResult<T> better than returning T?

Next: MVC and Razor Pages