
Previous article: ASP.NET Core Design Patterns Revolution: Repository, Singleton & Clean Architecture (Part-18 of 40)
Table of Contents
Introduction to Architectural Patterns
Layered Architecture
Clean Architecture & Onion Architecture
Domain-Driven Design (DDD)
Command Query Responsibility Segregation (CQRS)
Event Sourcing
Microservices Architecture
Vertical Slice Architecture
Repository Pattern & Unit of Work
Real-World E-Commerce Implementation
Testing Strategies
Best Practices & Anti-Patterns
1. Introduction to Architectural Patterns
Why Architecture Matters?
Architecture is the foundation that determines your application's scalability, maintainability, and success. Poor architecture leads to:
Spaghetti Code : Unmaintainable, tangled dependencies
Rigid Systems : Difficult to change or extend
Performance Bottlenecks : Poor scalability under load
High Technical Debt : Costly rewrites and fixes
Real-Life Analogy: City Planning
Think of software architecture like city planning:
Monolithic = Small town with everything in one place
Layered = Organized city with residential, commercial zones
Microservices = Metropolitan area with specialized districts
DDD = Planning based on community needs and boundaries
Evolution of ASP.NET Core Architecture
// Traditional N-Tier (2000s)
Presentation → Business Logic → Data Access
// Layered Architecture (2010s)
UI → Service Layer → Repository → Database
// Modern Patterns (2020s)
Clean Architecture, CQRS, DDD, Microservices
2. Layered Architecture
Traditional 3-Tier Architecture
// Project Structure
ECommerceApp/
├── ECommerceApp.Web/ // Presentation Layer
├── ECommerceApp.Services/ // Business Logic Layer
├── ECommerceApp.Data/ // Data Access Layer
└── ECommerceApp.Models/ // Shared Models
Implementation Example
// Data Layer - Repository
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<List<Product>> GetAllAsync();
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
public class ProductRepository : IProductRepository
{
private readonly ApplicationDbContext _context;
public ProductRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
public async Task<List<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}
public async Task AddAsync(Product product)
{
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
}
// Other implementations...
}
Service Layer
// Business Logic Layer
public interface IProductService
{
Task<ProductDto> GetProductAsync(int id);
Task<List<ProductDto>> GetProductsAsync();
Task CreateProductAsync(CreateProductRequest request);
Task UpdateProductAsync(UpdateProductRequest request);
Task DeleteProductAsync(int id);
}
public class ProductService : IProductService
{
private readonly IProductRepository _productRepository;
private readonly IMapper _mapper;
public ProductService(IProductRepository productRepository, IMapper mapper)
{
_productRepository = productRepository;
_mapper = mapper;
}
public async Task<ProductDto> GetProductAsync(int id)
{
var product = await _productRepository.GetByIdAsync(id);
if (product == null)
throw new NotFoundException($"Product with ID {id} not found");
return _mapper.Map<ProductDto>(product);
}
public async Task CreateProductAsync(CreateProductRequest request)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
StockQuantity = request.StockQuantity,
CreatedAt = DateTime.UtcNow
};
await _productRepository.AddAsync(product);
}
}
Controller Layer
// Presentation Layer
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetProduct(int id)
{
try
{
var product = await _productService.GetProductAsync(id);
return Ok(product);
}
catch (NotFoundException ex)
{
return NotFound(ex.Message);
}
}
[HttpPost]
public async Task<ActionResult> CreateProduct(CreateProductRequest request)
{
await _productService.CreateProductAsync(request);
return CreatedAtAction(nameof(GetProduct), new { id = request.Id }, null);
}
}
Pros and Cons of Layered Architecture
Pros
Simple to understand and implement
Clear separation of concerns
Easy to test individual layers
Cons
Tight coupling between layers
Business logic often leaks into multiple layers
Difficult to change database technology
Can lead to anemic domain models
3. Clean Architecture & Onion Architecture
Clean Architecture Principles
Clean Architecture emphasizes
Independence from Frameworks : Not dependent on UI, database, or external agencies
Testable : Business rules can be tested without UI, database, and web server
Independence from UI : UI can change easily without changing business rules
Independence from Database : Business rules not bound to the database
Independence from External Agencies : Business rules don't know anything about outside interfaces
Onion Architecture Layers
Domain Layer (Core)
/ \
Application Layer Infrastructure Layer
\ /
Presentation Layer (UI)
Project Structure
ECommerceClean/
├── ECommerceClean.Domain/ # Core business logic
├── ECommerceClean.Application/ # Use cases & application logic
├── ECommerceClean.Infrastructure/ # External concerns
├── ECommerceClean.Web/ # Presentation layer
└── ECommerceClean.Tests/ # Test projects
Domain Layer (Core)
// Domain Entities
public class Product : Entity, IAggregateRoot
{
public string Name { get; private set; }
public string Description { get; private set; }
public decimal Price { get; private set; }
public int StockQuantity { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Private constructor for EF
private Product() { }
public Product(string name, string description, decimal price, int stockQuantity)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description;
Price = price > 0 ? price : throw new ArgumentException("Price must be positive");
StockQuantity = stockQuantity;
CreatedAt = DateTime.UtcNow;
}
// Domain methods
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new DomainException("Price must be positive");
Price = newPrice;
UpdatedAt = DateTime.UtcNow;
}
public void IncreaseStock(int quantity)
{
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
StockQuantity += quantity;
UpdatedAt = DateTime.UtcNow;
}
public void DecreaseStock(int quantity)
{
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
if (StockQuantity < quantity)
throw new DomainException("Insufficient stock");
StockQuantity -= quantity;
UpdatedAt = DateTime.UtcNow;
}
}
// Base Entity
public abstract class Entity
{
public int Id { get; protected set; }
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent eventItem)
{
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// Domain Events
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public class ProductPriceChangedEvent : IDomainEvent
{
public int ProductId { get; }
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public DateTime OccurredOn { get; }
public ProductPriceChangedEvent(int productId, decimal oldPrice, decimal newPrice)
{
ProductId = productId;
OldPrice = oldPrice;
NewPrice = newPrice;
OccurredOn = DateTime.UtcNow;
}
}
Application Layer
// Commands and Queries
public static class ProductCommands
{
public record CreateProductCommand(string Name, string Description, decimal Price, int StockQuantity) : IRequest<int>;
public record UpdateProductPriceCommand(int ProductId, decimal NewPrice) : IRequest;
public record DeleteProductCommand(int ProductId) : IRequest;
}
public static class ProductQueries
{
public record GetProductQuery(int ProductId) : IRequest<ProductDto>;
public record GetProductsQuery(int PageNumber = 1, int PageSize = 10) : IRequest<PagedList<ProductDto>>;
}
// Command Handlers
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly IProductRepository _productRepository;
public CreateProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product(request.Name, request.Description, request.Price, request.StockQuantity);
await _productRepository.AddAsync(product);
return product.Id;
}
}
public class UpdateProductPriceCommandHandler : IRequestHandler<UpdateProductPriceCommand>
{
private readonly IProductRepository _productRepository;
public UpdateProductPriceCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task Handle(UpdateProductPriceCommand request, CancellationToken cancellationToken)
{
var product = await _productRepository.GetByIdAsync(request.ProductId);
if (product == null)
throw new NotFoundException($"Product with ID {request.ProductId} not found");
var oldPrice = product.Price;
product.UpdatePrice(request.NewPrice);
await _productRepository.UpdateAsync(product);
}
}
// Query Handlers
public class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDto>
{
private readonly IProductRepository _productRepository;
private readonly IMapper _mapper;
public GetProductQueryHandler(IProductRepository productRepository, IMapper mapper)
{
_productRepository = productRepository;
_mapper = mapper;
}
public async Task<ProductDto> Handle(GetProductQuery request, CancellationToken cancellationToken)
{
var product = await _productRepository.GetByIdAsync(request.ProductId);
if (product == null)
throw new NotFoundException($"Product with ID {request.ProductId} not found");
return _mapper.Map<ProductDto>(product);
}
}
Infrastructure Layer
// Repository Implementation
public class ProductRepository : IProductRepository
{
private readonly ApplicationDbContext _context;
public ProductRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task AddAsync(Product product)
{
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
}
}
// DbContext
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}
// Entity Configuration
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(100);
builder.Property(p => p.Description)
.HasMaxLength(500);
builder.Property(p => p.Price)
.HasColumnType("decimal(18,2)");
builder.Property(p => p.StockQuantity)
.IsRequired();
builder.Property(p => p.CreatedAt)
.IsRequired();
}
}
Presentation Layer
// Minimal API Endpoints
public static class ProductEndpoints
{
public static void MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/products");
group.MapGet("/{id}", GetProduct)
.WithName("GetProduct")
.Produces<ProductDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateProduct)
.Produces<int>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
group.MapPut("/{id}/price", UpdateProductPrice)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
}
private static async Task<IResult> GetProduct(
int id,
IMediator mediator,
CancellationToken cancellationToken)
{
try
{
var query = new GetProductQuery(id);
var product = await mediator.Send(query, cancellationToken);
return Results.Ok(product);
}
catch (NotFoundException ex)
{
return Results.NotFound(ex.Message);
}
}
private static async Task<IResult> CreateProduct(
CreateProductCommand command,
IMediator mediator,
CancellationToken cancellationToken)
{
try
{
var productId = await mediator.Send(command, cancellationToken);
return Results.CreatedAtRoute("GetProduct", new { id = productId }, productId);
}
catch (DomainException ex)
{
return Results.BadRequest(ex.Message);
}
}
private static async Task<IResult> UpdateProductPrice(
int id,
UpdateProductPriceRequest request,
IMediator mediator,
CancellationToken cancellationToken)
{
try
{
var command = new UpdateProductPriceCommand(id, request.NewPrice);
await mediator.Send(command, cancellationToken);
return Results.NoContent();
}
catch (NotFoundException ex)
{
return Results.NotFound(ex.Message);
}
catch (DomainException ex)
{
return Results.BadRequest(ex.Message);
}
}
}
Dependency Injection Setup
// Program.cs setup
var builder = WebApplication.CreateBuilder(args);
// Add layers
builder.Services.AddDomain();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddPresentation();
var app = builder.Build();
// Configure pipeline
app.UseInfrastructure();
app.MapProductEndpoints();
app.Run();
// Extension methods for DI
public static class DependencyInjection
{
public static IServiceCollection AddDomain(this IServiceCollection services)
{
// Domain services registration
return services;
}
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(CreateProductCommand).Assembly));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddAutoMapper(typeof(ProductProfile).Assembly);
return services;
}
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<IProductRepository, ProductRepository>();
return services;
}
public static IServiceCollection AddPresentation(this IServiceCollection services)
{
services.AddControllers();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
return services;
}
}
4. Domain-Driven Design (DDD)
DDD Building Blocks
Entities
public class Order : Entity, IAggregateRoot
{
private readonly List<OrderItem> _orderItems = new();
public int CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
private Order() { }
public Order(int customerId)
{
CustomerId = customerId;
Status = OrderStatus.Pending;
CreatedAt = DateTime.UtcNow;
}
public void AddItem(int productId, string productName, decimal unitPrice, int quantity)
{
var existingItem = _orderItems.FirstOrDefault(item => item.ProductId == productId);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
var orderItem = new OrderItem(productId, productName, unitPrice, quantity);
_orderItems.Add(orderItem);
}
UpdateTotalAmount();
}
public void RemoveItem(int productId)
{
var item = _orderItems.FirstOrDefault(item => item.ProductId == productId);
if (item != null)
{
_orderItems.Remove(item);
UpdateTotalAmount();
}
}
public void CompleteOrder()
{
if (Status != OrderStatus.Pending)
throw new DomainException("Only pending orders can be completed");
if (!_orderItems.Any())
throw new DomainException("Cannot complete empty order");
Status = OrderStatus.Completed;
AddDomainEvent(new OrderCompletedEvent(Id, CustomerId, TotalAmount));
}
public void CancelOrder()
{
if (Status == OrderStatus.Completed || Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel completed or shipped order");
Status = OrderStatus.Cancelled;
AddDomainEvent(new OrderCancelledEvent(Id, CustomerId));
}
private void UpdateTotalAmount()
{
TotalAmount = _orderItems.Sum(item => item.UnitPrice * item.Quantity);
}
}
Value Objects
public record Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string Country { get; }
public string ZipCode { get; }
public Address(string street, string city, string state, string country, string zipCode)
{
Street = street ?? throw new ArgumentNullException(nameof(street));
City = city ?? throw new ArgumentNullException(nameof(city));
State = state ?? throw new ArgumentNullException(nameof(state));
Country = country ?? throw new ArgumentNullException(nameof(country));
ZipCode = zipCode ?? throw new ArgumentNullException(nameof(zipCode));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return Country;
yield return ZipCode;
}
}
public abstract record ValueObject
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public virtual bool Equals(ValueObject? other)
{
if (other is null) return false;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
}
Aggregates
public class OrderItem : Entity
{
public int ProductId { get; private set; }
public string ProductName { get; private set; }
public decimal UnitPrice { get; private set; }
public int Quantity { get; private set; }
private OrderItem() { }
public OrderItem(int productId, string productName, decimal unitPrice, int quantity)
{
ProductId = productId;
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
UnitPrice = unitPrice > 0 ? unitPrice : throw new ArgumentException("Unit price must be positive");
Quantity = quantity > 0 ? quantity : throw new ArgumentException("Quantity must be positive");
}
public void IncreaseQuantity(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
Quantity += quantity;
}
public void DecreaseQuantity(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
if (Quantity < quantity)
throw new InvalidOperationException("Insufficient quantity");
Quantity -= quantity;
}
}
Domain Services
public interface IOrderService
{
Task<Order> CreateOrderAsync(int customerId, List<OrderItemDto> items);
Task<bool> CanCustomerPlaceOrderAsync(int customerId);
}
public class OrderService : IOrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
public OrderService(IOrderRepository orderRepository, ICustomerRepository customerRepository)
{
_orderRepository = orderRepository;
_customerRepository = customerRepository;
}
public async Task<Order> CreateOrderAsync(int customerId, List<OrderItemDto> items)
{
var customer = await _customerRepository.GetByIdAsync(customerId);
if (customer == null)
throw new DomainException($"Customer with ID {customerId} not found");
if (!await CanCustomerPlaceOrderAsync(customerId))
throw new DomainException("Customer cannot place order at this time");
var order = new Order(customerId);
foreach (var item in items)
{
order.AddItem(item.ProductId, item.ProductName, item.UnitPrice, item.Quantity);
}
return order;
}
public async Task<bool> CanCustomerPlaceOrderAsync(int customerId)
{
// Business rules for order placement
var recentOrders = await _orderRepository.GetRecentOrdersAsync(customerId, TimeSpan.FromHours(24));
return recentOrders.Count < 10; // Limit orders per day
}
}
Domain Events
public class OrderCompletedEvent : IDomainEvent
{
public int OrderId { get; }
public int CustomerId { get; }
public decimal TotalAmount { get; }
public DateTime OccurredOn { get; }
public OrderCompletedEvent(int orderId, int customerId, decimal totalAmount)
{
OrderId = orderId;
CustomerId = customerId;
TotalAmount = totalAmount;
OccurredOn = DateTime.UtcNow;
}
}
// Domain Event Handler
public class OrderCompletedEventHandler : INotificationHandler<OrderCompletedEvent>
{
private readonly IEmailService _emailService;
private readonly IInventoryService _inventoryService;
public OrderCompletedEventHandler(IEmailService emailService, IInventoryService inventoryService)
{
_emailService = emailService;
_inventoryService = inventoryService;
}
public async Task Handle(OrderCompletedEvent notification, CancellationToken cancellationToken)
{
// Send confirmation email
await _emailService.SendOrderConfirmationAsync(notification.OrderId, notification.CustomerId);
// Update inventory
await _inventoryService.ReserveItemsForOrderAsync(notification.OrderId);
}
}
5. Command Query Responsibility Segregation (CQRS)
CQRS Pattern Overview
CQRS separates read and write operations:
Commands : Write operations that change state
Queries : Read operations that return data without side effects
CQRS Implementation
Command Side
// Commands
public static class OrderCommands
{
public record CreateOrderCommand(int CustomerId, List<OrderItemDto> Items) : IRequest<int>;
public record CancelOrderCommand(int OrderId) : IRequest;
public record CompleteOrderCommand(int OrderId) : IRequest;
}
// Command Handlers
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IOrderService _orderService;
private readonly IOrderRepository _orderRepository;
public CreateOrderCommandHandler(IOrderService orderService, IOrderRepository orderRepository)
{
_orderService = orderService;
_orderRepository = orderRepository;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var order = await _orderService.CreateOrderAsync(request.CustomerId, request.Items);
await _orderRepository.AddAsync(order);
return order.Id;
}
}
public class CompleteOrderCommandHandler : IRequestHandler<CompleteOrderCommand>
{
private readonly IOrderRepository _orderRepository;
public CompleteOrderCommandHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task Handle(CompleteOrderCommand request, CancellationToken cancellationToken)
{
var order = await _orderRepository.GetByIdAsync(request.OrderId);
if (order == null)
throw new NotFoundException($"Order with ID {request.OrderId} not found");
order.CompleteOrder();
await _orderRepository.UpdateAsync(order);
}
}
Query Side

Join the conversation! Your thoughts help the community grow.