Back to blog
Backend Systemsintermediate

ASP.NET Core Web API: Build Production REST APIs

Build production-ready REST APIs with ASP.NET Core 8. Covers minimal APIs, controllers, middleware, authentication with JWT, validation, error handling, versioning, and deployment.

LearnixoApril 13, 20268 min read
View Source
.NETASP.NET CoreREST APIJWTMiddlewareC#
Share:𝕏

What We're Building

A production-ready OrderFlow API — a REST API for order management. By the end you'll have:

  • Endpoint routing with controllers and minimal APIs
  • JWT authentication + role-based authorization
  • Request validation with FluentValidation
  • Global error handling
  • API versioning
  • Swagger/OpenAPI documentation
  • Rate limiting
  • Health checks

Project Setup

Bash
dotnet new webapi -n OrderFlow.Api --use-controllers
cd OrderFlow.Api
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package FluentValidation.AspNetCore
dotnet add package Swashbuckle.AspNetCore
dotnet run

Minimal API vs Controllers

Minimal APIs (top-level, great for microservices):

C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/orders/{id}", async (int id, IOrderService svc) =>
{
    var order = await svc.GetByIdAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

app.MapPost("/orders", async (CreateOrderRequest req, IOrderService svc) =>
{
    var id = await svc.CreateAsync(req);
    return Results.CreatedAtRoute("GetOrder", new { id }, new { id });
});

app.Run();

Controller-based (better for large APIs with shared filters and versioning):

C#
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _service;

    public OrdersController(IOrderService service)
        => _service = service;

    [HttpGet("{id:int}", Name = "GetOrder")]
    [ProducesResponseType<OrderDto>(200)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        var order = await _service.GetByIdAsync(id, ct);
        return order is null ? NotFound() : Ok(order);
    }

    [HttpGet]
    public async Task<IActionResult> GetAll(
        [FromQuery] int page = 1,
        [FromQuery] int size = 20,
        [FromQuery] string? status = null,
        CancellationToken ct = default)
    {
        var result = await _service.GetPagedAsync(page, size, status, ct);
        return Ok(result);
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderRequest request,
        CancellationToken ct)
    {
        var id = await _service.CreateAsync(request, ct);
        return CreatedAtRoute("GetOrder", new { id }, new { id });
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(
        int id,
        [FromBody] UpdateOrderRequest request,
        CancellationToken ct)
    {
        await _service.UpdateAsync(id, request, ct);
        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        await _service.DeleteAsync(id, ct);
        return NoContent();
    }
}

Request/Response Models

C#
// DTOs (Data Transfer Objects) — what the API accepts/returns
public record CreateOrderRequest(
    int CustomerId,
    List<OrderItemRequest> Items,
    string? Notes
);

public record OrderItemRequest(int ProductId, int Quantity);

public record OrderDto(
    int Id,
    int CustomerId,
    string CustomerName,
    decimal Total,
    string Status,
    DateTime CreatedAt,
    List<OrderItemDto> Items
);

public record PagedResult<T>(
    IReadOnlyList<T> Items,
    int Page,
    int PageSize,
    int TotalCount
)
{
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasNext => Page < TotalPages;
    public bool HasPrev => Page > 1;
}

Request Validation with FluentValidation

C#
// Validator
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerId).GreaterThan(0);
        RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item");
        RuleForEach(x => x.Items).SetValidator(new OrderItemRequestValidator());
    }
}

public class OrderItemRequestValidator : AbstractValidator<OrderItemRequest>
{
    public OrderItemRequestValidator()
    {
        RuleFor(x => x.ProductId).GreaterThan(0);
        RuleFor(x => x.Quantity).InclusiveBetween(1, 1000);
    }
}

// Register in Program.cs
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();
// Validation errors automatically return 400 with details

Global Error Handling

Don't let exceptions leak to clients. Use a global handler.

C#
// Custom exception types
public class NotFoundException : Exception
{
    public NotFoundException(string resource, object id)
        : base($"{resource} with id '{id}' was not found.") { }
}

public class ValidationException : Exception
{
    public IReadOnlyDictionary<string, string[]> Errors { get; }
    public ValidationException(IReadOnlyDictionary<string, string[]> errors)
        : base("One or more validation errors occurred.")
        => Errors = errors;
}

public class ForbiddenException : Exception
{
    public ForbiddenException(string message) : base(message) { }
}

// Exception handler middleware
public class ExceptionHandlerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlerMiddleware> _logger;

    public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext ctx, Exception ex)
    {
        var (statusCode, title, detail) = ex switch
        {
            NotFoundException nfe      => (404, "Not Found", nfe.Message),
            ValidationException ve     => (400, "Validation Error", ve.Message),
            ForbiddenException fe      => (403, "Forbidden", fe.Message),
            UnauthorizedAccessException => (401, "Unauthorized", "Authentication required"),
            _                          => (500, "Server Error", "An unexpected error occurred")
        };

        if (statusCode == 500)
            _logger.LogError(ex, "Unhandled exception");

        ctx.Response.StatusCode = statusCode;
        ctx.Response.ContentType = "application/problem+json";

        var problem = new ProblemDetails
        {
            Title = title,
            Detail = detail,
            Status = statusCode,
            Instance = ctx.Request.Path,
        };

        if (ex is ValidationException valEx)
            problem.Extensions["errors"] = valEx.Errors;

        await ctx.Response.WriteAsJsonAsync(problem);
    }
}

