Skip to main content

Routing in ASP.NET Core: Templates, Constraints, Query Strings – Web API | Lesson 72

Published / updated

Introduction​

Routing decides which code handles a request. When GET /api/classes/10/sections/A/students arrives, routing matches it to an action, pulls out 10 and A as values, and only then does your code run.

So far, SchoolApp.Api has two simple routes. This lesson adds a paged list of all 10,012 students and a nested route for class lists, and explains every part of a route template along the way.

By the end of this lesson, you will be able to:

  • Read and write route templates with literal segments and parameters.
  • Combine a controller's route with each action's route.
  • Restrict parameters with route constraints, and explain why they give 404, not 400.
  • Read optional values from the query string with [FromQuery], with defaults and validation.
  • Design nested routes for related resources, and a paging response.

Why this matters​

Routes are the public face of an API. Once clients use them, changing them breaks those clients, so they are worth designing carefully the first time.

Routing also protects the code behind it. A constraint that rejects classes/ten before the action runs means the action never has to deal with nonsense, and paging stops a client from asking for 10,012 students in one response.

The concept​

Example: route templates​

A route template is a pattern for paths. Fixed words are literal segments; names in braces are route parameters, filled from the path:

Template pieceExampleMatches
Literalapi/studentsExactly that text (letters in any case)
Parameter{admissionNumber}Any one segment; the value goes to the parameter
Constrained parameter{classId:int}One segment that is a whole number
Optional parameter{sectionName?}The segment may be missing
Default value{page=1}Uses 1 when the segment is missing
Catch-all{*path}Everything that is left, slashes included

Think of a building's address board: "Building 10, Floor A, Students room". The template is the pattern of the address, and the parameters are the numbers on the doors.

Example: controller and action routes combine​

A controller's [Route("api/classes")] is a prefix. Each action's [HttpGet("...")] adds to it:

Controller routeAction routeFull route
api/students[HttpGet]GET api/students
api/students[HttpGet("{admissionNumber}")]GET api/students/{admissionNumber}
api/classes[HttpGet("{classId:int:range(1,10)}/sections/{sectionName:length(1,2)}/students")]GET api/classes/{classId}/sections/{sectionName}/students

Many projects write [Route("api/[controller]")], where [controller] becomes the class name without "Controller". SchoolApp writes routes out in full instead, lowercase and with hyphens between words, such as api/academic-years, so a renamed class can never change a public address by accident.

Example: route constraints​

A route constraint limits what a parameter matches:

ConstraintMatchesSMS use
intWhole numbersclassId
range(1,10)Numbers in a rangeClasses 1 to 10
length(1,2)Text of that many charactersSection names A to V
minlength(n), maxlength(n)Text at least or at most n characters
alphaLetters only
guid, bool, datetimeValues of that type
regex(...)A regular expressionUse sparingly; hard to read

Constraints are for matching, not for validation. When a constraint fails, the route simply does not match, and the client gets 404 Not Found with no reason. For "this value is wrong, here is why", use validation, which answers 400 Bad Request (Lesson 73).

Example: path or query string​

Use the path forUse the query string for
Identifying a resource: /api/students/ADM-2017-0003Options on a list: ?page=2&pageSize=20
Required valuesOptional values with defaults
Hierarchy: /api/classes/10/sections/A/studentsFilters and sorting: ?category=Fiction

A good test: if the value changes which thing you get, it belongs in the path; if it changes how you get a list of things, it belongs in the query string.

Apply it to the SMS​

Step 1: a query interface for lists​

List screens need DTOs and pages, not entities with rules. They get their own small interface in Core:

src/SchoolApp.Core/Interfaces/IStudentQueries.cs
using SchoolApp.Core.Common;
using SchoolApp.Core.Dtos;

namespace SchoolApp.Core.Interfaces;

// Lesson 72: read-only student queries for list screens. They return DTOs, not entities, and are kept
// apart from IStudentRepository, which loads and saves entities with rules (Lesson 62).
public interface IStudentQueries
{
// One page, sorted by last name, first name, and Id.
Task<PagedResult<StudentDto>> GetPageAsync(int page, int pageSize, CancellationToken cancellationToken = default);

// The current year's class list for one section, from dbo.usp_GetClassList.
Task<List<ClassListEntryDto>> GetClassListAsync(int classId, string sectionName, CancellationToken cancellationToken = default);
}

