
Web Api
- 22 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with backend & apis tasks.
About
web-api is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted coding.
- web-api
- Backend & APIs
- AI-coding skill
Web Api by the numbers
- 22 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,440 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill web-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 22 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with backend & apis tasks.
Files
ASP.NET Core Web API
Trigger On
- working on controller-based APIs in ASP.NET Core
- needing controller-specific extensibility or conventions
- migrating or reviewing existing API controllers and filters
Workflow
1. Use controllers when the API needs controller-centric features, not simply because older templates did so. 2. Keep controllers thin: map HTTP concerns to application services or handlers, and avoid embedding data access and business rules directly in actions. 3. Use clear DTO boundaries, explicit validation, and predictable HTTP status behavior. 4. Review authentication and authorization at both controller and endpoint levels so the API surface is not accidentally inconsistent. 5. Keep OpenAPI generation, versioning, and error contract behavior deliberate rather than incidental. 6. Use minimal-apis for new simple APIs instead of defaulting to controllers out of habit.
Current Upstream Notes
dotnet/aspnetcorev9.0.17is servicing. Controller-based API guidance still depends on whether the project needs controller conventions, advanced model binding, OData, JsonPatch, or existing filter conventions.- Use the
aspnetcore-10.0Learn overview for exact current routing, OpenAPI, auth, and hosting links before changing public API contracts.
Deliver
- controller APIs with explicit contracts and policies
- reduced controller bloat
- tests or smoke checks for critical API behavior
Validate
- controller features are actually justified
- actions do not hide business logic and persistence details
- HTTP semantics stay predictable across endpoints
Controller Structure
Use primary constructors (C# 12+) for dependency injection and keep controllers focused on HTTP concerns:
[ApiController]
[Route("api/[controller]")]
public class OrdersController(
IOrderService orderService,
ILogger<OrdersController> logger) : ControllerBase
{
[HttpGet("{id:guid}")]
[ProducesResponseType<OrderDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var order = await orderService.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
[HttpPost]
[ProducesResponseType<OrderDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var order = await orderService.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
}Model Binding
Explicitly declare binding sources for clarity:
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetWithOptions(
[FromRoute] Guid id,
[FromQuery] bool includeDeleted = false,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId = null,
CancellationToken ct = default)
{
// Route: id, Query: includeDeleted, Header: X-Correlation-Id
}Use record types with required members for request DTOs:
public record CreateProductRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string> Tags { get; init; } = [];
}Validation
Prefer FluentValidation for complex validation rules:
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator(IProductRepository products)
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer ID is required");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty()
.MustAsync(async (id, ct) => await products.ExistsAsync(id, ct))
.WithMessage("Product does not exist");
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100);
});
}
}Configure consistent Problem Details responses:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest,
Instance = context.HttpContext.Request.Path
};
return new BadRequestObjectResult(problemDetails);
};
});API Versioning
Configure URL path versioning:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("1.0")]
public class ProductsV1Controller(IProductService productService) : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var product = await productService.GetAsync(id, ct);
return Ok(product);
}
}Exception Handling
Use global exception handlers for consistent error responses:
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception occurred");
var problemDetails = exception switch
{
ValidationException validationEx => new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Detail = validationEx.Message
},
NotFoundException notFoundEx => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Resource Not Found",
Detail = notFoundEx.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error"
}
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
httpContext.Response.StatusCode = problemDetails.Status ?? 500;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}References
- patterns.md - Controller patterns, model binding, validation, versioning, response handling, and filter patterns
- anti-patterns.md - Common API mistakes to avoid including fat controllers, inconsistent errors, missing cancellation tokens, and improper HTTP semantics
{
"version": "1.0.1",
"category": "Web"
}
ASP.NET Core Web API Anti-Patterns
Fat Controllers
Problem: Business Logic in Controllers
Controllers should delegate to services, not implement business logic directly.
// BAD: Controller contains business logic, data access, and validation
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly AppDbContext _context;
public OrdersController(AppDbContext context)
{
_context = context;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
// Business logic embedded in controller
if (request.Items.Count == 0)
return BadRequest("Order must have items");
var customer = await _context.Customers.FindAsync(request.CustomerId);
if (customer == null)
return BadRequest("Customer not found");
decimal total = 0;
var orderItems = new List<OrderItem>();
foreach (var item in request.Items)
{
var product = await _context.Products.FindAsync(item.ProductId);
if (product == null)
return BadRequest($"Product {item.ProductId} not found");
if (product.Stock < item.Quantity)
return BadRequest($"Insufficient stock for {product.Name}");
product.Stock -= item.Quantity;
total += product.Price * item.Quantity;
orderItems.Add(new OrderItem
{
ProductId = product.Id,
Quantity = item.Quantity,
UnitPrice = product.Price
});
}
// Apply discount logic
if (customer.IsPremium)
total *= 0.9m;
var order = new Order
{
CustomerId = customer.Id,
Items = orderItems,
Total = total,
CreatedAt = DateTime.UtcNow
};
_context.Orders.Add(order);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
}// GOOD: Thin controller delegating to services
[ApiController]
[Route("api/[controller]")]
public class OrdersController(IOrderService orderService) : ControllerBase
{
[HttpPost]
[ProducesResponseType<OrderDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var result = await orderService.CreateAsync(request, ct);
return result.Match<IActionResult>(
success => CreatedAtAction(nameof(GetOrder), new { id = success.Id }, success),
failure => BadRequest(failure.ToProblemDetails()));
}
}---
Inconsistent Error Responses
Problem: Mixed Error Response Formats
// BAD: Inconsistent error responses across endpoints
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var item = await _service.GetAsync(id);
if (item == null)
return NotFound(); // Returns empty body
}
[HttpPost]
public async Task<IActionResult> Create(CreateRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState); // Returns ModelState dictionary
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateRequest request)
{
try
{
await _service.UpdateAsync(id, request);
return Ok();
}
catch (NotFoundException)
{
return NotFound(new { error = "Item not found" }); // Returns anonymous object
}
catch (ValidationException ex)
{
return BadRequest(ex.Message); // Returns plain string
}
}// GOOD: Consistent Problem Details responses
[ApiController]
[Route("api/[controller]")]
public class ItemsController(IItemService itemService) : ControllerBase
{
[HttpGet("{id}")]
[ProducesResponseType<ItemDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var item = await itemService.GetAsync(id, ct);
if (item is null)
{
return Problem(
detail: $"Item with ID {id} was not found",
statusCode: StatusCodes.Status404NotFound,
title: "Resource Not Found");
}
return Ok(item);
}
[HttpPost]
[ProducesResponseType<ItemDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
{
// Validation handled automatically via [ApiController] and configured InvalidModelStateResponseFactory
var item = await itemService.CreateAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
}
}---
Missing Cancellation Token Support
Problem: Ignoring CancellationToken
// BAD: No cancellation token - wastes resources when client disconnects
[HttpGet]
public async Task<IActionResult> GetAll()
{
var items = await _repository.GetAllAsync(); // No CT
var enriched = await _enrichmentService.EnrichAsync(items); // No CT
return Ok(enriched);
}
[HttpPost("import")]
public async Task<IActionResult> Import(ImportRequest request)
{
// Long-running operation with no cancellation support
foreach (var item in request.Items)
{
await _service.ProcessAsync(item);
}
return Ok();
}// GOOD: Proper cancellation token propagation
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
{
var items = await _repository.GetAllAsync(ct);
var enriched = await _enrichmentService.EnrichAsync(items, ct);
return Ok(enriched);
}
[HttpPost("import")]
public async Task<IActionResult> Import(
ImportRequest request,
CancellationToken ct)
{
foreach (var item in request.Items)
{
ct.ThrowIfCancellationRequested();
await _service.ProcessAsync(item, ct);
}
return Ok();
}---
Exposing Entity Models Directly
Problem: Returning Database Entities as API Responses
// BAD: Exposing EF entities directly
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var user = await _context.Users
.Include(u => u.Orders)
.FirstOrDefaultAsync(u => u.Id == id);
return Ok(user); // Exposes navigation properties, internal fields, circular references
}
// Entity with sensitive data
public class User
{
public int Id { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; } // Leaked!
public string SecurityStamp { get; set; } // Leaked!
public decimal InternalCreditScore { get; set; } // Leaked!
public List<Order> Orders { get; set; } // Circular reference issues
}// GOOD: Using DTOs with explicit mapping
public record UserDto(
int Id,
string Email,
string DisplayName,
IReadOnlyList<OrderSummaryDto> RecentOrders);
public record OrderSummaryDto(
int Id,
DateTime CreatedAt,
decimal Total);
[HttpGet("{id}")]
[ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var user = await _userService.GetByIdAsync(id, ct);
if (user is null)
return NotFound();
return Ok(user); // Returns UserDto, not entity
}---
Synchronous Blocking in Async Methods
Problem: Blocking Calls in Async Context
// BAD: Blocking calls that can cause thread pool starvation
[HttpGet]
public async Task<IActionResult> Get()
{
var data = _httpClient.GetStringAsync("https://api.example.com/data").Result; // Blocks!
var processed = Task.Run(() => ProcessData(data)).Result; // Blocks!
Thread.Sleep(1000); // Blocks the thread
return Ok(processed);
}
[HttpPost]
public async Task<IActionResult> Create(Request request)
{
// Using .Wait() blocks the thread
_backgroundService.ProcessAsync(request).Wait();
return Ok();
}// GOOD: Fully async operations
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
var data = await _httpClient.GetStringAsync("https://api.example.com/data", ct);
var processed = await ProcessDataAsync(data, ct);
await Task.Delay(1000, ct); // If delay is actually needed
return Ok(processed);
}
[HttpPost]
public async Task<IActionResult> Create(Request request, CancellationToken ct)
{
await _backgroundService.ProcessAsync(request, ct);
return Ok();
}---
Over-Fetching Data
Problem: Loading All Data When Only Some Is Needed
// BAD: Loading entire entity graph when only ID and name needed
[HttpGet]
public async Task<IActionResult> GetProductNames()
{
var products = await _context.Products
.Include(p => p.Category)
.Include(p => p.Supplier)
.Include(p => p.Reviews)
.Include(p => p.Images)
.ToListAsync();
return Ok(products.Select(p => new { p.Id, p.Name }));
}
// BAD: N+1 query pattern
[HttpGet]
public async Task<IActionResult> GetOrdersWithCustomers()
{
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
order.Customer = await _context.Customers.FindAsync(order.CustomerId); // N+1!
}
return Ok(orders);
}// GOOD: Project only what you need
[HttpGet]
public async Task<IActionResult> GetProductNames(CancellationToken ct)
{
var products = await _context.Products
.Select(p => new ProductNameDto(p.Id, p.Name))
.ToListAsync(ct);
return Ok(products);
}
// GOOD: Eager load related data in single query
[HttpGet]
public async Task<IActionResult> GetOrdersWithCustomers(CancellationToken ct)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Select(o => new OrderWithCustomerDto(
o.Id,
o.Total,
o.Customer.Name))
.ToListAsync(ct);
return Ok(orders);
}---
Improper Dependency Injection Scopes
Problem: Scope Mismatch and Captive Dependencies
// BAD: Singleton capturing scoped service (captive dependency)
public class CacheService // Registered as Singleton
{
private readonly AppDbContext _context; // Scoped! Will cause issues
public CacheService(AppDbContext context)
{
_context = context; // Context disposed after first request
}
}
// BAD: Creating services manually instead of using DI
[ApiController]
public class BadController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var service = new OrderService(new AppDbContext()); // Manual creation
return Ok(service.GetOrders());
}
}// GOOD: Proper scope management
public class CacheService(IServiceScopeFactory scopeFactory) // Singleton-safe
{
public async Task<T?> GetOrSetAsync<T>(
string key,
Func<AppDbContext, Task<T>> factory,
CancellationToken ct)
{
// Create scope when needed
using var scope = scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await factory(context);
}
}
// GOOD: Use DI properly
[ApiController]
public class GoodController(IOrderService orderService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
return Ok(await orderService.GetOrdersAsync(ct));
}
}---
Missing or Inconsistent Validation
Problem: Incomplete or Scattered Validation
// BAD: Validation scattered and inconsistent
[HttpPost]
public async Task<IActionResult> Create(CreateUserRequest request)
{
// Some validation in controller
if (string.IsNullOrEmpty(request.Email))
return BadRequest("Email required");
// Some in service
var result = await _userService.CreateAsync(request); // Might throw or return error
// Duplicate validation
if (!IsValidEmail(request.Email))
return BadRequest("Invalid email format");
return Ok(result);
}
// BAD: No validation at all
[HttpPost]
public async Task<IActionResult> CreateUnsafe(CreateRequest request)
{
// Trust the input blindly
await _repository.InsertAsync(request.ToEntity());
return Ok();
}// GOOD: Centralized validation with FluentValidation
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator(IUserRepository users)
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MustAsync(async (email, ct) => !await users.EmailExistsAsync(email, ct))
.WithMessage("Email already registered");
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches("[A-Z]").WithMessage("Password must contain uppercase letter")
.Matches("[0-9]").WithMessage("Password must contain digit");
RuleFor(x => x.Age)
.InclusiveBetween(18, 120);
}
}
// Controller stays clean
[HttpPost]
[ProducesResponseType<UserDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
{
// Validation already performed by pipeline
var user = await _userService.CreateAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = user.Id }, user);
}---
Swallowing Exceptions
Problem: Catching and Hiding Errors
// BAD: Swallowing exceptions silently
[HttpPost]
public async Task<IActionResult> Process(ProcessRequest request)
{
try
{
await _service.ProcessAsync(request);
return Ok();
}
catch (Exception)
{
// Swallowed - no logging, no indication of failure
return Ok(); // Returns success even on failure!
}
}
// BAD: Generic catch returning vague error
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
try
{
return Ok(await _service.GetAsync(id));
}
catch (Exception)
{
return StatusCode(500, "An error occurred"); // No details, no logging
}
}// GOOD: Let global exception handler manage unexpected errors
[HttpPost]
public async Task<IActionResult> Process(ProcessRequest request, CancellationToken ct)
{
// No try-catch for unexpected exceptions - let global handler deal with them
await _service.ProcessAsync(request, ct);
return Ok();
}
// GOOD: Handle expected exceptions explicitly, log and surface appropriately
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
try
{
return Ok(await _service.GetAsync(id, ct));
}
catch (EntityNotFoundException)
{
// Expected case - return 404
return NotFound();
}
// Unexpected exceptions propagate to global handler
}---
Hardcoded Configuration
Problem: Magic Strings and Hardcoded Values
// BAD: Hardcoded configuration values
[HttpGet]
public async Task<IActionResult> GetExternal()
{
var client = new HttpClient
{
BaseAddress = new Uri("https://api.example.com"), // Hardcoded
Timeout = TimeSpan.FromSeconds(30) // Hardcoded
};
client.DefaultRequestHeaders.Add("X-Api-Key", "abc123secret"); // Hardcoded secret!
var response = await client.GetAsync("/data");
return Ok(await response.Content.ReadAsStringAsync());
}// GOOD: Configuration-driven approach
public class ExternalApiOptions
{
public const string SectionName = "ExternalApi";
public required string BaseUrl { get; init; }
public required int TimeoutSeconds { get; init; }
}
// In Program.cs
builder.Services.Configure<ExternalApiOptions>(
builder.Configuration.GetSection(ExternalApiOptions.SectionName));
builder.Services.AddHttpClient("ExternalApi", (sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ExternalApiOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
});
// Controller
[HttpGet]
public async Task<IActionResult> GetExternal(
[FromServices] IHttpClientFactory clientFactory,
CancellationToken ct)
{
var client = clientFactory.CreateClient("ExternalApi");
var response = await client.GetAsync("/data", ct);
return Ok(await response.Content.ReadAsStringAsync(ct));
}---
Missing OpenAPI Documentation
Problem: Undocumented or Poorly Documented APIs
// BAD: No response type documentation
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var item = await _service.GetAsync(id);
if (item == null) return NotFound();
return Ok(item);
}
// BAD: Object return type loses type information
[HttpGet]
public async Task<object> GetAll()
{
return await _service.GetAllAsync();
}// GOOD: Fully documented endpoint
/// <summary>
/// Retrieves a specific item by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the item.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The requested item.</returns>
/// <response code="200">Returns the requested item.</response>
/// <response code="404">If the item is not found.</response>
[HttpGet("{id}")]
[ProducesResponseType<ItemDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var item = await _service.GetAsync(id, ct);
if (item is null)
{
return Problem(
detail: $"Item with ID {id} not found",
statusCode: StatusCodes.Status404NotFound);
}
return Ok(item);
}
/// <summary>
/// Retrieves all items with optional filtering.
/// </summary>
[HttpGet]
[ProducesResponseType<IReadOnlyList<ItemDto>>(StatusCodes.Status200OK)]
public async Task<IReadOnlyList<ItemDto>> GetAll(
[FromQuery] string? category,
CancellationToken ct)
{
return await _service.GetAllAsync(category, ct);
}---
Ignoring HTTP Semantics
Problem: Misusing HTTP Methods and Status Codes
// BAD: GET with side effects
[HttpGet("send-email/{userId}")]
public async Task<IActionResult> SendEmail(int userId)
{
await _emailService.SendWelcomeEmail(userId); // Side effect on GET!
return Ok();
}
// BAD: Always returning 200 OK
[HttpPost]
public async Task<IActionResult> Create(CreateRequest request)
{
var result = await _service.CreateAsync(request);
return Ok(result); // Should be 201 Created
}
// BAD: Wrong status codes
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var exists = await _service.ExistsAsync(id);
if (!exists)
return Ok(); // Should be 404
await _service.DeleteAsync(id);
return Ok(new { message = "Deleted" }); // Should be 204 No Content
}// GOOD: Proper HTTP semantics
[HttpPost("users/{userId}/welcome-email")]
public async Task<IActionResult> SendWelcomeEmail(int userId, CancellationToken ct)
{
await _emailService.SendWelcomeEmailAsync(userId, ct);
return Accepted(); // 202 for async operations
}
[HttpPost]
[ProducesResponseType<ItemDto>(StatusCodes.Status201Created)]
public async Task<IActionResult> Create(CreateRequest request, CancellationToken ct)
{
var item = await _service.CreateAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var deleted = await _service.DeleteAsync(id, ct);
if (!deleted)
return NotFound();
return NoContent();
}---
Tight Controller-to-Controller Coupling
Problem: Controllers Calling Other Controllers
// BAD: Controller calling another controller
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
private readonly OrdersController _ordersController;
private readonly UsersController _usersController;
public ReportsController(
OrdersController ordersController,
UsersController usersController)
{
_ordersController = ordersController;
_usersController = usersController;
}
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
var orders = await _ordersController.GetAll() as OkObjectResult;
var users = await _usersController.GetAll() as OkObjectResult;
// Process and combine...
}
}// GOOD: Controllers depend on shared services
[ApiController]
[Route("api/[controller]")]
public class ReportsController(
IOrderService orderService,
IUserService userService,
IReportGenerator reportGenerator) : ControllerBase
{
[HttpGet("summary")]
[ProducesResponseType<ReportSummaryDto>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetSummary(CancellationToken ct)
{
var orders = await orderService.GetAllAsync(ct);
var users = await userService.GetAllAsync(ct);
var summary = reportGenerator.GenerateSummary(orders, users);
return Ok(summary);
}
}ASP.NET Core Web API Patterns
Controller Patterns
Thin Controllers with Service Delegation
Controllers should map HTTP concerns to services, not implement business logic.
[ApiController]
[Route("api/[controller]")]
public class OrdersController(
IOrderService orderService,
ILogger<OrdersController> logger) : ControllerBase
{
[HttpGet("{id:guid}")]
[ProducesResponseType<OrderDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var order = await orderService.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
[HttpPost]
[ProducesResponseType<OrderDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var order = await orderService.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
}Feature-Sliced Controllers
Group related endpoints by feature rather than by entity when it improves cohesion.
[ApiController]
[Route("api/checkout")]
public class CheckoutController(
ICartService cartService,
IPaymentService paymentService,
IOrderService orderService) : ControllerBase
{
[HttpPost("validate")]
public async Task<IActionResult> ValidateCart(CancellationToken ct)
{
var result = await cartService.ValidateCurrentCartAsync(ct);
return result.IsValid ? Ok() : BadRequest(result.Errors);
}
[HttpPost("payment")]
public async Task<IActionResult> ProcessPayment(
PaymentRequest request,
CancellationToken ct)
{
var result = await paymentService.ProcessAsync(request, ct);
return result.Succeeded ? Ok(result.TransactionId) : BadRequest(result.Error);
}
[HttpPost("complete")]
public async Task<IActionResult> CompleteOrder(CancellationToken ct)
{
var order = await orderService.CreateFromCartAsync(ct);
return CreatedAtRoute("GetOrder", new { id = order.Id }, order);
}
}Base Controller for Shared Behavior
Use a base controller for cross-cutting concerns like user context extraction.
[ApiController]
public abstract class ApiControllerBase(ICurrentUserService currentUser) : ControllerBase
{
protected Guid UserId => currentUser.UserId
?? throw new UnauthorizedAccessException("User not authenticated");
protected string? TenantId => currentUser.TenantId;
protected IActionResult Problem(Error error) => error.Type switch
{
ErrorType.NotFound => NotFound(error.ToProblemDetails()),
ErrorType.Validation => BadRequest(error.ToProblemDetails()),
ErrorType.Conflict => Conflict(error.ToProblemDetails()),
ErrorType.Forbidden => Forbid(),
_ => StatusCode(500, error.ToProblemDetails())
};
}---
Model Binding Patterns
Binding from Multiple Sources
Combine route, query, header, and body binding explicitly.
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetWithOptions(
[FromRoute] Guid id,
[FromQuery] bool includeDeleted = false,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId = null,
CancellationToken ct = default)
{
// Route: id
// Query: ?includeDeleted=true
// Header: X-Correlation-Id
}
[HttpPost("{id:guid}/comments")]
public async Task<IActionResult> AddComment(
[FromRoute] Guid id,
[FromBody] CreateCommentRequest request,
[FromServices] ICommentService commentService,
CancellationToken ct)
{
// Explicit binding sources for clarity
}Custom Model Binder for Complex Types
public class CommaSeparatedArrayBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
var value = valueProviderResult.FirstValue;
if (string.IsNullOrEmpty(value))
{
bindingContext.Result = ModelBindingResult.Success(Array.Empty<string>());
return Task.CompletedTask;
}
var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
bindingContext.Result = ModelBindingResult.Success(values);
return Task.CompletedTask;
}
}
// Usage
[HttpGet]
public IActionResult Search(
[ModelBinder(typeof(CommaSeparatedArrayBinder))] string[] tags)
{
// GET /api/items?tags=csharp,dotnet,api
}Record DTOs with Required Members
public record CreateProductRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string> Tags { get; init; } = [];
}
public record UpdateProductRequest
{
public string? Name { get; init; }
public decimal? Price { get; init; }
public string? Description { get; init; }
}---
Validation Patterns
FluentValidation Integration
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator(IProductRepository products)
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer ID is required");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty()
.MustAsync(async (id, ct) => await products.ExistsAsync(id, ct))
.WithMessage("Product does not exist");
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100);
});
}
}
// Registration
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();Custom Validation Filter
public class ValidateModelFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (!context.ModelState.IsValid)
{
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult(new ValidationProblemDetails(errors));
return;
}
await next();
}
}Validation with Problem Details
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
});
// Configure validation to return Problem Details
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest,
Instance = context.HttpContext.Request.Path
};
return new BadRequestObjectResult(problemDetails);
};
});---
API Versioning Patterns
URL Path Versioning
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("1.0")]
public class ProductsV1Controller(IProductService productService) : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var product = await productService.GetAsync(id, ct);
return Ok(product);
}
}
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("2.0")]
public class ProductsV2Controller(IProductService productService) : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var product = await productService.GetEnrichedAsync(id, ct);
return Ok(product); // Returns enhanced DTO
}
}Header-Based Versioning
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
});Version Deprecation
[ApiController]
[Route("api/v{version:apiVersion}/legacy")]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class LegacyController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult GetV1() => Ok("Deprecated endpoint");
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult GetV2() => Ok("Current endpoint");
}---
Response Patterns
Typed Response with TypedResults
[ApiController]
[Route("api/[controller]")]
public class UsersController(IUserService userService) : ControllerBase
{
[HttpGet("{id:guid}")]
[ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<Results<Ok<UserDto>, NotFound<ProblemDetails>>> GetById(
Guid id,
CancellationToken ct)
{
var user = await userService.GetByIdAsync(id, ct);
if (user is null)
{
return TypedResults.NotFound(new ProblemDetails
{
Title = "User not found",
Detail = $"No user exists with ID {id}",
Status = StatusCodes.Status404NotFound
});
}
return TypedResults.Ok(user);
}
}Pagination Response Pattern
public record PagedResponse<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount)
{
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}
[HttpGet]
[ProducesResponseType<PagedResponse<ProductDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var result = await productService.GetPagedAsync(page, pageSize, ct);
Response.Headers.Append("X-Total-Count", result.TotalCount.ToString());
Response.Headers.Append("X-Total-Pages", result.TotalPages.ToString());
return Ok(result);
}Result Pattern Integration
public class Result<T>
{
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error is null;
private Result(T value) => Value = value;
private Result(Error error) => Error = error;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new(error);
}
// Controller extension
public static class ControllerExtensions
{
public static IActionResult ToActionResult<T>(
this ControllerBase controller,
Result<T> result) where T : class
{
if (result.IsSuccess)
{
return controller.Ok(result.Value);
}
return result.Error!.Type switch
{
ErrorType.NotFound => controller.NotFound(result.Error.ToProblemDetails()),
ErrorType.Validation => controller.BadRequest(result.Error.ToProblemDetails()),
ErrorType.Conflict => controller.Conflict(result.Error.ToProblemDetails()),
_ => controller.StatusCode(500, result.Error.ToProblemDetails())
};
}
}---
Exception Handling Patterns
Global Exception Handler
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception occurred");
var problemDetails = exception switch
{
ValidationException validationEx => new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Detail = validationEx.Message,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
},
NotFoundException notFoundEx => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Resource Not Found",
Detail = notFoundEx.Message,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4"
},
UnauthorizedAccessException => new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Type = "https://tools.ietf.org/html/rfc7235#section-3.1"
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
}
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
httpContext.Response.StatusCode = problemDetails.Status ?? 500;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
// Registration
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();---
Filter Patterns
Action Filter for Logging
public class LoggingActionFilter(ILogger<LoggingActionFilter> logger) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var actionName = context.ActionDescriptor.DisplayName;
var arguments = context.ActionArguments;
logger.LogInformation(
"Executing {Action} with arguments {@Arguments}",
actionName,
arguments);
var stopwatch = Stopwatch.StartNew();
var result = await next();
stopwatch.Stop();
if (result.Exception is not null)
{
logger.LogError(
result.Exception,
"Action {Action} failed after {ElapsedMs}ms",
actionName,
stopwatch.ElapsedMilliseconds);
}
else
{
logger.LogInformation(
"Action {Action} completed in {ElapsedMs}ms",
actionName,
stopwatch.ElapsedMilliseconds);
}
}
}Resource Filter for Caching
public class ETagFilter : IAsyncResourceFilter
{
public async Task OnResourceExecutionAsync(
ResourceExecutingContext context,
ResourceExecutionDelegate next)
{
var result = await next();
if (result.Result is ObjectResult { Value: not null } objectResult)
{
var content = JsonSerializer.Serialize(objectResult.Value);
var etag = $"\"{ComputeHash(content)}\"";
context.HttpContext.Response.Headers.ETag = etag;
if (context.HttpContext.Request.Headers.IfNoneMatch == etag)
{
context.Result = new StatusCodeResult(StatusCodes.Status304NotModified);
}
}
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToBase64String(bytes)[..22];
}
}