How to Implement Clean Architecture in C#: A Beginner’s Guide

Okay, here’s a detailed article on implementing Clean Architecture in C#, aimed at beginners:

How to Implement Clean Architecture in C#: A Beginner’s Guide

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), is a software design philosophy that emphasizes separation of concerns and testability. It achieves this by dividing your application into distinct layers, each with a specific responsibility and dependency direction. The core principle is that inner layers should not depend on outer layers. This creates a system that is:

  • Independent of Frameworks: Your core business logic is not tied to a specific database, UI framework, or external library.
  • Testable: You can easily test your business rules in isolation without needing a database or UI.
  • Independent of UI: The UI can change without impacting the core logic.
  • Independent of Database: You can swap databases (e.g., from SQL Server to PostgreSQL) without altering your core.
  • Independent of any external agency: Your business rules don’t know anything about the outside world.

This guide will walk you through implementing Clean Architecture in C#, providing a practical example. We’ll build a simple “Product Catalog” application.

1. The Layers

Clean Architecture typically consists of four main layers (although variations exist):

  • Entities (Innermost Layer): These represent your core business objects and rules. They are the most stable part of your application. Think of them as the fundamental concepts your application deals with, independent of how those concepts are used or presented.

    • Example: Product (with properties like Id, Name, Description, Price), Category. These are simple classes, POCOs (Plain Old CLR Objects).
  • Use Cases (Interactors): This layer contains the application-specific business rules. Each use case represents a single action the user can perform (e.g., “Add Product,” “Get Product by ID”). Use Cases orchestrate the Entities.

    • Example: AddProductUseCase, GetProductByIdUseCase, GetAllProductsUseCase. These classes encapsulate the logic of interacting with your entities.
  • Interface Adapters (Controllers, Presenters, Gateways): This layer acts as a bridge between the Use Cases and the outer layers (like the UI or database). It contains:

    • Controllers: In an MVC or Web API context, these handle user input and delegate to Use Cases.
    • Presenters: Format the output from Use Cases for presentation (e.g., converting data to a ViewModel). In simpler scenarios, you may omit Presenters and have Controllers directly return ViewModels.
    • Gateways: Interfaces that define how to interact with external systems (e.g., IProductRepository for database access, IEmailService for sending emails). The implementation of these interfaces resides in the Infrastructure layer.
  • Frameworks & Drivers (Outermost Layer): This is the least stable layer and contains the concrete implementations of things like:

    • UI: ASP.NET Core MVC, Blazor, Console App, etc.
    • Database: Entity Framework Core, Dapper, etc.
    • External Services: Email services, message queues, etc.
    • Web Framework: ASP.NET Core

2. Project Structure

A well-structured solution is crucial. Here’s a recommended project layout:

ProductCatalog.sln
├── ProductCatalog.Core
│ ├── Entities
│ │ └── Product.cs
│ │ └── Category.cs
│ ├── Interfaces (Sometimes called "Abstractions")
│ │ └── IProductRepository.cs
│ └── UseCases
│ └── AddProduct
│ └── AddProductUseCase.cs
│ └── AddProductRequest.cs
│ └── AddProductResponse.cs
│ └── GetProductById
│ └── GetProductByIdUseCase.cs
│ └── ...
├── ProductCatalog.Infrastructure
│ ├── Data
│ │ └── ProductRepository.cs (Implements IProductRepository)
│ │ └── ProductCatalogDbContext.cs (EF Core DbContext)
│ └── Services
│ └── EmailService.cs (Example)
├── ProductCatalog.Presentation (or ProductCatalog.Web)
│ ├── Controllers
│ │ └── ProductController.cs
│ ├── ViewModels
│ │ └── ProductViewModel.cs
│ └── Views
│ └── Product
│ └── Index.cshtml
│ └── ...
└── ProductCatalog.Tests (Optional, but highly recommended)
├── ProductCatalog.Core.Tests
└── ...

  • ProductCatalog.Core: Contains Entities, Use Cases, and Interfaces (Abstractions). This project should have no dependencies on other projects.
  • ProductCatalog.Infrastructure: Contains implementations of the interfaces defined in ProductCatalog.Core. This project depends on ProductCatalog.Core.
  • ProductCatalog.Presentation (or .Web): The UI layer. This project depends on ProductCatalog.Core (for Use Cases and possibly ViewModels) and ProductCatalog.Infrastructure (to satisfy interface dependencies via dependency injection).
  • ProductCatalog.Tests: Unit and integration tests.

