ASP.NET Core REST API Development Guide

A practical guide to building REST APIs with ASP.NET Core — routing, controllers, versioning, authentication, validation, error handling, and OpenAPI docs.

ASP.NET Core REST Guide

ASP.NET Core REST API Development Guide

  • Monday, August 3, 2026

A practical guide to building REST APIs with ASP.NET Core — routing, controllers, versioning, authentication, validation, error handling, and OpenAPI docs.

ASP.NET Core is one of the most capable platforms for building REST APIs fast, well-documented, with strong built-in support for authentication, validation, versioning, and documentation. This guide covers the full API development lifecycle in ASP.NET Core, from project setup through production-ready concerns like error handling, API versioning, and security. It's written for developers who know C# and want a practical, opinionated reference rather than a framework overview.

Project Setup: Controllers vs Minimal APIs

ASP.NET Core offers two programming models for REST APIs. Choosing the right one upfront affects how the rest of the guide applies.

Controller-based APIs use [ApiController]-decorated classes inheriting from ControllerBase. They provide the full MVC feature set - action filters, model binding, built-in validation, attribute routing, and strong conventions. This is the right choice for APIs with complex business logic, shared behaviour across multiple endpoints, or teams familiar with the MVC pattern.

Minimal APIs use a lambda-based syntax registered directly in Program.cs. They have less overhead, less ceremony, and map well to simple, high-throughput endpoints. Introduced in .NET 6 and significantly improved in .NET 7/8.

// Program.cs — Minimal API setup
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

// Simple Minimal API endpoint
app.MapGet("/products/{id}", async (int id, IProductRepository repo)
    => await repo.GetAsync(id) is Product p
        ? Results.Ok(p)
        : Results.NotFound());

app.Run();
// Controller-based API setup
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;

    public ProductsController(IProductRepository repo)
        => _repo = repo;

    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetProduct(int id)
    {
        var product = await _repo.GetAsync(id);
        return product is null ? NotFound() : Ok(product);
    }
}

Recommendation: Use controllers for most production APIs - the model binding, validation, and filter pipeline pay for themselves as complexity grows. Use Minimal APIs for lightweight services, internal endpoints, or prototyping where the controller overhead genuinely isn't needed.

Routing

ASP.NET Core's routing system maps incoming HTTP requests to the appropriate action method or endpoint handler. Understanding it prevents a class of bugs that surface only in production.

Attribute routing on controllers:

[ApiController]
[Route("api/v1/[controller]")]  // → api/v1/products
public class ProductsController : ControllerBase
{
    [HttpGet]                       // GET api/v1/products
    [HttpGet("{id:int}")]           // GET api/v1/products/42
    [HttpGet("search")]             // GET api/v1/products/search
    [HttpPost]                      // POST api/v1/products
    [HttpPut("{id:int}")]           // PUT api/v1/products/42
    [HttpDelete("{id:int}")]        // DELETE api/v1/products/42
}

Route constraints enforce parameter types at the routing level, before any action code runs:

[HttpGet("{id:int}")]          // only matches integers
[HttpGet("{slug:alpha}")]      // only matches alphabetic strings
[HttpGet("{date:datetime}")]   // only matches parseable dates
[HttpGet("{id:guid}")]         // only matches GUIDs

Route naming and link generation:

[HttpGet("{id}", Name = "GetProduct")]
public async Task<ActionResult<Product>> GetProduct(int id) { ... }

// After creating a resource, return 201 with Location header
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(CreateProductDto dto)
{
    var product = await _repo.CreateAsync(dto);
    return CreatedAtRoute("GetProduct",
        new { id = product.Id }, product);
}

CreatedAtRoute returns HTTP 201 with a Location header pointing to the newly created resource - the correct REST pattern for POST responses, and something Created(url, body) handles more simply when you're not using named routes.

Request and Response Models

Separating request/response models (DTOs) from domain entities is one of the most important API design decisions. It decouples the API contract from the internal data model, prevents over-posting vulnerabilities, and allows the two to evolve independently.

