Building Scalable .NET APIs: Lessons from Production Systems
A comprehensive guide to building production-ready .NET APIs, covering architecture patterns, performance optimization, and best practices learned from developing large-scale distributed systems.
Building Scalable .NET APIs: Lessons from Production Systems
After years of building and maintaining large-scale distributed systems in production, I've learned that creating a truly scalable .NET API requires more than just knowing the framework—it demands a deep understanding of architecture patterns, performance considerations, and operational excellence.
Table of Contents
The Foundation: Clean Architecture
When building APIs that need to scale, the architecture you choose in the beginning will either accelerate or hinder your growth. I've consistently found success with Clean Architecture principles:
Project Structure
MyAPI.Solution/
├── MyAPI.Domain/ # Business entities and interfaces
├── MyAPI.Application/ # Business logic and use cases
├── MyAPI.Infrastructure/ # Data access, external services
├── MyAPI.API/ # Controllers, middleware, startup
└── MyAPI.Tests/ # Unit and integration tests
This separation ensures:
- Testability: Business logic is isolated from infrastructure concerns
- Maintainability: Changes in one layer don't cascade
- Scalability: Easy to swap implementations as needs change
Dependency Injection Best Practices
// Program.cs - Clean service registration
var builder = WebApplication.CreateBuilder(args);
// Add services by layer
builder.Services.AddDomainServices();
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructureServices(builder.Configuration);
// Configure lifetime appropriately
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddTransient<IEmailService, EmailService>();
Key Lesson: Always register services with the correct lifetime. I once debugged a production issue for hours only to find a Singleton service holding a Scoped DbContext—a recipe for disaster.
Performance Optimization Strategies
1. Asynchronous Everything
// ❌ Bad - Blocking calls
public IActionResult GetOrders()
{
var orders = _orderRepository.GetAll(); // Synchronous!
return Ok(orders);
}
// ✅ Good - Async all the way
public async Task<IActionResult> GetOrdersAsync(CancellationToken cancellationToken)
{
var orders = await _orderRepository.GetAllAsync(cancellationToken);
return Ok(orders);
}
Real-world impact: By converting blocking I/O calls to async, we increased our API's throughput by 3x under load. The thread pool thanked us.
2. Response Caching and Compression
// Add response caching
builder.Services.AddResponseCaching();
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
// Use it wisely
[HttpGet]
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category" })]
public async Task<IActionResult> GetProductsAsync(string category)
{
// Expensive query here
}
3. Database Optimization
// Always project only what you need
public async Task<IEnumerable<OrderDto>> GetOrderSummariesAsync()
{
return await _context.Orders
.AsNoTracking() // Read-only queries
.Where(o => o.Status == OrderStatus.Active)
.Select(o => new OrderDto // Project to DTO
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Total,
OrderDate = o.OrderDate
})
.ToListAsync();
}
Lesson learned: We reduced query times from 2s to 200ms by adding .AsNoTracking() and projecting to DTOs instead of loading full entities.
Building for Resilience
Implementing Retry Policies with Polly
// Install: Polly
builder.Services.AddHttpClient<IExternalApiClient, ExternalApiClient>()
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
_logger.LogWarning(
"Retry {RetryAttempt} after {Delay}s due to {Exception}",
retryAttempt, timespan.TotalSeconds, outcome.Exception?.Message);
}
))
.AddTransientHttpErrorPolicy(policy =>
policy.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromMinutes(1)
));
Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>()
.AddRedis(builder.Configuration.GetConnectionString("Redis"))
.AddCheck<ExternalApiHealthCheck>("external-api");
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
API Versioning Done Right
// Install: Asp.Versioning.Mvc
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
// Controller implementation
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class OrdersController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public async Task<IActionResult> GetOrdersV1() { /* ... */ }
[HttpGet]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetOrdersV2() { /* ... */ }
}
Security Best Practices
JWT Authentication
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"]))
};
});
Rate Limiting (ASP.NET Core 7+)
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
});
Logging and Monitoring
Structured Logging with Serilog
// Install: Serilog.AspNetCore, Serilog.Sinks.Console, Serilog.Sinks.File
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/api-.txt", rollingInterval: RollingInterval.Day)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "MyAPI")
.CreateLogger();
builder.Host.UseSerilog();
// Usage in controllers
public class OrdersController : ControllerBase
{
private readonly ILogger<OrdersController> _logger;
[HttpPost]
public async Task<IActionResult> CreateOrderAsync(CreateOrderRequest request)
{
_logger.LogInformation(
"Creating order for customer {CustomerId} with {ItemCount} items",
request.CustomerId, request.Items.Count);
try
{
var result = await _orderService.CreateAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = result.Id }, result);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Order validation failed for customer {CustomerId}",
request.CustomerId);
return BadRequest(ex.Errors);
}
}
}
Testing Strategy
Integration Tests with WebApplicationFactory
public class OrdersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real services with test doubles
services.RemoveAll<IOrderService>();
services.AddScoped<IOrderService, MockOrderService>();
});
}).CreateClient();
}
[Fact]
public async Task GetOrders_ReturnsSuccessStatusCode()
{
// Arrange & Act
var response = await _client.GetAsync("/api/v1/orders");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var orders = JsonSerializer.Deserialize<List<OrderDto>>(content);
Assert.NotEmpty(orders);
}
}
Production Deployment Checklist
From my experience deploying APIs to production:
- ✅ Use environment variables for sensitive configuration
- ✅ Enable HTTPS only with HSTS headers
- ✅ Implement proper error handling - never expose stack traces to clients
- ✅ Set up monitoring - Application Insights, Datadog, or similar
- ✅ Configure CORS properly - don't use
AllowAnyOrigin()in production - ✅ Use distributed caching - Redis for multi-instance deployments
- ✅ Implement graceful shutdown - allow in-flight requests to complete
- ✅ Set up CI/CD pipelines - automated testing and deployment
- ✅ Configure auto-scaling - based on CPU, memory, or custom metrics
- ✅ Document your API - Swagger/OpenAPI with examples
Key Takeaways
Building scalable .NET APIs is an iterative process. Start with solid architecture principles, optimize based on real metrics (not assumptions), and always design for failure. The patterns and practices outlined here have helped me build systems that handle millions of requests per day while maintaining sub-100ms response times.
Remember: Premature optimization is the root of all evil, but having a scalable architecture from day one is just good planning.
Have questions or want to discuss .NET API development? Feel free to reach out!