Routing in ASP.NET Core: Templates, Constraints, Query Strings – Web API | Lesson 72
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 piece | Example | Matches |
|---|---|---|
| Literal | api/students | Exactly 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 route | Action route | Full 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:
| Constraint | Matches | SMS use |
|---|---|---|
int | Whole numbers | classId |
range(1,10) | Numbers in a range | Classes 1 to 10 |
length(1,2) | Text of that many characters | Section names A to V |
minlength(n), maxlength(n) | Text at least or at most n characters | |
alpha | Letters only | |
guid, bool, datetime | Values of that type | |
regex(...) | A regular expression | Use 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 for | Use the query string for |
|---|---|
Identifying a resource: /api/students/ADM-2017-0003 | Options on a list: ?page=2&pageSize=20 |
| Required values | Optional values with defaults |
Hierarchy: /api/classes/10/sections/A/students | Filters 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:
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:
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
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
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
# 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:
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
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
| Request | Status | Why |
|---|---|---|
/api/classes/10/sections/A/students | 200, Aarav, Diya, Kabir, Meera | Matches; four students in 10-A |
/api/classes/10/sections/a/students | 200, the same four | SQL Server compares section names without case |
/api/classes/11/sections/A/students | 404, empty body | range(1,10) failed: no route matched |
/api/classes/ten/sections/A/students | 404, empty body | int failed |
/api/classes/10/sections/ABC/students | 404, empty body | length(1,2) failed |
/api/classes/10/sections/Z/students | 404, ProblemDetails body | The 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
- Using constraints as validation. Clients get a bare 404 and no reason. Fix: constraints for matching shapes; validation attributes for rules.
[controller]tokens with renamed classes. A refactor changes the public URL. Fix: write routes in full.- Unpaged lists. One request loads 10,012 rows. Fix: page every list, with a maximum page size.
- Filters in the path, such as
/api/books/category/Fiction. It confuses identity with options. Fix:/api/books?category=Fiction. - Deep nesting.
/api/years/1/classes/10/sections/A/students/3/marksis hard to use and to change. Fix: at most two parent levels; give uniquely keyed resources their own route.
In real 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, andlengthdecide whether a route matches; failing gives 404. [FromQuery]reads optional values, with defaults and[Range]validation that gives 400.GET /api/studentsreturns aPagedResult<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:
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 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
What does a failed route constraint return?
Show answer
Answer: B. 404 Not Found, because no route matched /api/classes/11/... got an empty 404 before any code ran.
Where should a page number go?
Show answer
Answer: B. In the query string: /api/students?page=2 Paging is an option on a list, not part of its identity.
A controller has the route api/students, and an action has HttpGet with the template {admissionNumber}. What is the full route?
Show answer
Answer: B. /api/students/{admissionNumber} The action route is added to the controller's route.
Why did page=abc fail differently from page=0?
Show answer
Answer: B. abc cannot be converted to a number: a model binding error Binding fails before validation can even check the range.
Why does SchoolApp write routes in full instead of using [controller]?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.