// Domain entity - internal
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public DateTime CreatedAt { get; set; }
    public string InternalSku { get; set; } = default!; // should not be in API response
}

// Request DTO — what the client sends
public record CreateProductDto(
    string Name,
    decimal Price,
    int InitialStock);

// Response DTO — what the API returns
public record ProductDto(
    int Id,
    string Name,
    decimal Price,
    bool InStock);

Object mapping: Map between entities and DTOs explicitly rather than using reflection-based mappers for simple cases. For larger codebases with many mappings, AutoMapper or Mapperly (a source-generator-based mapper with no runtime reflection overhead) are worth the setup.

// Explicit mapping - simple, fast, no dependencies
public static ProductDto ToDto(this Product p)
    => new(p.Id, p.Name, p.Price, p.StockQuantity > 0);

Input Validation

[ApiController] automatically validates model state and returns HTTP 400 with a ProblemDetails response when validation fails — no manual if (!ModelState.IsValid) check needed.

Data annotations for simple validation:

public record CreateProductDto(
    [Required]
    [MaxLength(200)]
    string Name,

    [Range(0.01, 99999.99)]
    decimal Price,

    [Range(0, int.MaxValue)]
    int InitialStock);

FluentValidation for complex business rules:
Data annotations handle format validation; FluentValidation handles business rule validation that requires context or conditional logic:

public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
{
    private readonly IProductRepository _repo;

    public CreateProductDtoValidator(IProductRepository repo)
    {
        _repo = repo;

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200)
            .MustAsync(async (name, ct)
                => !await _repo.ExistsByNameAsync(name))
            .WithMessage("A product with this name already exists.");

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .WithMessage("Price must be greater than zero.");
    }
}

Register FluentValidation with ASP.NET Core's validation pipeline:

builder.Services.AddValidatorsFromAssemblyContaining<CreateProductDtoValidator>();
builder.Services.AddFluentValidationAutoValidation();

Error Handling and Problem Details

Consistent error responses are one of the most important qualities of a well-designed API. Consumers need to understand what went wrong without parsing different response shapes depending on where the error occurred.

Problem Details (RFC 7807) is the standard error response format for REST APIs and is built into ASP.NET Core:

// Program.cs - .NET 7+
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
app.UseStatusCodePages();

This produces consistent JSON error responses:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
    "title": "Not Found",
    "status": 404,
    "detail": "Product with ID 42 was not found.",
    "instance": "/api/v1/products/42"
}

Global exception handler for custom error types:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception");

        var problemDetails = exception switch
        {
            NotFoundException ex => new ProblemDetails
            {
                Status = StatusCodes.Status404NotFound,
                Title = "Not Found",
                Detail = ex.Message
            },
            ValidationException ex => new ProblemDetails
            {
                Status = StatusCodes.Status422UnprocessableEntity,
                Title = "Validation Failed",
                Detail = ex.Message
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "Internal Server Error"
            }
        };

        context.Response.StatusCode = problemDetails.Status!.Value;
        await context.Response.WriteAsJsonAsync(problemDetails, ct);
        return true;
    }
}

Register it:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

Authentication and Authorization

Most production REST APIs require authentication. ASP.NET Core's middleware pipeline handles authentication before authorization, and both integrate cleanly with JWT tokens - the standard for REST API authentication.

JWT Bearer authentication setup:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

builder.Services.AddAuthorization();

// In middleware pipeline
app.UseAuthentication();
app.UseAuthorization();

Protecting endpoints:

[Authorize]                           // requires any authenticated user
[Authorize(Roles = "Admin")]          // requires Admin role
[Authorize(Policy = "PremiumPlan")]   // requires custom policy
[AllowAnonymous]                      // overrides controller-level [Authorize]

Policy-based authorization for business rules:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("PremiumPlan", policy =>
        policy.RequireClaim("subscription_plan", "premium", "enterprise"));
});