EfStudentRepository already has both methods, from Lessons 59 and 61; it now declares IStudentQueries too, and AddSchoolData registers it:

src/SchoolApp.Data/DependencyInjection.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SchoolApp.Core.Interfaces;
using SchoolApp.Data.EfCore;

namespace SchoolApp.Data;

// Lesson 69: one call registers everything SchoolApp.Data offers, so the API's Program.cs does not need
// to know the classes inside this project: builder.Services.AddSchoolData(connectionString).
public static class DependencyInjection
{
public static IServiceCollection AddSchoolData(this IServiceCollection services, string connectionString)
{
// Singleton: one SqlConnectionFactory for the whole app; it only holds the connection string.
services.AddSingleton(new SqlConnectionFactory(connectionString));

// Scoped (AddDbContext's default): one SchoolDbContext per request, shared by everything in it.
services.AddDbContext<SchoolDbContext>(options => options.UseSqlServer(connectionString));

// Scoped: repositories use the request's DbContext, so they live exactly as long as it does.
services.AddScoped<IStudentRepository, EfStudentRepository>();
services.AddScoped<IStudentQueries, EfStudentRepository>(); // Lesson 72
services.AddScoped<IFeeInvoiceRepository, EfFeeInvoiceRepository>();

return services;
}
}

Separating reads for screens from loading and saving entities is a small step toward CQRS (command and query responsibility segregation), which Module 13 discusses for larger systems.

Step 2: the paged list​

src/SchoolApp.Api/Controllers/StudentsController.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SchoolApp.Api.Options;
using SchoolApp.Core.Common;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;

namespace SchoolApp.Api.Controllers;

// Lesson 68: the first SMS endpoint backed by SchoolDb. The controller only translates HTTP:
// the route value goes into StudentService, and the result comes back as 200 or 404.
// Lesson 71: /// comments and Produces... attributes describe the endpoint in the OpenAPI document.
// Lesson 72: a paged list at GET /api/students, with page and pageSize from the query string.
[ApiController]
[Route("api/students")]
[Produces("application/json")]
public class StudentsController : ControllerBase
{
private readonly StudentService _students;
private readonly IStudentQueries _queries;
private readonly SchoolOptions _school;

// ASP.NET Core passes in these services for each request (dependency injection, Lesson 69).
public StudentsController(StudentService students, IStudentQueries queries, IOptions<SchoolOptions> school)
{
_students = students;
_queries = queries;
_school = school.Value;
}

// GET /api/students?page=2&pageSize=20
/// <summary>Gets one page of students, sorted by last name, first name, and Id.</summary>
/// <param name="page">The page number, starting at 1.</param>
/// <param name="pageSize">Students per page, 1 to 100. Leave it out for the school's default.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The page, with the total count for page numbers.</response>
/// <response code="400">The page or page size is out of range.</response>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedResult<StudentDto>>> GetPage(
[FromQuery, Range(1, int.MaxValue)] int page = 1,
[FromQuery, Range(1, Paging.MaxPageSize)] int? pageSize = null,
CancellationToken cancellationToken = default)
{
return await _queries.GetPageAsync(page, pageSize ?? _school.DefaultPageSize, cancellationToken);
}

