Claude Skill

dotnet-web-api

Build or maintain controller-based ASP.NET Core APIs when the project needs controller conventions, advanced model binding, validation extensions, OData, JsonPatch, or existing API patterns.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-web-api-bfa4ebd.zip · 12 KB
Part of postpartum-genushyacinthus29/dotnet-skills — 80 skills

Install

skills CLI npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-web-api
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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 dotnet-minimal-apis for new simple APIs instead of defaulting to controllers out of habit.

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
Files (dotnet-skills)
  • references
    • anti-patterns.md 21.3 KB
      # ASP.NET Core Web API Anti-Patterns
      
      ## Fat Controllers
      
      ### Problem: Business Logic in Controllers
      
      Controllers should delegate to services, not implement business logic directly.
      
      ```csharp
      // 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);
          }
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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
          }
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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();
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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();
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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);
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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());
          }
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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();
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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
          }
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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());
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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();
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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
      }
      ```
      
      ```csharp
      // 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
      
      ```csharp
      // 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...
          }
      }
      ```
      
      ```csharp
      // 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);
          }
      }
      ```
      
    • patterns.md 16.5 KB
      # ASP.NET Core Web API Patterns
      
      ## Controller Patterns
      
      ### Thin Controllers with Service Delegation
      
      Controllers should map HTTP concerns to services, not implement business logic.
      
      ```csharp
      [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.
      
      ```csharp
      [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.
      
      ```csharp
      [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.
      
      ```csharp
      [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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      builder.Services.AddApiVersioning(options =>
      {
          options.DefaultApiVersion = new ApiVersion(1, 0);
          options.AssumeDefaultVersionWhenUnspecified = true;
          options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
      });
      ```
      
      ### Version Deprecation
      
      ```csharp
      [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
      
      ```csharp
      [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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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
      
      ```csharp
      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];
          }
      }
      ```
      
  • SKILL.md 7.3 KB
    ---
    name: dotnet-web-api
    version: "1.0.0"
    category: "Web"
    description: "Build or maintain controller-based ASP.NET Core APIs when the project needs controller conventions, advanced model binding, validation extensions, OData, JsonPatch, or existing API patterns."
    compatibility: "Requires an ASP.NET Core API project that uses or should use controllers."
    ---
    
    # 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 `dotnet-minimal-apis` for new simple APIs instead of defaulting to controllers out of habit.
    
    ## 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:
    
    ```csharp
    [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:
    
    ```csharp
    [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:
    
    ```csharp
    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:
    
    ```csharp
    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:
    
    ```csharp
    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:
    
    ```csharp
    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:
    
    ```csharp
    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](references/patterns.md) - Controller patterns, model binding, validation, versioning, response handling, and filter patterns
    - [anti-patterns.md](references/anti-patterns.md) - Common API mistakes to avoid including fat controllers, inconsistent errors, missing cancellation tokens, and improper HTTP semantics
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related