For APIs using Microsoft Entra ID (Azure AD) rather than issuing their own tokens, replace the AddJwtBearer configuration with AddMicrosoftIdentityWebApi — it handles Entra ID token validation automatically with a simpler configuration.

API Versioning

APIs change. Versioning prevents breaking changes from reaching existing clients while allowing the API to evolve. ASP.NET Core's Asp. Versioning package (Microsoft's official library) provides URL path, query string, and header-based versioning.

Setup:

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;  // adds api-supported-versions header
}).AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

URL path versioning (most common for REST APIs):

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase { ... }

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsV2Controller : ControllerBase { ... }

Deprecating old versions:

[ApiVersion("1.0", Deprecated = true)]

This adds a api-deprecated-versions response header, signaling to clients that they should upgrade - without breaking them immediately.

Versioning strategy: Version on breaking changes only — removing a field, changing a field's type, or changing the semantics of an existing endpoint. Adding new optional fields or new endpoints doesn't require a version bump. Most stable REST APIs go years between major version increments.

OpenAPI Documentation with Swagger

API documentation should be generated from code, not maintained separately. Swashbuckle (the most common ASP.NET Core OpenAPI library) generates interactive documentation from your controllers and XML comments.

Basic Swashbuckle setup:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Your API",
        Version = "v1",
        Description = "API documentation",
        Contact = new OpenApiContact
        {
            Name = "Facile Technolab",
            Url = new Uri("https://www.faciletechnolab.com")
        }
    });

    // Include XML comments from controller documentation
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));

    // Add JWT authentication to Swagger UI
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT"
    });
    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });
});

XML documentation on controller actions:

/// <summary>Gets a product by ID.</summary>
/// <param name="id">The product identifier.</param>
/// <returns>The product if found.</returns>
/// <response code="200">Product found.</response>
/// <response code="404">Product not found.</response>
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetProduct(int id) { ... }

Enable XML documentation generation in the project file:

<PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

For .NET 9 projects, Microsoft's built-in Microsoft.AspNetCore.OpenApi package replaces Swashbuckle as the recommended option - simpler setup, better performance, no third-party dependency.

Pagination, Filtering, and Sorting

APIs returning lists need consistent patterns for pagination, filtering, and sorting. Inconsistent implementations across endpoints are a common source of client-side complexity.

Cursor-based pagination (preferred for large or frequently-updated datasets):

public record PagedRequest(
    int? Cursor = null,  // ID of the last item on the previous page
    int PageSize = 20);

public record PagedResponse<T>(
    IEnumerable<T> Items,
    int? NextCursor,
    bool HasMore);

[HttpGet]
public async Task<ActionResult<PagedResponse<ProductDto>>> GetProducts(
    [FromQuery] PagedRequest request)
{
    var items = await _repo.GetProductsAsync(request.Cursor, request.PageSize + 1);
    var hasMore = items.Count > request.PageSize;
    var page = items.Take(request.PageSize).Select(p => p.ToDto());

    return Ok(new PagedResponse<ProductDto>(
        page,
        hasMore ? items.Last().Id : null,
        hasMore));
}

Offset-based pagination (simpler, appropriate for smaller datasets):

public record PaginationParams(int Page = 1, int PageSize = 20);

[HttpGet]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts(
    [FromQuery] PaginationParams pagination,
    [FromQuery] string? search,
    [FromQuery] string? sortBy,
    [FromQuery] bool descending = false)
{
    var (items, total) = await _repo.GetProductsAsync(
        pagination, search, sortBy, descending);

    Response.Headers["X-Total-Count"] = total.ToString();
    Response.Headers["X-Page"] = pagination.Page.ToString();
    Response.Headers["X-Page-Size"] = pagination.PageSize.ToString();

    return Ok(items.Select(p => p.ToDto()));
}

Add pagination metadata in response headers (X-Total-Count, X-Page) rather than wrapping every response in a pagination envelope - it keeps the response body clean and consistent with endpoints that don't paginate. Read more about multi-tenant SaaS API.

Rate Limiting (.NET 7+)