// GET /api/students/ADM-2017-0003
// ActionResult<StudentDto>: either a StudentDto (200 OK) or another result, such as 404.
/// <summary>Gets one student by admission number.</summary>
/// <param name="admissionNumber">The student's admission number, such as ADM-2017-0003.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The student.</response>
/// <response code="404">No student has this admission number.</response>
[HttpGet("{admissionNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)] // the body type comes from ActionResult<StudentDto>
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<StudentDto>> GetByAdmissionNumber(string admissionNumber, CancellationToken cancellationToken)
{
StudentDto? student = await _students.FindAsync(admissionNumber, cancellationToken);
if (student is null)
{
return NotFound();
}
return student; // converted to Ok(student)
}
}

[HttpGet] with no template gives GET /api/students. [FromQuery] reads page and pageSize from the query string. page defaults to 1, and pageSize defaults to null, which the action replaces with the school's default page size from Lesson 70.

[Range(...)] on the parameters is validation. With [ApiController], a value out of range never reaches the action: the client gets a 400 that says which parameter was wrong.

Step 3: the nested class-list route​

src/SchoolApp.Api/Controllers/ClassesController.cs
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Controllers;

// Lesson 72: a nested route. The students of a section of a class:
// GET /api/classes/10/sections/A/students. Route constraints reject impossible values before the action runs.
[ApiController]
[Route("api/classes")]
[Produces("application/json")]
public class ClassesController : ControllerBase
{
private readonly IStudentQueries _queries;

public ClassesController(IStudentQueries queries)
{
_queries = queries;
}

/// <summary>Gets the current year's class list for one section, in roll number order.</summary>
/// <param name="classId">The class, 1 to 10.</param>
/// <param name="sectionName">The section, such as A.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The students, in roll number order.</response>
/// <response code="404">No such class or section, or nobody is enrolled in it.</response>
// int: whole numbers only. range(1,10): classes 1 to 10. length(1,2): section names A to V.
[HttpGet("{classId:int:range(1,10)}/sections/{sectionName:length(1,2)}/students")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ClassListEntryDto>>> GetSectionStudents(
int classId, string sectionName, CancellationToken cancellationToken)
{
List<ClassListEntryDto> students = await _queries.GetClassListAsync(classId, sectionName, cancellationToken);
if (students.Count == 0)
{
return NotFound();
}
return students;
}
}

The route reads like the school itself: class 10, its section A, that section's students. The constraints stop impossible values before the database is touched: there is no class 11, and no section called ABC.

Step 4: send the requests​

src/SchoolApp.Api/SchoolApp.Api.http
# Lesson 66: requests you can send from Visual Studio or VS Code (REST Client):
# click "Send Request" above each one. ### separates requests.
@SchoolApp.Api_HostAddress = http://localhost:5080

GET {{SchoolApp.Api_HostAddress}}/api/school
Accept: application/json

###

# Lesson 71: the OpenAPI document. Open http://localhost:5080/scalar in a browser to read it as a page.
GET {{SchoolApp.Api_HostAddress}}/openapi/v1.json

###

# Lesson 68: a student from SchoolDb, from the controller and from the minimal API.
GET {{SchoolApp.Api_HostAddress}}/api/students/ADM-2017-0003

###

GET {{SchoolApp.Api_HostAddress}}/api/students/ADM-1999-0001

###

# Lesson 69: which objects dependency injection created. Send it twice and compare the ids.
GET {{SchoolApp.Api_HostAddress}}/api/diagnostics/lifetimes

###

# Lesson 72: paging with the query string, and a nested route with constraints.
GET {{SchoolApp.Api_HostAddress}}/api/students?page=3&pageSize=5

###

GET {{SchoolApp.Api_HostAddress}}/api/students?page=0

###

GET {{SchoolApp.Api_HostAddress}}/api/classes/10/sections/A/students

###

GET {{SchoolApp.Api_HostAddress}}/api/classes/11/sections/A/students

###

The third page of five students:

Output
HTTP/1.1 200 OK
{"items":[{"id":634,"admissionNumber":"ADM-2026-00622","fullName":"Bhavya Ali","dateOfBirth":"2020-09-14","gender":"Female","status":"Active"},{"id":694,"admissionNumber":"ADM-2026-00682","fullName":"Bhavya Ali","dateOfBirth":"2020-11-13","gender":"Female","status":"Active"},{"id":754,"admissionNumber":"ADM-2026-00742","fullName":"Bhavya Ali","dateOfBirth":"2020-01-13","gender":"Female","status":"Active"},{"id":814,"admissionNumber":"ADM-2026-00802","fullName":"Bhavya Ali","dateOfBirth":"2020-03-13","gender":"Female","status":"Active"},{"id":874,"admissionNumber":"ADM-2026-00862","fullName":"Bhavya Ali","dateOfBirth":"2020-05-12","gender":"Female","status":"Active"}],"page":3,"pageSize":5,"totalCount":10012,"totalPages":2003,"hasNextPage":true}

These are the same five students as Lesson 61's paging demo: the API reuses the same query. Without pageSize, the response had 20 items, totalPages 501, from the default in appsettings.json.

Example: a paging response​

The response is PagedResult<StudentDto> from Lesson 48, as JSON. Next to the items, it tells the client everything needed to draw page buttons: the page, the page size, the total count, the total pages, and whether there is a next page.

Its computed properties, TotalPages and HasNextPage, became JSON fields too. Every SchoolApp list endpoint uses this shape, so a client writes its paging code once.

Example: three kinds of 400​

Output
GET /api/students?page=0
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"page":["The field page must be between 1 and 2147483647."]},"traceId":"00-51e5daec997f0367a44335810ce65e16-346f123bff8f7310-00"}

