Mastering Repository & Unit of Work Patterns in .NET: Real-World Implementation Guide (Part 2)
Welcome back to our comprehensive journey through Repository and Unit of Work patterns! In Part 1, we explored the theoretical foundations…
Mastering Repository & Unit of Work Patterns in .NET: Real-World Implementation Guide (Part 2)

Welcome back to our comprehensive journey through Repository and Unit of Work patterns! In Part 1, we explored the theoretical foundations and core concepts. Now, it’s time to roll up our sleeves and build a complete, production-ready e-commerce order management system using ASP.NET Core Web API with .NET 8.
This part will transform theory into practice, showing you exactly how these patterns work in real-world scenarios. We’ll build a system capable of handling complex business operations while maintaining clean, scalable and testable code architecture.
Problem Statement: Building ShopFlow — An E-commerce Order Management System
Business Requirements
We’re building ShopFlow, a modern e-commerce order management system that needs to handle:
Core Functionality:
- Customer registration and authentication
- Product catalog management with categories
- Shopping cart operations
- Order processing with multiple items
- Inventory management and stock tracking
- Payment processing integration
- Order status tracking and updates
Technical Requirements:
- Handle concurrent users and transactions
- Maintain data consistency across operations
- Support multiple payment methods
- Provide real-time inventory updates
- Generate comprehensive reports
- Support audit trails for all transactions
- Scale to handle thousands of orders per hour
Quality Requirements:
- 99.9% uptime during business hours
- Response times under 200ms for API calls
- Support for horizontal scaling
- Comprehensive error handling and logging
- Full test coverage for business logic
The Challenge
Traditional approaches often lead to tightly coupled code, making it difficult to:
- Test business logic in isolation
- Handle complex transaction scenarios
- Maintain consistent data across multiple entities
- Scale individual components independently
- Adapt to changing business requirements
Our implementation will demonstrate how Repository and Unit of Work patterns solve these challenges while providing a solid foundation for enterprise-scale applications.
Project Architecture and Setup
Clean Architecture Structure
Our ShopFlow system follows Clean Architecture principles, organized into distinct layers with clear dependencies:

