DEV Community

Korir Moses
Korir Moses

Posted on

Clean Architecture in .NET: Moving Beyond Generic Repositories and Leveraging IServiceScopeFactory

Abstract
In modern software development, architectural patterns such as Domain-Driven Design (DDD), Clean Architecture, and Command Query Responsibility Segregation (CQRS) have gained widespread adoption. However, their implementation often leads to over-engineering through unnecessary abstractions like generic IRepository interfaces. This article examines when these patterns add value versus when they introduce complexity, proposes intent-driven alternatives to generic repositories, and demonstrates the proper application of IServiceScopeFactory for managing service lifetimes in .NET applications.
Introduction
The pursuit of "good architecture" has led many development teams to adopt heavyweight patterns and abstractions without sufficient consideration of their actual requirements. While patterns like Clean Architecture provide valuable guidance, their mechanical application often results in code that is more complex than necessary. This analysis focuses on identifying when architectural patterns serve genuine needs versus when they constitute over-engineering.
Clean Architecture: Principles and Pragmatism
Clean Architecture, as articulated by Robert C. Martin, establishes a layered approach to software design with clear separation of concerns:
Core Layers:

Entities: Domain models containing business rules, isolated from infrastructure concerns
Use Cases: Application services orchestrating business workflows
Interface Adapters: Translation layer between domain and external systems
Infrastructure: Databases, web frameworks, and external services

The fundamental principle—the Dependency Rule—ensures that business logic remains independent of infrastructure decisions, enhancing testability and maintainability.
The Context-Dependent Nature of Architectural Patterns
When DDD and CQRS Add Value:

Complex business domains requiring sophisticated modeling
Systems with distinct read/write optimization needs
Applications where domain expertise is critical to success
Large teams requiring shared understanding through ubiquitous language

When They Become Overhead:

CRUD-centric applications with straightforward business rules
Small teams lacking domain modeling expertise
Tight delivery timelines where complexity impedes progress
Domains that don't justify the learning curve and maintenance overhead

The key insight is that architectural patterns should solve actual problems, not theoretical ones.
The Generic Repository Anti-Pattern
The IRepository interface represents a common abstraction that appears beneficial but often creates more problems than it solves:

public interface IRepository<T>
{
    T GetById(Guid id);
    void Add(T entity);
    void Delete(T entity);
    IEnumerable<T> GetAll();
}
Enter fullscreen mode Exit fullscreen mode

Critical Limitations
1. Semantic Mismatch with Domain Operations
Business operations rarely align with generic CRUD operations. Consider an e-commerce scenario:

Retrieving orders may require specific filtering by status, customer, or date range
Order updates might be restricted based on fulfillment status
Deletion might be prohibited for auditing reasons

Generic repositories cannot express these domain-specific constraints effectively.
2. Abstraction Leakage
Many implementations expose IQueryable, creating tight coupling between domain logic and ORM-specific constructs. This coupling:

Makes unit testing more complex
Prevents clean separation between layers
Reduces flexibility in data access strategy changes
**

  1. Promotion of Anemic Domain Models** By externalizing all data access logic, generic repositories encourage treating domain entities as mere data containers, violating object-oriented principles and reducing code expressiveness. Intent-Driven Repository Design A superior approach involves creating repositories that reflect actual business operations:
public interface IOrderRepository
{
    Task<Order?> GetPendingOrderForCustomerAsync(Guid customerId);
    Task<IEnumerable<Order>> GetOrdersByStatusAsync(OrderStatus status);
    Task AddAsync(Order order);
    Task<bool> CanCancelOrderAsync(Guid orderId);
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Intent-Driven Design
Enhanced Clarity: Method names communicate business intent clearly
Better Encapsulation: Implementation details remain hidden
Improved Testability: Mock objects can simulate specific business scenarios
Domain Alignment: Interfaces reflect real-world operations
Refactoring Example
Before (Generic Approach):

public class OrderService
{
    private readonly IRepository<Order> _orderRepository;

