.NET & C# Development · Lesson 2 of 11

ASP.NET Core Web API

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