LinhGo Labs
LinhGo Labs
Clean Architecture in .NET Realworld Example

Clean Architecture in .NET Realworld Example

In the previous post, we took an overall look at Clean Architecture. In this article, letโ€™s explore a real-world example of how to use Clean Architecture.

๐Ÿ“š Clean Architecture Series:

The complete source code for this implementation is available on GitHub:

๐Ÿ”— Repository: DotNetCleanArchitecture

  • โœ… Complete Product Management System implementation
  • โœ… All layers: Domain, Application, Infrastructure, WebAPI
  • โœ… CQRS pattern with Commands and Queries
  • โœ… Repository Pattern and Unit of Work
  • โœ… Entity Framework Core with InMemory database
  • โœ… Domain Events implementation
  • โœ… Value Objects (Money)
  • โœ… REST API with Swagger documentation
  • โœ… Unit tests and integration tests
  • โœ… Docker support

Feel free to clone, explore, and experiment with the code!


Let’s walk through a complete implementation of a Product entity following Clean Architecture principles.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    Product Management System             โ”‚
โ”‚                                                          โ”‚
โ”‚  Features:                                              โ”‚
โ”‚  โ€ข Create Product with validation                       โ”‚
โ”‚  โ€ข Update Product details                               โ”‚
โ”‚  โ€ข Manage Stock (Add/Remove)                           โ”‚
โ”‚  โ€ข Activate/Deactivate Products                        โ”‚
โ”‚  โ€ข Query Products                                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        WebAPI Layer                           โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  ProductsController                                      โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ POST   /api/products        (Create)              โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ GET    /api/products        (GetAll)              โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ GET    /api/products/{id}   (GetById)             โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ PUT    /api/products/{id}   (Update)              โ”‚  โ”‚
โ”‚  โ”‚  โ””โ”€โ”€ DELETE /api/products/{id}   (Delete)              โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                          โ”‚                                     โ”‚
โ”‚                          โ”‚ HTTP Requests                       โ”‚
โ”‚                          โ–ผ                                     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                     Application Layer                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”‚
โ”‚  โ”‚   Commands (Write)   โ”‚      โ”‚   Queries (Read)     โ”‚      โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚      โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚      โ”‚
โ”‚  โ”‚  โ”‚ CreateProduct   โ”‚ โ”‚      โ”‚  โ”‚ GetAllProducts  โ”‚ โ”‚      โ”‚
โ”‚  โ”‚  โ”‚ UpdateProduct   โ”‚ โ”‚      โ”‚  โ”‚ GetProductById  โ”‚ โ”‚      โ”‚
โ”‚  โ”‚  โ”‚ DeleteProduct   โ”‚ โ”‚      โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚      โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                     โ”‚
โ”‚           โ”‚                              โ”‚                     โ”‚
โ”‚           โ”‚ Uses Domain                  โ”‚ Uses Repository    โ”‚
โ”‚           โ–ผ                              โ–ผ                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  Interfaces (Contracts)                                  โ”‚  โ”‚
โ”‚  โ”‚  โ€ข IProductRepository                                    โ”‚  โ”‚
โ”‚  โ”‚  โ€ข IUnitOfWork                                           โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                          โ–ฒ                                     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                          โ”‚                                     โ”‚
โ”‚                     Domain Layer                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  Product (Aggregate Root)                               โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ Properties: Id, Name, SKU, Price, Stock           โ”‚  โ”‚
โ”‚  โ”‚  โ”œโ”€โ”€ Methods:                                           โ”‚  โ”‚
โ”‚  โ”‚  โ”‚   โ€ข Create() โ† Factory Method                        โ”‚  โ”‚
โ”‚  โ”‚  โ”‚   โ€ข UpdateDetails()                                  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚   โ€ข AddStock() / RemoveStock()                       โ”‚  โ”‚
โ”‚  โ”‚  โ”‚   โ€ข Activate() / Deactivate()                        โ”‚  โ”‚
โ”‚  โ”‚  โ””โ”€โ”€ Domain Events:                                     โ”‚  โ”‚
โ”‚  โ”‚      โ€ข ProductCreated                                   โ”‚  โ”‚
โ”‚  โ”‚      โ€ข ProductUpdated                                   โ”‚  โ”‚
โ”‚  โ”‚      โ€ข ProductStockChanged                              โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  Money (Value Object)                                   โ”‚  โ”‚
โ”‚  โ”‚  โ€ข Amount + Currency                                     โ”‚  โ”‚
โ”‚  โ”‚  โ€ข Immutable                                            โ”‚  โ”‚
โ”‚  โ”‚  โ€ข Equality by value                                    โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                                                               โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                   Infrastructure Layer                        โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  ProductRepository (implements IProductRepository)      โ”‚  โ”‚
โ”‚  โ”‚  โ€ข GetByIdAsync()                                       โ”‚  โ”‚
โ”‚  โ”‚  โ€ข GetAllAsync()                                        โ”‚  โ”‚
โ”‚  โ”‚  โ€ข AddAsync()                                           โ”‚  โ”‚
โ”‚  โ”‚  โ€ข UpdateAsync()                                        โ”‚  โ”‚
โ”‚  โ”‚  โ€ข DeleteAsync()                                        โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  ApplicationDbContext (EF Core)                         โ”‚  โ”‚
โ”‚  โ”‚  โ€ข DbSet<Product>                                       โ”‚  โ”‚
โ”‚  โ”‚  โ€ข Entity Configurations                                โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                          โ”‚                                     โ”‚
โ”‚                          โ–ผ                                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚               Database (SQL Server / InMemory)          โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Product entity encapsulates all business logic and rules:

