.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 runMinimal 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 detailsGlobal 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