    public async Task<IEnumerable<Order>> GetCustomerOrders(Guid customerId)
    {
        var allOrders = await _orderRepository.GetAll();
        return allOrders.Where(o => o.CustomerId == customerId);
    }
}
Enter fullscreen mode Exit fullscreen mode

After (Intent-Driven Approach):

public class OrderService
{
    private readonly IOrderRepository _orderRepository;

    public Task<IEnumerable<Order>> GetCustomerOrders(Guid customerId)
    {
        return _orderRepository.GetOrdersByCustomerIdAsync(customerId);
    }
}
Enter fullscreen mode Exit fullscreen mode

The refactored version moves filtering logic into the repository where it belongs, making the service more focused and the abstraction more meaningful.
Understanding IServiceScopeFactory in .NET
While generic repositories often represent unnecessary abstraction, IServiceScopeFactory serves a legitimate architectural purpose in .NET's dependency injection system.
The Service Lifetime Challenge
.NET Core supports three service lifetimes:

Singleton: Created once per application
Scoped: Created once per HTTP request
Transient: Created each time requested

Problems arise when singleton services need to consume scoped services (like DbContext) outside of HTTP request contexts, such as in background services or hosted workers.
IServiceScopeFactory Solution
IServiceScopeFactory enables manual creation of service scopes, allowing safe resolution of scoped services in non-request contexts:

public class OrderProcessingBackgroundService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderProcessingBackgroundService> _logger;

    public OrderProcessingBackgroundService(
        IServiceScopeFactory scopeFactory,
        ILogger<OrderProcessingBackgroundService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
            var dbContext = scope.ServiceProvider.GetRequiredService<OrderDbContext>();

            try
            {
                await ProcessPendingOrders(orderService, dbContext);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing orders");
            }

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }

    private async Task ProcessPendingOrders(IOrderService orderService, OrderDbContext dbContext)
    {
        // Business logic implementation
        var pendingOrders = await orderService.GetPendingOrdersAsync();
        foreach (var order in pendingOrders)
        {
            await orderService.ProcessOrderAsync(order.Id);
        }

        await dbContext.SaveChangesAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach maintains proper service lifetimes while enabling background processing capabilities.
Implementation Recommendations
1. Start Simple
Begin with straightforward service classes and add abstractions only when genuine needs emerge. Premature abstraction often creates more problems than it solves.
2. Focus on Intent
When creating interfaces, ensure they reflect actual business operations rather than technical CRUD operations.
3. Respect Service Lifetimes
Use IServiceScopeFactory appropriately when consuming scoped services outside HTTP request contexts.

  1. Iterative Refinement Allow architecture to evolve with understanding. What starts as a simple service can be refactored into more sophisticated patterns as complexity justifies the investment. 5. Team Capability Alignment Ensure architectural choices align with team expertise and project timelines. Complex patterns require corresponding knowledge and maintenance commitment. Conclusion Effective software architecture balances structure with pragmatism. While Clean Architecture principles provide valuable guidance, their implementation should be tailored to actual requirements rather than theoretical ideals. Generic repositories often represent over-abstraction, while intent-driven interfaces better serve domain needs. Meanwhile, IServiceScopeFactory addresses legitimate technical requirements in .NET applications. The goal remains constant: creating maintainable, testable, and comprehensible code. This is achieved through thoughtful application of patterns rather than their mechanical adoption. By understanding when patterns add value versus when they introduce unnecessary complexity, development teams can make informed architectural decisions that serve both immediate needs and long-term maintainability. References and Further Reading

Martin, Robert C. "Clean Architecture: A Craftsman's Guide to Software Structure and Design"
Evans, Eric. "Domain-Driven Design: Tackling Complexity in the Heart of Software"
Microsoft Documentation: "Dependency injection in .NET"
Fowler, Martin. "Patterns of Enterprise Application Architecture"

Top comments (0)