Instruction file imported from nguyenmanhtuan2004/apis.locknlock (
.cursor/rules/03-api-design.mdc). Copyright stays with the author.
API Design Guidelines
Impact Level: HIGH
Well-designed APIs are intuitive, consistent, and evolve gracefully over time.
Table of Contents
- RESTful Principles
- API Versioning
- Request/Response Design
- Error Handling
- OpenAPI Documentation
- Pagination and Filtering
- Security Headers
RESTful Principles
API-01: Use Nouns for Resources, Verbs for Actions
// GOOD: Resource-based URLs
GET /api/v1/orders // Get all orders
GET /api/v1/orders/{id} // Get specific order
POST /api/v1/orders // Create order
PUT /api/v1/orders/{id} // Update order (full replacement)
PATCH /api/v1/orders/{id} // Partial update
DELETE /api/v1/orders/{id} // Delete order
// GOOD: Nested resources
GET /api/v1/orders/{orderId}/items // Get order items
POST /api/v1/orders/{orderId}/items // Add item to order
GET /api/v1/customers/{customerId}/orders // Get customer's orders
// GOOD: Actions as sub-resources (when needed)
POST /api/v1/orders/{id}/cancel // Cancel order
POST /api/v1/orders/{id}/ship // Ship order
// BAD: Verbs in URLs
GET /api/v1/getOrders
POST /api/v1/createOrder
POST /api/v1/deleteOrder/{id}
API-02: Use Proper HTTP Methods
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Replace resource | Yes | No |
| PATCH | Partial update | No | No |
| DELETE | Remove resource | Yes | No |
[ApiController]
[Route("api/v1/[controller]")]
public class OrdersController(IMediator mediator) : ControllerBase
{
[HttpGet]
[ProducesResponseType<PagedResponse<OrderSummaryDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetOrders(
[FromQuery] GetOrdersQuery query,
CancellationToken ct)
{
var result = await mediator.Send(query, ct);
return Ok(result);
}
[HttpGet("{id:guid}")]
[ProducesResponseType<OrderDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var result = await mediator.Send(new GetOrderQuery(id), ct);
return result is not null ? Ok(result) : NotFound();
}
[HttpPost]
[ProducesResponseType<Guid>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command,
CancellationToken ct)
{
var result = await mediator.Send(command, ct);
return result.Match(
id => CreatedAtAction(nameof(GetOrder), new { id }, id),
error => BadRequest(ToProblemDetails(error)));
}
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateOrder(
Guid id,
[FromBody] UpdateOrderCommand command,
CancellationToken ct)
{
var result = await mediator.Send(command with { Id = id }, ct);
return result.IsSuccess ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteOrder(Guid id, CancellationToken ct)
{
var result = await mediator.Send(new DeleteOrderCommand(id), ct);
return result.IsSuccess ? NoContent() : NotFound();
}
}
API-03: Use Proper HTTP Status Codes
// Success codes
return Ok(data); // 200 - Success with body
return Created(uri, data); // 201 - Created
return CreatedAtAction(...); // 201 - Created with location
return Accepted(); // 202 - Accepted (async processing)
return NoContent(); // 204 - Success without body
// Client error codes
return BadRequest(problemDetails); // 400 - Validation error
return Unauthorized(); // 401 - Not authenticated
return Forbid(); // 403 - Not authorized
return NotFound(); // 404 - Resource not found
return Conflict(problemDetails); // 409 - Conflict (duplicate, etc.)
return UnprocessableEntity(); // 422 - Semantic error
// Server error codes
return StatusCode(500, ...); // 500 - Internal error (avoid details)
return StatusCode(503, ...); // 503 - Service unavailable
API Versioning
API-04: Use URI Path Versioning (Recommended)
// Program.cs
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;
});
// Controller
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class OrdersController : ControllerBase
{
[HttpGet("{id:guid}")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> GetOrderV1(Guid id, CancellationToken ct)
{
// V1 implementation
}
[HttpGet("{id:guid}")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetOrderV2(Guid id, CancellationToken ct)
{
// V2 implementation with enhanced response
}
}
API-05: Versioning Strategies
// Option 1: URI Path (Recommended)
// GET /api/v1/orders
// GET /api/v2/orders
options.ApiVersionReader = new UrlSegmentApiVersionReader();
// Option 2: Query String
// GET /api/orders?api-version=1.0
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
// Option 3: Header
// GET /api/orders
// X-Api-Version: 1.0
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
// Option 4: Combined (multiple readers)
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
API-06: Deprecation Strategy
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0", Deprecated = true)] // Mark as deprecated
[ApiVersion("2.0")]
public class OrdersController : ControllerBase
{
// Response headers will include:
// api-deprecated-versions: 1.0
// api-supported-versions: 2.0
}
// Sunset header middleware
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
if (context.GetRequestedApiVersion()?.MajorVersion == 1)
{
context.Response.Headers["Sunset"] = "Sat, 31 Dec 2025 23:59:59 GMT";
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Link"] =
"</api/v2/docs>; rel=\"successor-version\"";
}
return Task.CompletedTask;
});
await next();
});
Request/Response Design
API-07: Use DTOs for API Contracts
// Request DTOs
public record CreateOrderRequest(
[Required] Guid CustomerId,
[Required, MinLength(1)] List<OrderItemRequest> Items,
string? Notes);
public record OrderItemRequest(
[Required] Guid ProductId,
[Range(1, 1000)] int Quantity);
// Response DTOs
public record OrderResponse(
Guid Id,
Guid CustomerId,
string CustomerName,
OrderStatus Status,
decimal TotalAmount,
DateTime CreatedAt,
IReadOnlyList<OrderItemResponse> Items);
public record OrderItemResponse(
Guid ProductId,
string ProductName,
int Quantity,
decimal UnitPrice,
decimal Subtotal);
// Paged response
public record PagedResponse<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount,
int TotalPages)
{
public bool HasPreviousPage => Page > 1;
public bool HasNextPage => Page < TotalPages;
}
API-08: Keep Controllers Thin
// GOOD: Thin controller
[ApiController]
[Route("api/v1/[controller]")]
public class OrdersController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command,
CancellationToken ct)
{
var result = await mediator.Send(command, ct);
return result.Match(
id => CreatedAtAction(nameof(GetOrder), new { id }, id),
error => error.ToProblemResult());
}
}
// BAD: Fat controller
[ApiController]
[Route("api/v1/[controller]")]
public class OrdersController(
IOrderRepository orders,
ICustomerRepository customers,
IProductRepository products,
IEmailService email) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
// Validation logic here - WRONG
if (request.Items.Count == 0)
return BadRequest("Items required");
// Business logic here - WRONG
var customer = await customers.GetByIdAsync(request.CustomerId);
if (customer is null)
return NotFound("Customer not found");
var order = new Order { CustomerId = request.CustomerId };
foreach (var item in request.Items)
{
var product = await products.GetByIdAsync(item.ProductId);
// More business logic...
}
await orders.AddAsync(order);
// Side effects here - WRONG
await email.SendConfirmationAsync(customer.Email, order);
return Ok(order);
}
}
Error Handling
API-09: Use Problem Details (RFC 7807)
// Standard Problem Details response
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Failed",
"status": 400,
"detail": "One or more validation errors occurred.",
"instance": "/api/v1/orders",
"traceId": "00-1234567890abcdef-1234567890abcdef-00",
"errors": {
"CustomerId": ["The CustomerId field is required."],
"Items": ["At least one item is required."]
}
}
// Configure Problem Details
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
});
// Custom error types
public static class ProblemDetailsExtensions
{
public static IResult ToProblemResult(this Error error) => error.Type switch
{
ErrorType.Validation => Results.Problem(
title: "Validation Error",
detail: error.Description,
statusCode: StatusCodes.Status400BadRequest,
type: "https://api.example.com/errors/validation"),
ErrorType.NotFound => Results.Problem(
title: "Not Found",
detail: error.Description,
statusCode: StatusCodes.Status404NotFound,
type: "https://api.example.com/errors/not-found"),
ErrorType.Conflict => Results.Problem(
title: "Conflict",
detail: error.Description,
statusCode: StatusCodes.Status409Conflict,
type: "https://api.example.com/errors/conflict"),
_ => Results.Problem(
title: "Internal Server Error",
statusCode: StatusCodes.Status500InternalServerError)
};
}
API-10: Global Exception Handling
// Exception handling middleware
public class ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
logger.LogWarning(ex, "Validation failed");
await HandleValidationExceptionAsync(context, ex);
}
catch (NotFoundException ex)
{
logger.LogWarning(ex, "Resource not found");
await HandleNotFoundExceptionAsync(context, ex);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
await HandleUnknownExceptionAsync(context);
}
}
private static async Task HandleValidationExceptionAsync(
HttpContext context,
ValidationException exception)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
var problemDetails = new ValidationProblemDetails(
exception.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()))
{
Type = "https://api.example.com/errors/validation",
Title = "Validation Failed",
Status = StatusCodes.Status400BadRequest
};
await context.Response.WriteAsJsonAsync(problemDetails);
}
private static async Task HandleUnknownExceptionAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
var problemDetails = new ProblemDetails
{
Type = "https://api.example.com/errors/internal",
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
// NEVER expose exception details in production
Detail = "An unexpected error occurred. Please try again later."
};
await context.Response.WriteAsJsonAsync(problemDetails);
}
}
OpenAPI Documentation
API-11: Configure Swagger/OpenAPI
// Program.cs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Order API",
Version = "v1",
Description = "API for managing orders",
Contact = new OpenApiContact
{
Name = "API Support",
Email = "support@example.com"
}
});
// Add JWT authentication
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Enter your JWT token"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
// Include XML comments
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});
API-12: Document Endpoints with Attributes
/// <summary>
/// Creates a new order for a customer.
/// </summary>
/// <param name="request">The order creation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The ID of the created order.</returns>
/// <response code="201">Order created successfully.</response>
/// <response code="400">Invalid request data.</response>
/// <response code="404">Customer not found.</response>
[HttpPost]
[ProducesResponseType<Guid>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
// Implementation
}
Pagination and Filtering
API-13: Implement Consistent Pagination
// Pagination request
public record PagedRequest(
[Range(1, int.MaxValue)] int Page = 1,
[Range(1, 100)] int PageSize = 20);
// Paginated query
public record GetOrdersQuery(
Guid? CustomerId,
OrderStatus? Status,
DateTime? FromDate,
DateTime? ToDate,
int Page = 1,
int PageSize = 20,
string? SortBy = "CreatedAt",
bool SortDescending = true) : IRequest<PagedResponse<OrderSummaryDto>>;
// Handler
public class GetOrdersQueryHandler(ApplicationDbContext context)
: IRequestHandler<GetOrdersQuery, PagedResponse<OrderSummaryDto>>
{
public async Task<PagedResponse<OrderSummaryDto>> Handle(
GetOrdersQuery query,
CancellationToken ct)
{
var queryable = context.Orders.AsQueryable();
// Apply filters
if (query.CustomerId.HasValue)
queryable = queryable.Where(o => o.CustomerId == new CustomerId(query.CustomerId.Value));
if (query.Status.HasValue)
queryable = queryable.Where(o => o.Status == query.Status.Value);
if (query.FromDate.HasValue)
queryable = queryable.Where(o => o.CreatedAt >= query.FromDate.Value);
if (query.ToDate.HasValue)
queryable = queryable.Where(o => o.CreatedAt <= query.ToDate.Value);
// Get total count
var totalCount = await queryable.CountAsync(ct);
// Apply sorting
queryable = query.SortBy?.ToLowerInvariant() switch
{
"createdat" => query.SortDescending
? queryable.OrderByDescending(o => o.CreatedAt)
: queryable.OrderBy(o => o.CreatedAt),
"totalamount" => query.SortDescending
? queryable.OrderByDescending(o => o.TotalAmount)
: queryable.OrderBy(o => o.TotalAmount),
_ => queryable.OrderByDescending(o => o.CreatedAt)
};
// Apply pagination
var items = await queryable
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(o => new OrderSummaryDto(
o.Id.Value,
o.CustomerId.Value,
o.Status,
o.TotalAmount.Amount,
o.CreatedAt))
.ToListAsync(ct);
return new PagedResponse<OrderSummaryDto>(
items,
query.Page,
query.PageSize,
totalCount,
(int)Math.Ceiling(totalCount / (double)query.PageSize));
}
}
API-14: Include Pagination Metadata
// Response with pagination links
public record PagedResponse<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount,
int TotalPages,
PaginationLinks? Links = null);
public record PaginationLinks(
string? Self,
string? First,
string? Previous,
string? Next,
string? Last);
// Generate links in controller
[HttpGet]
public async Task<IActionResult> GetOrders([FromQuery] GetOrdersQuery query, CancellationToken ct)
{
var result = await mediator.Send(query, ct);
var links = new PaginationLinks(
Self: Url.Action(nameof(GetOrders), new { query.Page, query.PageSize }),
First: query.Page > 1 ? Url.Action(nameof(GetOrders), new { Page = 1, query.PageSize }) : null,
Previous: result.HasPreviousPage ? Url.Action(nameof(GetOrders), new { Page = query.Page - 1, query.PageSize }) : null,
Next: result.HasNextPage ? Url.Action(nameof(GetOrders), new { Page = query.Page + 1, query.PageSize }) : null,
Last: Url.Action(nameof(GetOrders), new { Page = result.TotalPages, query.PageSize })
);
return Ok(result with { Links = links });
}
Security Headers
API-15: Configure Security Headers
// Security headers middleware
app.Use(async (context, next) =>
{
// Prevent MIME type sniffing
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
// Prevent clickjacking
context.Response.Headers["X-Frame-Options"] = "DENY";
// XSS protection (legacy browsers)
context.Response.Headers["X-XSS-Protection"] = "1; mode=block";
// Referrer policy
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
// Content Security Policy (for APIs returning HTML)
context.Response.Headers["Content-Security-Policy"] = "default-src 'none'";
// HSTS (only over HTTPS)
if (context.Request.IsHttps)
{
context.Response.Headers["Strict-Transport-Security"] =
"max-age=31536000; includeSubDomains";
}
await next();
});
// Or use NWebsec
app.UseXContentTypeOptions();
app.UseXXssProtection(options => options.EnabledWithBlockMode());
app.UseXfo(options => options.Deny());
app.UseReferrerPolicy(options => options.StrictOriginWhenCrossOrigin());
API-16: Configure CORS Properly
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(
"https://app.example.com",
"https://admin.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
options.AddPolicy("Development", policy =>
{
policy.WithOrigins("http://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// Apply based on environment
if (app.Environment.IsDevelopment())
app.UseCors("Development");
else
app.UseCors("Production");
Minimal APIs (Alternative)
API-17: Use Minimal APIs for Simple Endpoints
// Program.cs
var app = builder.Build();
// Group endpoints
var orders = app.MapGroup("/api/v1/orders")
.WithTags("Orders")
.RequireAuthorization();
orders.MapGet("/", GetOrders)
.WithName("GetOrders")
.Produces<PagedResponse<OrderSummaryDto>>();
orders.MapGet("/{id:guid}", GetOrder)
.WithName("GetOrder")
.Produces<OrderDto>()
.ProducesProblem(StatusCodes.Status404NotFound);
orders.MapPost("/", CreateOrder)
.WithName("CreateOrder")
.Produces<Guid>(StatusCodes.Status201Created)
.ProducesValidationProblem();
// Handler methods
static async Task<IResult> GetOrders(
[AsParameters] GetOrdersQuery query,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.Send(query, ct);
return Results.Ok(result);
}
static async Task<IResult> GetOrder(
Guid id,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.Send(new GetOrderQuery(id), ct);
return result is not null
? Results.Ok(result)
: Results.NotFound();
}
static async Task<IResult> CreateOrder(
CreateOrderCommand command,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.Send(command, ct);
return result.Match(
id => Results.CreatedAtRoute("GetOrder", new { id }, id),
error => error.ToProblemResult());
}