// src/DotNetCleanArchitecture.Domain/Entities/Product.cs

public sealed class Product : BaseEntity
{
    // Properties with private setters (Encapsulation)
    public string Name { get; private set; }
    public string Sku { get; private set; }
    public Money Price { get; private set; }        // Value Object
    public int StockQuantity { get; private set; }
    public bool IsActive { get; private set; }

    // Private constructor for EF Core
    private Product() { }

    // Factory Method - Only way to create a Product
    public static Product Create(
        string name, 
        string sku, 
        Money price, 
        int stockQuantity)
    {
        // โœ… Business Rules Enforced Here
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Product name cannot be empty");

        if (name.Length > 200)
            throw new ArgumentException("Name cannot exceed 200 characters");

        if (string.IsNullOrWhiteSpace(sku))
            throw new ArgumentException("SKU cannot be empty");

        var product = new Product
        {
            Name = name,
            Sku = sku,
            Price = price,
            StockQuantity = stockQuantity,
            IsActive = true
        };

        // โœ… Domain Event Published
        product.AddDomainEvent(
            new ProductCreatedDomainEvent(product.Id, product.Name, product.Sku)
        );

        return product;
    }

    // Business Operations
    public void AddStock(int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");

        StockQuantity += quantity;
        SetUpdatedAt();

        // Publish domain event
        AddDomainEvent(new ProductStockChangedDomainEvent(Id, StockQuantity));
    }

    public void RemoveStock(int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");

        if (StockQuantity < quantity)
            throw new InvalidOperationException("Insufficient stock");

        StockQuantity -= quantity;
        SetUpdatedAt();

        AddDomainEvent(new ProductStockChangedDomainEvent(Id, StockQuantity));
    }

    public void Activate() => IsActive = true;
    public void Deactivate() => IsActive = false;
    public bool IsInStock() => StockQuantity > 0;
    public bool IsAvailable() => IsActive && IsInStock();
}

Key Points:

  • โœ… Encapsulation: Private setters prevent invalid state
  • โœ… Factory Method: Create() ensures valid object creation
  • โœ… Business Rules: Validation inside the entity
  • โœ… Domain Events: Track important business events
  • โœ… Rich Behavior: Methods that make sense in business context

Value objects represent concepts that are defined by their attributes:

// src/DotNetCleanArchitecture.Domain/ValueObjects/Money.cs

public sealed class Money : ValueObject
{
    public decimal Amount { get; private set; }
    public string Currency { get; private set; }

