Building a Web API with ASP.NET Core
- #aspnet-core
- #web-api
- #rest
- #csharp
- #dotnet
- #ef-core
- #swagger
In questa lezione
- 1. Introduction
- 2. Creating the project
- 3. Controllers vs minimal APIs
- 3.1 Controllers
- 3.2 Minimal APIs
- 4. Routing
- 5. Model binding
- 6. DTOs vs entities
- 7. Validation
- 8. Returning results
- 8.1 Controllers: ActionResult of T
- 8.2 Minimal APIs: TypedResults
- 9. Wiring DI and EF Core
- 10. Swagger / OpenAPI
- 11. Interview questions
- 12. Quiz
- 13. Exercises
- 13.1 Equipment CRUD with controllers
- 13.2 The same API with minimal APIs
- 13.3 Nested resources with filtering
1. Introduction
In the previous lessons we saw how HTTP works and how to design a REST API. Now we build one with ASP.NET Core, the cross-platform web framework of .NET.
We create a small API for engineering projects (Project, Document, Equipment), covering controllers vs minimal APIs, routing, model binding, DTOs, validation, results, DI + EF Core and Swagger.
2. Creating the project
dotnet new webapi -n Projects.Api --use-controllers
cd Projects.Api
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL # also brings in EF Core
dotnet run
Without --use-controllers the template uses minimal APIs. The whole app starts from Program.cs:
var builder = WebApplication.CreateBuilder(args);
// 1. Register services (DI container)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// 2. Configure the HTTP pipeline (middleware)
if (app.Environment.IsDevelopment())
app.UseSwagger().UseSwaggerUI();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
There are two clear phases: first you register services on builder.Services, then you build the app and configure the request pipeline. The pipeline is covered in detail in the next lesson.
Nota
WebApplication.CreateBuilder already configures a lot for you: Kestrel (the web server), configuration from appsettings.json and environment variables, logging and the DI container. This is called the generic host.
3. Controllers vs minimal APIs
ASP.NET Core offers two styles to define endpoints.
3.1 Controllers
A controller is a class that groups related endpoints (actions).
[ApiController]
[Route("api/[controller]")] // → api/projects
public class ProjectsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll() => Ok(new[] { "Ship A", "Plant B" });
[HttpGet("{id:int}")]
public IActionResult GetById(int id) => Ok($"Project {id}");
}
ControllerBase is the base class for APIs (no view support, unlike Controller in MVC). The [ApiController] attribute enables useful behaviour: automatic 400 responses on invalid models, binding inference and ProblemDetails for errors.
3.2 Minimal APIs
Minimal APIs define endpoints directly with lambdas, without classes:
app.MapGet("/api/projects", () => new[] { "Ship A", "Plant B" });
app.MapGet("/api/projects/{id:int}", (int id) => $"Project {id}");
You can group endpoints with a shared prefix: app.MapGroup("/api/projects").
| Controllers | Minimal APIs | |
|---|---|---|
| Structure | Classes and attributes | Lambdas / methods mapped in code |
| Boilerplate | More | Less |
| Filters, conventions | Mature, rich | Endpoint filters, growing |
| Performance | Very good | Slightly better |
| Typical use | Large, structured APIs | Microservices, small APIs, new projects |
Suggerimento
Interview tip: don’t say one style is “better”. Say: “Controllers are great for big APIs with many conventions and team habits; minimal APIs have less ceremony and are the modern default for small services. Both run on the same routing and DI, so I’m comfortable with either.”
4. Routing
Routing maps an incoming URL and HTTP method to an endpoint. ASP.NET Core uses endpoint routing; with controllers you normally use attribute routing.
[ApiController]
[Route("api/projects/{projectId:int}/documents")]
public class DocumentsController : ControllerBase
{
[HttpGet] // GET api/projects/5/documents
public IActionResult List(int projectId) => Ok();
[HttpGet("{documentId:int}")] // GET api/projects/5/documents/12
public IActionResult Get(int projectId, int documentId) => Ok();
}
The :int part is a route constraint. If the value is not an integer, the route doesn’t match and the client gets 404. Other constraints: guid, alpha, min(1), length(3,20).
Attenzione
Route constraints are for matching, not for input validation. Use {id:int} to choose the right endpoint, but validate business rules (for example “the project must exist”) in your code and return 404 or 400 yourself.
5. Model binding
Model binding takes data from the HTTP request and converts it into the parameters of your action. Sources:
| Attribute | Source | Example |
|---|---|---|
[FromRoute] | URL segment | /api/projects/5 |
[FromQuery] | Query string | ?page=2&status=Active |
[FromBody] | Request body (JSON) | POST payload |
[FromHeader] | HTTP header | X-Correlation-Id |
[FromServices] | DI container | a service |
With [ApiController] (and in minimal APIs) the source is usually inferred: simple types come from route/query, complex types from the body.
[HttpGet] // GET api/projects?status=Active&page=2
public IActionResult Search([FromQuery] string? status, [FromQuery] int page = 1)
=> Ok(new { status, page });
6. DTOs vs entities
An entity is the class mapped to a database table by EF Core. A DTO (Data Transfer Object) is the class that travels over HTTP. Keep them separate.
// Entity (database)
public class Project
{
public int Id { get; set; }
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public DateTime CreatedAt { get; set; }
public List<Document> Documents { get; set; } = [];
}
// DTOs (API contract)
public record ProjectDto(int Id, string Code, string Name);
public record CreateProjectRequest(string Code, string Name);
Why DTOs?
- Security: the client cannot set fields like
IdorCreatedAt(no over-posting). - Stable contract: you can change the database without breaking clients.
- No cycles: returning entities with navigation properties can create JSON loops and huge payloads.
- Shape per use case: a list endpoint can return less data than a detail endpoint.
Pericolo
Never bind a request body directly to an EF entity and call SaveChanges. A malicious client could send extra fields (for example "OwnerId": 1) and change data they should not touch. This is the classic over-posting / mass-assignment vulnerability.
C# record types are perfect for DTOs: immutable, short, with value equality. Mapping can be manual (a small ToDto() extension method) or done with a library like Mapster or AutoMapper.
7. Validation
With [ApiController], Data Annotations on the DTO are checked automatically. If the model is invalid, the framework returns 400 Bad Request with a ValidationProblemDetails body, before your action runs.
using System.ComponentModel.DataAnnotations;
public record CreateProjectRequest(
[Required, StringLength(20, MinimumLength = 3)] string Code,
[Required, MaxLength(200)] string Name);
On positional records, put the attributes on the constructor parameters (no property: target): MVC validates them and reports errors with the property name.
Response example (simplified):
{ "title": "One or more validation errors occurred.", "status": 400,
"errors": { "Code": ["The field Code must be a string with a minimum length of 3..."] } }
For complex rules (cross-field checks, patterns on codes) many teams use FluentValidation, e.g. RuleFor(x => x.Code).NotEmpty().Matches(...).
Note: minimal APIs in .NET 8 don’t run Data Annotations validation automatically. You validate manually, use an endpoint filter, or use FluentValidation (.NET 10 adds built-in validation for minimal APIs).
8. Returning results
8.1 Controllers: ActionResult of T
ActionResult<T> lets you return either a value of type T (200 OK) or any other result (404, 400…). It also tells Swagger the response type.
[HttpGet("{id:int}")]
public async Task<ActionResult<ProjectDto>> GetById(int id, CancellationToken ct)
{
var project = await service.GetAsync(id, ct); // service = injected IProjectService (section 9)
if (project is null)
return NotFound(); // 404
return project; // 200 + JSON
}
[HttpPost]
public async Task<ActionResult<ProjectDto>> Create(CreateProjectRequest req, CancellationToken ct)
{
var created = await service.CreateAsync(req, ct);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); // 201 + Location
}
Common helpers: Ok(), Created...(), NoContent(), BadRequest(), NotFound(), Conflict(), Problem().
8.2 Minimal APIs: TypedResults
TypedResults returns strongly typed results. Combined with Results<T1, T2> the compiler and OpenAPI know every possible response.
static async Task<Results<Ok<ProjectDto>, NotFound>> GetById(
int id, IProjectService service, CancellationToken ct)
{
var project = await service.GetAsync(id, ct);
return project is null
? TypedResults.NotFound()
: TypedResults.Ok(project);
}
Suggerimento
Always accept a CancellationToken in async endpoints and pass it down to EF Core. If the client disconnects, ASP.NET Core cancels the token and the database query stops, saving server resources. See async/await.
9. Wiring DI and EF Core
A typical layering: the endpoint calls a service, the service uses the DbContext.
graph LR
C[Client] -->|HTTP JSON| E[Controller / Endpoint]
E -->|DTO| S[ProjectService]
S -->|LINQ| D[AppDbContext]
D -->|SQL| P[(PostgreSQL)]
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IProjectService, ProjectService>();
public class ProjectService(AppDbContext db) : IProjectService
{
public Task<ProjectDto?> GetAsync(int id, CancellationToken ct) =>
db.Projects
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProjectDto(p.Id, p.Code, p.Name))
.FirstOrDefaultAsync(ct);
public async Task<ProjectDto> CreateAsync(CreateProjectRequest req, CancellationToken ct)
{
var project = new Project { Code = req.Code, Name = req.Name, CreatedAt = DateTime.UtcNow };
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project.ToDto();
}
}
The controller receives the service through its constructor, e.g. with a C# 12 primary constructor: public class ProjectsController(IProjectService service) : ControllerBase.
Key points:
AddDbContextregisters theDbContextas scoped: one instance per HTTP request.- Services that use the
DbContextmust be scoped (or transient), never singleton. Selectinto a DTO generates SQL with only the needed columns.AsNoTrackingmakes read-only queries faster.
More details in Dependency Injection and Entity Framework.
Attenzione
Injecting a scoped DbContext into a singleton service creates a captive dependency: the same context is shared across requests and threads, which is not thread-safe. In Development, ASP.NET Core detects this with scope validation and throws at startup.
10. Swagger / OpenAPI
OpenAPI is a standard JSON/YAML description of your API. Swagger UI is a web page that reads it and lets you try the endpoints. In .NET 8 the template uses Swashbuckle (AddSwaggerGen, UseSwagger, UseSwaggerUI, shown in section 2). The JSON document is at /swagger/v1/swagger.json, the UI at /swagger.
To document responses in controllers, use [ProducesResponseType]:
[ProducesResponseType<ProjectDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
The OpenAPI document is also useful for integration: other teams can generate typed clients (C#, TypeScript) from it with tools like NSwag or Kiota.
Nota
Starting with .NET 9, the template uses Microsoft.AspNetCore.OpenApi (AddOpenApi / MapOpenApi) instead of Swashbuckle. The idea is the same: generate an OpenAPI document from your endpoints.
11. Interview questions
Q: What is the difference between controllers and minimal APIs? Both are built on the same routing, DI and middleware. Controllers group actions in classes and use attributes, filters and conventions, which works well for large APIs. Minimal APIs map endpoints with lambdas and have less boilerplate. I would pick based on the size of the project and the team’s existing conventions.
Q: Why should you use DTOs instead of returning EF entities? DTOs decouple the API contract from the database model, so I can change the schema without breaking clients. They also prevent over-posting, because the client can only send the fields I expose. Finally, they avoid serialization problems like cycles in navigation properties and let me return only the data each endpoint needs.
Q: What does the ApiController attribute do?
It enables API-specific behaviour: automatic model validation with a 400 ProblemDetails response, inference of binding sources like body and route, and the requirement of attribute routing. It removes a lot of repetitive code like checking ModelState.IsValid in every action.
Q: What is model binding?
Model binding reads values from the request, such as route segments, query string, headers and JSON body, and converts them into the typed parameters of my action. I can control the source with attributes like [FromQuery] or [FromBody]. If conversion fails, the model state becomes invalid.
Q: What is the lifetime of a DbContext in ASP.NET Core and why? By default it is scoped, so there is one instance per HTTP request. A DbContext is not thread-safe and tracks entities, so sharing it between requests would cause bugs. Because of this, any service that depends on it should also be scoped.
Q: What status code do you return when creating a resource?
I return 201 Created, with a Location header pointing to the new resource and usually the created object in the body. In controllers I use CreatedAtAction, in minimal APIs TypedResults.Created.
Q: How do you validate input in an ASP.NET Core API?
For simple rules I use Data Annotations on DTOs, which [ApiController] checks automatically. For more complex rules I use FluentValidation. Business rules that need the database, like a unique project code, I check in the service layer and return 409 Conflict or 400 with ProblemDetails.
12. Quiz
Mettiti alla prova
0/8 risposte
Which base class should an API controller inherit from?
What happens with
[ApiController]when the request body fails Data Annotations validation?In the route template
{id:int}, what is:int?What is the main security risk of binding request bodies directly to EF entities?
What is the default lifetime of a DbContext registered with
AddDbContext?Which helper returns 201 Created with a Location header in a controller?
What is the advantage of
Results<Ok<ProjectDto>, NotFound>in minimal APIs?Why do you pass a
CancellationTokento EF Core methods in an endpoint?
13. Exercises
13.1 Equipment CRUD with controllers
Goal: build a full CRUD API for Equipment (Id, Tag, Description, ProjectId).
- Create the entity and add a
DbSet<Equipment>toAppDbContext(PostgreSQL withUseNpgsql). - Create
EquipmentDto,CreateEquipmentRequestandUpdateEquipmentRequestrecords with Data Annotations. - Create
IEquipmentService/EquipmentService(scoped) and anEquipmentControllerwith GET list, GET by id, POST, PUT and DELETE. - Return the correct status codes: 200, 201, 204, 404.
- Test everything from Swagger UI.
Hint: for PUT and DELETE return NoContent() on success and NotFound() if FindAsync returns null.
13.2 The same API with minimal APIs
Goal: compare the two styles.
- Rewrite the Equipment endpoints using
MapGroup("/api/equipment"). - Use
TypedResultsandResults<...>for every handler. - Move the handlers into a static class
EquipmentEndpointswith an extension methodMapEquipmentEndpoints(this IEndpointRouteBuilder app). - Write down three differences you noticed compared to the controller version.
Hint: services like IEquipmentService are injected directly as handler parameters.
13.3 Nested resources with filtering
Goal: expose documents of a project: GET /api/projects/{projectId}/documents?status=Approved&page=1&pageSize=20.
- Add a
Documententity (Id, Number, Title, Revision, Status, ProjectId). - Bind
projectIdfrom the route andstatus,page,pageSizefrom the query string. - Return 404 if the project does not exist.
- Apply
Where,OrderBy,SkipandTake, projecting to a DTO. - Return a paged response with
itemsandtotalCount.
Hint: run CountAsync before Skip/Take, and limit pageSize to a maximum (for example 100).