• Home
  • /
  • Articles
  • /
  • Software Development
  • /
  • Controller-Based Web API in ASP.NET Core
HEADLINES

Building a Controller-Based Web API with ASP.NET Core

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.


  • Techm Studios
  • BY TECHM STUDIOS
  • |
  • Sept 09, 2026
  • |
  • UPDATED: Sept 09, 2026
  • |
  • 0 COMMENTS
Controller-Based Web API in ASP.NET Core
© Microsoft Learn / Images

ASP.NET Core · Web API
.NET 8 / .NET 9+ C# REST

01What Is a Controller-Based Web API?

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.

Key idea: a controller-based Web API is one or more classes that inherit from ControllerBase (not Controller, which also supports views). Each public method decorated with an HTTP verb attribute becomes a callable endpoint.

02Setting Up the Project

You can scaffold a new controller-based Web API using the .NET CLI:

bash
dotnet 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:

C# — Controllers/WeatherForecastController.cs
[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();
    }
}

03Anatomy of a Controller

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).
  • Inherits from ControllerBase — provides helper methods like Ok(), NotFound(), CreatedAtAction(), and access to ModelState.
  • Action methods decorated with [HttpGet], [HttpPost], [HttpPut], [HttpDelete], or [HttpPatch].

04Defining the Model

Start with a simple POCO (plain old C# object) representing the resource:

C# — Models/TodoItem.cs
namespace 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.cs
public class TodoItemDTO
{
    public long Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

05Adding a Data Layer (EF Core In-Memory)

For a quick, database-free starting point, use Entity Framework Core's in-memory provider:

bash
dotnet 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:

C# — 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();

06Building CRUD Endpoints

You can hand-write the controller, or scaffold it with the ASP.NET Core code generator:

bash
dotnet 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
    };
}
VerbRouteActionTypical response
GET/api/TodoItemsList all items200 OK
GET/api/TodoItems/{id}Get one item200 OK / 404 Not Found
POST/api/TodoItemsCreate an item201 Created
PUT/api/TodoItems/{id}Replace an item204 No Content / 404
DELETE/api/TodoItems/{id}Delete an item204 No Content / 404

07Validation and Status Codes

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.

C#
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) → 200
  • CreatedAtAction(...) → 201 with a Location header
  • NoContent() → 204
  • BadRequest() → 400
  • NotFound() → 404

08Routing and Attribute Conventions

Controller-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.

09Dependency Injection

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:

C#
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:

C#
builder.Services.AddScoped<IProductRepository, ProductRepository>();

10Documenting the API with OpenAPI / Swagger

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.

bash
dotnet 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.

11Testing the API

You can exercise endpoints with curl, an .http file, Swagger UI, or Postman:

bash
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.

12Best Practices

  • Use DTOs, not entities, in request/response bodies to avoid leaking internal schema and prevent over-posting.
  • Keep controllers thin — push business logic into services or a repository/use-case layer injected via DI.
  • Return the right status codes (201 for creation with a Location header, 204 for successful updates/deletes with no body, 404 for missing resources).
  • Validate input with data annotations or FluentValidation; let [ApiController] handle the automatic 400 responses.
  • Version your API (URL segment, header, or query string) once it has external consumers.
  • Use async/await end-to-end for I/O-bound calls (database, HTTP, file access).
  • Document with OpenAPI so consumers and generated clients stay in sync with the real contract.

13Controllers vs. Minimal APIs

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:

  • Model-binding extensibility (custom IModelBinder/IModelBinderProvider)
  • Built-in support for a larger surface of filters and conventions
  • A team already familiar with the MVC-style pattern, or migrating an existing MVC codebase
  • Very large numbers of endpoints organized by resource, where classes aid structure

Note: both approaches run on the same underlying ASP.NET Core routing and hosting pipeline, so you can mix controllers and minimal endpoints in the same application if needed.
Written for ASP.NET Core (.NET 8 / .NET 9+). Code samples follow current Microsoft Learn conventions for controller-based Web APIs and are intended as a practical reference, not a verbatim reproduction of any single source.

Also Read: Redis Caching in ASP.NET Core

SPONSORED ADS

COMMENTS
LEAVE A REPLY
SPONSORED ADS
POPULAR
AI-Generated Fashion Design

AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.


SPONSORED JOBS

TECHM JOBS
POPULAR
Journalist Jobs, South Africa

Browse most trending Journalist jobs in South Africa. Apply today

Photographer Jobs, South Africa

Browse most trending Photographer jobs in South Africa. Apply today

Graphical Designer Jobs, United Kingdom

Browse most trending Fashion jobs in United Kingdom. Apply today


TECHM PRODUCTS


TOP POSTS
STAY IN TOUCH
SPONSORED ADS
Subscribe to Updates

Get the latest creative news from FooBar about art, design and business.

Subscribe