    private Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    // Factory method with validation
    public static Money Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative");

        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency cannot be empty");

        return new Money(amount, currency.ToUpperInvariant());
    }

    // Business operations
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                "Cannot add money with different currencies"
            );

        return new Money(Amount + other.Amount, Currency);
    }

    // Value objects are compared by value, not reference
    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }

    public override string ToString() => $"{Amount:N2} {Currency}";
}

Why Value Objects?

  • โœ… Immutability: Cannot be changed after creation
  • โœ… Self-Validation: Business rules enforced
  • โœ… Equality by Value: Two Money objects with same amount/currency are equal
  • โœ… Type Safety: Can’t accidentally mix up price with quantity

Commands handle write operations:

// src/DotNetCleanArchitecture.Application/UseCases/Products/Commands/CreateProductCommand.cs

public sealed class CreateProductCommand
{
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateProductCommand(
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<ProductDto> ExecuteAsync(
        CreateProductDto dto, 
        CancellationToken cancellationToken = default)
    {
        // โœ… Business validation
        if (await _productRepository.SkuExistsAsync(dto.Sku, cancellationToken))
        {
            throw new InvalidOperationException(
                $"Product with SKU '{dto.Sku}' already exists"
            );
        }

        // โœ… Use domain factory method
        var price = Money.Create(dto.Price, dto.Currency);
        var product = Product.Create(
            dto.Name,
            dto.Sku,
            price,
            dto.StockQuantity
        );

        // โœ… Persist through repository
        await _productRepository.AddAsync(product, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        // โœ… Return DTO (not domain entity)
        return MapToDto(product);
    }

    private static ProductDto MapToDto(Product product)
    {
        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Sku = product.Sku,
            Price = product.Price.Amount,
            Currency = product.Price.Currency,
            StockQuantity = product.StockQuantity,
            IsActive = product.IsActive,
            CreatedAt = product.CreatedAt
        };
    }
}

Key Points:

  • โœ… Single Responsibility: Only creates products
  • โœ… Uses Domain Logic: Calls Product.Create()
  • โœ… Repository Pattern: Abstracts data access
  • โœ… Unit of Work: Manages transactions
  • โœ… DTOs: External representation separate from domain

Queries handle read operations:

// src/DotNetCleanArchitecture.Application/UseCases/Products/Queries/GetAllProductsQuery.cs

public sealed class GetAllProductsQuery
{
    private readonly IProductRepository _productRepository;

    public GetAllProductsQuery(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<IReadOnlyList<ProductDto>> ExecuteAsync(
        CancellationToken cancellationToken = default)
    {
        var products = await _productRepository.GetAllAsync(cancellationToken);
        return products.Select(MapToDto).ToList();
    }

    private static ProductDto MapToDto(Product product)
    {
        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Sku = product.Sku,
            Price = product.Price.Amount,
            Currency = product.Price.Currency,
            StockQuantity = product.StockQuantity,
            IsActive = product.IsActive,
            CreatedAt = product.CreatedAt,
            UpdatedAt = product.UpdatedAt
        };
    }
}

CQRS Benefits:

  • โœ… Separation: Read and write models are separate
  • โœ… Optimization: Can optimize queries differently than commands
  • โœ… Scalability: Can scale reads and writes independently
  • โœ… Clarity: Clear intent (command vs query)

// src/DotNetCleanArchitecture.Infrastructure/Persistence/Repositories/ProductRepository.cs

public class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _context;

    public ProductRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Product?> GetByIdAsync(
        Guid id, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
    }

    public async Task<IReadOnlyList<Product>> GetAllAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.Products
            .OrderBy(p => p.Name)
            .ToListAsync(cancellationToken);
    }

    public async Task<Product> AddAsync(
        Product product, 
        CancellationToken cancellationToken = default)
    {
        await _context.Products.AddAsync(product, cancellationToken);
        return product;
    }

    public async Task<bool> SkuExistsAsync(
        string sku, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Products
            .AnyAsync(p => p.Sku == sku, cancellationToken);
    }

    // ... other methods
}

