Building Scalable Web APIs with ASP.NET Core and Clean Architecture
Clean Architecture is a software design pattern that emphasizes separation of concerns and dependency inversion. In this comprehensive guide, I'll show you how to implement these principles in ASP.NET Core to create robust, maintainable web APIs.
Why Clean Architecture?
Clean Architecture provides several benefits:
- Testability: Easy to unit test business logic
- Maintainability: Changes in one layer don't affect others
- Flexibility: Easy to swap out frameworks or databases
- Scalability: Structure supports growing complexity
Project Structure
src/
├── Core/
│ ├── Domain/
│ │ ├── Entities/
│ │ ├── Interfaces/
│ │ └── ValueObjects/
│ └── Application/
│ ├── DTOs/
│ ├── Interfaces/
│ ├── Services/
│ └── UseCases/
├── Infrastructure/
│ ├── Data/
│ ├── ExternalServices/
│ └── Repositories/
└── Presentation/
└── WebAPI/
├── Controllers/
├── Middleware/
└── Program.cs
Domain Layer Implementation
Start with your domain entities:
// Domain/Entities/Product.cs
public class Product
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public DateTime CreatedAt { get; private set; }
public Product(string name, decimal price)
{
Id = Guid.NewGuid();
Name = name ?? throw new ArgumentNullException(nameof(name));
Price = price > 0 ? price : throw new ArgumentException("Price must be positive");
CreatedAt = DateTime.UtcNow;
}
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new ArgumentException("Price must be positive");
Price = newPrice;
}
}
Application Layer Services
Implement your business logic in application services:
// Application/Services/ProductService.cs
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
private readonly ILogger<ProductService> _logger;
public ProductService(IProductRepository repository, ILogger<ProductService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<ProductDto> CreateProductAsync(CreateProductRequest request)
{
var product = new Product(request.Name, request.Price);
await _repository.AddAsync(product);
_logger.LogInformation("Product created with ID: {ProductId}", product.Id);
return MapToDto(product);
}
public async Task<ProductDto> GetProductAsync(Guid id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
throw new NotFoundException($"Product with ID {id} not found");
return MapToDto(product);
}
}
Infrastructure Implementation
Set up your data layer with Entity Framework:
// Infrastructure/Data/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.Price)
.HasColumnType("decimal(18,2)");
});
}
}
API Controllers
Keep your controllers thin:
// Presentation/WebAPI/Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpPost]
public async Task<ActionResult<ProductDto>> CreateProduct([FromBody] CreateProductRequest request)
{
var product = await _productService.CreateProductAsync(request);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductDto>> GetProduct(Guid id)
{
var product = await _productService.GetProductAsync(id);
return Ok(product);
}
}
Dependency Injection Setup
Configure your services in Program.cs:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Testing Your Architecture
The clean separation makes testing straightforward:
[Test]
public async Task CreateProduct_ValidInput_ReturnsProductDto()
{
// Arrange
var mockRepository = new Mock<IProductRepository>();
var mockLogger = new Mock<ILogger<ProductService>>();
var service = new ProductService(mockRepository.Object, mockLogger.Object);
var request = new CreateProductRequest("Test Product", 99.99m);
// Act
var result = await service.CreateProductAsync(request);
// Assert
Assert.That(result.Name, Is.EqualTo("Test Product"));
Assert.That(result.Price, Is.EqualTo(99.99m));
mockRepository.Verify(r => r.AddAsync(It.IsAny<Product>()), Times.Once);
}
Best Practices
- Keep domain logic pure - No external dependencies
- Use interfaces for dependencies - Enable easy testing and swapping
- Implement proper error handling - Use custom exceptions
- Add comprehensive logging - For debugging and monitoring
- Validate inputs early - At the API boundary
Conclusion
Clean Architecture in ASP.NET Core provides a solid foundation for building enterprise-grade applications. The separation of concerns makes your code more maintainable, testable, and adaptable to changing requirements.
Next up: I'll cover implementing CQRS patterns and event sourcing in this architecture!