Clean Architecture Project Structure for ASP.NET Core Web API
Project Structure:
ShopFlow/
├── src/
│ ├── ShopFlow.Domain/ # Core business logic and entities
│ ├── ShopFlow.Application/ # Use cases and business rules
│ ├── ShopFlow.Infrastructure/ # Data access and external services
│ └── ShopFlow.WebApi/ # API controllers and configuration
├── tests/
│ ├── ShopFlow.Domain.Tests/
│ ├── ShopFlow.Application.Tests/
│ ├── ShopFlow.Infrastructure.Tests/
│ └── ShopFlow.WebApi.Tests/
└── docs/
└── api-documentation/
Setting Up the Solution
Step 1: Create the Solution Structure
# Create solution
dotnet new sln -n ShopFlow
# Create projects
dotnet new classlib -n ShopFlow.Domain
dotnet new classlib -n ShopFlow.Application
dotnet new classlib -n ShopFlow.Infrastructure
dotnet new webapi -n ShopFlow.WebApi
# Add projects to solution
dotnet sln add src/ShopFlow.Domain/ShopFlow.Domain.csproj
dotnet sln add src/ShopFlow.Application/ShopFlow.Application.csproj
dotnet sln add src/ShopFlow.Infrastructure/ShopFlow.Infrastructure.csproj
dotnet sln add src/ShopFlow.WebApi/ShopFlow.WebApi.csproj
Step 2: Configure Project Dependencies
<!-- ShopFlow.Application.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShopFlow.Domain\ShopFlow.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.8.0" />
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="AutoMapper" Version="12.0.1" />
</ItemGroup>
</Project>
<!-- ShopFlow.Infrastructure.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShopFlow.Domain\ShopFlow.Domain.csproj" />
<ProjectReference Include="..\ShopFlow.Application\ShopFlow.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>
Domain Layer Implementation
Core Entities
Base Entity with Audit Properties:
// ShopFlow.Domain/Common/BaseEntity.cs
namespace ShopFlow.Domain.Common;
public abstract class BaseEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public string? UpdatedBy { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
Customer Entity:
// ShopFlow.Domain/Entities/Customer.cs
using ShopFlow.Domain.Common;
namespace ShopFlow.Domain.Entities;
public class Customer : BaseEntity
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PhoneNumber { get; set; } = string.Empty;
public DateTime DateOfBirth { get; set; }
public CustomerStatus Status { get; set; }
// Navigation properties
public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
public virtual ICollection<Address> Addresses { get; set; } = new List<Address>();
// Business methods
public string GetFullName() => $"{FirstName} {LastName}";
public bool IsActive() => Status == CustomerStatus.Active;
public void Deactivate()
{
Status = CustomerStatus.Inactive;
UpdatedAt = DateTime.UtcNow;
}
}
public enum CustomerStatus
{
Active,
Inactive,
Suspended
}
Product Entity with Business Logic:
// ShopFlow.Domain/Entities/Product.cs
using ShopFlow.Domain.Common;
namespace ShopFlow.Domain.Entities;
public class Product : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string SKU { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public int ReorderLevel { get; set; }
public ProductStatus Status { get; set; }
public int CategoryId { get; set; }
// Navigation properties
public virtual Category Category { get; set; } = null!;
public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
// Business methods
public bool IsInStock() => StockQuantity > 0 && Status == ProductStatus.Active;
public bool IsLowStock() => StockQuantity <= ReorderLevel;
public void ReduceStock(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
if (StockQuantity < quantity)
throw new InvalidOperationException($"Insufficient stock. Available: {StockQuantity}, Requested: {quantity}");
StockQuantity -= quantity;
UpdatedAt = DateTime.UtcNow;
}
public void IncreaseStock(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
StockQuantity += quantity;
UpdatedAt = DateTime.UtcNow;
}
}
public enum ProductStatus
{
Active,
Inactive,
Discontinued
}
Order Entity with Complex Business Logic:
// ShopFlow.Domain/Entities/Order.cs
using ShopFlow.Domain.Common;
namespace ShopFlow.Domain.Entities;
public class Order : BaseEntity
{
public string OrderNumber { get; set; } = string.Empty;
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
public OrderStatus Status { get; set; }
public decimal Subtotal { get; private set; }
public decimal TaxAmount { get; private set; }
public decimal ShippingAmount { get; set; }
public decimal TotalAmount { get; private set; }
public int ShippingAddressId { get; set; }
public int BillingAddressId { get; set; }
public string? Notes { get; set; }
// Navigation properties
public virtual Customer Customer { get; set; } = null!;
public virtual Address ShippingAddress { get; set; } = null!;
public virtual Address BillingAddress { get; set; } = null!;
public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
public virtual ICollection<Payment> Payments { get; set; } = new List<Payment>();
// Business methods
public void AddItem(Product product, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to non-draft orders");
var existingItem = OrderItems.FirstOrDefault(i => i.ProductId == product.Id);
if (existingItem != null)
{
existingItem.Quantity += quantity;
existingItem.UpdateTotalPrice();
}
else
{
var orderItem = new OrderItem
{
ProductId = product.Id,
Product = product,
Quantity = quantity,
UnitPrice = unitPrice,
OrderId = Id
};
orderItem.UpdateTotalPrice();
OrderItems.Add(orderItem);
}
CalculateTotals();
}
public void RemoveItem(int productId)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot remove items from non-draft orders");
var item = OrderItems.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
OrderItems.Remove(item);
CalculateTotals();
}
}
public void CalculateTotals()
{
Subtotal = OrderItems.Sum(item => item.TotalPrice);
TaxAmount = Subtotal * 0.08m; // 8% tax rate
TotalAmount = Subtotal + TaxAmount + ShippingAmount;
}
public bool CanBeProcessed()
{
return Status == OrderStatus.Draft &&
OrderItems.Any() &&
TotalAmount > 0;
}
public void Process()
{
if (!CanBeProcessed())
throw new InvalidOperationException("Order cannot be processed in current state");
Status = OrderStatus.Processing;
UpdatedAt = DateTime.UtcNow;
}
public void Ship()
{
if (Status != OrderStatus.Processing)
throw new InvalidOperationException("Only processing orders can be shipped");
Status = OrderStatus.Shipped;
UpdatedAt = DateTime.UtcNow;
}
public void Complete()
{
if (Status != OrderStatus.Shipped)
throw new InvalidOperationException("Only shipped orders can be completed");
Status = OrderStatus.Completed;
UpdatedAt = DateTime.UtcNow;
}
public void Cancel()
{
if (Status == OrderStatus.Completed)
throw new InvalidOperationException("Cannot cancel completed orders");
Status = OrderStatus.Cancelled;
UpdatedAt = DateTime.UtcNow;
}
}
public enum OrderStatus
{
Draft,
Processing,
Shipped,
Completed,
Cancelled
}
OrderItem Entity:
// ShopFlow.Domain/Entities/OrderItem.cs
using ShopFlow.Domain.Common;
namespace ShopFlow.Domain.Entities;
public class OrderItem : BaseEntity
{
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; private set; }
// Navigation properties
public virtual Order Order { get; set; } = null!;
public virtual Product Product { get; set; } = null!;
// Business methods
public void UpdateTotalPrice()
{
TotalPrice = Quantity * UnitPrice;
UpdatedAt = DateTime.UtcNow;
}
}
Repository Interfaces
Generic Repository Interface:
// ShopFlow.Domain/Interfaces/IGenericRepository.cs
using System.Linq.Expressions;
using ShopFlow.Domain.Common;
namespace ShopFlow.Domain.Interfaces;
public interface IGenericRepository<T> where T : BaseEntity
{
// Query operations
Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default);
// Command operations
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
// Pagination
Task<PagedResult<T>> GetPagedAsync<TKey>(
Expression<Func<T, bool>>? predicate,
Expression<Func<T, TKey>> orderBy,
bool ascending = true,
int pageNumber = 1,
int pageSize = 10,
CancellationToken cancellationToken = default);
}
public class PagedResult<T>
{
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber < TotalPages;
}
Entity-Specific Repository Interfaces:
// ShopFlow.Domain/Interfaces/ICustomerRepository.cs
using ShopFlow.Domain.Entities;
namespace ShopFlow.Domain.Interfaces;
public interface ICustomerRepository : IGenericRepository<Customer>
{
Task<Customer?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
Task<IEnumerable<Customer>> GetActiveCustomersAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<Customer>> GetCustomersWithOrdersAsync(CancellationToken cancellationToken = default);
Task<bool> IsEmailUniqueAsync(string email, int? excludeCustomerId = null, CancellationToken cancellationToken = default);
}
// ShopFlow.Domain/Interfaces/IProductRepository.cs
using ShopFlow.Domain.Entities;
namespace ShopFlow.Domain.Interfaces;
public interface IProductRepository : IGenericRepository<Product>
{
Task<IEnumerable<Product>> GetByCategoryAsync(int categoryId, CancellationToken cancellationToken = default);
Task<IEnumerable<Product>> GetLowStockProductsAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<Product>> SearchAsync(string searchTerm, CancellationToken cancellationToken = default);
Task<Product?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default);
Task<bool> IsSkuUniqueAsync(string sku, int? excludeProductId = null, CancellationToken cancellationToken = default);
}
// ShopFlow.Domain/Interfaces/IOrderRepository.cs
using ShopFlow.Domain.Entities;
namespace ShopFlow.Domain.Interfaces;
public interface IOrderRepository : IGenericRepository<Order>
{
Task<IEnumerable<Order>> GetByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
Task<IEnumerable<Order>> GetByStatusAsync(OrderStatus status, CancellationToken cancellationToken = default);
Task<IEnumerable<Order>> GetOrdersWithItemsAsync(CancellationToken cancellationToken = default);
Task<Order?> GetOrderWithDetailsAsync(int orderId, CancellationToken cancellationToken = default);
Task<string> GenerateOrderNumberAsync(CancellationToken cancellationToken = default);
}
Unit of Work Interface
// ShopFlow.Domain/Interfaces/IUnitOfWork.cs
namespace ShopFlow.Domain.Interfaces;
public interface IUnitOfWork : IDisposable
{
// Repository properties
ICustomerRepository Customers { get; }
IProductRepository Products { get; }
IOrderRepository Orders { get; }
ICategoryRepository Categories { get; }
IAddressRepository Addresses { get; }
IPaymentRepository Payments { get; }
// Transaction management
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
// Bulk operations
Task<int> ExecuteSqlAsync(string sql, params object[] parameters);
Task<int> ExecuteSqlAsync(string sql, CancellationToken cancellationToken, params object[] parameters);
}
Infrastructure Layer Implementation
Entity Framework Core Configuration
Database Context:
// ShopFlow.Infrastructure/Data/ShopFlowDbContext.cs
using Microsoft.EntityFrameworkCore;
using ShopFlow.Domain.Common;
using ShopFlow.Domain.Entities;
namespace ShopFlow.Infrastructure.Data;
public class ShopFlowDbContext : DbContext
{
public ShopFlowDbContext(DbContextOptions<ShopFlowDbContext> options) : base(options)
{
}
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Address> Addresses => Set<Address>();
public DbSet<Payment> Payments => Set<Payment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply all configurations from assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopFlowDbContext).Assembly);
// Global query filters for soft delete
modelBuilder.Entity<Customer>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Product>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Order>().HasQueryFilter(e => !e.IsDeleted);
// Set precision for decimal properties
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var properties = entityType.ClrType.GetProperties()
.Where(p => p.PropertyType == typeof(decimal) || p.PropertyType == typeof(decimal?));
foreach (var property in properties)
{
modelBuilder.Entity(entityType.Name).Property(property.Name)
.HasPrecision(18, 2);
}
}
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Audit trail implementation
var entries = ChangeTracker.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.CreatedBy = "System"; // In real app, get from current user context
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = DateTime.UtcNow;
entry.Entity.UpdatedBy = "System"; // In real app, get from current user context
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
Entity Configurations:
// ShopFlow.Infrastructure/Data/Configurations/CustomerConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ShopFlow.Domain.Entities;
namespace ShopFlow.Infrastructure.Data.Configurations;
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customers");
builder.HasKey(x => x.Id);
builder.Property(x => x.FirstName)
.IsRequired()
.HasMaxLength(100);
builder.Property(x => x.LastName)
.IsRequired()
.HasMaxLength(100);
builder.Property(x => x.Email)
.IsRequired()
.HasMaxLength(255);
builder.HasIndex(x => x.Email)
.IsUnique();
builder.Property(x => x.PhoneNumber)
.HasMaxLength(20);
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
// Relationships
builder.HasMany(x => x.Orders)
.WithOne(x => x.Customer)
.HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Addresses)
.WithOne(x => x.Customer)
.HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
}
}
// ShopFlow.Infrastructure/Data/Configurations/ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(x => x.Description)
.HasMaxLength(1000);
builder.Property(x => x.SKU)
.IsRequired()
.HasMaxLength(50);
builder.HasIndex(x => x.SKU)
.IsUnique();
builder.Property(x => x.Price)
.IsRequired();
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
// Relationships
builder.HasOne(x => x.Category)
.WithMany(x => x.Products)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.OrderItems)
.WithOne(x => x.Product)
.HasForeignKey(x => x.ProductId)
.OnDelete(DeleteBehavior.Restrict);
}
}
Repository Implementations
Generic Repository Implementation:
// ShopFlow.Infrastructure/Repositories/GenericRepository.cs
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using ShopFlow.Domain.Common;
using ShopFlow.Domain.Interfaces;
using ShopFlow.Infrastructure.Data;
namespace ShopFlow.Infrastructure.Repositories;
public class GenericRepository<T> : IGenericRepository<T> where T : BaseEntity
{
protected readonly ShopFlowDbContext _context;
protected readonly DbSet<T> _dbSet;
public GenericRepository(ShopFlowDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
}
public virtual async Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.ToListAsync(cancellationToken);
}
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
}
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
}
public virtual async Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.AnyAsync(predicate, cancellationToken);
}
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default)
{
if (predicate == null)
return await _dbSet.CountAsync(cancellationToken);
return await _dbSet.CountAsync(predicate, cancellationToken);
}
public virtual async Task<T> AddAsync(T entity, CancellationToken cancellationToken = default)
{
await _dbSet.AddAsync(entity, cancellationToken);
return entity;
}
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
await _dbSet.AddRangeAsync(entities, cancellationToken);
return entities;
}
public virtual Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
{
_dbSet.Attach(entity);
_context.Entry(entity).State = EntityState.Modified;
return Task.CompletedTask;
}
public virtual Task DeleteAsync(T entity, CancellationToken cancellationToken = default)
{
if (_context.Entry(entity).State == EntityState.Detached)
{
_dbSet.Attach(entity);
}
_dbSet.Remove(entity);
return Task.CompletedTask;
}
public virtual Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
_dbSet.RemoveRange(entities);
return Task.CompletedTask;
}
public virtual async Task<PagedResult<T>> GetPagedAsync<TKey>(
Expression<Func<T, bool>>? predicate,
Expression<Func<T, TKey>> orderBy,
bool ascending = true,
int pageNumber = 1,
int pageSize = 10,
CancellationToken cancellationToken = default)
{
var query = _dbSet.AsQueryable();
if (predicate != null)
query = query.Where(predicate);
var totalCount = await query.CountAsync(cancellationToken);
query = ascending
? query.OrderBy(orderBy)
: query.OrderByDescending(orderBy);
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<T>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
}
}
Specific Repository Implementations:
// ShopFlow.Infrastructure/Repositories/CustomerRepository.cs
using Microsoft.EntityFrameworkCore;
using ShopFlow.Domain.Entities;
using ShopFlow.Domain.Interfaces;
using ShopFlow.Infrastructure.Data;
namespace ShopFlow.Infrastructure.Repositories;
public class CustomerRepository : GenericRepository<Customer>, ICustomerRepository
{
public CustomerRepository(ShopFlowDbContext context) : base(context)
{
}
public async Task<Customer?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _dbSet.FirstOrDefaultAsync(c => c.Email == email, cancellationToken);
}
public async Task<IEnumerable<Customer>> GetActiveCustomersAsync(CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(c => c.Status == CustomerStatus.Active)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Customer>> GetCustomersWithOrdersAsync(CancellationToken cancellationToken = default)
{
return await _dbSet
.Include(c => c.Orders)
.Where(c => c.Orders.Any())
.ToListAsync(cancellationToken);
}
public async Task<bool> IsEmailUniqueAsync(string email, int? excludeCustomerId = null, CancellationToken cancellationToken = default)
{
var query = _dbSet.Where(c => c.Email == email);
if (excludeCustomerId.HasValue)
query = query.Where(c => c.Id != excludeCustomerId.Value);
return !await query.AnyAsync(cancellationToken);
}
}
// ShopFlow.Infrastructure/Repositories/ProductRepository.cs
public class ProductRepository : GenericRepository<Product>, IProductRepository
{
public ProductRepository(ShopFlowDbContext context) : base(context)
{
}
public async Task<IEnumerable<Product>> GetByCategoryAsync(int categoryId, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.CategoryId == categoryId && p.Status == ProductStatus.Active)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Product>> GetLowStockProductsAsync(CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.StockQuantity <= p.ReorderLevel && p.Status == ProductStatus.Active)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Product>> SearchAsync(string searchTerm, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.Name.Contains(searchTerm) || p.Description.Contains(searchTerm))
.Where(p => p.Status == ProductStatus.Active)
.ToListAsync(cancellationToken);
}
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default)
{
return await _dbSet.FirstOrDefaultAsync(p => p.SKU == sku, cancellationToken);
}
public async Task<bool> IsSkuUniqueAsync(string sku, int? excludeProductId = null, CancellationToken cancellationToken = default)
{
var query = _dbSet.Where(p => p.SKU == sku);
if (excludeProductId.HasValue)
query = query.Where(p => p.Id != excludeProductId.Value);
return !await query.AnyAsync(cancellationToken);
}
}
// ShopFlow.Infrastructure/Repositories/OrderRepository.cs
public class OrderRepository : GenericRepository<Order>, IOrderRepository
{
public OrderRepository(ShopFlowDbContext context) : base(context)
{
}
public async Task<IEnumerable<Order>> GetByCustomerAsync(int customerId, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.OrderDate)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Order>> GetByStatusAsync(OrderStatus status, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(o => o.Status == status)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<Order>> GetOrdersWithItemsAsync(CancellationToken cancellationToken = default)
{
return await _dbSet
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.ToListAsync(cancellationToken);
}
public async Task<Order?> GetOrderWithDetailsAsync(int orderId, CancellationToken cancellationToken = default)
{
return await _dbSet
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.Include(o => o.ShippingAddress)
.Include(o => o.BillingAddress)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}
public async Task<string> GenerateOrderNumberAsync(CancellationToken cancellationToken = default)
{
var today = DateTime.UtcNow.Date;
var prefix = today.ToString("yyyyMMdd");
var lastOrder = await _dbSet
.Where(o => o.OrderNumber.StartsWith(prefix))
.OrderByDescending(o => o.OrderNumber)
.FirstOrDefaultAsync(cancellationToken);
if (lastOrder == null)
{
return $"{prefix}001";
}
var lastSequence = int.Parse(lastOrder.OrderNumber.Substring(8));
return $"{prefix}{(lastSequence + 1):D3}";
}
}
Unit of Work Implementation
// ShopFlow.Infrastructure/UnitOfWork/UnitOfWork.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using ShopFlow.Domain.Interfaces;
using ShopFlow.Infrastructure.Data;
using ShopFlow.Infrastructure.Repositories;
namespace ShopFlow.Infrastructure.UnitOfWork;
public class UnitOfWork : IUnitOfWork
{
private readonly ShopFlowDbContext _context;
private IDbContextTransaction? _transaction;
// Lazy-loaded repositories
private ICustomerRepository? _customers;
private IProductRepository? _products;
private IOrderRepository? _orders;
private ICategoryRepository? _categories;
private IAddressRepository? _addresses;
private IPaymentRepository? _payments;
public UnitOfWork(ShopFlowDbContext context)
{
_context = context;
}
public ICustomerRepository Customers =>
_customers ??= new CustomerRepository(_context);
public IProductRepository Products =>
_products ??= new ProductRepository(_context);
public IOrderRepository Orders =>
_orders ??= new OrderRepository(_context);
public ICategoryRepository Categories =>
_categories ??= new CategoryRepository(_context);
public IAddressRepository Addresses =>
_addresses ??= new AddressRepository(_context);
public IPaymentRepository Payments =>
_payments ??= new PaymentRepository(_context);
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
{
_transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
}
public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
{
try
{
await SaveChangesAsync(cancellationToken);
if (_transaction != null)
{
await _transaction.CommitAsync(cancellationToken);
}
}
catch
{
await RollbackTransactionAsync(cancellationToken);
throw;
}
finally
{
if (_transaction != null)
{
await _transaction.DisposeAsync();
_transaction = null;
}
}
}
public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default)
{
if (_transaction != null)
{
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task<int> ExecuteSqlAsync(string sql, params object[] parameters)
{
return await _context.Database.ExecuteSqlRawAsync(sql, parameters);
}
public async Task<int> ExecuteSqlAsync(string sql, CancellationToken cancellationToken, params object[] parameters)
{
return await _context.Database.ExecuteSqlRawAsync(sql, cancellationToken, parameters);
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
Application Layer Implementation
Business Logic Services
Order Processing Service:
// ShopFlow.Application/Services/OrderService.cs
using ShopFlow.Domain.Entities;
using ShopFlow.Domain.Interfaces;
namespace ShopFlow.Application.Services;
public interface IOrderService
{
Task<Order> CreateOrderAsync(int customerId, int shippingAddressId, int billingAddressId, CancellationToken cancellationToken = default);
Task<Order> AddItemToOrderAsync(int orderId, int productId, int quantity, CancellationToken cancellationToken = default);
Task<Order> RemoveItemFromOrderAsync(int orderId, int productId, CancellationToken cancellationToken = default);
Task<Order> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default);
Task<Order> ShipOrderAsync(int orderId, CancellationToken cancellationToken = default);
Task<Order> CompleteOrderAsync(int orderId, CancellationToken cancellationToken = default);
Task<Order> CancelOrderAsync(int orderId, string reason, CancellationToken cancellationToken = default);
}
public class OrderService : IOrderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
public OrderService(IUnitOfWork unitOfWork, IInventoryService inventoryService, IPaymentService paymentService)
{
_unitOfWork = unitOfWork;
_inventoryService = inventoryService;
_paymentService = paymentService;
}
public async Task<Order> CreateOrderAsync(int customerId, int shippingAddressId, int billingAddressId, CancellationToken cancellationToken = default)
{
await _unitOfWork.BeginTransactionAsync(cancellationToken);
try
{
// Validate customer exists and is active
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, cancellationToken);
if (customer == null)
throw new InvalidOperationException($"Customer with ID {customerId} not found");
if (!customer.IsActive())
throw new InvalidOperationException("Cannot create order for inactive customer");
// Validate addresses
var shippingAddress = await _unitOfWork.Addresses.GetByIdAsync(shippingAddressId, cancellationToken);
if (shippingAddress == null)
throw new InvalidOperationException($"Shipping address with ID {shippingAddressId} not found");
var billingAddress = await _unitOfWork.Addresses.GetByIdAsync(billingAddressId, cancellationToken);
if (billingAddress == null)
throw new InvalidOperationException($"Billing address with ID {billingAddressId} not found");
// Generate order number
var orderNumber = await _unitOfWork.Orders.GenerateOrderNumberAsync(cancellationToken);
// Create order
var order = new Order
{
OrderNumber = orderNumber,
CustomerId = customerId,
OrderDate = DateTime.UtcNow,
Status = OrderStatus.Draft,
ShippingAddressId = shippingAddressId,
BillingAddressId = billingAddressId,
ShippingAmount = 9.99m // Default shipping cost
};
order.CalculateTotals();
await _unitOfWork.Orders.AddAsync(order, cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
return order;
}
catch
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
throw;
}
}
public async Task<Order> AddItemToOrderAsync(int orderId, int productId, int quantity, CancellationToken cancellationToken = default)
{
await _unitOfWork.BeginTransactionAsync(cancellationToken);
try
{
// Get order with items
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(orderId, cancellationToken);
if (order == null)
throw new InvalidOperationException($"Order with ID {orderId} not found");
// Get product
var product = await _unitOfWork.Products.GetByIdAsync(productId, cancellationToken);
if (product == null)
throw new InvalidOperationException($"Product with ID {productId} not found");
// Validate product availability
if (!product.IsInStock())
throw new InvalidOperationException($"Product '{product.Name}' is not available");
if (product.StockQuantity < quantity)
throw new InvalidOperationException($"Insufficient stock. Available: {product.StockQuantity}, Requested: {quantity}");
// Add item to order
order.AddItem(product, quantity, product.Price);
await _unitOfWork.Orders.UpdateAsync(order, cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
return order;
}
catch
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
throw;
}
}
public async Task<Order> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
await _unitOfWork.BeginTransactionAsync(cancellationToken);
try
{
// Get order with full details
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(orderId, cancellationToken);
if (order == null)
throw new InvalidOperationException($"Order with ID {orderId} not found");
if (!order.CanBeProcessed())
throw new InvalidOperationException("Order cannot be processed in its current state");
// Reserve inventory for all order items
foreach (var item in order.OrderItems)
{
await _inventoryService.ReserveStockAsync(item.ProductId, item.Quantity, cancellationToken);
}
// Process payment
var paymentResult = await _paymentService.ProcessPaymentAsync(order.Id, order.TotalAmount, cancellationToken);
if (!paymentResult.IsSuccessful)
{
// Unreserve inventory if payment fails
foreach (var item in order.OrderItems)
{
await _inventoryService.UnreserveStockAsync(item.ProductId, item.Quantity, cancellationToken);
}
throw new InvalidOperationException($"Payment processing failed: {paymentResult.ErrorMessage}");
}
// Update order status
order.Process();
await _unitOfWork.Orders.UpdateAsync(order, cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
return order;
}
catch
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
throw;
}
}
// Additional methods implementation...
}
Inventory Management Service:
// ShopFlow.Application/Services/InventoryService.cs
using ShopFlow.Domain.Interfaces;
namespace ShopFlow.Application.Services;
public interface IInventoryService
{
Task<bool> ReserveStockAsync(int productId, int quantity, CancellationToken cancellationToken = default);
Task<bool> UnreserveStockAsync(int productId, int quantity, CancellationToken cancellationToken = default);
Task<bool> CommitStockReservationAsync(int productId, int quantity, CancellationToken cancellationToken = default);
Task<IEnumerable<Product>> GetLowStockProductsAsync(CancellationToken cancellationToken = default);
Task<bool> AdjustStockAsync(int productId, int adjustment, string reason, CancellationToken cancellationToken = default);
}
public class InventoryService : IInventoryService
{
private readonly IUnitOfWork _unitOfWork;
public InventoryService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<bool> ReserveStockAsync(int productId, int quantity, CancellationToken cancellationToken = default)
{
var product = await _unitOfWork.Products.GetByIdAsync(productId, cancellationToken);
if (product == null || !product.IsInStock() || product.StockQuantity < quantity)
{
return false;
}
// In a real implementation, you might have a separate ReservedStock table
// For this example, we'll directly reduce the stock
product.ReduceStock(quantity);
await _unitOfWork.Products.UpdateAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> CommitStockReservationAsync(int productId, int quantity, CancellationToken cancellationToken = default)
{
// In this simplified implementation, stock is already committed during reservation
// In a more complex scenario, you would move from reserved to committed state
return true;
}
public async Task<IEnumerable<Product>> GetLowStockProductsAsync(CancellationToken cancellationToken = default)
{
return await _unitOfWork.Products.GetLowStockProductsAsync(cancellationToken);
}
// Additional methods...
}
Web API Implementation
Controllers
Orders Controller with Advanced Features:
// ShopFlow.WebApi/Controllers/OrdersController.cs
using Microsoft.AspNetCore.Mvc;
using ShopFlow.Application.DTOs;
using ShopFlow.Application.Services;
using ShopFlow.Domain.Entities;
namespace ShopFlow.WebApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
private readonly IMapper _mapper;
public OrdersController(IOrderService orderService, IMapper mapper)
{
_orderService = orderService;
_mapper = mapper;
}
[HttpPost]
public async Task<ActionResult<OrderResponseDto>> CreateOrder(CreateOrderRequestDto request, CancellationToken cancellationToken)
{
try
{
var order = await _orderService.CreateOrderAsync(
request.CustomerId,
request.ShippingAddressId,
request.BillingAddressId,
cancellationToken);
var response = _mapper.Map<OrderResponseDto>(order);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, response);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderResponseDto>> GetOrder(int id, CancellationToken cancellationToken)
{
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(id, cancellationToken);
if (order == null)
return NotFound();
var response = _mapper.Map<OrderResponseDto>(order);
return Ok(response);
}
[HttpPost("{id}/items")]
public async Task<ActionResult<OrderResponseDto>> AddItemToOrder(int id, AddOrderItemRequestDto request, CancellationToken cancellationToken)
{
try
{
var order = await _orderService.AddItemToOrderAsync(id, request.ProductId, request.Quantity, cancellationToken);
var response = _mapper.Map<OrderResponseDto>(order);
return Ok(response);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpPost("{id}/process")]
public async Task<ActionResult<OrderResponseDto>> ProcessOrder(int id, CancellationToken cancellationToken)
{
try
{
var order = await _orderService.ProcessOrderAsync(id, cancellationToken);
var response = _mapper.Map<OrderResponseDto>(order);
return Ok(response);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<IEnumerable<OrderSummaryDto>>> GetCustomerOrders(int customerId, CancellationToken cancellationToken)
{
var orders = await _unitOfWork.Orders.GetByCustomerAsync(customerId, cancellationToken);
var response = _mapper.Map<IEnumerable<OrderSummaryDto>>(orders);
return Ok(response);
}
}
Dependency Injection Configuration
// ShopFlow.WebApi/Program.cs
using Microsoft.EntityFrameworkCore;
using ShopFlow.Application.Services;
using ShopFlow.Domain.Interfaces;
using ShopFlow.Infrastructure.Data;
using ShopFlow.Infrastructure.Repositories;
using ShopFlow.Infrastructure.UnitOfWork;
var builder = WebApplication.CreateBuilder(args);
// Database configuration
builder.Services.AddDbContext<ShopFlowDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Repository registration
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<IAddressRepository, AddressRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
// Unit of Work registration
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Service registration
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
// AutoMapper
builder.Services.AddAutoMapper(typeof(Program));
// Controllers
builder.Services.AddControllers();
// API Documentation
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// Database migration in development
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ShopFlowDbContext>();
context.Database.EnsureCreated();
}
app.Run();
Comprehensive Testing Strategy
Unit Testing
Repository Tests:
// ShopFlow.Infrastructure.Tests/Repositories/CustomerRepositoryTests.cs
using Microsoft.EntityFrameworkCore;
using ShopFlow.Domain.Entities;
using ShopFlow.Infrastructure.Data;
using ShopFlow.Infrastructure.Repositories;
namespace ShopFlow.Infrastructure.Tests.Repositories;
public class CustomerRepositoryTests : IDisposable
{
private readonly ShopFlowDbContext _context;
private readonly CustomerRepository _repository;
public CustomerRepositoryTests()
{
var options = new DbContextOptionsBuilder<ShopFlowDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new ShopFlowDbContext(options);
_repository = new CustomerRepository(_context);
}
[Fact]
public async Task GetByEmailAsync_ExistingEmail_ReturnsCustomer()
{
// Arrange
var customer = new Customer
{
FirstName = "John",
LastName = "Doe",
Email = "john.doe@email.com",
Status = CustomerStatus.Active
};
await _context.Customers.AddAsync(customer);
await _context.SaveChangesAsync();
// Act
var result = await _repository.GetByEmailAsync("john.doe@email.com");
// Assert
Assert.NotNull(result);
Assert.Equal("John", result.FirstName);
Assert.Equal("john.doe@email.com", result.Email);
}
[Fact]
public async Task GetByEmailAsync_NonExistingEmail_ReturnsNull()
{
// Act
var result = await _repository.GetByEmailAsync("nonexistent@email.com");
// Assert
Assert.Null(result);
}
[Fact]
public async Task IsEmailUniqueAsync_UniqueEmail_ReturnsTrue()
{
// Arrange
var customer = new Customer
{
FirstName = "Jane",
LastName = "Smith",
Email = "jane.smith@email.com",
Status = CustomerStatus.Active
};
await _context.Customers.AddAsync(customer);
await _context.SaveChangesAsync();
// Act
var result = await _repository.IsEmailUniqueAsync("new.email@email.com");
// Assert
Assert.True(result);
}
public void Dispose()
{
_context.Dispose();
}
}
Service Tests with Mocks:
// ShopFlow.Application.Tests/Services/OrderServiceTests.cs
using Moq;
using ShopFlow.Application.Services;
using ShopFlow.Domain.Entities;
using ShopFlow.Domain.Interfaces;
namespace ShopFlow.Application.Tests.Services;
public class OrderServiceTests
{
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<IInventoryService> _inventoryServiceMock;
private readonly Mock<IPaymentService> _paymentServiceMock;
private readonly OrderService _orderService;
public OrderServiceTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork>();
_inventoryServiceMock = new Mock<IInventoryService>();
_paymentServiceMock = new Mock<IPaymentService>();
_orderService = new OrderService(
_unitOfWorkMock.Object,
_inventoryServiceMock.Object,
_paymentServiceMock.Object);
}
[Fact]
public async Task CreateOrderAsync_ValidData_CreatesOrderSuccessfully()
{
// Arrange
var customerId = 1;
var shippingAddressId = 2;
var billingAddressId = 3;
var customer = new Customer
{
Id = customerId,
FirstName = "John",
LastName = "Doe",
Status = CustomerStatus.Active
};
var shippingAddress = new Address { Id = shippingAddressId };
var billingAddress = new Address { Id = billingAddressId };
_unitOfWorkMock.Setup(u => u.Customers.GetByIdAsync(customerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(customer);
_unitOfWorkMock.Setup(u => u.Addresses.GetByIdAsync(shippingAddressId, It.IsAny<CancellationToken>()))
.ReturnsAsync(shippingAddress);
_unitOfWorkMock.Setup(u => u.Addresses.GetByIdAsync(billingAddressId, It.IsAny<CancellationToken>()))
.ReturnsAsync(billingAddress);
_unitOfWorkMock.Setup(u => u.Orders.GenerateOrderNumberAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync("20240108001");
_unitOfWorkMock.Setup(u => u.Orders.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Order order, CancellationToken ct) => order);
// Act
var result = await _orderService.CreateOrderAsync(customerId, shippingAddressId, billingAddressId);
// Assert
Assert.NotNull(result);
Assert.Equal(customerId, result.CustomerId);
Assert.Equal(OrderStatus.Draft, result.Status);
Assert.Equal("20240108001", result.OrderNumber);
_unitOfWorkMock.Verify(u => u.BeginTransactionAsync(It.IsAny<CancellationToken>()), Times.Once);
_unitOfWorkMock.Verify(u => u.CommitTransactionAsync(It.IsAny<CancellationToken>()), Times.Once);
_unitOfWorkMock.Verify(u => u.Orders.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateOrderAsync_InactiveCustomer_ThrowsInvalidOperationException()
{
// Arrange
var customerId = 1;
var customer = new Customer
{
Id = customerId,
Status = CustomerStatus.Inactive
};
_unitOfWorkMock.Setup(u => u.Customers.GetByIdAsync(customerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(customer);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => _orderService.CreateOrderAsync(customerId, 1, 1));
Assert.Contains("inactive customer", exception.Message);
_unitOfWorkMock.Verify(u => u.RollbackTransactionAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}
Integration Testing
// ShopFlow.WebApi.Tests/Controllers/OrdersControllerTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using ShopFlow.Infrastructure.Data;
using ShopFlow.Application.DTOs;
namespace ShopFlow.WebApi.Tests.Controllers;
public class OrdersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public OrdersControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove existing DbContext
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ShopFlowDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Add in-memory database for testing
services.AddDbContext<ShopFlowDbContext>(options =>
options.UseInMemoryDatabase("TestDatabase"));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ValidData_ReturnsCreatedOrder()
{
// Arrange
await SeedTestData();
var request = new CreateOrderRequestDto
{
CustomerId = 1,
ShippingAddressId = 1,
BillingAddressId = 1
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/orders", content);
// Assert
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var order = JsonSerializer.Deserialize<OrderResponseDto>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
Assert.NotNull(order);
Assert.Equal(1, order.CustomerId);
Assert.Equal("Draft", order.Status);
}
private async Task SeedTestData()
{
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ShopFlowDbContext>();
await context.Database.EnsureCreatedAsync();
if (!await context.Customers.AnyAsync())
{
var customer = new Customer
{
Id = 1,
FirstName = "Test",
LastName = "Customer",
Email = "test@example.com",
Status = CustomerStatus.Active
};
var address = new Address
{
Id = 1,
CustomerId = 1,
Street = "123 Test St",
City = "Test City",
Country = "Test Country"
};
context.Customers.Add(customer);
context.Addresses.Add(address);
await context.SaveChangesAsync();
}
}
}
Performance Optimization and Best Practices
Database Performance Optimization
Connection Pooling Configuration:
// ShopFlow.WebApi/Program.cs - Database Configuration
builder.Services.AddDbContext<ShopFlowDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), sqlOptions =>
{
sqlOptions.CommandTimeout(30);
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
});
}, ServiceLifetime.Scoped);
// Connection pooling for high-performance scenarios
builder.Services.AddDbContextPool<ShopFlowDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
}, poolSize: 128);
Query Optimization Strategies:
// ShopFlow.Infrastructure/Repositories/OptimizedProductRepository.cs
public class OptimizedProductRepository : ProductRepository
{
public OptimizedProductRepository(ShopFlowDbContext context) : base(context)
{
}
// Compiled query for frequently used operations
private static readonly Func<ShopFlowDbContext, int, Task<Product?>> GetProductByIdCompiled =
EF.CompileAsyncQuery((ShopFlowDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
public override async Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await GetProductByIdCompiled(_context, id);
}
// Optimized search with pagination
public async Task<PagedResult<Product>> SearchOptimizedAsync(
string searchTerm,
int categoryId = 0,
int pageNumber = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = _dbSet.AsNoTracking()
.Where(p => p.Status == ProductStatus.Active);
if (!string.IsNullOrEmpty(searchTerm))
{
query = query.Where(p =>
p.Name.Contains(searchTerm) ||
p.Description.Contains(searchTerm));
}
if (categoryId > 0)
{
query = query.Where(p => p.CategoryId == categoryId);
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(p => p.Name)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(p => new Product // Projection to reduce data transfer
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
StockQuantity = p.StockQuantity,
SKU = p.SKU
})
.ToListAsync(cancellationToken);
return new PagedResult<Product>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
}
}
Caching Implementation
// ShopFlow.Application/Services/CachedProductService.cs
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Distributed;
public class CachedProductService : IProductService
{
private readonly IProductService _productService;
private readonly IMemoryCache _memoryCache;
private readonly IDistributedCache _distributedCache;
private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(15);
public CachedProductService(
IProductService productService,
IMemoryCache memoryCache,
IDistributedCache distributedCache)
{
_productService = productService;
_memoryCache = memoryCache;
_distributedCache = distributedCache;
}
public async Task<Product?> GetProductAsync(int id, CancellationToken cancellationToken = default)
{
var cacheKey = $"product:{id}";
// Try memory cache first (L1 cache)
if (_memoryCache.TryGetValue(cacheKey, out Product? cachedProduct))
{
return cachedProduct;
}
// Try distributed cache (L2 cache)
var distributedCacheValue = await _distributedCache.GetStringAsync(cacheKey, cancellationToken);
if (!string.IsNullOrEmpty(distributedCacheValue))
{
var product = JsonSerializer.Deserialize<Product>(distributedCacheValue);
_memoryCache.Set(cacheKey, product, TimeSpan.FromMinutes(5)); // Shorter expiration for L1
return product;
}
// Fetch from database
var dbProduct = await _productService.GetProductAsync(id, cancellationToken);
if (dbProduct != null)
{
// Cache in both layers
_memoryCache.Set(cacheKey, dbProduct, TimeSpan.FromMinutes(5));
await _distributedCache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(dbProduct),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _cacheExpiration
},
cancellationToken);
}
return dbProduct;
}
}
Error Handling and Resilience
Global Exception Handler:
// ShopFlow.WebApi/Middleware/GlobalExceptionHandlerMiddleware.cs
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
public GlobalExceptionHandlerMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
await HandleExceptionAsync(context, ex);
}
}
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var response = context.Response;
response.ContentType = "application/json";
var errorResponse = new ErrorResponse();
switch (exception)
{
case InvalidOperationException:
response.StatusCode = StatusCodes.Status400BadRequest;
errorResponse.Message = exception.Message;
break;
case KeyNotFoundException:
response.StatusCode = StatusCodes.Status404NotFound;
errorResponse.Message = "The requested resource was not found";
break;
case UnauthorizedAccessException:
response.StatusCode = StatusCodes.Status401Unauthorized;
errorResponse.Message = "Unauthorized access";
break;
case DbUpdateConcurrencyException:
response.StatusCode = StatusCodes.Status409Conflict;
errorResponse.Message = "The resource was modified by another user";
break;
default:
response.StatusCode = StatusCodes.Status500InternalServerError;
errorResponse.Message = "An internal server error occurred";
break;
}
var jsonResponse = JsonSerializer.Serialize(errorResponse);
await response.WriteAsync(jsonResponse);
}
}
public class ErrorResponse
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string TraceId { get; set; } = Activity.Current?.Id ?? string.Empty;
}
Retry Policies with Polly:
// ShopFlow.Infrastructure/Resilience/DatabaseRetryPolicy.cs
using Polly;
using Polly.Extensions.Http;
public static class RetryPolicies
{
public static IAsyncPolicy<HttpResponseMessage> GetHttpRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryCount, context) =>
{
Console.WriteLine($"Retry {retryCount} after {timespan}s");
});
}
public static IAsyncPolicy GetDatabaseRetryPolicy()
{
return Policy
.Handle<SqlException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
}
Modern Alternatives and Future Considerations
CQRS with MediatR
// ShopFlow.Application/Features/Orders/Commands/CreateOrderCommand.cs
using MediatR;
public class CreateOrderCommand : IRequest<OrderResponseDto>
{
public int CustomerId { get; set; }
public int ShippingAddressId { get; set; }
public int BillingAddressId { get; set; }
}
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResponseDto>
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public CreateOrderCommandHandler(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<OrderResponseDto> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// Command handling logic similar to the service method
// but focused on a single operation
await _unitOfWork.BeginTransactionAsync(cancellationToken);
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(request.CustomerId, cancellationToken);
if (customer == null)
throw new InvalidOperationException($"Customer with ID {request.CustomerId} not found");
var orderNumber = await _unitOfWork.Orders.GenerateOrderNumberAsync(cancellationToken);
var order = new Order
{
OrderNumber = orderNumber,
CustomerId = request.CustomerId,
OrderDate = DateTime.UtcNow,
Status = OrderStatus.Draft,
ShippingAddressId = request.ShippingAddressId,
BillingAddressId = request.BillingAddressId
};
await _unitOfWork.Orders.AddAsync(order, cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
return _mapper.Map<OrderResponseDto>(order);
}
catch
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
throw;
}
}
}
Specification Pattern Enhancement
// ShopFlow.Domain/Specifications/ProductSpecifications.cs
using System.Linq.Expressions;
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
return ToExpression().Compile()(entity);
}
}
public class ProductInStockSpecification : Specification<Product>
{
public override Expression<Func<Product, bool>> ToExpression()
{
return product => product.StockQuantity > 0 && product.Status == ProductStatus.Active;
}
}
public class ProductByCategorySpecification : Specification<Product>
{
private readonly int _categoryId;
public ProductByCategorySpecification(int categoryId)
{
_categoryId = categoryId;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => product.CategoryId == _categoryId;
}
}
public class CombinedProductSpecification : Specification<Product>
{
private readonly List<Specification<Product>> _specifications;
public CombinedProductSpecification(params Specification<Product>[] specifications)
{
_specifications = specifications.ToList();
}
public override Expression<Func<Product, bool>> ToExpression()
{
if (!_specifications.Any())
return product => true;
var combinedExpression = _specifications.First().ToExpression();
foreach (var specification in _specifications.Skip(1))
{
combinedExpression = CombineExpressions(combinedExpression, specification.ToExpression());
}
return combinedExpression;
}
private Expression<Func<T, bool>> CombineExpressions<T>(
Expression<Func<T, bool>> first,
Expression<Func<T, bool>> second)
{
var parameter = Expression.Parameter(typeof(T));
var combinedBody = Expression.AndAlso(
Expression.Invoke(first, parameter),
Expression.Invoke(second, parameter)
);
return Expression.Lambda<Func<T, bool>>(combinedBody, parameter);
}
}
Best Practices for High-Scale Applications
Connection Pooling and Resource Management
Database Connection Optimization:
// ShopFlow.Infrastructure/Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOptimizedDatabase(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContextPool<ShopFlowDbContext>(options =>
{
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), sqlOptions =>
{
sqlOptions.CommandTimeout(30);
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
// Enable sensitive data logging only in development
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
{
options.EnableSensitiveDataLogging();
}
// Connection pooling optimization
sqlOptions.MaxBatchSize(100);
});
// Query tracking optimization
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}, poolSize: 1024); // Adjust based on expected concurrent users
return services;
}
}
Monitoring and Observability
Application Insights Integration:
// ShopFlow.WebApi/Program.cs - Monitoring Configuration
builder.Services.AddApplicationInsightsTelemetry(builder.Configuration);
// Custom telemetry
builder.Services.AddSingleton<ITelemetryInitializer, CustomTelemetryInitializer>();
// Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ShopFlowDbContext>()
.AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
.AddCheck<CustomHealthCheck>("custom-health-check");
public class CustomTelemetryInitializer : ITelemetryInitializer
{
public void Initialize(ITelemetry telemetry)
{
telemetry.Context.GlobalProperties["ApplicationVersion"] = "1.0.0";
telemetry.Context.GlobalProperties["Environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
}
}
Pros and Cons Analysis
Repository & Unit of Work Patterns:
Pros:
- Clean Separation: Clear boundaries between business logic and data access
- Testability: Easy to mock and unit test business logic
- Consistency: Ensures transactional integrity across operations
- Flexibility: Can switch underlying data access technologies
- Maintainability: Centralized data access logic reduces code duplication
Cons:
- Complexity: Additional layers increase cognitive load
- Performance Overhead: Extra abstraction layers can impact performance
- Learning Curve: Requires understanding of patterns and their interactions
- Over-engineering: Can be overkill for simple applications
Modern Alternatives:
1. Direct DbContext Usage with Services
- Simpler implementation
- Better performance for read operations
- Less abstraction overhead
- Suitable for smaller applications
2. CQRS with MediatR
- Clear separation of commands and queries
- Better scalability for complex domains
- Easier to implement different optimization strategies for reads vs writes
3. Vertical Slice Architecture
- Features organized by business capabilities
- Reduces coupling between features
- Easier to understand and maintain individual features
Conclusion
Throughout this comprehensive implementation guide, we've built a complete e-commerce order management system that demonstrates the power and flexibility of Repository and Unit of Work patterns in modern .NET applications. Our ShopFlow system showcases:
Key Achievements:
- Clean Architecture: Separation of concerns across well-defined layers
- Comprehensive Testing: Unit, integration, and performance testing strategies
- Performance Optimization: Database optimization, caching, and resilience patterns
- Enterprise Readiness: Error handling, monitoring, and scalability considerations
- Modern Patterns: Integration with contemporary approaches like CQRS and Specification patterns
Real-World Applications: The patterns and techniques demonstrated in ShopFlow are used by industry leaders like Amazon, Google, and Microsoft to handle millions of transactions daily. The architectural decisions we've made provide:
- Scalability: Horizontal scaling capabilities through proper abstraction
- Maintainability: Clean code that's easy to understand, modify, and extend
- Reliability: Robust error handling and transaction management
- Performance: Optimized queries, caching strategies, and connection pooling
Moving Forward: As you implement these patterns in your own projects, consider:
- Start Simple: Begin with basic repository implementations and add complexity as needed
- Measure Performance: Profile your application to identify actual bottlenecks
- Embrace Testing: Comprehensive test coverage pays dividends in maintenance
- Stay Current: Monitor emerging patterns and technologies in the .NET ecosystem
- Know Your Alternatives: Understand when other patterns might be more appropriate
The Repository and Unit of Work patterns remain valuable tools in the .NET developer's toolkit, especially for enterprise applications requiring clean architecture, comprehensive testing, and long-term maintainability. While newer patterns like CQRS and Vertical Slice Architecture offer alternatives for specific scenarios, the foundational principles of separation of concerns and transactional consistency remain timeless.
Your Next Steps:
- Implement these patterns in a small prototype project
- Experiment with the performance optimization techniques
- Practice writing comprehensive tests for your repositories and services
- Explore integration with modern tools like Entity Framework Core 8, .NET 8 performance features, and cloud-native patterns
The journey to mastering enterprise .NET development continues with each project you build. Use these patterns as your foundation, but always adapt them to solve the specific problems your applications face. Remember, the best architecture is one that serves your business needs while remaining maintainable and scalable for future growth.
Whether you're building the next unicorn startup or maintaining critical enterprise systems, these patterns provide the solid foundation you need to create applications that stand the test of time and scale.
I hope you enjoyed reading this blog!
I’d love to hear your thoughts. please share your feedback or questions in the comments below and let me know if you’d like any clarifications on the topics covered. If you enjoyed this blog, don’t forget to like it and subscribe for more technology insights.
Stay tuned! In upcoming posts, I’ll be diving into advanced .NET topics such Job Scheduling, CLR Execution, ASP.NET Core detailed guide, Software architectures in detail, Authentication & Authorization Techniques and much more.
Thank you for joining me on this learning journey!
메타데이터
- post_id
- 2f326f4ac0b4
- slug
- mastering-repository-unit-of-work-patterns-in-net-real-world-implementation-guide-part-2-2f326f4ac0b4
- url
- https://medium.com/@bhargavkoya56/mastering-repository-unit-of-work-patterns-in-net-real-world-implementation-guide-part-2-2f326f4ac0b4
- canonical_url
- https://medium.com/@bhargavkoya56/mastering-repository-unit-of-work-patterns-in-net-real-world-implementation-guide-part-2-2f326f4ac0b4
- author_url
- https://medium.com/@bhargavkoya56
- status
- ok
- fetched_at
- 2026-09-18 20:22:38