// src/DotNetCleanArchitecture.Infrastructure/Persistence/Configurations/ProductConfiguration.cs

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products");
        builder.HasKey(p => p.Id);

        builder.Property(p => p.Name)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(p => p.Sku)
            .IsRequired()
            .HasMaxLength(50);

        builder.HasIndex(p => p.Sku)
            .IsUnique();

        // โœ… Value Object as Owned Entity
        builder.OwnsOne(p => p.Price, priceBuilder =>
        {
            priceBuilder.Property(m => m.Amount)
                .HasColumnName("Price")
                .HasPrecision(18, 2)
                .IsRequired();

            priceBuilder.Property(m => m.Currency)
                .HasColumnName("Currency")
                .HasMaxLength(3)
                .IsRequired();
        });

        // โœ… Ignore domain events (not persisted)
        builder.Ignore(p => p.DomainEvents);
    }
}

Key Points:

  • โœ… Fluent API: Configuration separate from domain
  • โœ… Owned Entities: Value objects mapped correctly
  • โœ… Constraints: Database constraints mirror business rules

// src/DotNetCleanArchitecture.WebAPI/Controllers/ProductsController.cs

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly CreateProductCommand _createProductCommand;
    private readonly GetAllProductsQuery _getAllProductsQuery;
    private readonly GetProductByIdQuery _getProductByIdQuery;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        CreateProductCommand createProductCommand,
        GetAllProductsQuery getAllProductsQuery,
        GetProductByIdQuery getProductByIdQuery,
        ILogger<ProductsController> logger)
    {
        _createProductCommand = createProductCommand;
        _getAllProductsQuery = getAllProductsQuery;
        _getProductByIdQuery = getProductByIdQuery;
        _logger = logger;
    }

    /// <summary>
    /// Get all products
    /// </summary>
    [HttpGet]
    [ProducesResponseType(typeof(IReadOnlyList<ProductDto>), 200)]
    public async Task<ActionResult<IReadOnlyList<ProductDto>>> GetAll(
        CancellationToken cancellationToken)
    {
        try
        {
            var products = await _getAllProductsQuery.ExecuteAsync(cancellationToken);
            return Ok(products);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error getting all products");
            return StatusCode(500, "An error occurred");
        }
    }

    /// <summary>
    /// Create a new product
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), 201)]
    [ProducesResponseType(400)]
    public async Task<ActionResult<ProductDto>> Create(
        [FromBody] CreateProductDto dto,
        CancellationToken cancellationToken)
    {
        try
        {
            var product = await _createProductCommand.ExecuteAsync(
                dto, 
                cancellationToken
            );
            
            return CreatedAtAction(
                nameof(GetById), 
                new { id = product.Id }, 
                product
            );
        }
        catch (ArgumentException ex)
        {
            _logger.LogWarning(ex, "Validation error");
            return BadRequest(ex.Message);
        }
        catch (InvalidOperationException ex)
        {
            _logger.LogWarning(ex, "Business rule violation");
            return BadRequest(ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating product");
            return StatusCode(500, "An error occurred");
        }
    }

    // ... other endpoints
}

Controller Responsibilities:

  • โœ… HTTP Concerns: Handle requests/responses
  • โœ… Validation: Input validation (can add FluentValidation)
  • โœ… Orchestration: Call appropriate use cases
  • โœ… Error Handling: Convert exceptions to HTTP status codes
  • โœ… Logging: Log important events

// src/DotNetCleanArchitecture.WebAPI/Program.cs

var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

// โœ… Register Application Layer
builder.Services.AddApplication();

// โœ… Register Infrastructure Layer
builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();

// Configure middleware
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.MapControllers();

app.Run();

Let’s trace a complete request through all layers:

1๏ธโƒฃ  HTTP Request
    โ”‚
    โ”‚  POST /api/products
    โ”‚  {
    โ”‚    "name": "Wireless Mouse",
    โ”‚    "sku": "MOUSE-001",
    โ”‚    "price": 29.99,
    โ”‚    "currency": "USD",
    โ”‚    "stockQuantity": 50
    โ”‚  }
    โ”‚
    โ–ผ