3. Code Example

Let’s illustrate with a simplified example, focusing on adding a product.

ProductCatalog.Core

“`csharp
// ProductCatalog.Core/Entities/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
}

// ProductCatalog.Core/Interfaces/IProductRepository.cs
public interface IProductRepository
{
Task AddProductAsync(Product product);
Task GetProductByIdAsync(int id);
Task> GetAllProductsAsync();
}
// ProductCatalog.Core/UseCases/AddProduct/AddProductUseCase.cs
public class AddProductUseCase
{
private readonly IProductRepository _productRepository;

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

public async Task<AddProductResponse> ExecuteAsync(AddProductRequest request)
{
    // Validation (can be done here or in a separate validator)
    if (string.IsNullOrWhiteSpace(request.Name))
    {
        return new AddProductResponse { Success = false, ErrorMessage = "Product name is required." };
    }

    var product = new Product
    {
        Name = request.Name,
        Description = request.Description,
        Price = request.Price
    };

    await _productRepository.AddProductAsync(product);

    return new AddProductResponse { Success = true, ProductId = product.Id };
}

}

// ProductCatalog.Core/UseCases/AddProduct/AddProductRequest.cs
public class AddProductRequest
{
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
}
// ProductCatalog.Core/UseCases/AddProduct/AddProductResponse.cs
public class AddProductResponse
{
public bool Success { get; set; }
public int? ProductId { get; set; } // Nullable in case of failure
public string ErrorMessage { get; set; }
}
“`

ProductCatalog.Infrastructure

“`csharp
// ProductCatalog.Infrastructure/Data/ProductRepository.cs
public class ProductRepository : IProductRepository
{
private readonly ProductCatalogDbContext _dbContext;

public ProductRepository(ProductCatalogDbContext dbContext)
{
    _dbContext = dbContext;
}

public async Task AddProductAsync(Product product)
{
    _dbContext.Products.Add(product);
    await _dbContext.SaveChangesAsync();
}
//Implement other methods of IProductRepository
public async Task<Product> GetProductByIdAsync(int id)
{
    return await _dbContext.Products.FindAsync(id);
}

 public async Task<List<Product>> GetAllProductsAsync()
{
    return await _dbContext.Products.ToListAsync();
}

}

// ProductCatalog.Infrastructure/Data/ProductCatalogDbContext.cs (using EF Core)
public class ProductCatalogDbContext : DbContext
{
public ProductCatalogDbContext(DbContextOptions options) : base(options)
{
}

public DbSet<Product> Products { get; set; }

}
“`

ProductCatalog.Presentation (ASP.NET Core MVC)

