Back to: Microservices using ASP.NET Core Web API Tutorials
Clean Architecture in ASP.NET Core Web API with a Real-time Example
In this post, we will explore what Clean Architecture is, why it matters, its key layers, and then walk through a real‐time Product Management System built with ASP.NET Core Web API. Modern software applications require codebases that are maintainable, testable, and scalable. As systems grow, messy code and tightly coupled dependencies can slow down delivery and introduce bugs. To overcome this, we need to use Clean Architecture.
What Is Clean Architecture?
Clean Architecture is a software design philosophy introduced by Robert C. Martin (commonly known as Uncle Bob) that organizes code into distinct layers with clear responsibilities. The key idea is to separate concerns so that each layer of the application is independent of the others. This means changes or updates in one part of the system will have minimal or no impact on other parts.
The key idea is to divide your application into several distinct layers, where:
- Each layer has a well-defined responsibility.
- The most important part, your business logic or core rules, is completely independent of any external factors, such as databases, web frameworks, or user interfaces.
- This separation means that if you ever need to change the database, redesign the UI, or switch frameworks, your core business rules remain untouched.
Why is Clean Architecture Important?
Let’s take a real-life example: Imagine you own a bakery with several machines:
- A bread-baking machine that bakes bread.
- A dough-mixer that mixes ingredients.
- A packaging machine that packages finished bread.
If all these machines are tightly connected (for example, the dough-mixer’s operation depends directly on the bread-baking machine), then when one machine breaks down or needs an upgrade, the whole bakery might halt. It becomes difficult to fix or replace a single machine without disturbing the entire operation. For a better understanding, please refer to the following image.
With Clean Architecture, each machine operates independently. If the dough mixer breaks, it only affects the mixing step; the baking and packaging machines can still operate. You can upgrade or replace any machine without interrupting the bakery’s operation.
Understanding the Layers of Clean Architecture:
The Clean Architecture organizes the system into 4 distinct layers. Let us understand these layers. For a better understanding, please refer to the following image.
Domain Layer
The Domain Layer is the heart of business logic, comprising a pure domain model that contains business rules and validations, independent of infrastructure and application concerns.
It Contains:
- Entities: The core business objects (such as Product, Employee, Payment, Order, etc.) with properties and business rule validations. For example, the Product entity ensures that the price is positive and the stock isn’t negative.
Infrastructure Layer
The Infrastructure Layer implements all external dependencies, including databases, external APIs, and file systems. It provides the concrete classes that implement the interfaces defined in the Application Layer.
It Contains:
- EF Core DbContext: The actual class used to interact with your SQL Server database (e.g., ApplicationDbContext), defining tables and relationships.
- Repository Implementations: Concrete classes that use EF Core DbContext to perform CRUD operations as defined by the repository interfaces in the Application Layer.
- Other Infrastructure Services (if any): e.g., Email Services, Logging, External API Clients.
This layer isolates external dependencies from core business logic. The Infrastructure Layer is all about implementation details that can be swapped out or changed without affecting the core application logic.
Application Layer
The Application Layer defines the business use cases and application-specific logic. It manages how different parts of the system interact to fulfill business requirements without worrying about how the data is stored or presented in the user interface. This layer is framework-agnostic (no dependencies on EF Core, ASP.NET, etc.).
It Contains:
- Repository Interfaces: Define the contract for data access operations required by the business logic, without specifying how data is actually stored or retrieved (that’s the responsibility of Infrastructure). This promotes loose coupling between data access and business logic.
- Service Interfaces: Define business operations (use cases) such as creating, retrieving, updating, and deleting resources (e.g., Products, Employees).
- Service Implementations: Implement service interfaces that encapsulate business workflows. They implement business logic by calling repositories and managing domain entities, often handling validation and business rules.
- DTOs (Data Transfer Objects): Define the data structures used to transfer data between layers, shaping and validating data flowing between the Application Layer and external layers. By placing DTOs here, the Application Layer controls how data contracts are defined and used, ensuring strong separation of concerns.
Interface Adapters Layer (Presentation Layer)
The Interface Adapters Layer serves as a bridge between the outside world (such as HTTP clients, UI, or external APIs) and the application’s core logic.
- Adapts incoming data (HTTP requests) into internal application models.
- Adapts outgoing data into appropriate formats (JSON responses).
- Handles API-level validations (e.g., model validation via Data Annotations).
- Manages security concerns at the API level (authentication, authorization).
It Contains:
- API Controllers: Handle HTTP requests, validate input, invoke application services, and return appropriate responses.
Real-time Example: Product Management System using Clean Architecture in ASP.NET Core Web API
We will build a simple Product Management System using Clean Architecture principles with ASP.NET Core Web API, Entity Framework Core (EF Core) Code-First approach, and SQL Server as the database.
First, create a new ASP.NET Core Web API project named ProductManagement. As we will be using Entity Framework Core with SQL Server, we need to install the necessary packages. So, please execute the following commands in Visual Studio Command Prompt.
- Install-Package Microsoft.EntityFrameworkCore.SqlServer
- Install-Package Microsoft.EntityFrameworkCore.Tools
Creating the Domain Layer
The Domain Layer defines the essential business entities and rules, independent of frameworks or infrastructure. First, create a folder named Domain in the project root directory. Next, create a subfolder named Entities within the Domain folder. Then, inside the Domain/Entities folder, create a class file named Product.cs and copy and paste the following code.
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace ProductManagement.Domain.Entities { public class Product { public int Id { get; set; } [Required, StringLength(100, MinimumLength = 2)] public string Name { get; set; } = null!; [StringLength(500)] public string? Description { get; set; } [Column(TypeName = "decimal(18,2)")] [Range(0.01, 1000000, ErrorMessage = "Price must be positive and less than 10 lakh.")] public decimal Price { get; set; } [Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")] public int Stock { get; set; } // Business rule: Ensure Price is positive and Stock is not negative public void ValidateBusinessRules() { if (Price <= 0) throw new InvalidOperationException("Product price must be positive."); if (Stock < 0) throw new InvalidOperationException("Product stock cannot be negative."); } } }
Creating Application Layer:
The Application Layer defines the use cases (business logic), service contracts, and repository interfaces. It manages business workflows and rules, but does not include framework-specific or database code. The following are the Key Elements:
- DTOs (e.g., ProductDTO)
- Repository Interfaces (e.g., IProductRepository)
- Service Interfaces (e.g., IProductService)
- Service Implementations (e.g., ProductService)
First, create a folder named Application at the project root directory.
Creating DTOs:
Let’s start with the application layer by creating the DTOs. Inside the Application folder, create a new folder named DTOs, where we will store all our DTOs.
Creating Product Response DTO
Create a class file named ProductDTO.cs within the Application/DTOs folder and copy and paste the following code.
namespace ProductManagement.Application.DTOs { public class ProductDTO { public int Id { get; set; } public string Name { get; set; } = null!; public string? Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } } }
Creating Product Create DTO
Create a class file named CreateProductDTO.cs within the Application/DTOs folder and copy and paste the following code.
using System.ComponentModel.DataAnnotations; namespace ProductManagement.Application.DTOs { public class CreateProductDTO { [Required(ErrorMessage = "Product name is required.")] [MaxLength(100, ErrorMessage = "Product name cannot exceed 100 characters.")] public string Name { get; set; } = null!; [StringLength(500)] public string? Description { get; set; } [Range(0.01, 1000000, ErrorMessage = "Price must be positive and less than 10 lakh.")] public decimal Price { get; set; } [Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")] public int Stock { get; set; } } }
Creating Product Update DTO
Create a class file named UpdateProductDTO.cs within the Application/DTOs folder and copy and paste the following code.
using System.ComponentModel.DataAnnotations; namespace ProductManagement.Application.DTOs { public class UpdateProductDTO { [Required] public int Id { get; set; } [Required(ErrorMessage = "Product name is required.")] [MaxLength(100, ErrorMessage = "Product name cannot exceed 100 characters.")] public string Name { get; set; } = null!; [StringLength(500)] public string? Description { get; set; } [Range(0.01, 1000000, ErrorMessage = "Price must be positive and less than 10 lakh.")] public decimal Price { get; set; } [Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")] public int Stock { get; set; } } }
Creating Product Repository Interface
Create a folder named Interfaces within the Application folder. Again, create a new folder named Repositories within the Application/Interfaces folder. Then, inside the Application/Interfaces/Repositories folder, create an interface named IProductRepository.cs and copy and paste the following code.
using ProductManagement.Domain.Entities; namespace ProductManagement.Application.Interfaces.Repositories { public interface IProductRepository { Task<IEnumerable<Product>> GetAllAsync(); Task<Product?> GetByIdAsync(int id); Task<Product?> AddAsync(Product product); Task UpdateAsync(Product product); Task DeleteAsync(int id); } }
Creating Product Service Interface
Create a new folder named Services within the Application/Interfaces folder. Then, inside the Application/Interfaces/Services folder, create a class file named IProductService.cs and copy and paste the following code.
using ProductManagement.Application.DTOs; namespace ProductManagement.Application.Interfaces.Services { public interface IProductService { Task<IEnumerable<ProductDTO>> GetAllProductsAsync(); Task<ProductDTO?> GetProductByIdAsync(int id); Task<ProductDTO?> AddProductAsync(CreateProductDTO productDto); Task UpdateProductAsync(UpdateProductDTO productDto); Task DeleteProductAsync(int id); } }
Creating Product Service Implementations
Create a folder named Services within the Application folder. Then, inside the Application/Services folder, create a class file named ProductService.cs and copy and paste the following code.
using ProductManagement.Application.DTOs; using ProductManagement.Application.Interfaces.Repositories; using ProductManagement.Application.Interfaces.Services; using ProductManagement.Domain.Entities; namespace ProductManagement.Application.Services { public class ProductService : IProductService { private readonly IProductRepository _repository; public ProductService(IProductRepository repository) { _repository = repository; } private ProductDTO MapToDTO(Product product) { return new ProductDTO { Id = product.Id, Name = product.Name, Description = product.Description, Price = product.Price, Stock = product.Stock }; } public async Task<IEnumerable<ProductDTO>> GetAllProductsAsync() { var products = await _repository.GetAllAsync(); var dtos = new List<ProductDTO>(); foreach (var product in products) { dtos.Add(MapToDTO(product)); } return dtos; } public async Task<ProductDTO?> GetProductByIdAsync(int id) { var product = await _repository.GetByIdAsync(id); if (product == null) return null; return MapToDTO(product); } public async Task<ProductDTO?> AddProductAsync(CreateProductDTO productDto) { var product = new Product { Name = productDto.Name, Description = productDto.Description, Price = productDto.Price, Stock = productDto.Stock }; product.ValidateBusinessRules(); var newProduct = await _repository.AddAsync(product); if (newProduct != null) return MapToDTO(newProduct); return null; } public async Task UpdateProductAsync(UpdateProductDTO productDto) { var product = await _repository.GetByIdAsync(productDto.Id); if (product == null) throw new Exception("Product not found."); product.Name = productDto.Name; product.Description = productDto.Description; product.Price = productDto.Price; product.Stock = productDto.Stock; product.ValidateBusinessRules(); await _repository.UpdateAsync(product); } public async Task DeleteProductAsync(int id) { await _repository.DeleteAsync(id); } } }
Creating Infrastructure Layer
The Infrastructure Layer implements data access and other technical concerns (file system, email, etc.), which are external to your business logic. The following are the Key Elements:
- DbContext (ApplicationDbContext)
- Repository Implementations (ProductRepository)
First, create a folder named Infrastructure at the project root directory.
Creating Application DbContext
Create a new folder named Data within the Infrastructure folder. Then, inside the Infrastructure/Data folder, create a class file named ApplicationDbContext.cs and copy and paste the following code.
using Microsoft.EntityFrameworkCore; using ProductManagement.Domain.Entities; namespace ProductManagement.Infrastructure.Data { public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Seed initial products modelBuilder.Entity<Product>().HasData( new Product { Id = 1, Name = "Laptop", Description = "High-end gaming laptop", Price = 1500.00m, Stock = 10 }, new Product { Id = 2, Name = "Smartphone", Description = "Latest model smartphone", Price = 800.00m, Stock = 25 }, new Product { Id = 3, Name = "Wireless Headphones", Description = "Noise cancelling headphones", Price = 200.00m, Stock = 40 } ); } public DbSet<Product> Products { get; set; } = null!; } }
Creating Product Repository
Create a new folder named Repositories within the Infrastructure folder. Then, inside the Infrastructure/Repositories folder, create a class file named ProductRepository.cs and copy and paste the following code.
using ProductManagement.Application.Interfaces.Repositories; using ProductManagement.Domain.Entities; using ProductManagement.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace ProductManagement.Infrastructure.Repositories { public class ProductRepository : IProductRepository { private readonly ApplicationDbContext _context; public ProductRepository(ApplicationDbContext context) { _context = context; } public async Task<IEnumerable<Product>> GetAllAsync() { return await _context.Products.AsNoTracking().ToListAsync(); } public async Task<Product?> GetByIdAsync(int id) { return await _context.Products.AsNoTracking().FirstOrDefaultAsync(prd => prd.Id == id); } public async Task<Product?> AddAsync(Product product) { await _context.Products.AddAsync(product); await _context.SaveChangesAsync(); return product; } public async Task UpdateAsync(Product product) { _context.Products.Update(product); await _context.SaveChangesAsync(); } public async Task DeleteAsync(int id) { var product = await _context.Products.FindAsync(id); if (product != null) { _context.Products.Remove(product); await _context.SaveChangesAsync(); } } } }
Creating Interface Adapters Layer (Presentation Layer)
The Interface Adapters Layer handles incoming HTTP requests, interacts with the application layer, and returns HTTP responses to the client. The following are the Key Elements:
- API Controllers (e.g., ProductController)
Creating Product API Controller
So, create a folder named InterfaceAdapters at the project root directory, and then inside the InterfaceAdapters folder, create another folder named Controllers. Then, inside the InterfaceAdapters/Controllers folder, create an empty API controller named ProductController.cs and copy and paste the following code.
using Microsoft.AspNetCore.Mvc; using ProductManagement.Application.DTOs; using ProductManagement.Application.Interfaces.Services; namespace ProductManagement.InterfaceAdapters.Controllers { [Route("api/[controller]")] [ApiController] public class ProductController : ControllerBase { private readonly IProductService _service; public ProductController(IProductService service) { _service = service; } [HttpGet] public async Task<IEnumerable<ProductDTO>> Get() { return await _service.GetAllProductsAsync(); } [HttpGet("{id}")] public async Task<ActionResult<ProductDTO>> Get(int id) { var product = await _service.GetProductByIdAsync(id); if (product == null) return NotFound(); return Ok(product); } [HttpPost] public async Task<IActionResult> Post(CreateProductDTO productDto) { var product = await _service.AddProductAsync(productDto); return Ok(product); } [HttpPut("{id}")] public async Task<IActionResult> Put(UpdateProductDTO productDto) { await _service.UpdateProductAsync(productDto); return NoContent(); } [HttpDelete("{id}")] public async Task<IActionResult> Delete(int id) { await _service.DeleteProductAsync(id); return NoContent(); } } }
appsettings.json File
This file contains the application settings, including the database connection string. So, please modify the appsettings.json file as follows:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=ProductManagementDB;Trusted_Connection=True;TrustServerCertificate=True;" } }
Configuring Dependency Injection:
The Program.cs class file configures the application’s services and middleware components. So, please modify the Program class as follows:
using Microsoft.EntityFrameworkCore; using ProductManagement.Application.Interfaces.Repositories; using ProductManagement.Application.Interfaces.Services; using ProductManagement.Application.Services; using ProductManagement.Infrastructure.Data; using ProductManagement.Infrastructure.Repositories; namespace ProductManagement { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers() .AddJsonOptions(options => { //Disable Camel case naming conventions for JSON Serialization and Deserialization options.JsonSerializerOptions.PropertyNamingPolicy = null; }); // Add DbContext with SQL Server builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Register services and repositories builder.Services.AddScoped<IProductRepository, ProductRepository>(); builder.Services.AddScoped<IProductService, ProductService>(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); } } }
Creating and Applying Database Migration:
In Visual Studio, open the Package Manager Console and execute the Add-Migration and Update-Database command as follows to generate the Migration file and then apply the Migration file to create the ProductManagementDB database and the required Products table:
Once you execute the above commands, verify the database, and you should see the ProductManagementDB database with the required Products table as shown in the image below.
Benefits of Clean Architecture in ASP.NET Core Web API
The following are the benefits of developing an application by following clean Architecture:
- Separation of Concerns: Each layer has a clear responsibility, making the system easier to understand, develop, and maintain.
- Testability: Since the business logic is isolated from infrastructure and UI concerns, unit testing is straightforward and does not require real databases or UI.
- Maintainability and Extensibility: Changes in one layer (e.g., switching databases or UI frameworks) do not affect other layers, reducing the risk of regressions.
- Independence from Frameworks: The core business logic and application rules do not depend on any specific technology or framework.
- Flexibility: Infrastructure details, such as data storage, third-party services, or UI, can be swapped or updated without impacting business rules.
- Better Code Quality: Encourages use of interfaces, dependency inversion, and domain-driven design principles, resulting in cleaner, more robust code.
By structuring our application into distinct layers such as Domain, Application, Infrastructure, and Interface Adapters, we ensure that core business rules remain independent and protected from external concerns. Adopting Clean Architecture in ASP.NET Core Web API development not only leads to cleaner and more manageable codebases but also prepares your system for future growth, technology changes, and evolving business requirements with minimal affect.
In the next article, I will discuss Domain-Driven Design (DDD) Principles with Examples. In this article, I explain Clean Architecture in ASP.NET Core Web API. I hope you enjoy this article, Clean Architecture in ASP.NET Core Web API with Examples.
Nice explanation Sir.