2๏ธโƒฃ  ProductsController (WebAPI Layer)
    โ”‚  โ€ข Receives HTTP request
    โ”‚  โ€ข Validates input
    โ”‚  โ€ข Calls CreateProductCommand
    โ”‚
    โ–ผ

3๏ธโƒฃ  CreateProductCommand (Application Layer)
    โ”‚  โ€ข Checks if SKU already exists (business rule)
    โ”‚  โ€ข Creates Money value object
    โ”‚  โ€ข Calls Product.Create() factory method
    โ”‚
    โ–ผ

4๏ธโƒฃ  Product.Create() (Domain Layer)
    โ”‚  โ€ข Validates business rules
    โ”‚  โ€ข Creates Product entity
    โ”‚  โ€ข Publishes ProductCreatedDomainEvent
    โ”‚  โ€ข Returns valid Product
    โ”‚
    โ–ผ

5๏ธโƒฃ  ProductRepository.AddAsync() (Infrastructure Layer)
    โ”‚  โ€ข Adds product to DbContext
    โ”‚
    โ–ผ

6๏ธโƒฃ  UnitOfWork.SaveChangesAsync() (Infrastructure Layer)
    โ”‚  โ€ข Commits transaction to database
    โ”‚  โ€ข Dispatches domain events
    โ”‚
    โ–ผ

7๏ธโƒฃ  Response Back to Client
    โ”‚  โ€ข Maps Product to ProductDto
    โ”‚  โ€ข Returns 201 Created with product data
    โ”‚
    โ””โ”€โ”€โ–บ HTTP Response
         {
           "id": "123e4567-e89b-12d3-a456-426614174000",
           "name": "Wireless Mouse",
           "sku": "MOUSE-001",
           "price": 29.99,
           "currency": "USD",
           "stockQuantity": 50,
           "isActive": true,
           "createdAt": "2025-12-21T10:30:00Z"
         }

  1. Keep Domain Pure

    // โœ… Good: Pure domain logic
    public void AddStock(int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");
    
        StockQuantity += quantity;
    }
  2. Use Factory Methods

    // โœ… Good: Factory method enforces rules
    public static Product Create(string name, string sku, Money price)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name required");
    
        return new Product { Name = name, Sku = sku, Price = price };
    }
  3. Encapsulate with Private Setters

    // โœ… Good: Cannot be modified from outside
    public string Name { get; private set; }
  4. Use Value Objects

    // โœ… Good: Money is a value object
    public Money Price { get; private set; }
    
    // โŒ Bad: Primitives can be mixed up
    public decimal Price { get; set; }
    public string Currency { get; set; }
  5. Define Interfaces in Application Layer

    // Application/Interfaces/IProductRepository.cs
    public interface IProductRepository
    {
        Task<Product?> GetByIdAsync(Guid id);
    }
  1. Don’t Reference Outer Layers from Inner Layers

    // โŒ BAD: Domain referencing Infrastructure
    public class Product
    {
        private readonly IProductRepository _repository; // WRONG!
    }
  2. Don’t Put Business Logic in Controllers

    // โŒ BAD: Business logic in controller
    [HttpPost]
    public async Task<IActionResult> Create(CreateProductDto dto)
    {
        if (string.IsNullOrEmpty(dto.Name)) // Business rule in wrong place
            return BadRequest();
    
        var product = new Product { Name = dto.Name }; // Direct instantiation
        await _context.Products.AddAsync(product); // Direct DB access
        await _context.SaveChangesAsync();
        return Ok();
    }
  3. Don’t Expose Domain Entities Directly

    // โŒ BAD: Returning domain entity
    [HttpGet]
    public async Task<Product> Get(Guid id)
    {
        return await _repository.GetByIdAsync(id);
    }
    
    // โœ… GOOD: Return DTO
    [HttpGet]
    public async Task<ProductDto> Get(Guid id)
    {
        var product = await _repository.GetByIdAsync(id);
        return MapToDto(product);
    }

  • โœ… Building complex business applications with lots of business rules
  • โœ… Project has a long lifespan (5+ years)
  • โœ… Multiple teams working on different parts
  • โœ… Requirements are likely to change frequently
  • โœ… Need to support multiple platforms (Web, Mobile, Desktop)
  • โœ… Testability is critical
  • โœ… Technology stack might change (database, framework, etc.)
  • โš ๏ธ Building a simple CRUD application with minimal business logic
  • โš ๏ธ Rapid prototyping or MVP development
  • โš ๏ธ Very small team (1-2 developers)
  • โš ๏ธ Short-lived project (< 6 months)
  • โš ๏ธ Team is unfamiliar with design patterns and DDD
  • โš ๏ธ Time to market is more important than architecture

