Claude Skill

dotnet-minimal-apis

Design and implement Minimal APIs in ASP.NET Core using handler-first endpoints, route groups, filters, and lightweight composition suited to modern .NET services.

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-minimal-apis-bfa4ebd.zip · 11 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-minimal-apis
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

Minimal APIs

Trigger On

  • building new HTTP APIs in ASP.NET Core
  • creating lightweight microservices
  • choosing between Minimal APIs and controllers
  • organizing endpoints with route groups
  • implementing validation and filters

Documentation

References

  • patterns.md - detailed route groups, filters, TypedResults patterns, parameter binding, error handling, and testing
  • anti-patterns.md - common Minimal API mistakes to avoid

When to Use Minimal APIs vs Controllers

Use Minimal APIs Use Controllers
New projects Existing MVC/API projects
Microservices Complex model binding
Simple CRUD APIs OData, JsonPatch
Lightweight handlers Heavy use of attributes
.NET 8+ projects Need [ApiController] features

Workflow

  1. Define endpoints directly in Program.cs (for small APIs)
  2. Use route groups for related endpoints
  3. Move handlers to separate classes as the API grows
  4. Apply filters for cross-cutting concerns
  5. Use TypedResults for type-safe responses
  6. Generate OpenAPI docs with .WithOpenApi()

Basic Patterns

Simple Endpoints

var app = builder.Build();

app.MapGet("/", () => "Hello World");

app.MapGet("/products/{id}", (int id) => Results.Ok(new { Id = id }));

app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product));

TypedResults (Strongly-Typed)

app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, AppDb db) =>
{
    var product = db.Products.Find(id);
    return product is not null
        ? TypedResults.Ok(product)
        : TypedResults.NotFound();
});

Dependency Injection

app.MapGet("/products", async (IProductService service) =>
{
    return await service.GetAllAsync();
});

// Or with [FromServices] for clarity
app.MapGet("/products", async ([FromServices] IProductService service) =>
    await service.GetAllAsync());

Route Groups

Basic Grouping

var products = app.MapGroup("/api/products");

products.MapGet("/", GetAll);
products.MapGet("/{id}", GetById);
products.MapPost("/", Create);
products.MapPut("/{id}", Update);
products.MapDelete("/{id}", Delete);

Groups with Shared Configuration

var api = app.MapGroup("/api")
    .RequireAuthorization()
    .AddEndpointFilter<ValidationFilter>();

var products = api.MapGroup("/products")
    .WithTags("Products");

var orders = api.MapGroup("/orders")
    .WithTags("Orders")
    .RequireAuthorization("AdminOnly");

Endpoint Filters

Inline Filter

app.MapGet("/products/{id}", (int id) => Results.Ok(id))
    .AddEndpointFilter(async (context, next) =>
    {
        var id = context.GetArgument<int>(0);
        if (id <= 0)
            return Results.BadRequest("Invalid ID");

        return await next(context);
    });

Class-Based Filter

public class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var argument = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (argument is null)
            return Results.BadRequest("Invalid request body");

        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<T>>();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(argument);
            if (!result.IsValid)
                return Results.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

// Usage
products.MapPost("/", Create)
    .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

Global Filters via Root Group

// All endpoints inherit filters from root group
var root = app.MapGroup("")
    .AddEndpointFilter<LoggingFilter>()
    .AddEndpointFilter<ErrorHandlingFilter>();

root.MapGet("/health", () => Results.Ok());
root.MapGroup("/api/products").MapGet("/", GetProducts);

Organizing Larger APIs

Extension Method Pattern

// ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/products")
            .WithTags("Products");

        group.MapGet("/", GetAll);
        group.MapGet("/{id}", GetById);
        group.MapPost("/", Create);

        return group;
    }

    private static async Task<Ok<List<Product>>> GetAll(IProductService service)
        => TypedResults.Ok(await service.GetAllAsync());

    private static async Task<Results<Ok<Product>, NotFound>> GetById(
        int id, IProductService service)
    {
        var product = await service.GetByIdAsync(id);
        return product is not null
            ? TypedResults.Ok(product)
            : TypedResults.NotFound();
    }

    private static async Task<Created<Product>> Create(
        CreateProductRequest request, IProductService service)
    {
        var product = await service.CreateAsync(request);
        return TypedResults.Created($"/api/products/{product.Id}", product);
    }
}

// Program.cs
app.MapProductEndpoints();
app.MapOrderEndpoints();

Request/Response DTOs

// Separate from domain models
public record CreateProductRequest(string Name, decimal Price);
public record UpdateProductRequest(string Name, decimal Price);
public record ProductResponse(int Id, string Name, decimal Price);

// Don't expose domain entities directly
app.MapPost("/products", (CreateProductRequest request, IMapper mapper) =>
{
    var product = mapper.Map<Product>(request);
    // ...
    return TypedResults.Created($"/products/{product.Id}",
        mapper.Map<ProductResponse>(product));
});

Anti-Patterns to Avoid

Anti-Pattern Why It's Bad Better Approach
Everything in Program.cs Unmaintainable Use extension methods
No route groups Repetitive config Group related endpoints
Manual validation Error-prone Use filters + FluentValidation
Exposing entities Tight coupling Use DTOs
No TypedResults No compile-time checks Use TypedResults
Ignoring OpenAPI No documentation Add .WithOpenApi()

OpenAPI Integration

builder.Services.AddOpenApi();

app.MapOpenApi();  // Serves OpenAPI spec

app.MapGet("/products", GetProducts)
    .WithName("GetProducts")
    .WithSummary("Get all products")
    .WithDescription("Returns a list of all available products")
    .Produces<List<Product>>(StatusCodes.Status200OK)
    .ProducesProblem(StatusCodes.Status500InternalServerError);