GET /api/students?pageSize=500
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"pageSize":["The field pageSize must be between 1 and 100."]},"traceId":"00-a68c5d5bf670ef2dba6fea111a720cd2-ddf66a083139f4e5-00"}

GET /api/students?page=abc
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"page":["The value 'abc' is not valid."]},"traceId":"00-49cc694194c7d877fd77e34d8899e66b-08773c7af43acdb4-00"}

All three are ValidationProblemDetails: ProblemDetails plus an errors object that names each bad parameter. abc failed earlier than the others: it could not even be converted to a number, which ASP.NET Core calls a model binding error.

Example: constraints give 404​

RequestStatusWhy
/api/classes/10/sections/A/students200, Aarav, Diya, Kabir, MeeraMatches; four students in 10-A
/api/classes/10/sections/a/students200, the same fourSQL Server compares section names without case
/api/classes/11/sections/A/students404, empty bodyrange(1,10) failed: no route matched
/api/classes/ten/sections/A/students404, empty bodyint failed
/api/classes/10/sections/ABC/students404, empty bodylength(1,2) failed
/api/classes/10/sections/Z/students404, ProblemDetails bodyThe route matched; the action found nobody and returned NotFound()

Compare the empty 404s with the last row. A failed constraint means no action ran at all, so the 404 is empty.

A 404 from the action comes through [ApiController], with a ProblemDetails body. Lesson 74 makes both look the same.

Example: when two routes could match​

What if one action had {admissionNumber} and another had {id:int}, both under api/students? A request for /api/students/3 fits both templates. ASP.NET Core does not pick at random: more specific templates win, so a literal beats a parameter, and a constrained parameter beats an unconstrained one.

When two templates are equally specific, the request fails with an "ambiguous match" error, a 500. That is a design bug to fix in the routes, not something to catch. SchoolApp avoids it by giving each route a distinct shape.

Example: how deep to nest​

Nesting shows ownership: sections belong to classes, students are listed per section. But every level makes the address longer and harder to use.

A common rule is at most two levels of parent, as here: class, then section. A resource with its own unique key, such as a student's admission number, gets its own short route, /api/students/{admissionNumber}, however it was found.

Common mistakes​

  1. Using constraints as validation. Clients get a bare 404 and no reason. Fix: constraints for matching shapes; validation attributes for rules.
  2. [controller] tokens with renamed classes. A refactor changes the public URL. Fix: write routes in full.
  3. Unpaged lists. One request loads 10,012 rows. Fix: page every list, with a maximum page size.
  4. Filters in the path, such as /api/books/category/Fiction. It confuses identity with options. Fix: /api/books?category=Fiction.
  5. Deep nesting. /api/years/1/classes/10/sections/A/students/3/marks is hard to use and to change. Fix: at most two parent levels; give uniquely keyed resources their own route.

In real projects​

From 16 years of .NET projects

An attendance app called GET /api/students to fill a drop-down list. On a small school it was fine. On the first large school, every page load downloaded nine thousand students, and the app took eleven seconds to open.

The endpoint had no paging and no filter, so the client did the only thing it could. We added paging with a maximum page size, plus a sectionId filter, and the screen asked only for its own section.

That endpoint had been a design decision nobody made. Since then, every list endpoint I review must answer two questions: what is the maximum it can return, and how does a client ask for less?

Best practices​

  • Write routes in full: lowercase, plural nouns, hyphens between words.
  • Use constraints to match the shape of a value; use validation to explain wrong values.
  • Put identity in the path, and options such as paging and filters in the query string.
  • Page every list, return the total count, and enforce a maximum page size.
  • Nest at most two parent levels.

Recap​

  • A route template has literal segments and parameters; controller and action routes combine.
  • Constraints such as int, range, and length decide whether a route matches; failing gives 404.
  • [FromQuery] reads optional values, with defaults and [Range] validation that gives 400.
  • GET /api/students returns a PagedResult<StudentDto> with everything a client needs for paging.
  • Nested routes show ownership: /api/classes/10/sections/A/students.