ASP.NET Core 7 introduced a built-in rate limiting middleware, eliminating the need for third-party packages like AspNetCoreRateLimit for most use cases.

builder.Services.AddRateLimiter(options =>
{
    // Fixed window: 100 requests per minute per IP
    options.AddFixedWindowLimiter("fixed", config =>
    {
        config.PermitLimit = 100;
        config.Window = TimeSpan.FromMinutes(1);
        config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        config.QueueLimit = 10;
    });

    // Sliding window: smoother distribution
    options.AddSlidingWindowLimiter("sliding", config =>
    {
        config.PermitLimit = 100;
        config.Window = TimeSpan.FromMinutes(1);
        config.SegmentsPerWindow = 6; // 10-second segments
        config.QueueLimit = 0;
    });

    // Return 429 Too Many Requests when limit is exceeded
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

app.UseRateLimiter();

Apply per-endpoint:

[HttpGet]
[EnableRateLimiting("fixed")]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts() { ... }

For APIs where different clients have different rate limits (authenticated users vs anonymous, different subscription tiers), partition the rate limiter by user ID or API key rather than IP address:

options.AddFixedWindowLimiter("per-user", config =>
{
    config.PermitLimit = 1000;
    config.Window = TimeSpan.FromMinutes(1);
}).PartitionedByUser(); // partition by authenticated user ID

CORS Configuration

Cross-Origin Resource Sharing must be configured when browser-based clients (React, Angular, Vue frontends) call the API from a different domain.

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
        policy.WithOrigins(
                "https://app.yourdomain.com",
                "https://www.yourdomain.com")
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials()); // only if using cookies/auth headers

    // Development policy — broader, not for production
    options.AddPolicy("AllowAll", policy =>
        policy.AllowAnyOrigin()
              .AllowAnyMethod()
              .AllowAnyHeader());
});

app.UseCors("AllowFrontend"); // production

Common CORS mistake: Returning Access-Control-Allow-Origin: * alongside AllowCredentials() is invalid and rejected by browsers. Use specific origins when credentials are involved.

Testing ASP.NET Core APIs

A production-ready API needs tests at two levels: unit tests for business logic and integration tests for the full API pipeline including routing, middleware, and database.

Integration tests with WebApplicationFactory:

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real database with in-memory
                services.RemoveAll<AppDbContext>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetProduct_ReturnsOk_WhenProductExists()
    {
        // Arrange — seed test data
        // Act
        var response = await _client.GetAsync("/api/v1/products/1");
        // Assert
        response.EnsureSuccessStatusCode();
        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        Assert.NotNull(product);
    }

    [Fact]
    public async Task GetProduct_Returns404_WhenProductNotFound()
    {
        var response = await _client.GetAsync("/api/v1/products/99999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task CreateProduct_Returns201_WithLocation()
    {
        var dto = new CreateProductDto("Test Product", 9.99m, 100);
        var response = await _client.PostAsJsonAsync("/api/v1/products", dto);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);
    }
}

What to test: Every HTTP status code your API can return, request validation failures (send invalid data, confirm 400 response), authentication failures (call protected endpoints without token, confirm 401), and pagination edge cases (empty results, last page).

Production Checklist

Before deploying an ASP.NET Core REST API to production:

Design
□ DTOs separate from domain entities on all endpoints
□ Consistent pagination pattern across all list endpoints
□ Problem Details used for all error responses
□ HTTP status codes semantically correct
   (201 for POST creates, 204 for deletes, 422 for business rule failures)

Security
□ JWT validation configured with issuer, audience, and key validation
□ All sensitive endpoints decorated with [Authorize]
□ CORS origins explicitly listed — no AllowAnyOrigin in production
□ Rate limiting enabled on public endpoints
□ Secrets in Azure Key Vault or environment variables — not in appsettings.json

Documentation
□ Swagger/OpenAPI configured and accessible in non-production environments
□ XML comments on all public controller actions
□ [ProducesResponseType] attributes on all action methods

Versioning
□ API versioning configured
□ Default version set and documented
□ Breaking changes trigger a version increment

