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.mdsource 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:
- Call the service
- 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(); } }