Homework​

Library module task: In a single-file library API reading SchoolDb, add GET /api/books with an optional category filter in the query string, and GET /api/books/{isbn}/copies with a length(13) constraint on the ISBN. Try Fiction books, Wings of Fire's copies, and an ISBN that is too short.

A correct result looks like:

Output
GET /api/books?category=Fiction
HTTP/1.1 200 OK
[{"isbn":"9780143039655","title":"Malgudi Days","category":"Fiction"},{"isbn":"9780141325293","title":"The Jungle Book","category":"Fiction"}]
GET /api/books/9788173711466/copies
HTTP/1.1 200 OK
[{"copyNumber":1,"status":"Available"},{"copyNumber":2,"status":"Available"}]
GET /api/books/123/copies
HTTP/1.1 404 Not Found
Show the solution
library-assignment/072-library-routing.cs
// Library homework, Lesson 72: filtering with the query string, and a nested route with a constraint,
// reading the library tables in SchoolDb with Dapper.
// Run with: dotnet run library-assignment/072-library-routing.cs
#:project ../src/SchoolApp.Data/SchoolApp.Data.csproj
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Dapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using SchoolApp.Data;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton(SqlConnectionFactory.FromEnvironment());
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5072");

public record BookSummary(string Isbn, string Title, string Category);
public record CopyStatus(short CopyNumber, string Status);

[ApiController]
[Route("api/books")]
public class BooksController : ControllerBase
{
private readonly SqlConnectionFactory _connections;

public BooksController(SqlConnectionFactory connections)
{
_connections = connections;
}

// GET /api/books?category=Fiction: the query string filters; leaving it out lists every book.
[HttpGet]
public async Task<IEnumerable<BookSummary>> List([FromQuery] string? category)
{
await using SqlConnection connection = _connections.Create();
return await connection.QueryAsync<BookSummary>("""
SELECT Isbn, Title, Category
FROM dbo.Books
WHERE IsDeleted = 0 AND (@Category IS NULL OR Category = @Category)
ORDER BY Title;
""", new { Category = category });
}

// GET /api/books/9788173711466/copies: copies belong to a book, so they sit under it.
// length(13): an ISBN-13 has 13 characters, so anything else is a 404 without touching the database.
[HttpGet("{isbn:length(13)}/copies")]
public async Task<ActionResult<IEnumerable<CopyStatus>>> Copies(string isbn)
{
await using SqlConnection connection = _connections.Create();
List<CopyStatus> copies = (await connection.QueryAsync<CopyStatus>("""
SELECT c.CopyNumber, c.Status
FROM dbo.BookCopies AS c
JOIN dbo.Books AS b ON b.Id = c.BookId
WHERE b.Isbn = @Isbn
ORDER BY c.CopyNumber;
""", new { Isbn = new DbString { Value = isbn, IsAnsi = true, Length = 13 } })).ToList();
return copies.Count == 0 ? NotFound() : copies;
}
}

@Category IS NULL OR Category = @Category lets one query serve both cases: with no filter, the parameter is NULL and every book matches. The short ISBN never reaches the database, because the constraint fails first.

Quiz​

  1. What does a failed route constraint return?

    1. 400 Bad Request with a reason
    2. 404 Not Found, because no route matched
    3. 500
    4. 200 with an empty list
    Show answer

    Answer: B. 404 Not Found, because no route matched /api/classes/11/... got an empty 404 before any code ran.

  2. Where should a page number go?

    1. In the path: /api/students/page/2
    2. In the query string: /api/students?page=2
    3. In a header
    4. In the body of a GET
    Show answer

    Answer: B. In the query string: /api/students?page=2 Paging is an option on a list, not part of its identity.

  3. A controller has the route api/students, and an action has HttpGet with the template {admissionNumber}. What is the full route?

    1. /{admissionNumber}
    2. /api/students/{admissionNumber}
    3. /api/{admissionNumber}/students
    4. /students
    Show answer

    Answer: B. /api/students/{admissionNumber} The action route is added to the controller's route.

  4. Why did page=abc fail differently from page=0?

    1. It did not fail
    2. abc cannot be converted to a number: a model binding error
    3. abc is a reserved word
    4. Route constraints
    Show answer

    Answer: B. abc cannot be converted to a number: a model binding error Binding fails before validation can even check the range.

  5. Why does SchoolApp write routes in full instead of using [controller]?

    1. It is faster
    2. Renaming a class should never change a public URL
    3. [controller] does not work in .NET 10
    4. For OpenAPI
    Show answer

    Answer: B. Renaming a class should never change a public URL Public routes are a contract with clients.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F What is a route template? concept