“`csharp
// ProductCatalog.Presentation/Controllers/ProductController.cs
public class ProductController : Controller
{
private readonly AddProductUseCase _addProductUseCase;
private readonly GetProductByIdUseCase _getProductByIdUseCase;
private readonly GetAllProductsUseCase _getAllProductsUseCase;
// Constructor injection
public ProductController(AddProductUseCase addProductUseCase,
GetProductByIdUseCase getProductByIdUseCase,
GetAllProductsUseCase getAllProductsUseCase)
{
_addProductUseCase = addProductUseCase;
_getProductByIdUseCase = getProductByIdUseCase;
_getAllProductsUseCase = getAllProductsUseCase;
}

// GET: /Product
public async Task<IActionResult> Index()
{
    var products = await _getAllProductsUseCase.ExecuteAsync();
    var viewModel = products.Select(p => new ProductViewModel
    {
        Id = p.Id,
        Name = p.Name,
        Description = p.Description,
        Price = p.Price
    }).ToList();

    return View(viewModel);
}

// GET: /Product/Details/5
public async Task<IActionResult> Details(int id)
{
    var product = await _getProductByIdUseCase.ExecuteAsync(id);
    if(product == null)
    {
        return NotFound();
    }
    var viewModel = new ProductViewModel
    {
        Id = product.Id,
        Name = product.Name,
        Description = product.Description,
        Price= product.Price,
    };
    return View(viewModel);
}
// GET: /Product/Create
public IActionResult Create()
{
    return View();
}

// POST: /Product/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ProductViewModel model)
{
    if (ModelState.IsValid)
    {
        var request = new AddProductRequest
        {
            Name = model.Name,
            Description = model.Description,
            Price = model.Price
        };

        var response = await _addProductUseCase.ExecuteAsync(request);

        if (response.Success)
        {
            return RedirectToAction(nameof(Index));
        }
        else
        {
            ModelState.AddModelError("", response.ErrorMessage);
        }
    }
    return View(model);
}

}

// ProductCatalog.Presentation/ViewModels/ProductViewModel.cs
public class ProductViewModel
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
}

// In Startup.cs (or Program.cs in .NET 6+) – Dependency Injection
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext(options =>
options.UseSqlServer(Configuration.GetConnectionString(“DefaultConnection”)));

services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<AddProductUseCase>();
services.AddScoped<GetProductByIdUseCase>();
services.AddScoped<GetAllProductsUseCase>();

services.AddControllersWithViews();

}
“`

4. Key Considerations

  • Dependency Injection: Dependency Injection (DI) is essential for Clean Architecture. It allows you to inject concrete implementations of interfaces (like IProductRepository) into your Use Cases and Controllers. ASP.NET Core has built-in DI support.
  • Request/Response Models: Use Cases should take request objects as input and return response objects. This decouples them from the specific details of the UI or external requests.
  • Validation: Consider using a dedicated validation library (like FluentValidation) to keep validation logic separate from your Use Cases. You can place validation logic within the Use Case itself, or create separate validator classes.
  • Error Handling: Use Cases should handle errors gracefully and return appropriate error information in their response objects. The UI can then display these errors to the user.
  • Testing: Clean Architecture makes testing much easier. You can unit test your Use Cases by mocking the repository interfaces. You can also write integration tests to verify the interaction between layers.
  • Asynchronous Operations: Use async and await for database operations and other potentially long-running tasks to keep your application responsive.
  • CQRS (Command Query Responsibility Segregation): For more complex applications, consider separating your “write” operations (Commands, like AddProduct) from your “read” operations (Queries, like GetProductById). This can further improve performance and scalability. You could have separate Use Cases (and even separate repositories) for commands and queries.
  • MediatR: The MediatR library can be a helpful tool for implementing CQRS and decoupling your Controllers from your Use Cases. It provides a simple way to send commands and queries to their respective handlers.

5. Benefits Revisited

  • Testability: The AddProductUseCase can be tested in isolation by mocking IProductRepository.
  • Maintainability: Changes to the database (e.g., switching from EF Core to Dapper) only require modifying ProductRepository in the Infrastructure layer.
  • Flexibility: The UI can be easily changed (e.g., from MVC to Blazor) without affecting the core business logic.
  • Scalability: The layered architecture and use of patterns like CQRS can help build scalable applications.

This guide provides a solid foundation for implementing Clean Architecture in C#. Remember to adapt the structure and complexity to the specific needs of your project. Start simple and gradually add complexity as needed. The key is to maintain the separation of concerns and dependency direction to reap the benefits of this powerful architectural approach.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top