Adding a Get By ID Endpoint in .NET Aspire on Linux
Returning every record from a database table is useful for listing pages, but most API consumers also need to fetch a single record by its identifier. That second endpoint introduces decisions the "get all" route did not require: what return type makes sense for a single object versus a collection, what happens when the ID does not match any record, and how to communicate that failure to the caller with the correct HTTP status code.
In his video "Adding a Get By ID Endpoint in .NET Aspire on Linux," Tim Corey continues the Tiny Ticket API by adding a GET /api/tickets/{id} endpoint. What starts as a copy-paste of the existing route turns into a live walkthrough of edge case handling: returning a single object instead of an array, checking for null results, and using TypedResults to return either a 200 OK with the ticket or a 404 Not Found when the ID does not exist. If you are following the C# on Linux series or building minimal APIs that need proper status code responses, this episode covers the full thought process.
Copying the Get All Route as a Starting Point
[0:35 - 1:45] Tim opens the API service's Program.cs and duplicates the existing GET /api/tickets endpoint. The new route needs a path parameter for the ticket ID, so the URL pattern changes to include {id:int} and the handler receives an int id parameter. The stored procedure reference switches from spTickets_GetAll to spTickets_Get, which expects an ID parameter.
app.MapGet("/api/tickets/{id:int}", async (int id, IDbConnection db) =>
{
var tickets = await db.LoadSqlAsync<TicketModel>("spTickets_Get", new { id });
// Initial version: returns a list, which we'll fix next
return tickets;
});app.MapGet("/api/tickets/{id:int}", async (int id, IDbConnection db) =>
{
var tickets = await db.LoadSqlAsync<TicketModel>("spTickets_Get", new { id });
// Initial version: returns a list, which we'll fix next
return tickets;
});A naming convenience worth noting: the stored procedure parameter is lowercase id, which matches the C# parameter name exactly. That means Dapper can map the anonymous object new { id } directly without specifying a property name. If the SQL parameter used a different casing, the anonymous object would need an explicit property assignment like new { Id = id }.
Returning a Single Object Instead of a List
[2:39 - 4:16] The first test through Swagger reveals a problem: passing ID 2 returns a 200 with the correct ticket, but the response body is wrapped in a JSON array. When a caller requests a single resource by ID, they expect a single object, not a collection containing one element.
Appending .FirstOrDefault() to the query result fixes the wrapping. FirstOrDefault returns the first element if the list has items, or null if the list is empty. That solves the array problem, but it introduces a new question: what should the API return when the ID does not match any record?
var output = tickets.FirstOrDefault();var output = tickets.FirstOrDefault();The single-line change produces the correct response shape for existing records. However, testing with ID 4, which does not exist in the database, reveals a deeper gap. The response comes back as null with a 200 status code. That is technically valid HTTP, but it misleads the caller: a 200 means the request succeeded and the resource was found, when in reality nothing matched.
Handling Not Found with TypedResults
[4:41 - 11:44] This section is where Tim works through the design decisions in real time, which makes it valuable to watch rather than just read the final code. His thought process moves through several iterations:
First, he considers using .First() instead of .FirstOrDefault(), which throws an exception when the list is empty. That produces a 500 error, which is worse than a null 200 because 500 implies a server bug rather than a missing resource.
Then he steps back and builds a null check. He stores the result in a variable, checks whether it is null, and returns different responses for each case. The challenge is that a minimal API handler needs to declare its return type explicitly when it can return more than one shape of response.
The solution is TypedResults, which lets you specify the possible response types in the method signature:
app.MapGet("/api/tickets/{id:int}", async Task<Results<Ok<TicketModel>, NotFound>> (int id, IDbConnection db) =>
{
var tickets = await db.LoadSqlAsync<TicketModel>("spTickets_Get", new { id });
var output = tickets?.FirstOrDefault();
if (output is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(output);
});app.MapGet("/api/tickets/{id:int}", async Task<Results<Ok<TicketModel>, NotFound>> (int id, IDbConnection db) =>
{
var tickets = await db.LoadSqlAsync<TicketModel>("spTickets_Get", new { id });
var output = tickets?.FirstOrDefault();
if (output is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(output);
});That return type, Task<Results<Ok<TicketModel>, NotFound>>, tells the framework (and Swagger) that this endpoint produces either a 200 with a TicketModel body or a 404 with no body. Colored bracket matching in VS Code helps navigate the nested angle brackets, which stack up quickly with generic result types.
One subtle bug surfaces during testing: the first version calls TypedResults.NotFound() but does not return it. The endpoint compiles because NotFound() is a valid expression, but without the return keyword, execution falls through to the Ok path. Tim catches this when Swagger still shows a 200 for a missing ID, adds the return, and the 404 appears correctly on the next run.
Testing Both Paths in Swagger
[11:44 - 14:09] With the final code in place, Tim runs through both scenarios in the Swagger UI. Passing ID 3 returns a 200 with the ticket object. Passing ID 4 returns a 404 with an empty response body.
He also points out a detail in the Swagger interface that can confuse first-time users: the "Responses" section below the execute button shows the possible response codes (200 and 404), not the actual result. The actual server response appears in a separate panel above that section. Mixing up the two panels is a common source of "why am I getting a 200?" confusion.
The TypedResults approach also improves the Swagger documentation automatically. Because the return type declares both Ok<TicketModel> and NotFound, Swagger displays both as potential outcomes with their respective schemas. Callers reading the API documentation know they need to handle a 404 case without the developer writing separate OpenAPI annotations.
Wrapping Up: Edge Cases Before Features
[14:09 - 15:09] What started as a simple copy-paste of the get-all endpoint turned into a deeper exercise in API design. The final version handles the happy path (record found), the expected failure (record not found), and a defensive null check for unexpected scenarios (query returning null). Tim's approach of working through these cases live, rather than presenting polished code, demonstrates the kind of iterative thinking that production endpoints require.
Conclusion
[15:09 - 15:20] Adding a get-by-ID endpoint to a minimal API involves three decisions beyond the route definition: use FirstOrDefault to unwrap the collection, check for null to distinguish "not found" from "found," and declare TypedResults in the return type so the framework returns the correct HTTP status code. The Results<Ok<T>, NotFound> pattern is reusable across any endpoint that needs to communicate success or absence.
Series navigation: This article is part of the C# on Linux series building the Tiny Ticket app. Previous: Adding Swagger UI. Next: Adding a POST Insert Endpoint.
Example Tip: When your minimal API handler returns multiple possible status codes, always declare them in the Results<> generic. This generates accurate Swagger documentation automatically and forces the compiler to verify that every code path returns a valid result type.
Watch full video on his YouTube Channel and gain more insights on building robust API endpoints in the C# on Linux series.