A pattern for request paths, made of literal segments and parameters in braces, such as api/students/{admissionNumber}. Routing matches the request's path and method against the templates and passes parameter values to the action.

See the example · Permalink

F How do a controller's [Route] and an action's [HttpGet] combine? concept

The controller's route is a prefix and the action's template is added to it. [Route("api/students")] with [HttpGet("{admissionNumber}")] gives GET api/students/{admissionNumber}; [HttpGet] alone gives GET api/students.

See the example · Permalink

F When should a value go in the path, and when in the query string? design

In the path when it identifies the resource or its place in a hierarchy, such as an admission number or a class and section. In the query string when it is an option on a list: paging, filtering, or sorting, such as ?page=2&pageSize=20.

See the example · Permalink

Mid (3-5 yrs)​

M What are route constraints, and what happens when one fails? concept

Rules on a route parameter, such as int, range(1,10), or length(1,2), that decide whether the route matches. When one fails, the route does not match, and the client gets 404 with no explanation, before any action runs. They are for matching, not for validation.

See the example · Permalink

M How do you read and validate optional query string values in a controller? coding

Add parameters with [FromQuery] and default values, such as int page = 1, and put validation attributes on them, such as [Range(1, 100)]. With [ApiController], an invalid value returns 400 ValidationProblemDetails naming the parameter, and the action never runs.

See the example · Permalink

M What happens when two routes could match the same request? concept

The more specific template wins: a literal over a parameter, a constrained parameter over an unconstrained one, so /api/students/3 chose {id:int} over {admissionNumber}. If two are equally specific, the request fails with an ambiguous match error (500), which must be fixed in the routes.

See the example · Permalink

Senior (6-9 yrs)​

S A list endpoint returns all 9,000 students and the mobile app is slow. How do you redesign it? scenario

Add paging with page and pageSize in the query string, a maximum page size, a stable sort ending with the key, and a response that includes the total count. Add filters such as sectionId so clients ask only for what the screen shows, and project to DTOs with only the needed fields.

How to answer: Cover the request shape, the response shape, limits, sorting stability, filtering, and the database side (indexes, projection). Mention keeping old clients working while they move to the paged version.

See the example · Permalink

S How would you design routes for students, their guardians, and their fee invoices? design

/api/students for the collection and /api/students/{id} for one student; /api/students/{id}/guardians and /api/students/{id}/fee-invoices for related collections; and /api/fee-invoices/{invoiceNumber} for an invoice by its own key. Keep nesting to two parent levels, use plural lowercase nouns with hyphens, and put filters in the query string.

How to answer: Explain the reasoning for each choice: ownership, unique keys, depth limits, and consistency, and how the routes appear in the OpenAPI document.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Offset paging with page numbers slows down on very deep pages. What alternative would you consider? design

Keyset (cursor) paging: the client sends the last key it saw, and the query asks for rows after it, such as WHERE (LastName, FirstName, Id) > (@last...), which an index can seek directly. It stays fast at any depth and is stable when rows are added, but it cannot jump to page 400; keep offset paging for screens that need page numbers.

How to answer: Explain why OFFSET reads and discards rows, describe keyset paging and its index needs, and choose per use case: infinite scroll and exports versus numbered pages.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-72-routing (code repository coming soon)
  • Files: src/SchoolApp.Core/Interfaces/IStudentQueries.cs, src/SchoolApp.Api/Controllers/StudentsController.cs, src/SchoolApp.Api/Controllers/ClassesController.cs, src/SchoolApp.Api/SchoolApp.Api.http, library-assignment/072-library-routing.cs
  • Video: coming soon

What's next​

Query values were validated with one attribute each. Next: model binding and validation in depth, with the first POST endpoint, admitting a student, and every way its request body can be wrong. Lesson 73: Model binding and validation

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.