Learn how to create RESTful API controllers following ASP.NET Core best practices.
Overview​
API Controllers in AppBlueprint follow these conventions:
- Inherit from
BaseControllerorControllerBase - Use attribute routing with API versioning
- Return
ActionResult<T>for type-safe responses - Include XML documentation comments
- Use proper HTTP status codes
- Validate inputs and handle errors gracefully
Controller Structure​
Basic Controller Template​
Location: Shared-Modules/AppBlueprint.Presentation.ApiModule/Controllers/ProjectController.cs
using AppBlueprint.Application.DTOs;
using AppBlueprint.Application.Interfaces;
using AppBlueprint.Domain.Entities;
using AppBlueprint.SharedKernel;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace AppBlueprint.Presentation.ApiModule.Controllers;
[Authorize]
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class ProjectController : ControllerBase
{
private readonly ILogger<ProjectController> _logger;
private readonly IProjectRepository _projectRepository;
public ProjectController(
ILogger<ProjectController> logger,
IProjectRepository projectRepository)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
}
/// <summary>
/// Gets all projects for a specific team.
/// </summary>
/// <param name="teamId">The team ID to filter projects.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of projects.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ProjectDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult<IEnumerable<ProjectDto>>> GetProjects(
[FromQuery] string? teamId,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(teamId))
{
return BadRequest("TeamId is required");
}
if (!TeamId.TryParse(teamId, out var parsedTeamId))
{
return BadRequest("Invalid team ID format");
}
_logger.LogInformation("Getting projects for team {TeamId}", teamId);
var projects = await _projectRepository.GetByTeamIdAsync(parsedTeamId, cancellationToken);
var projectDtos = projects.Select(p => new ProjectDto
{
Id = p.Id.ToString(),
Name = p.Name,
Description = p.Description,
TeamId = p.TeamId.ToString(),
Status = p.Status.ToString(),
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt
});
return Ok(projectDtos);
}
/// <summary>
/// Gets a specific project by ID.
/// </summary>
/// <param name="id">The project ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The project details.</returns>
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult<ProjectDto>> GetProject(
string id,
CancellationToken cancellationToken)
{
if (!ProjectId.TryParse(id, out var projectId))
{
return BadRequest("Invalid project ID format");
}
_logger.LogInformation("Getting project {ProjectId}", id);
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
if (project is null)
{
_logger.LogWarning("Project {ProjectId} not found", id);
return NotFound($"Project with ID {id} not found");
}
var projectDto = new ProjectDto
{
Id = project.Id.ToString(),
Name = project.Name,
Description = project.Description,
TeamId = project.TeamId.ToString(),
Status = project.Status.ToString(),
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt
};
return Ok(projectDto);
}
/// <summary>
/// Creates a new project.
/// </summary>
/// <param name="request">The project creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created project.</returns>
[HttpPost]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult<ProjectDto>> CreateProject(
[FromBody] CreateProjectRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (!TeamId.TryParse(request.TeamId, out var teamId))
{
return BadRequest("Invalid team ID format");
}
_logger.LogInformation("Creating new project: {ProjectName} for team {TeamId}",
request.Name, request.TeamId);
var project = Project.Create(request.Name, request.Description, teamId);
await _projectRepository.AddAsync(project, cancellationToken);
_logger.LogInformation("Project created successfully with ID: {ProjectId}", project.Id);
var projectDto = new ProjectDto
{
Id = project.Id.ToString(),
Name = project.Name,
Description = project.Description,
TeamId = project.TeamId.ToString(),
Status = project.Status.ToString(),
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt
};
return CreatedAtAction(
nameof(GetProject),
new { id = project.Id.ToString() },
projectDto);
}
/// <summary>
/// Updates an existing project.
/// </summary>
/// <param name="id">The project ID.</param>
/// <param name="request">The update request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated project.</returns>
[HttpPut("{id}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult<ProjectDto>> UpdateProject(
string id,
[FromBody] UpdateProjectRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (!ProjectId.TryParse(id, out var projectId))
{
return BadRequest("Invalid project ID format");
}
_logger.LogInformation("Updating project {ProjectId}", id);
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
if (project is null)
{
_logger.LogWarning("Project {ProjectId} not found", id);
return NotFound($"Project with ID {id} not found");
}
project.UpdateDetails(request.Name, request.Description);
await _projectRepository.UpdateAsync(project, cancellationToken);
_logger.LogInformation("Project updated successfully: {ProjectId}", id);
var projectDto = new ProjectDto
{
Id = project.Id.ToString(),
Name = project.Name,
Description = project.Description,
TeamId = project.TeamId.ToString(),
Status = project.Status.ToString(),
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt
};
return Ok(projectDto);
}
/// <summary>
/// Archives a project (soft delete).
/// </summary>
/// <param name="id">The project ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>No content.</returns>
[HttpPatch("{id}/archive")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult> ArchiveProject(
string id,
CancellationToken cancellationToken)
{
if (!ProjectId.TryParse(id, out var projectId))
{
return BadRequest("Invalid project ID format");
}
_logger.LogInformation("Archiving project {ProjectId}", id);
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
if (project is null)
{
_logger.LogWarning("Project {ProjectId} not found", id);
return NotFound($"Project with ID {id} not found");
}
project.Archive();
await _projectRepository.UpdateAsync(project, cancellationToken);
_logger.LogInformation("Project archived successfully: {ProjectId}", id);
return NoContent();
}
/// <summary>
/// Deletes a project permanently.
/// </summary>
/// <param name="id">The project ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>No content.</returns>
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[MapToApiVersion("1.0")]
public async Task<ActionResult> DeleteProject(
string id,
CancellationToken cancellationToken)
{
if (!ProjectId.TryParse(id, out var projectId))
{
return BadRequest("Invalid project ID format");
}
_logger.LogInformation("Deleting project {ProjectId}", id);
await _projectRepository.DeleteAsync(projectId, cancellationToken);
_logger.LogInformation("Project deleted successfully: {ProjectId}", id);
return NoContent();
}
}
DTOs (Data Transfer Objects)​
Create request and response DTOs in the Application layer.
Location: Shared-Modules/AppBlueprint.Application/DTOs/ProjectDto.cs
using System.ComponentModel.DataAnnotations;
namespace AppBlueprint.Application.DTOs;
public record ProjectDto
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required string TeamId { get; init; }
public required string Status { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
}
public record CreateProjectRequest
{
[Required(ErrorMessage = "Project name is required")]
[StringLength(200, ErrorMessage = "Name must not exceed 200 characters")]
public required string Name { get; init; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description must not exceed 2000 characters")]
public required string Description { get; init; }
[Required(ErrorMessage = "Team ID is required")]
public required string TeamId { get; init; }
}
public record UpdateProjectRequest
{
[Required(ErrorMessage = "Project name is required")]
[StringLength(200, ErrorMessage = "Name must not exceed 200 characters")]
public required string Name { get; init; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description must not exceed 2000 characters")]
public required string Description { get; init; }
}
HTTP Status Codes Guide​
Use appropriate status codes for different scenarios:
| Status Code | When to Use | Example |
|---|---|---|
| 200 OK | Successful GET, PUT, PATCH | Returning data, successful update |
| 201 Created | Successful POST | New resource created |
| 204 No Content | Successful DELETE or action with no response | Delete succeeded |
| 400 Bad Request | Invalid input, validation failed | Missing required fields |
| 401 Unauthorized | Missing/invalid authentication | No JWT token |
| 403 Forbidden | Authenticated but not authorized | User not in correct role |
| 404 Not Found | Resource doesn't exist | Entity with ID not found |
| 409 Conflict | Resource conflict | Duplicate entry |
| 500 Internal Server Error | Unexpected server error | Unhandled exception |
Best Practices​
✅ DO​
- Use async/await with cancellation tokens
- Validate strongly-typed IDs before use
- Log at appropriate levels (Information, Warning, Error)
- Return DTOs, not domain entities
- Use XML documentation comments
- Include proper ProducesResponseType attributes
- Validate ModelState for POST/PUT requests
- Use primary constructor syntax where appropriate
❌ DON'T​
- Don't return domain entities directly
- Don't catch and hide exceptions without logging
- Don't use
stringfor IDs - parse to strongly-typed IDs - Don't forget authorization attributes
- Don't ignore cancellation tokens
- Don't expose internal implementation details
Advanced Patterns​
Multi-Tenancy​
Extract tenant ID from HttpContext:
private string GetTenantId()
{
return HttpContext.Items["TenantId"]?.ToString()
?? throw new InvalidOperationException("Tenant ID not found");
}
[HttpGet]
public async Task<ActionResult<IEnumerable<ProjectDto>>> GetProjects(
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var projects = await _projectRepository.GetByTenantIdAsync(tenantId, cancellationToken);
// ...
}
Action Filters​
Create reusable filters for cross-cutting concerns:
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
[HttpPost]
[ValidateModel]
public async Task<ActionResult> CreateProject([FromBody] CreateProjectRequest request)
{
// ModelState already validated by filter
}
Error Handling​
Implement global exception handling:
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var response = new
{
Message = "An error occurred processing your request",
Details = exception.Message
};
return context.Response.WriteAsJsonAsync(response);
}
}
Testing Controllers​
Test controller actions using integration tests:
public class ProjectControllerTests
{
[Test]
public async Task CreateProject_WithValidData_ReturnsCreated()
{
// Arrange
var request = new CreateProjectRequest
{
Name = "Test Project",
Description = "Test Description",
TeamId = TeamId.NewId().ToString()
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/project", request);
// Assert
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
}
}
Next Steps​
Examples in Codebase​
See existing implementations:
TodoController- Basic CRUD operationsTeamController- Relationships and validationAuthenticationController- Complex business logicDashboard.razor- Tailwind CSS admin dashboard