Web API Controller Structure — Route Prefixes and Action Names

Web API controllers have a fundamentally different routing philosophy from MVC controllers. Where MVC uses conventional routing (URL derived from controller/action names at runtime), Web API controllers use attribute routing exclusively — every endpoint’s URL is declared explicitly with [Route], [HttpGet], [HttpPost], etc. This explicit declaration makes URL structure intentional, versioning straightforward, and the API contract clear. Every route decision is visible in the code without knowing the routing configuration.

Web API Controller Structure

// ── Standard Web API controller ───────────────────────────────────────────
[ApiController]
[Route("api/[controller]")]   // [controller] = "Posts" (strips "Controller" suffix)
public class PostsController(IPostService service) : ControllerBase
{
    // GET api/posts
    [HttpGet]
    public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetAll(
        [FromQuery] int page = 1,
        [FromQuery] int size = 10,
        CancellationToken ct = default)
        => Ok(await service.GetPageAsync(page, size, ct));

    // GET api/posts/42
    [HttpGet("{id:int}", Name = "GetPostById")]
    public async Task<ActionResult<PostDto>> GetById(int id, CancellationToken ct)
    {
        var post = await service.GetByIdAsync(id, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // POST api/posts
    [HttpPost]
    public async Task<ActionResult<PostDto>> Create(
        CreatePostRequest request, CancellationToken ct)
    {
        var post = await service.CreateAsync(request, ct);
        return CreatedAtAction(nameof(GetById), new { id = post.Id }, post);
    }
}

// ── Hardcoded route prefix vs [controller] token ──────────────────────────
[Route("api/[controller]")]   // uses controller name → /api/posts
[Route("api/v1/posts")]       // hardcoded → /api/v1/posts (for explicit versioning)
[Route("api/blog-posts")]     // kebab-case URL → /api/blog-posts

// ── Multiple controllers for different resources ──────────────────────────
[ApiController, Route("api/[controller]")]
public class UsersController(IUserService service) : ControllerBase { }

[ApiController, Route("api/[controller]")]
public class CommentsController(ICommentService service) : ControllerBase { }

[ApiController, Route("api/[controller]")]
public class TagsController(ITagService service) : ControllerBase { }
Note: [Route("api/[controller]")] uses the [controller] token which is replaced at startup with the controller class name minus the “Controller” suffix — PostsController becomes posts. This is case-insensitive in routing but conventionally lowercase. The token is evaluated once at startup, not per-request. If you rename the controller class, the route changes — for stable versioned APIs, use hardcoded strings like [Route("api/v1/posts")] to prevent accidental route changes from class renames.
Tip: Create a base API controller class that applies common attributes to all API controllers: [ApiController], common response types, and any global filters. This prevents forgetting [ApiController] on new controllers: [ApiController] [Route("api/[controller]")] public abstract class ApiControllerBase : ControllerBase { }. All API controllers then inherit from this and automatically get the shared configuration. This pattern is used in the Clean Architecture Capstone in Part 9.
Warning: Do not mix ControllerBase (Web API) and Controller (MVC) base classes in the same controller. Controller inherits from ControllerBase and adds view-related features (View(), TempData, etc.) that are meaningless for a JSON API. Using Controller as the base for an API controller adds unnecessary overhead and signals incorrect intent. Always use ControllerBase for API controllers.

Controller Organisation Patterns

// ── Nested resource controller — comments on a post ───────────────────────
[ApiController]
[Route("api/posts/{postId:int}/comments")]
public class PostCommentsController(ICommentService service) : ControllerBase
{
    // GET api/posts/42/comments
    [HttpGet]
    public async Task<ActionResult<IReadOnlyList<CommentDto>>> GetByPost(
        int postId, CancellationToken ct)
    {
        var post = await service.PostExistsAsync(postId, ct);
        if (!post) return NotFound(new { message = $"Post {postId} not found." });
        return Ok(await service.GetByPostIdAsync(postId, ct));
    }

    // POST api/posts/42/comments
    [HttpPost]
    public async Task<ActionResult<CommentDto>> Create(
        int postId, CreateCommentRequest request, CancellationToken ct)
    {
        var comment = await service.CreateAsync(postId, request, ct);
        return CreatedAtAction(nameof(GetById),
            new { postId, id = comment.Id }, comment);
    }

    // GET api/posts/42/comments/7
    [HttpGet("{id:int}", Name = "GetCommentById")]
    public async Task<ActionResult<CommentDto>> GetById(
        int postId, int id, CancellationToken ct)
    {
        var comment = await service.GetByIdAsync(postId, id, ct);
        return comment is null ? NotFound() : Ok(comment);
    }
}

Common Mistakes

Mistake 1 — Inheriting from Controller instead of ControllerBase

❌ Wrong — loads MVC view infrastructure for a JSON API:

public class PostsController : Controller { }  // wrong base for Web API!

✅ Correct — always use ControllerBase for Web API controllers.

Mistake 2 — Using [controller] token with versioned APIs (class rename changes route)

❌ Wrong — renaming PostsController to BlogPostsController silently changes the API route from /api/posts to /api/blogposts.

✅ Correct — use hardcoded route strings for versioned APIs: [Route("api/v1/posts")].

🧠 Test Yourself

A controller is named BlogPostsController with [Route("api/[controller]")]. What URL prefix does it use?