Operations
□ Structured logging with request correlation IDs
□ Health check endpoint configured (/health)
□ Global exception handler returning Problem Details
□ Response compression enabled

Frequently Asked Questions

Should I use controllers or Minimal APIs for a new ASP.NET Core REST API?

For most production APIs, controllers are still the better default - model binding, validation pipeline, action filters, and attribute routing are well-understood, well-documented, and easier to maintain as the API grows. Minimal APIs are the better choice for lightweight services, high-throughput simple endpoints, or microservices where the full MVC pipeline overhead isn't justified. For APIs expected to grow in complexity over time, controllers' built-in structure prevents the codebase from becoming unwieldy in ways that Minimal APIs don't enforce.

What is the correct HTTP status code for different scenarios?

200 OK for successful GET, PUT, PATCH responses. 201 Created for successful POST responses that create a resource (include a Location header). 204 No Content for successful DELETE responses and PUT/PATCH when no body is returned. 400 Bad Request for malformed request syntax. 401 Unauthorized when authentication is required and not provided. 403 Forbidden when authentication succeeded but authorization failed. 404 Not Found when the resource doesn't exist. 409 Conflict when the request conflicts with current state (duplicate creation). 422 Unprocessable Entity for business rule validation failures. 429 Too Many Requests for rate limit violations. 500 Internal Server Error for unexpected server errors - use Problem Details to describe the error without exposing internal details.

How do you handle authentication in an ASP.NET Core REST API?

JWT Bearer tokens are the standard for REST API authentication. The client authenticates once (against an identity endpoint or an external identity provider like Microsoft Entra ID) and receives a JWT. The JWT is sent in the Authorization: Bearer <token> header on subsequent requests. ASP.NET Core's AddJwtBearer middleware validates the token on every request. For APIs consumed by other services (machine-to-machine), use OAuth 2.0 Client Credentials flow rather than user-based JWT.

What is the best way to version a REST API in ASP.NET Core?

URL path versioning (/api/v1/products, /api/v2/products) is the most visible and widely understood approach - it's explicit, easy to test in a browser, and works without custom headers or query parameters. Microsoft's Asp.Versioning package supports URL path, query string (?api-version=2.0), and header-based versioning. The most important rule is to version on breaking changes only - adding new optional fields or new endpoints doesn't require a version increment, which keeps the version number meaningful when it does change.

How do you document a REST API in ASP.NET Core?

Use Swashbuckle (for .NET 6/7/8) or Microsoft's built-in OpenAPI support (for .NET 9+) to generate OpenAPI/Swagger documentation from your controller code and XML comments. The generated documentation is interactive - developers can test endpoints directly from the browser. Add [ProducesResponseType] attributes to all action methods to document which status codes each endpoint can return, and XML comments on actions to provide plain-English descriptions. Keep Swagger UI disabled in production (or protected) to avoid exposing API structure to unauthorized users.

How do you implement pagination in an ASP.NET Core REST API?

Two main approaches: offset-based pagination (page number + page size) is simpler to implement and explain but has consistency issues on frequently-updated datasets. Cursor-based pagination (pass the ID of the last item seen) is more complex but handles insertions and deletions between pages correctly. For most B2B SaaS APIs with moderate data volumes, offset pagination is sufficient. Return pagination metadata in response headers (X-Total-Count, X-Page) rather than wrapping every response in a pagination envelope - it keeps response bodies consistent whether or not an endpoint supports pagination.

Closing

ASP.NET Core provides a complete platform for building production REST APIs - the built-in support for validation, authentication, versioning, error handling, rate limiting, and documentation covers most of what a production API needs without additional libraries. The patterns in this guide - DTOs, Problem Details, global exception handling, WebApplicationFactory integration tests - are the ones that make APIs maintainable over time, not just working at launch.

If you're building a REST API on ASP.NET Core and want an experienced team to design or extend it, our ASP.NET Core development team has built API-first systems across healthcare, fintech, and enterprise SaaS.