DEV Community

Vikrant Bagal
Vikrant Bagal

Posted on

Clean Architecture in .NET 2026: Building Modern, Scalable Applications

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)                      │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

✅ 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Checklist for Clean Architecture

  1. Enforce Layer Boundaries: Use project references and dependency validation
  2. Implement Domain Events: For decoupled business logic
  3. Use Value Objects: For data integrity
  4. Write Repository Interfaces: For domain-specific queries
  5. Test Business Logic Isolated: Without external dependencies
  6. Validate DI Lifetimes: Catch scope violations in development
  7. Implement Graceful Degradation: For infrastructure failures
  8. Document Architecture: Keep team aligned on boundaries

Common Pitfalls to Avoid

  1. Over-Engineering: Don't apply Clean Architecture to simple CRUD apps
  2. Repository Overuse: Only use when needed for domain abstraction
  3. Circular Dependencies: Keep dependencies pointing to the center
  4. Ignoring Domain Events: Use for business process orchestration
  5. 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)