A Product Management System demonstrating:

LayerWhat We CreatedPurpose
DomainProduct entity, Money value object, Domain eventsPure business logic
ApplicationCreate/Update/Delete commands, Get queries, DTOsUse cases and orchestration
InfrastructureEF Core repository, DbContext, configurationsData access implementation
WebAPIREST API controller, 5 endpointsHTTP interface
โœ… Clean Architecture        โ†’ Layered structure with dependency rule
โœ… Domain-Driven Design       โ†’ Entities, Value Objects, Events
โœ… CQRS                       โ†’ Commands and Queries separated
โœ… Repository Pattern         โ†’ Abstract data access
โœ… Unit of Work Pattern       โ†’ Transaction management
โœ… Factory Pattern            โ†’ Product.Create() method
โœ… Dependency Injection       โ†’ Loose coupling throughout
โœ… SOLID Principles           โ†’ All five principles applied
  • 26 C# Files across 4 layers
  • 1 Aggregate Root (Product)
  • 1 Value Object (Money)
  • 3 Domain Events
  • 3 Commands (Write operations)
  • 2 Queries (Read operations)
  • 5 REST Endpoints
  • 0 Framework Dependencies in Domain layer
  1. โœ… Product name is required and max 200 characters
  2. โœ… SKU must be unique
  3. โœ… Price cannot be negative
  4. โœ… Stock quantity cannot be negative
  5. โœ… Cannot remove more stock than available
  6. โœ… Currency must be valid (3-letter code)
  7. โœ… Domain events published for important actions

Clean Architecture provides a robust foundation for building maintainable, testable, and scalable applications. While it requires more upfront investment, the long-term benefits are substantial:

  1. Independence is Key: Business logic should be independent of frameworks, UI, and databases

  2. The Dependency Rule: Dependencies always point inwardโ€”inner layers know nothing about outer layers

  3. Testability: With proper separation, you can test business logic without any infrastructure

  4. Flexibility: Swap out databases, frameworks, or UI without touching core business logic

  5. Maintainability: Clear boundaries make code easier to understand and modify

  6. Team Collaboration: Different teams can work on different layers independently

For complex business applications with long lifespans: Absolutely YES โœ…
For simple CRUD apps or rapid prototypes: Probably NOT โŒ

Clean Architecture is not a silver bullet, but when applied appropriately, it creates systems that are:

  • Easy to test
  • Easy to understand
  • Easy to maintain
  • Easy to extend
  • Hard to break

The key is knowing when to use it and how much complexity to introduce based on your project’s needs.


  • Clean Architecture by Robert C. Martin (Uncle Bob)
  • Domain-Driven Design by Eric Evans
  • Microsoft’s eShopOnContainers (reference implementation)

GitHub Repository: DotNetCleanArchitecture

Project structure:

  • Domain: src/DotNetCleanArchitecture.Domain/
  • Application: src/DotNetCleanArchitecture.Application/
  • Infrastructure: src/DotNetCleanArchitecture.Infrastructure/
  • WebAPI: src/DotNetCleanArchitecture.WebAPI/

Clone and run:

git clone https://github.com/yourusername/DotNetCleanArchitecture.git
cd DotNetCleanArchitecture
dotnet restore
dotnet run --project src/DotNetCleanArchitecture.WebAPI

Related Articles:

Patterns & Principles:

  • SOLID Principles
  • CQRS Pattern
  • Event Sourcing
  • Repository Pattern
  • Unit of Work Pattern
  • Dependency Injection

Questions? Leave a comment below. Happy coding!!!