Claude-skill-registry controller-patterns

ASP.NET Core controller patterns including thin controllers, routing, parameter binding, response types, and DTOs. Use when creating or reviewing API controllers.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/controller-patterns" ~/.claude/skills/majiayu000-claude-skill-registry-controller-patterns && rm -rf "$T"
manifest: skills/data/controller-patterns/SKILL.md
source content

Controller Patterns

Overview

Controllers should be thin - they handle HTTP concerns only. All business logic belongs in services.

Thin Controller Pattern

The Rule

Each controller action should be 2-3 lines maximum:

  1. Call the service
  2. Return the result
// Good - thin controller
[HttpGet("{id:guid}")]
public async Task<ActionResult<TaskResponse>> GetById(Guid id, CancellationToken cancellationToken)
{
    var result = await taskService.GetByIdAsync(id, cancellationToken);
    return result is null ? NotFound() : Ok(result);
}

// Bad - fat controller with business logic
[HttpGet("{id:guid}")]
public async Task<ActionResult<TaskResponse>> GetById(Guid id)
{
    if (id == Guid.Empty) return BadRequest("Invalid ID");
    var task = await repository.GetByIdAsync(id);
    if (task is null) return NotFound();
    var response = new TaskResponse(task.Id, task.Title, task.Description);
    logger.LogInformation("Retrieved task {Id}", id);
    return Ok(response);
}

Route Conventions

Resource Naming

  • Use plural nouns:
    /api/tasks
    ,
    /api/users
  • Use kebab-case for multi-word resources:
    /api/task-items

Route Attributes

[ApiController]
[Route("api/[controller]")]
public class TasksController(ITaskService taskService) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<TaskResponse>>> GetAll(CancellationToken cancellationToken)

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<TaskResponse>> GetById(Guid id, CancellationToken cancellationToken)

    [HttpPost]
    public async Task<ActionResult<TaskResponse>> Create([FromBody] CreateTaskRequest request, CancellationToken cancellationToken)

    [HttpPut("{id:guid}")]
    public async Task<ActionResult<TaskResponse>> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken cancellationToken)

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
}

Parameter Binding

FromBody for Complex Types

[HttpPost]
public async Task<ActionResult<TaskResponse>> Create([FromBody] CreateTaskRequest request)

FromQuery for Filtering/Pagination

[HttpGet]
public async Task<ActionResult<PagedResult<TaskResponse>>> GetAll(
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 10,
    [FromQuery] string? status = null,
    CancellationToken cancellationToken = default)

FromRoute for Resource IDs

[HttpGet("{id:guid}")]
public async Task<ActionResult<TaskResponse>> GetById([FromRoute] Guid id)

Response Types

ActionResult<T> for All Responses

// Good - explicit return type
public async Task<ActionResult<TaskResponse>> GetById(Guid id)

// Avoid - IActionResult loses type info
public async Task<IActionResult> GetById(Guid id)

Proper Status Codes

// 200 OK - successful GET
return Ok(result);

// 201 Created - successful POST
return CreatedAtAction(nameof(GetById), new { id = task.Id }, response);

// 204 No Content - successful DELETE
return NoContent();

// 400 Bad Request - validation failure
return BadRequest(ModelState);

// 404 Not Found - resource doesn't exist
return NotFound();

Request/Response DTOs

Separate Request and Response Types

// Request DTO - what client sends
public record CreateTaskRequest(
    [Required] string Title,
    string? Description);

// Response DTO - what API returns
public record TaskResponse(
    Guid Id,
    string Title,
    string? Description,
    bool IsCompleted,
    DateTime CreatedAt);

Never Expose Domain Models Directly

// Bad - exposes internal model
[HttpGet("{id:guid}")]
public async Task<ActionResult<TaskItem>> GetById(Guid id)

// Good - uses response DTO
[HttpGet("{id:guid}")]
public async Task<ActionResult<TaskResponse>> GetById(Guid id)

Validation

Use Data Annotations on DTOs

public record CreateTaskRequest(
    [Required]
    [StringLength(200, MinimumLength = 1)]
    string Title,

    [StringLength(2000)]
    string? Description);

Model State is Automatic

With

[ApiController]
, invalid model state returns 400 automatically - no manual checks needed.

Complete Controller Example

using Microsoft.AspNetCore.Mvc;

namespace TaskApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class TasksController(ITaskService taskService) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<TaskResponse>>> GetAll(CancellationToken cancellationToken)
    {
        var tasks = await taskService.GetAllAsync(cancellationToken);
        return Ok(tasks);
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<TaskResponse>> GetById(Guid id, CancellationToken cancellationToken)
    {
        var task = await taskService.GetByIdAsync(id, cancellationToken);
        return task is null ? NotFound() : Ok(task);
    }

    [HttpPost]
    public async Task<ActionResult<TaskResponse>> Create([FromBody] CreateTaskRequest request, CancellationToken cancellationToken)
    {
        var task = await taskService.CreateAsync(request, cancellationToken);
        return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);
    }

    [HttpPut("{id:guid}")]
    public async Task<ActionResult<TaskResponse>> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken cancellationToken)
    {
        var task = await taskService.UpdateAsync(id, request, cancellationToken);
        return task is null ? NotFound() : Ok(task);
    }

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
    {
        var deleted = await taskService.DeleteAsync(id, cancellationToken);
        return deleted ? NoContent() : NotFound();
    }
}