Clean Architecture in .NET 2026: Building Modern, Scalable Applications
Clean Architecture has matured significantly by 2026. No longer just a theoretical concept, it's now the de facto standard for building maintainable, testable .NET applications. This comprehensive guide covers the principles, patterns, and production-ready practices you need to know.
Why Clean Architecture Matters in 2026
The shift toward Clean Architecture isn't about following trends—it's about solving real-world problems:
- Maintainability: As applications grow, code becomes impossible to understand without proper boundaries
- Testability: Business logic can be tested without databases, frameworks, or external services
- Team Scaling: Clear boundaries allow multiple teams to work on different layers simultaneously
- Technology Agility: Swap databases, frameworks, or UI technologies without重写整个应用
The Layered Architecture (2026 Standard)
┌─────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ (Minimal APIs, MVC, Blazor, GraphQL, SignalR) │
└─────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ (Commands, Queries, DTOs, Validators, Transactors) │
└─────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ (Entities, Value Objects, Domain Events, │
│ Repository Interfaces) │
└─────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ (EF Core, External APIs, Message Queues, │
│ File Systems, Email Services) │
└─────────────────────────────────────────────────────┘
Key Principle: Dependencies always point toward the center. The domain layer has zero dependencies on external frameworks.
The Repository Pattern Debate: Lessons from 2026
The community has moved beyond the "should we use repositories?" debate. Here's what production teams have learned:
❌ What NOT to Do
// ❌ Generic repository adds little value
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
Task<IEnumerable<T>> GetAllAsync(CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
Task UpdateAsync(T entity, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
}
// ❌ DbContext IS a repository - avoid double abstraction
public class EfRepository<T> : IRepository<T>
{
private readonly ApplicationDbContext _context;
public EfRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<T?> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _context.Set<T>().FindAsync(new object[] { id }, ct);
}
// ... rest of implementation
}
✅ What TO Do
// ✅ Specific repository for domain boundaries
public interface IUserRepository
{
Task<User?> GetByIdAsync(UserId id, CancellationToken ct);
Task<User?> GetByEmailAsync(string email, CancellationToken ct);
Task AddAsync(User user, CancellationToken ct);
Task UpdateAsync(User user, CancellationToken ct);
Task<IEnumerable<Order>> GetOrdersWithItemsAsync(UserId id, CancellationToken ct);
}
// ✅ Implementation with EF Core
public class EfUserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public async Task<User?> GetByIdAsync(UserId id, CancellationToken ct)
{
return await _context.Users
.AsNoTracking()
.Include(u => u.Orders)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(u => u.Id == id, ct);
}
}
Key Insight: Use repositories only when you need to hide database complexity or provide domain-specific queries.
Modern .NET 9-10 Features in Clean Architecture
Primary Constructors for Clean Entities
// .NET 10: Domain Entity with Primary Constructor
public record User(
UserId Id,
EmailAddress Email,
PersonName Name,
DateTime CreatedAt,
List<Order> Orders = []
)
{
// Domain validation
public void UpdateEmail(string newEmail)
{
Email = new EmailAddress(newEmail); // Validates format
AddDomainEvent(new EmailUpdatedEvent(Id, new EmailAddress(newEmail)));
}
public void PlaceOrder(Order order)
{
Orders.Add(order);
order.SetParent(this);
}
}
Enhanced Entity Queries with LINQ
// Efficient queries with projection
public async Task<UserDTO?> GetDetailsAsync(UserId id, CancellationToken ct)
{
return await _context.Users
.Where(u => u.Id == id)
.Select(u => new UserDTO
{
Id = u.Id,
Email = u.Email.Value,
FullName = $"{u.Name.FirstName} {u.Name.LastName}",
OrderCount = u.Orders.Count,
LastOrderDate = u.Orders.Max(o => o.CreatedAt)
})
.FirstOrDefaultAsync(ct);
}
Domain-Driven Design Deep Dive
Value Objects: The Key to Domain Integrity
public sealed class EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Create(string value)
{
var emailRegex = new Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$");
if (!emailRegex.IsMatch(value))
{
throw new ArgumentException("Invalid email format");
}
return new EmailAddress(value.ToLower().Trim());
}
public static implicit operator string(EmailAddress email) => email.Value;
}
public sealed class Money
{
public decimal Amount { get; }
public Currency Code { get; }
public Money(decimal amount, string currencyCode)
{
Amount = decimal.Parse(amount.ToString("N2"));
Code = new Currency(currencyCode);
}
public Money Add(Money other)
{
if (Code != other.Code)
throw new InvalidOperationException("Currencies must match");
return new Money(Amount + other.Amount, Code);
}
}
Domain Events for Decoupled Business Logic
// Domain Event
public record OrderPlacedEvent(string OrderId, UserId CustomerId, decimal Total);
// Domain Entity
public record Order(
OrderId Id,
UserId CustomerId,
List<OrderItem> Items,
Money Total,
List<IDomainEvent> DomainEvents = []
)
{
public void ConfirmPayment()
{
// Business rule validation
if (Total.Amount <= 0)
throw new InvalidOperationException("Invalid order total");
State = OrderState.Confirmed;
// Add domain event
DomainEvents.Add(new OrderPlacedEvent(Id.Value, CustomerId, Total.Amount));
}
}
Dependency Injection Best Practices (2026)
Proper Lifetime Management
// Transient: New instance every time (stateless)
builder.Services.AddScoped<ITransientService, TransientService>();
// Scoped: New instance per request (DbContext, UnitOfWork)
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
builder.Services.AddScoped<ApplicationDbContext>();
// Singleton: Single instance for lifetime (caches, configuration)
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IOptions<AppSettings>, AppSettings>();
Validation for Common Mistakes
// In Development Environment
#if DEBUG
builder.Services.AddMvc()
.Services
.AddCheckScopes(); // Validates no scoped services in singletons
#endif
// Avoid Service Locator Pattern
// ❌ DON'T
public class BadService
{
private readonly IServiceProvider _serviceProvider;
public BadService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void DoSomething()
{
var dep = _serviceProvider.GetRequiredService<IDependency>();
}
}
// ✅ DO
public class GoodService
{
private readonly IDependency _dependency;
public GoodService(IDependency dependency)
{
_dependency = dependency;
}
}
Minimal APIs & Clean Architecture
The new .NET 9+ Minimal APIs work beautifully with Clean Architecture:
// Minimal API Endpoint
builderMap.MapPost("/api/users", async (
CreateUserCommand command,
IUserRepository userRepository,
IUnitOfWork unitOfWork,
CancellationToken ct) =>
{
var user = new User(
UserId.Create(),
new EmailAddress(command.Email),
new PersonName(command.FirstName, command.LastName)
);
user.AddDomainEvent(new UserCreatedEvent(user.Id));
await userRepository.AddAsync(user, ct);
await unitOfWork.SaveChangesAsync(ct);
return Results.Created($"/api/users/{user.Id.Value}",
new { id = user.Id.Value, email = user.Email, name = user.Name });
})
.WithTags("Users")
.RequireAuthorization();
Testing Strategy with Clean Architecture
Unit Testing Business Logic
public class OrderServiceTests
{
[Fact]
public async Task PlaceOrder_ShouldCreateOrderAndRaiseEvent()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
var mockUnitOfWork = new Mock<IUnitOfWork>();
var service = new OrderService(
mockRepository.Object,
mockUnitOfWork.Object
);
var orderCommand = new CreateOrderCommand { /* ... */ };
// Act
var result = await service.PlaceOrderAsync(orderCommand, CancellationToken.None);
// Assert
mockRepository.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
mockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}
Integration Testing Infrastructure
[Collection("Sequential")]
public class OrderRepositoryIntegrationTests : IDisposable
{
public OrderRepositoryIntegrationTests()
{
// Setup test database
}
[Fact]
public async Task GetOrderWithItems_ShouldIncludeAllRelatedData()
{
var repository = new EfOrderRepository(context);
var order = await repository.GetByIdWithItemsAsync(OrderId.Create(), CancellationToken.None);
Assert.NotNull(order);
Assert.Single(order.Items);
}
}
Production Checklist for Clean Architecture
- ✅ Enforce Layer Boundaries: Use project references and dependency validation
- ✅ Implement Domain Events: For decoupled business logic
- ✅ Use Value Objects: For data integrity
- ✅ Write Repository Interfaces: For domain-specific queries
- ✅ Test Business Logic Isolated: Without external dependencies
- ✅ Validate DI Lifetimes: Catch scope violations in development
- ✅ Implement Graceful Degradation: For infrastructure failures
- ✅ Document Architecture: Keep team aligned on boundaries
Common Pitfalls to Avoid
- Over-Engineering: Don't apply Clean Architecture to simple CRUD apps
- Repository Overuse: Only use when needed for domain abstraction
- Circular Dependencies: Keep dependencies pointing to the center
- Ignoring Domain Events: Use for business process orchestration
- Skipping Validation: Validate at domain boundaries, not just UI
Conclusion
Clean Architecture in 2026 is about making practical decisions while maintaining separation of concerns. By following these patterns and avoiding common pitfalls, you can build scalable, maintainable .NET applications that stand the test of time.
Remember: The goal isn't perfect architecture—it's business value delivered cleanly.
What's been your experience with Clean Architecture in .NET? Share your challenges and successes in the comments!
Top comments (0)