Deliver

  • clean, organized Minimal API endpoints
  • proper use of route groups and filters
  • type-safe responses with TypedResults
  • OpenAPI documentation
  • validation with endpoint filters

Validate

  • endpoints return correct status codes
  • validation filters catch invalid input
  • OpenAPI spec is accurate
  • route groups share common configuration
  • handlers are testable (can mock dependencies)
Files (dotnet-skills)
  • references
    • anti-patterns.md 15.1 KB
      # Minimal API Anti-Patterns
      
      ## Structural Anti-Patterns
      
      ### Monolithic Program.cs
      
      **Problem**: All endpoints defined directly in Program.cs becomes unmaintainable.
      
      ```csharp
      // BAD: Everything in Program.cs
      var builder = WebApplication.CreateBuilder(args);
      var app = builder.Build();
      
      app.MapGet("/products", async (AppDb db) => await db.Products.ToListAsync());
      app.MapGet("/products/{id}", async (int id, AppDb db) => await db.Products.FindAsync(id));
      app.MapPost("/products", async (Product p, AppDb db) => { db.Add(p); await db.SaveChangesAsync(); return p; });
      // ... 50 more endpoints
      app.MapGet("/orders", async (AppDb db) => await db.Orders.ToListAsync());
      // ... 50 more endpoints
      app.MapGet("/customers", async (AppDb db) => await db.Customers.ToListAsync());
      // ... and so on
      
      app.Run();
      ```
      
      **Solution**: Use extension methods to organize endpoints by domain.
      
      ```csharp
      // GOOD: Organized via extension methods
      app.MapProductEndpoints();
      app.MapOrderEndpoints();
      app.MapCustomerEndpoints();
      ```
      
      ### Flat Route Structure Without Groups
      
      **Problem**: Repeating configuration across related endpoints.
      
      ```csharp
      // BAD: No route groups, repetitive configuration
      app.MapGet("/api/products", GetProducts)
          .RequireAuthorization()
          .WithTags("Products");
      
      app.MapGet("/api/products/{id}", GetProductById)
          .RequireAuthorization()
          .WithTags("Products");
      
      app.MapPost("/api/products", CreateProduct)
          .RequireAuthorization()
          .WithTags("Products")
          .AddEndpointFilter<ValidationFilter>();
      ```
      
      **Solution**: Use route groups to share configuration.
      
      ```csharp
      // GOOD: Shared configuration via groups
      var products = app.MapGroup("/api/products")
          .RequireAuthorization()
          .WithTags("Products");
      
      products.MapGet("/", GetProducts);
      products.MapGet("/{id}", GetProductById);
      products.MapPost("/", CreateProduct).AddEndpointFilter<ValidationFilter>();
      ```
      
      ## Handler Anti-Patterns
      
      ### Anonymous Lambda Handlers
      
      **Problem**: Complex inline lambdas are hard to test and maintain.
      
      ```csharp
      // BAD: Complex inline logic
      app.MapPost("/products", async (CreateProductRequest request, AppDb db, IMapper mapper) =>
      {
          if (string.IsNullOrEmpty(request.Name))
              return Results.BadRequest("Name required");
          if (request.Price <= 0)
              return Results.BadRequest("Price must be positive");
          if (await db.Products.AnyAsync(p => p.Sku == request.Sku))
              return Results.Conflict("SKU exists");
      
          var product = mapper.Map<Product>(request);
          product.CreatedAt = DateTime.UtcNow;
          db.Products.Add(product);
          await db.SaveChangesAsync();
      
          return Results.Created($"/products/{product.Id}", mapper.Map<ProductResponse>(product));
      });
      ```
      
      **Solution**: Extract to named methods or service classes.
      
      ```csharp
      // GOOD: Extracted handler with validation filter
      products.MapPost("/", CreateProduct)
          .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
      
      private static async Task<Results<Created<ProductResponse>, Conflict<string>>> CreateProduct(
          CreateProductRequest request,
          IProductService service)
      {
          var result = await service.CreateAsync(request);
          return result.Match<Results<Created<ProductResponse>, Conflict<string>>>(
              success => TypedResults.Created($"/products/{success.Id}", success),
              conflict => TypedResults.Conflict(conflict.Message));
      }
      ```
      
      ### Mixing Concerns in Handlers
      
      **Problem**: Handlers doing validation, mapping, business logic, and persistence.
      
      ```csharp
      // BAD: Handler does everything
      app.MapPut("/products/{id}", async (int id, UpdateProductRequest request, AppDb db) =>
      {
          // Validation
          if (request.Price < 0) return Results.BadRequest("Invalid price");
      
          // Fetch
          var product = await db.Products.FindAsync(id);
          if (product is null) return Results.NotFound();
      
          // Authorization
          if (product.OwnerId != GetCurrentUserId()) return Results.Forbid();
      
          // Mapping
          product.Name = request.Name;
          product.Price = request.Price;
          product.UpdatedAt = DateTime.UtcNow;
      
          // Persistence
          await db.SaveChangesAsync();
      
          // Response mapping
          return Results.Ok(new ProductResponse(product.Id, product.Name, product.Price));
      });
      ```
      
      **Solution**: Separate concerns using filters and services.
      
      ```csharp
      // GOOD: Concerns separated
      products.MapPut("/{id}", UpdateProduct)
          .AddEndpointFilter<ValidationFilter<UpdateProductRequest>>()
          .AddEndpointFilter<ProductOwnershipFilter>();
      
      private static async Task<Results<Ok<ProductResponse>, NotFound>> UpdateProduct(
          int id,
          UpdateProductRequest request,
          IProductService service)
      {
          var result = await service.UpdateAsync(id, request);
          return result.Match<Results<Ok<ProductResponse>, NotFound>>(
              success => TypedResults.Ok(success),
              _ => TypedResults.NotFound());
      }
      ```
      
      ## Response Anti-Patterns
      
      ### Using Results Instead of TypedResults
      
      **Problem**: `Results` factory methods lose compile-time type checking.
      
      ```csharp
      // BAD: No compile-time checking of response types
      app.MapGet("/products/{id}", async (int id, AppDb db) =>
      {
          var product = await db.Products.FindAsync(id);
          return product is not null
              ? Results.Ok(product)      // IResult, no type info
              : Results.NotFound();      // IResult, no type info
      });
      ```
      
      **Solution**: Use `TypedResults` with union return types.
      
      ```csharp
      // GOOD: Compile-time checked response types
      app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound>> (int id, AppDb db) =>
      {
          var product = await db.Products.FindAsync(id);
          return product is not null
              ? TypedResults.Ok(product.ToDto())
              : TypedResults.NotFound();
      });
      ```
      
      ### Exposing Domain Entities
      
      **Problem**: Returning EF Core entities exposes internals and causes serialization issues.
      
      ```csharp
      // BAD: Exposing domain entity
      app.MapGet("/products/{id}", async (int id, AppDb db) =>
      {
          return await db.Products
              .Include(p => p.Category)
              .Include(p => p.Supplier)
              .FirstOrDefaultAsync(p => p.Id == id);
          // Leaks navigation properties, internal IDs, circular references
      });
      ```
      
      **Solution**: Use DTOs/response records.
      
      ```csharp
      // GOOD: Return DTO
      app.MapGet("/products/{id}", async Task<Results<Ok<ProductResponse>, NotFound>>
          (int id, IProductService service) =>
      {
          var dto = await service.GetByIdAsync(id);
          return dto is not null
              ? TypedResults.Ok(dto)
              : TypedResults.NotFound();
      });
      
      public record ProductResponse(
          int Id,
          string Name,
          decimal Price,
          string CategoryName,
          bool InStock);
      ```
      
      ### Inconsistent Status Codes
      
      **Problem**: Endpoints return inconsistent status codes for similar operations.
      
      ```csharp
      // BAD: Inconsistent responses
      app.MapPost("/products", (Product p, AppDb db) =>
      {
          db.Add(p);
          db.SaveChanges();
          return Results.Ok(p);  // Should be 201 Created
      });
      
      app.MapPost("/orders", (Order o, AppDb db) =>
      {
          db.Add(o);
          db.SaveChanges();
          return Results.Json(o, statusCode: 200);  // Also wrong
      });
      ```
      
      **Solution**: Follow REST conventions consistently.
      
      ```csharp
      // GOOD: Consistent REST responses
      app.MapPost("/products", async Task<Created<ProductResponse>>
          (CreateProductRequest request, IProductService service) =>
      {
          var product = await service.CreateAsync(request);
          return TypedResults.Created($"/products/{product.Id}", product);
      });
      ```
      
      ## Validation Anti-Patterns
      
      ### Inline Validation in Handlers
      
      **Problem**: Validation logic scattered across handlers, hard to maintain and test.
      
      ```csharp
      // BAD: Manual validation in handler
      app.MapPost("/products", (CreateProductRequest request, AppDb db) =>
      {
          var errors = new List<string>();
      
          if (string.IsNullOrWhiteSpace(request.Name))
              errors.Add("Name is required");
          if (request.Name?.Length > 100)
              errors.Add("Name must be 100 characters or less");
          if (request.Price <= 0)
              errors.Add("Price must be positive");
          if (string.IsNullOrWhiteSpace(request.Sku))
              errors.Add("SKU is required");
          if (!Regex.IsMatch(request.Sku ?? "", @"^[A-Z]{3}-\d{4}$"))
              errors.Add("SKU must match format XXX-0000");
      
          if (errors.Any())
              return Results.BadRequest(new { Errors = errors });
      
          // ... create product
      });
      ```
      
      **Solution**: Use validation filters with FluentValidation.
      
      ```csharp
      // GOOD: Declarative validation
      public class CreateProductValidator : AbstractValidator<CreateProductRequest>
      {
          public CreateProductValidator()
          {
              RuleFor(x => x.Name)
                  .NotEmpty()
                  .MaximumLength(100);
      
              RuleFor(x => x.Price)
                  .GreaterThan(0);
      
              RuleFor(x => x.Sku)
                  .NotEmpty()
                  .Matches(@"^[A-Z]{3}-\d{4}$")
                  .WithMessage("SKU must match format XXX-0000");
          }
      }
      
      products.MapPost("/", Create)
          .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
      ```
      
      ### Not Returning Problem Details
      
      **Problem**: Custom error formats break client expectations.
      
      ```csharp
      // BAD: Non-standard error format
      return Results.BadRequest(new
      {
          success = false,
          error_code = "VALIDATION_ERROR",
          messages = errors
      });
      ```
      
      **Solution**: Use RFC 7807 Problem Details.
      
      ```csharp
      // GOOD: Standard Problem Details
      return TypedResults.ValidationProblem(
          errors.ToDictionary(e => e.PropertyName, e => new[] { e.ErrorMessage }),
          title: "Validation failed",
          detail: "One or more validation errors occurred");
      ```
      
      ## Dependency Injection Anti-Patterns
      
      ### Service Locator Pattern
      
      **Problem**: Using HttpContext.RequestServices directly hides dependencies.
      
      ```csharp
      // BAD: Service locator
      app.MapGet("/products", (HttpContext context) =>
      {
          var db = context.RequestServices.GetRequiredService<AppDb>();
          var logger = context.RequestServices.GetRequiredService<ILogger>();
          var cache = context.RequestServices.GetRequiredService<IDistributedCache>();
      
          // ... use services
      });
      ```
      
      **Solution**: Declare dependencies as parameters.
      
      ```csharp
      // GOOD: Explicit dependencies
      app.MapGet("/products", async (
          AppDb db,
          ILogger<ProductsEndpoints> logger,
          IDistributedCache cache) =>
      {
          // Dependencies are clear and testable
      });
      ```
      
      ### Over-Injection
      
      **Problem**: Too many parameters indicate the handler does too much.
      
      ```csharp
      // BAD: Too many dependencies
      app.MapPost("/orders", async (
          CreateOrderRequest request,
          AppDb db,
          IMapper mapper,
          IInventoryService inventory,
          IPaymentService payment,
          IShippingService shipping,
          INotificationService notifications,
          ILogger<OrdersEndpoints> logger,
          IDistributedCache cache) =>
      {
          // This handler orchestrates too much
      });
      ```
      
      **Solution**: Introduce a service to coordinate the operation.
      
      ```csharp
      // GOOD: Single coordinating service
      app.MapPost("/orders", async Task<Results<Created<OrderResponse>, BadRequest<ProblemDetails>>>
          (CreateOrderRequest request, IOrderService orderService) =>
      {
          var result = await orderService.CreateAsync(request);
          return result.ToHttpResult();
      });
      ```
      
      ## OpenAPI Anti-Patterns
      
      ### Missing Response Documentation
      
      **Problem**: Endpoints without OpenAPI metadata have incomplete specs.
      
      ```csharp
      // BAD: No OpenAPI metadata
      app.MapGet("/products/{id}", GetProductById);
      ```
      
      **Solution**: Add comprehensive OpenAPI metadata.
      
      ```csharp
      // GOOD: Complete OpenAPI metadata
      app.MapGet("/products/{id}", GetProductById)
          .WithName("GetProductById")
          .WithSummary("Get a product by ID")
          .Produces<ProductResponse>(StatusCodes.Status200OK)
          .ProducesProblem(StatusCodes.Status404NotFound)
          .WithOpenApi();
      ```
      
      ### Inconsistent Naming
      
      **Problem**: Endpoint names don't follow conventions.
      
      ```csharp
      // BAD: Inconsistent naming
      app.MapGet("/products", GetAll).WithName("products_list");
      app.MapGet("/products/{id}", GetById).WithName("GetProduct");
      app.MapPost("/products", Create).WithName("create-product");
      ```
      
      **Solution**: Follow consistent naming convention.
      
      ```csharp
      // GOOD: Consistent PascalCase operation IDs
      app.MapGet("/products", GetAll).WithName("GetProducts");
      app.MapGet("/products/{id}", GetById).WithName("GetProductById");
      app.MapPost("/products", Create).WithName("CreateProduct");
      ```
      
      ## Security Anti-Patterns
      
      ### Missing Authorization
      
      **Problem**: Forgetting to protect sensitive endpoints.
      
      ```csharp
      // BAD: No authorization
      app.MapDelete("/products/{id}", DeleteProduct);
      app.MapGet("/admin/users", GetAllUsers);
      ```
      
      **Solution**: Apply authorization at group level or per-endpoint.
      
      ```csharp
      // GOOD: Authorization applied
      var api = app.MapGroup("/api")
          .RequireAuthorization();
      
      var admin = app.MapGroup("/admin")
          .RequireAuthorization("AdminOnly");
      ```
      
      ### Logging Sensitive Data
      
      **Problem**: Logging request bodies or headers that contain secrets.
      
      ```csharp
      // BAD: Logs sensitive data
      app.MapPost("/auth/login", async (LoginRequest request, ILogger logger) =>
      {
          logger.LogInformation("Login attempt: {@Request}", request);
          // Logs password!
      });
      ```
      
      **Solution**: Exclude sensitive fields from logging.
      
      ```csharp
      // GOOD: Exclude sensitive data
      public record LoginRequest(
          string Username,
          [property: JsonIgnore] string Password);
      
      // Or use destructuring carefully
      logger.LogInformation("Login attempt for user {Username}", request.Username);
      ```
      
      ## Performance Anti-Patterns
      
      ### N+1 Queries
      
      **Problem**: Lazy loading causes multiple database roundtrips.
      
      ```csharp
      // BAD: N+1 queries
      app.MapGet("/orders", async (AppDb db) =>
      {
          var orders = await db.Orders.ToListAsync();
          return orders.Select(o => new OrderResponse(
              o.Id,
              o.Customer.Name,  // N additional queries!
              o.Items.Count     // N additional queries!
          ));
      });
      ```
      
      **Solution**: Eager load required data.
      
      ```csharp
      // GOOD: Single query with includes
      app.MapGet("/orders", async (AppDb db) =>
      {
          var orders = await db.Orders
              .Include(o => o.Customer)
              .Include(o => o.Items)
              .Select(o => new OrderResponse(
                  o.Id,
                  o.Customer.Name,
                  o.Items.Count))
              .ToListAsync();
      
          return TypedResults.Ok(orders);
      });
      ```
      
      ### No Cancellation Token Support
      
      **Problem**: Long-running operations don't respect client disconnection.
      
      ```csharp
      // BAD: No cancellation
      app.MapGet("/reports/generate", async (IReportService service) =>
      {
          var report = await service.GenerateLargeReportAsync();
          // Continues even if client disconnects
          return Results.Ok(report);
      });
      ```
      
      **Solution**: Accept and pass cancellation tokens.
      
      ```csharp
      // GOOD: Cancellation token support
      app.MapGet("/reports/generate", async (
          IReportService service,
          CancellationToken cancellationToken) =>
      {
          var report = await service.GenerateLargeReportAsync(cancellationToken);
          return TypedResults.Ok(report);
      });
      ```
      
      ### Blocking Calls
      
      **Problem**: Synchronous operations block the thread pool.
      
      ```csharp
      // BAD: Blocking calls
      app.MapGet("/products", (AppDb db) =>
      {
          var products = db.Products.ToList();  // Synchronous!
          Thread.Sleep(1000);  // Blocking!
          return Results.Ok(products);
      });
      ```
      
      **Solution**: Use async operations throughout.
      
      ```csharp
      // GOOD: Fully async
      app.MapGet("/products", async (AppDb db) =>
      {
          await Task.Delay(1000);  // If delay needed
          var products = await db.Products.ToListAsync();
          return TypedResults.Ok(products);
      });
      ```
      
    • patterns.md 13.6 KB
      # Minimal API Patterns
      
      ## Route Groups
      
      ### Hierarchical Route Groups
      
      Build nested groups for complex API structures:
      
      ```csharp
      var api = app.MapGroup("/api/v1")
          .RequireAuthorization();
      
      var products = api.MapGroup("/products")
          .WithTags("Products");
      
      var productReviews = products.MapGroup("/{productId:int}/reviews")
          .WithTags("Product Reviews");
      
      productReviews.MapGet("/", GetReviewsForProduct);
      productReviews.MapPost("/", AddReviewToProduct);
      productReviews.MapGet("/{reviewId:int}", GetReviewById);
      ```
      
      ### Group with Parameter Validation
      
      Apply route constraints at the group level:
      
      ```csharp
      var products = app.MapGroup("/api/products/{productId:int:min(1)}")
          .AddEndpointFilter(async (context, next) =>
          {
              var productId = context.GetArgument<int>(0);
              var db = context.HttpContext.RequestServices.GetRequiredService<AppDb>();
      
              if (!await db.Products.AnyAsync(p => p.Id == productId))
                  return TypedResults.NotFound();
      
              return await next(context);
          });
      
      products.MapGet("/", (int productId) => ...);
      products.MapGet("/variants", (int productId) => ...);
      ```
      
      ### Versioned API Groups
      
      ```csharp
      var v1 = app.MapGroup("/api/v1").WithGroupName("v1");
      var v2 = app.MapGroup("/api/v2").WithGroupName("v2");
      
      v1.MapGet("/products", GetProductsV1);
      v2.MapGet("/products", GetProductsV2);
      ```
      
      ## Endpoint Filters
      
      ### Validation Filter with FluentValidation
      
      ```csharp
      public class FluentValidationFilter<T> : IEndpointFilter where T : class
      {
          public async ValueTask<object?> InvokeAsync(
              EndpointFilterInvocationContext context,
              EndpointFilterDelegate next)
          {
              var argument = context.Arguments.OfType<T>().FirstOrDefault();
      
              if (argument is null)
                  return TypedResults.BadRequest(new ProblemDetails
                  {
                      Title = "Missing request body",
                      Status = StatusCodes.Status400BadRequest
                  });
      
              var validator = context.HttpContext.RequestServices
                  .GetService<IValidator<T>>();
      
              if (validator is not null)
              {
                  var result = await validator.ValidateAsync(argument);
                  if (!result.IsValid)
                  {
                      return TypedResults.ValidationProblem(
                          result.ToDictionary(),
                          title: "Validation failed");
                  }
              }
      
              return await next(context);
          }
      }
      ```
      
      ### Logging Filter
      
      ```csharp
      public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter
      {
          public async ValueTask<object?> InvokeAsync(
              EndpointFilterInvocationContext context,
              EndpointFilterDelegate next)
          {
              var sw = Stopwatch.StartNew();
              var path = context.HttpContext.Request.Path;
              var method = context.HttpContext.Request.Method;
      
              logger.LogInformation("Request {Method} {Path} started", method, path);
      
              try
              {
                  var result = await next(context);
                  sw.Stop();
      
                  logger.LogInformation(
                      "Request {Method} {Path} completed in {ElapsedMs}ms",
                      method, path, sw.ElapsedMilliseconds);
      
                  return result;
              }
              catch (Exception ex)
              {
                  sw.Stop();
                  logger.LogError(ex,
                      "Request {Method} {Path} failed after {ElapsedMs}ms",
                      method, path, sw.ElapsedMilliseconds);
                  throw;
              }
          }
      }
      ```
      
      ### Rate Limiting Filter
      
      ```csharp
      public class RateLimitingFilter(IRateLimiter limiter) : IEndpointFilter
      {
          public async ValueTask<object?> InvokeAsync(
              EndpointFilterInvocationContext context,
              EndpointFilterDelegate next)
          {
              var clientId = context.HttpContext.User.FindFirst("sub")?.Value
                  ?? context.HttpContext.Connection.RemoteIpAddress?.ToString()
                  ?? "unknown";
      
              if (!await limiter.TryAcquireAsync(clientId))
              {
                  return TypedResults.StatusCode(StatusCodes.Status429TooManyRequests);
              }
      
              return await next(context);
          }
      }
      ```
      
      ### Idempotency Filter
      
      ```csharp
      public class IdempotencyFilter(IDistributedCache cache) : IEndpointFilter
      {
          public async ValueTask<object?> InvokeAsync(
              EndpointFilterInvocationContext context,
              EndpointFilterDelegate next)
          {
              var idempotencyKey = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
      
              if (string.IsNullOrEmpty(idempotencyKey))
                  return await next(context);
      
              var cacheKey = $"idempotency:{idempotencyKey}";
              var cachedResponse = await cache.GetStringAsync(cacheKey);
      
              if (cachedResponse is not null)
                  return TypedResults.Content(cachedResponse, "application/json");
      
              var result = await next(context);
      
              if (result is IValueHttpResult httpResult)
              {
                  var json = JsonSerializer.Serialize(httpResult.Value);
                  await cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
                  {
                      AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
                  });
              }
      
              return result;
          }
      }
      ```
      
      ### Filter Execution Order
      
      Filters execute in registration order (first registered runs first on request, last on response):
      
      ```csharp
      app.MapPost("/products", Create)
          .AddEndpointFilter<LoggingFilter>()       // 1st on request, 3rd on response
          .AddEndpointFilter<AuthorizationFilter>() // 2nd on request, 2nd on response
          .AddEndpointFilter<ValidationFilter>();   // 3rd on request, 1st on response
      ```
      
      ## TypedResults Patterns
      
      ### Union Return Types
      
      Use `Results<T1, T2, ...>` for compile-time checked multiple response types:
      
      ```csharp
      app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound, BadRequest<ProblemDetails>>>
          (int id, IProductService service) =>
      {
          if (id <= 0)
              return TypedResults.BadRequest(new ProblemDetails
              {
                  Title = "Invalid ID",
                  Detail = "Product ID must be positive"
              });
      
          var product = await service.GetByIdAsync(id);
      
          return product is not null
              ? TypedResults.Ok(product)
              : TypedResults.NotFound();
      });
      ```
      
      ### Complex Result Patterns
      
      ```csharp
      // Paginated results
      app.MapGet("/products", async Task<Ok<PagedResult<ProductDto>>>
          ([AsParameters] PaginationQuery query, IProductService service) =>
      {
          var result = await service.GetPagedAsync(query.Page, query.PageSize);
          return TypedResults.Ok(result);
      });
      
      public record PaginationQuery(int Page = 1, int PageSize = 20);
      
      public record PagedResult<T>(
          IReadOnlyList<T> Items,
          int TotalCount,
          int Page,
          int PageSize)
      {
          public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
          public bool HasNextPage => Page < TotalPages;
          public bool HasPreviousPage => Page > 1;
      }
      ```
      
      ### File Results
      
      ```csharp
      app.MapGet("/products/{id}/export", async Task<Results<FileStreamHttpResult, NotFound>>
          (int id, IProductService service) =>
      {
          var product = await service.GetByIdAsync(id);
      
          if (product is null)
              return TypedResults.NotFound();
      
          var stream = await service.ExportToCsvAsync(product);
          return TypedResults.File(stream, "text/csv", $"product-{id}.csv");
      });
      ```
      
      ### Accepted with Location
      
      ```csharp
      app.MapPost("/products/import", async Task<Accepted<ImportJobResponse>>
          (ImportRequest request, IImportService service) =>
      {
          var jobId = await service.StartImportAsync(request);
          return TypedResults.Accepted(
              $"/jobs/{jobId}",
              new ImportJobResponse(jobId, "Processing"));
      });
      ```
      
      ## Parameter Binding
      
      ### [AsParameters] for Complex Queries
      
      ```csharp
      public record ProductSearchQuery(
          string? Name,
          decimal? MinPrice,
          decimal? MaxPrice,
          string? Category,
          int Page = 1,
          int PageSize = 20,
          string SortBy = "name",
          bool Descending = false);
      
      app.MapGet("/products/search", async Task<Ok<PagedResult<ProductDto>>>
          ([AsParameters] ProductSearchQuery query, IProductService service) =>
      {
          var result = await service.SearchAsync(query);
          return TypedResults.Ok(result);
      });
      ```
      
      ### Header and Query Binding
      
      ```csharp
      app.MapGet("/products", async Task<Ok<List<ProductDto>>>
          ([FromHeader(Name = "X-Tenant-Id")] string tenantId,
           [FromQuery] string? category,
           IProductService service) =>
      {
          var products = await service.GetByTenantAsync(tenantId, category);
          return TypedResults.Ok(products);
      });
      ```
      
      ### Custom Model Binding
      
      ```csharp
      public record DateRange(DateOnly Start, DateOnly End) : IParsable<DateRange>
      {
          public static DateRange Parse(string s, IFormatProvider? provider)
          {
              var parts = s.Split("..");
              return new DateRange(DateOnly.Parse(parts[0]), DateOnly.Parse(parts[1]));
          }
      
          public static bool TryParse(string? s, IFormatProvider? provider, out DateRange result)
          {
              result = default!;
              if (string.IsNullOrEmpty(s)) return false;
      
              var parts = s.Split("..");
              if (parts.Length != 2) return false;
      
              if (!DateOnly.TryParse(parts[0], out var start)) return false;
              if (!DateOnly.TryParse(parts[1], out var end)) return false;
      
              result = new DateRange(start, end);
              return true;
          }
      }
      
      // Usage: /orders?dateRange=2024-01-01..2024-12-31
      app.MapGet("/orders", (DateRange dateRange) => ...);
      ```
      
      ## Error Handling
      
      ### Global Exception Handler
      
      ```csharp
      app.UseExceptionHandler(errorApp =>
      {
          errorApp.Run(async context =>
          {
              context.Response.ContentType = "application/problem+json";
      
              var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
      
              var problem = exception switch
              {
                  ValidationException vex => new ProblemDetails
                  {
                      Status = StatusCodes.Status400BadRequest,
                      Title = "Validation Error",
                      Detail = string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))
                  },
                  NotFoundException => new ProblemDetails
                  {
                      Status = StatusCodes.Status404NotFound,
                      Title = "Not Found"
                  },
                  _ => new ProblemDetails
                  {
                      Status = StatusCodes.Status500InternalServerError,
                      Title = "An error occurred"
                  }
              };
      
              context.Response.StatusCode = problem.Status ?? 500;
              await context.Response.WriteAsJsonAsync(problem);
          });
      });
      ```
      
      ### Result Pattern Integration
      
      ```csharp
      public static class ResultExtensions
      {
          public static IResult ToHttpResult<T>(this Result<T> result) =>
              result.IsSuccess
                  ? TypedResults.Ok(result.Value)
                  : result.Error switch
                  {
                      NotFoundError => TypedResults.NotFound(),
                      ValidationError ve => TypedResults.ValidationProblem(ve.Errors),
                      ConflictError ce => TypedResults.Conflict(ce.Message),
                      _ => TypedResults.Problem(result.Error.Message)
                  };
      }
      
      app.MapGet("/products/{id}", async (int id, IProductService service) =>
      {
          var result = await service.GetByIdAsync(id);
          return result.ToHttpResult();
      });
      ```
      
      ## OpenAPI Enhancements
      
      ### Rich Metadata
      
      ```csharp
      app.MapGet("/products/{id}", GetProductById)
          .WithName("GetProductById")
          .WithSummary("Get a product by ID")
          .WithDescription("Returns detailed information about a specific product including pricing and availability")
          .Produces<ProductDto>(StatusCodes.Status200OK, "application/json")
          .ProducesProblem(StatusCodes.Status404NotFound)
          .ProducesValidationProblem()
          .WithOpenApi(operation =>
          {
              operation.Parameters[0].Description = "The unique product identifier";
              operation.Parameters[0].Example = new OpenApiInteger(42);
              return operation;
          });
      ```
      
      ### Request/Response Examples
      
      ```csharp
      app.MapPost("/products", CreateProduct)
          .WithOpenApi(operation =>
          {
              operation.RequestBody.Content["application/json"].Example = new OpenApiObject
              {
                  ["name"] = new OpenApiString("Widget Pro"),
                  ["price"] = new OpenApiDouble(29.99),
                  ["category"] = new OpenApiString("Electronics")
              };
              return operation;
          });
      ```
      
      ## Testing Patterns
      
      ### WebApplicationFactory Setup
      
      ```csharp
      public class MinimalApiTests : IClassFixture<WebApplicationFactory<Program>>
      {
          private readonly HttpClient _client;
      
          public MinimalApiTests(WebApplicationFactory<Program> factory)
          {
              _client = factory.WithWebHostBuilder(builder =>
              {
                  builder.ConfigureServices(services =>
                  {
                      services.AddScoped<IProductService, MockProductService>();
                  });
              }).CreateClient();
          }
      
          [Fact]
          public async Task GetProducts_ReturnsOk()
          {
              var response = await _client.GetAsync("/api/products");
      
              response.StatusCode.Should().Be(HttpStatusCode.OK);
      
              var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
              products.Should().NotBeEmpty();
          }
      }
      ```
      
      ### Testing Filters in Isolation
      
      ```csharp
      [Fact]
      public async Task ValidationFilter_InvalidInput_ReturnsBadRequest()
      {
          var filter = new FluentValidationFilter<CreateProductRequest>();
      
          var httpContext = new DefaultHttpContext();
          httpContext.RequestServices = new ServiceCollection()
              .AddScoped<IValidator<CreateProductRequest>, CreateProductValidator>()
              .BuildServiceProvider();
      
          var context = new EndpointFilterInvocationContext(
              httpContext,
              new object[] { new CreateProductRequest("", -1) });
      
          var result = await filter.InvokeAsync(context, _ =>
              ValueTask.FromResult<object?>(TypedResults.Ok()));
      
          result.Should().BeOfType<ValidationProblem>();
      }
      ```
      
  • SKILL.md 8.1 KB
    ---
    name: dotnet-minimal-apis
    version: "1.0.0"
    category: "Web"
    description: "Design and implement Minimal APIs in ASP.NET Core using handler-first endpoints, route groups, filters, and lightweight composition suited to modern .NET services."
    compatibility: "Requires ASP.NET Core 6+, preferably .NET 8+ for full features."
    ---
    
    # Minimal APIs
    
    ## Trigger On
    
    - building new HTTP APIs in ASP.NET Core
    - creating lightweight microservices
    - choosing between Minimal APIs and controllers
    - organizing endpoints with route groups
    - implementing validation and filters
    
    ## Documentation
    
    - [Minimal APIs Overview](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-10.0)
    - [Minimal API Tutorial](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-10.0)
    - [Filters in Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/min-api-filters?view=aspnetcore-10.0)
    - [OpenAPI Support](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview?view=aspnetcore-10.0)
    - [Route Groups](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/route-handlers?view=aspnetcore-10.0#route-groups)
    
    ### References
    
    - [patterns.md](references/patterns.md) - detailed route groups, filters, TypedResults patterns, parameter binding, error handling, and testing
    - [anti-patterns.md](references/anti-patterns.md) - common Minimal API mistakes to avoid
    
    ## When to Use Minimal APIs vs Controllers
    
    | Use Minimal APIs | Use Controllers |
    |------------------|-----------------|
    | New projects | Existing MVC/API projects |
    | Microservices | Complex model binding |
    | Simple CRUD APIs | OData, JsonPatch |
    | Lightweight handlers | Heavy use of attributes |
    | .NET 8+ projects | Need `[ApiController]` features |
    
    ## Workflow
    
    1. **Define endpoints directly in Program.cs** (for small APIs)
    2. **Use route groups** for related endpoints
    3. **Move handlers to separate classes** as the API grows
    4. **Apply filters** for cross-cutting concerns
    5. **Use TypedResults** for type-safe responses
    6. **Generate OpenAPI docs** with `.WithOpenApi()`
    
    ## Basic Patterns
    
    ### Simple Endpoints
    ```csharp
    var app = builder.Build();
    
    app.MapGet("/", () => "Hello World");
    
    app.MapGet("/products/{id}", (int id) => Results.Ok(new { Id = id }));
    
    app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product));
    ```
    
    ### TypedResults (Strongly-Typed)
    ```csharp
    app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, AppDb db) =>
    {
        var product = db.Products.Find(id);
        return product is not null
            ? TypedResults.Ok(product)
            : TypedResults.NotFound();
    });
    ```
    
    ### Dependency Injection
    ```csharp
    app.MapGet("/products", async (IProductService service) =>
    {
        return await service.GetAllAsync();
    });
    
    // Or with [FromServices] for clarity
    app.MapGet("/products", async ([FromServices] IProductService service) =>
        await service.GetAllAsync());
    ```
    
    ## Route Groups
    
    ### Basic Grouping
    ```csharp
    var products = app.MapGroup("/api/products");
    
    products.MapGet("/", GetAll);
    products.MapGet("/{id}", GetById);
    products.MapPost("/", Create);
    products.MapPut("/{id}", Update);
    products.MapDelete("/{id}", Delete);
    ```
    
    ### Groups with Shared Configuration
    ```csharp
    var api = app.MapGroup("/api")
        .RequireAuthorization()
        .AddEndpointFilter<ValidationFilter>();
    
    var products = api.MapGroup("/products")
        .WithTags("Products");
    
    var orders = api.MapGroup("/orders")
        .WithTags("Orders")
        .RequireAuthorization("AdminOnly");
    ```
    
    ## Endpoint Filters
    
    ### Inline Filter
    ```csharp
    app.MapGet("/products/{id}", (int id) => Results.Ok(id))
        .AddEndpointFilter(async (context, next) =>
        {
            var id = context.GetArgument<int>(0);
            if (id <= 0)
                return Results.BadRequest("Invalid ID");
    
            return await next(context);
        });
    ```
    
    ### Class-Based Filter
    ```csharp
    public class ValidationFilter<T> : IEndpointFilter where T : class
    {
        public async ValueTask<object?> InvokeAsync(
            EndpointFilterInvocationContext context,
            EndpointFilterDelegate next)
        {
            var argument = context.Arguments
                .OfType<T>()
                .FirstOrDefault();
    
            if (argument is null)
                return Results.BadRequest("Invalid request body");
    
            var validator = context.HttpContext.RequestServices
                .GetService<IValidator<T>>();
    
            if (validator is not null)
            {
                var result = await validator.ValidateAsync(argument);
                if (!result.IsValid)
                    return Results.ValidationProblem(result.ToDictionary());
            }
    
            return await next(context);
        }
    }
    
    // Usage
    products.MapPost("/", Create)
        .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
    ```
    
    ### Global Filters via Root Group
    ```csharp
    // All endpoints inherit filters from root group
    var root = app.MapGroup("")
        .AddEndpointFilter<LoggingFilter>()
        .AddEndpointFilter<ErrorHandlingFilter>();
    
    root.MapGet("/health", () => Results.Ok());
    root.MapGroup("/api/products").MapGet("/", GetProducts);
    ```
    
    ## Organizing Larger APIs
    
    ### Extension Method Pattern
    ```csharp
    // ProductEndpoints.cs
    public static class ProductEndpoints
    {
        public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
        {
            var group = app.MapGroup("/api/products")
                .WithTags("Products");
    
            group.MapGet("/", GetAll);
            group.MapGet("/{id}", GetById);
            group.MapPost("/", Create);
    
            return group;
        }
    
        private static async Task<Ok<List<Product>>> GetAll(IProductService service)
            => TypedResults.Ok(await service.GetAllAsync());
    
        private static async Task<Results<Ok<Product>, NotFound>> GetById(
            int id, IProductService service)
        {
            var product = await service.GetByIdAsync(id);
            return product is not null
                ? TypedResults.Ok(product)
                : TypedResults.NotFound();
        }
    
        private static async Task<Created<Product>> Create(
            CreateProductRequest request, IProductService service)
        {
            var product = await service.CreateAsync(request);
            return TypedResults.Created($"/api/products/{product.Id}", product);
        }
    }
    
    // Program.cs
    app.MapProductEndpoints();
    app.MapOrderEndpoints();
    ```
    
    ## Request/Response DTOs
    
    ```csharp
    // Separate from domain models
    public record CreateProductRequest(string Name, decimal Price);
    public record UpdateProductRequest(string Name, decimal Price);
    public record ProductResponse(int Id, string Name, decimal Price);
    
    // Don't expose domain entities directly
    app.MapPost("/products", (CreateProductRequest request, IMapper mapper) =>
    {
        var product = mapper.Map<Product>(request);
        // ...
        return TypedResults.Created($"/products/{product.Id}",
            mapper.Map<ProductResponse>(product));
    });
    ```
    
    ## Anti-Patterns to Avoid
    
    | Anti-Pattern | Why It's Bad | Better Approach |
    |--------------|--------------|-----------------|
    | Everything in Program.cs | Unmaintainable | Use extension methods |
    | No route groups | Repetitive config | Group related endpoints |
    | Manual validation | Error-prone | Use filters + FluentValidation |
    | Exposing entities | Tight coupling | Use DTOs |
    | No TypedResults | No compile-time checks | Use `TypedResults` |
    | Ignoring OpenAPI | No documentation | Add `.WithOpenApi()` |
    
    ## OpenAPI Integration
    
    ```csharp
    builder.Services.AddOpenApi();
    
    app.MapOpenApi();  // Serves OpenAPI spec
    
    app.MapGet("/products", GetProducts)
        .WithName("GetProducts")
        .WithSummary("Get all products")
        .WithDescription("Returns a list of all available products")
        .Produces<List<Product>>(StatusCodes.Status200OK)
        .ProducesProblem(StatusCodes.Status500InternalServerError);
    ```
    
    ## Deliver
    
    - clean, organized Minimal API endpoints
    - proper use of route groups and filters
    - type-safe responses with TypedResults
    - OpenAPI documentation
    - validation with endpoint filters
    
    ## Validate
    
    - endpoints return correct status codes
    - validation filters catch invalid input
    - OpenAPI spec is accurate
    - route groups share common configuration
    - handlers are testable (can mock dependencies)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related