// Register in Program.cs
app.UseMiddleware<ExceptionHandlerMiddleware>();

JWT Authentication

C#
// Program.cs
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:Secret"]!))
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
    options.AddPolicy("CustomerOrAdmin", p => p.RequireRole("Customer", "Admin"));
});

// Token generation service
public class TokenService
{
    private readonly IConfiguration _config;
    public TokenService(IConfiguration config) => _config = config;

    public string GenerateToken(User user)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Email, user.Email),
            new Claim(ClaimTypes.Role, user.Role),
        };

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

// Auth controller
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    [HttpPost("login")]
    public async Task<IActionResult> Login(
        [FromBody] LoginRequest req,
        [FromServices] IUserService users,
        [FromServices] TokenService tokens)
    {
        var user = await users.ValidateAsync(req.Email, req.Password);
        if (user is null) return Unauthorized("Invalid credentials");

        return Ok(new { Token = tokens.GenerateToken(user), ExpiresIn = 3600 });
    }
}

// Protecting endpoints
[Authorize]
[HttpGet("{id:int}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
    // Get current user from claims
    var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
    var order = await _service.GetByIdAsync(id, userId, ct);
    return order is null ? NotFound() : Ok(order);
}

[Authorize(Policy = "AdminOnly")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
    await _service.DeleteAsync(id, ct);
    return NoContent();
}

Middleware Pipeline

C#
// Program.cs — order matters!
var app = builder.Build();

app.UseExceptionHandler("/error");  // or custom middleware
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthentication();    // before UseAuthorization
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();

// Custom middleware
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        await _next(context);
        sw.Stop();

        _logger.LogInformation(
            "{Method} {Path} responded {StatusCode} in {Elapsed}ms",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            sw.ElapsedMilliseconds);
    }
}

// CORS
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", p => p
        .WithOrigins("https://learnixo.io", "http://localhost:3000")
        .AllowAnyHeader()
        .AllowAnyMethod());
});

Rate Limiting (.NET 7+)

C#
builder.Services.AddRateLimiter(options =>
{
    // Fixed window: 100 requests per minute
    options.AddFixedWindowLimiter("api", limiter =>
    {
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.PermitLimit = 100;
        limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiter.QueueLimit = 5;
    });

    // Per-user limit
    options.AddSlidingWindowLimiter("per-user", limiter =>
    {
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.PermitLimit = 20;
        limiter.SegmentsPerWindow = 6;
    });

    options.OnRejected = async (context, ct) =>
    {
        context.HttpContext.Response.StatusCode = 429;
        await context.HttpContext.Response.WriteAsJsonAsync(
            new ProblemDetails { Title = "Too Many Requests", Status = 429 }, ct);
    };
});

// Apply to all endpoints
app.UseRateLimiter();

// Or per-endpoint
[EnableRateLimiting("per-user")]
[HttpPost]
public async Task<IActionResult> Create(...) { ... }

API Versioning

C#
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer

// Program.cs
builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.ApiVersionReader = ApiVersionReader.Combine(
            new UrlSegmentApiVersionReader(),
            new HeaderApiVersionReader("X-API-Version")
        );
    })
    .AddMvc()
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });

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

// V2 with breaking changes
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV2Controller : ControllerBase { ... }

Swagger / OpenAPI

C#
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "OrderFlow API",
        Version = "v1",
        Description = "Order management REST API",
    });

    // JWT support in Swagger UI
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Enter JWT: Bearer {token}",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {{
        new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } },
        Array.Empty<string>()
    }});
});

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "OrderFlow API v1"));
}

Health Checks

C#
builder.Services.AddHealthChecks()
    .AddSqlServer(builder.Configuration.GetConnectionString("Default")!)
    .AddRedis(builder.Configuration["Redis:ConnectionString"]!)
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddCheck<ExternalApiHealthCheck>("external-api");

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.MapHealthChecks("/health/ready",   // Kubernetes readiness
    new HealthCheckOptions { Predicate = hc => hc.Tags.Contains("ready") });
app.MapHealthChecks("/health/live",    // Kubernetes liveness
    new HealthCheckOptions { Predicate = _ => false });

appsettings.json Structure

JSON
{
  "Jwt": {
    "Secret": "your-very-long-secret-key-at-least-32-chars",
    "Issuer": "https://orderflow.io",
    "Audience": "https://orderflow.io"
  },
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=OrderFlow;Trusted_Connection=true;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Never store secrets in appsettings.json in production. Use:

  • User Secrets (development): dotnet user-secrets set "Jwt:Secret" "value"
  • Azure Key Vault (production)
  • Environment variables

Complete Program.cs

C#
var builder = WebApplication.CreateBuilder(args);

// Services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Auth
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(/* ... */);
builder.Services.AddAuthorization();

// Application services
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<TokenService>();

// Validation
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();

// CORS
builder.Services.AddCors(/* ... */);

// Health
builder.Services.AddHealthChecks().AddSqlServer(/* ... */);

// Rate limiting
builder.Services.AddRateLimiter(/* ... */);

var app = builder.Build();

// Middleware pipeline
app.UseSwagger();
app.UseSwaggerUI();
app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
app.MapHealthChecks("/health");

app.Run();

What to Learn Next

  • Entity Framework Core: Connect your API to a real database
  • Clean Architecture: Structure large APIs properly with CQRS
  • .NET Interview Questions (Mid-Level): API design, middleware, auth patterns

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.