A complete, practical walkthrough of designing, building, and testing a RESTful Web API using ASP.NET Core controllers — from project setup to CRUD endpoints, validation, dependency injection, and API documentation.
ASP.NET Core offers two ways to build HTTP APIs: Minimal APIs, which map
routes directly to handler delegates, and the controller-based approach, where
endpoints are grouped into classes that derive from ControllerBase. Controllers
follow the traditional MVC-style object-oriented pattern: each controller represents a resource
(such as Products or Orders), and its action methods map to HTTP verbs
(GET, POST, PUT, DELETE) on that resource.
Microsoft's official guidance is that new, simple projects can start with Minimal APIs, but the controller-based approach remains fully supported and is often preferred for larger APIs that benefit from model-binding extensibility, built-in filters, conventions, and the maturity of the MVC framework.
ControllerBase (not Controller, which also supports views). Each
public method decorated with an HTTP verb attribute becomes a callable endpoint.
You can scaffold a new controller-based Web API using the .NET CLI:
bashdotnet new webapi -controllers -o TodoApi
cd TodoApi
dotnet run
The -controllers flag tells the template to generate a controller-based project
instead of the default Minimal API template. If you're using Visual Studio, select the
ASP.NET Core Web API template, then make sure "Use controllers"
is checked in the project configuration dialog.
The generated project includes a sample controller, WeatherForecastController:
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild",
"Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
Every controller-based API controller shares a few conventions:
[ApiController] — enables API-specific behaviors: automatic HTTP 400
responses on model validation failure, binding source inference, and problem-details error
responses.
[Route("[controller]")] — sets the base route. The token
[controller] is replaced with the class name minus the "Controller" suffix
(e.g. TodoItemsController → /TodoItems).
ControllerBase — provides helper methods like
Ok(), NotFound(), CreatedAtAction(), and access to
ModelState.
[HttpGet], [HttpPost],
[HttpPut], [HttpDelete], or [HttpPatch].
Start with a simple POCO (plain old C# object) representing the resource:
C# — Models/TodoItem.csnamespace TodoApi.Models;
public class TodoItem
{
public long Id { get; set; }
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
It's common practice to separate the entity (used internally / with the database) from a DTO (Data Transfer Object) that shapes exactly what the client sends and receives. This avoids over-posting vulnerabilities and decouples your API contract from your storage schema:
C# — Models/TodoItemDTO.cspublic class TodoItemDTO
{
public long Id { get; set; }
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
For a quick, database-free starting point, use Entity Framework Core's in-memory provider:
bashdotnet add package Microsoft.EntityFrameworkCore.InMemory
C# — Models/TodoContext.cs
using Microsoft.EntityFrameworkCore;
namespace TodoApi.Models;
public class TodoContext : DbContext
{
public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }
public DbSet<TodoItem> TodoItems { get; set; } = null!;
}
Register the context in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<TodoContext>(opt =>
opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
You can hand-write the controller, or scaffold it with the ASP.NET Core code generator:
bashdotnet tool install -g dotnet-aspnet-codegenerator
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet aspnet-codegenerator controller -name TodoItemsController \
-async -api -m TodoItem -dc TodoContext -outDir Controllers
A hand-written, DTO-based version looks like this:
C# — Controllers/TodoItemsController.cs[Route("api/[controller]")]
[ApiController]
public class TodoItemsController : ControllerBase
{
private readonly TodoContext _context;
public TodoItemsController(TodoContext context) => _context = context;
// GET: api/TodoItems
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItemDTO>>> GetTodoItems()
{
return await _context.TodoItems
.Select(x => ItemToDTO(x))
.ToListAsync();
}
// GET: api/TodoItems/5
[HttpGet("{id}")]
public async Task<ActionResult<TodoItemDTO>> GetTodoItem(long id)
{
var todoItem = await _context.TodoItems.FindAsync(id);
if (todoItem == null) return NotFound();
return ItemToDTO(todoItem);
}
// POST: api/TodoItems
[HttpPost]
public async Task<ActionResult<TodoItemDTO>> PostTodoItem(TodoItemDTO todoDTO)
{
var todoItem = new TodoItem
{
IsComplete = todoDTO.IsComplete,
Name = todoDTO.Name
};
_context.TodoItems.Add(todoItem);
await _context.SaveChangesAsync();
return CreatedAtAction(
nameof(GetTodoItem),
new { id = todoItem.Id },
ItemToDTO(todoItem));
}
// PUT: api/TodoItems/5
[HttpPut("{id}")]
public async Task<IActionResult> PutTodoItem(long id, TodoItemDTO todoDTO)
{
if (id != todoDTO.Id) return BadRequest();
var todoItem = await _context.TodoItems.FindAsync(id);
if (todoItem == null) return NotFound();
todoItem.Name = todoDTO.Name;
todoItem.IsComplete = todoDTO.IsComplete;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException) when (!TodoItemExists(id))
{
return NotFound();
}
return NoContent();
}
// DELETE: api/TodoItems/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTodoItem(long id)
{
var todoItem = await _context.TodoItems.FindAsync(id);
if (todoItem == null) return NotFound();
_context.TodoItems.Remove(todoItem);
await _context.SaveChangesAsync();
return NoContent();
}
private bool TodoItemExists(long id) =>
_context.TodoItems.Any(e => e.Id == id);
private static TodoItemDTO ItemToDTO(TodoItem todoItem) => new()
{
Id = todoItem.Id,
Name = todoItem.Name,
IsComplete = todoItem.IsComplete
};
}
| Verb | Route | Action | Typical response |
|---|---|---|---|
| GET | /api/TodoItems | List all items | 200 OK |
| GET | /api/TodoItems/{id} | Get one item | 200 OK / 404 Not Found |
| POST | /api/TodoItems | Create an item | 201 Created |
| PUT | /api/TodoItems/{id} | Replace an item | 204 No Content / 404 |
| DELETE | /api/TodoItems/{id} | Delete an item | 204 No Content / 404 |
With [ApiController] applied, invalid model state (e.g. a required field missing,
or a data annotation violated) automatically triggers an HTTP 400 response with a
ProblemDetails payload — you don't need to call
ModelState.IsValid manually in most cases.
public class TodoItemDTO
{
public long Id { get; set; }
[Required, StringLength(100)]
public string? Name { get; set; }
public bool IsComplete { get; set; }
}
Use ActionResult<T> return types so you can return either data or a status code:
Ok(value) → 200CreatedAtAction(...) → 201 with a Location headerNoContent() → 204BadRequest() → 400NotFound() → 404Controller-based APIs typically use attribute routing rather than convention-based routing. Route templates can be combined at the controller and action level:
C#[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
[HttpGet] // GET api/products
[HttpGet("{id:int}")] // GET api/products/5
[HttpGet("category/{name}")] // GET api/products/category/electronics
[HttpPost] // POST api/products
[HttpPut("{id:int}")] // PUT api/products/5
[HttpDelete("{id:int}")] // DELETE api/products/5
}
Route constraints (like {id:int}) restrict which values match a route segment,
helping the framework pick the correct action and reject malformed requests early.
ASP.NET Core has dependency injection built in. Controllers receive their dependencies
(a DbContext, a repository, an HttpClient, a logger) through
constructor injection, and the framework's service container wires them up automatically:
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductRepository repository, ILogger<ProductsController> logger)
{
_repository = repository;
_logger = logger;
}
}
Register the implementation in Program.cs:
builder.Services.AddScoped<IProductRepository, ProductRepository>();
ASP.NET Core can generate an OpenAPI document describing your endpoints, and tools like Swagger UI or NSwag turn that document into an interactive test page.
bashdotnet add package Swashbuckle.AspNetCore
C# — Program.cs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ...
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
Once running, browse to /swagger to see and try every endpoint, including
request/response schemas generated from your DTOs and XML doc comments.
You can exercise endpoints with curl, an .http file, Swagger UI, or Postman:
curl -i https://localhost:7031/api/TodoItems
curl -i -X POST https://localhost:7031/api/TodoItems \
-H "Content-Type: application/json" \
-d '{"name":"Walk the dog","isComplete":false}'
ASP.NET Core project templates also generate a .http file you can run directly
inside Visual Studio or VS Code's REST Client / Endpoints Explorer.
Location header, 204 for successful updates/deletes with no body, 404 for
missing resources).
[ApiController] handle the automatic 400 responses.
Microsoft's current guidance recommends Minimal APIs as the default starting point for new, lightweight projects, while controller-based APIs remain the better fit when you need:
IModelBinder/IModelBinderProvider)Also Read: Redis Caching in ASP.NET Core
AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.
Browse most trending Journalist jobs in South Africa. Apply today
Browse most trending Photographer jobs in South Africa. Apply today
Browse most trending Fashion jobs in United Kingdom. Apply today
AI-Generated Fashion Design
SEP 10, 2026
Streetwear Luxury Collaborations
SEP 10, 2026 5,617 VIEWS
Get the latest creative news from FooBar about art, design and business.
Subscribe