👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Constrained Construction Antipattern in Dependency Injection

Constrained Construction Antipattern in Dependency Injection

Author - Abdul Rahman (Bhai)

Dependency Injection

7 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

The Constrained Construction anti-pattern occurs when you artificially limit object construction to force specific instantiation patterns. This typically manifests as forcing the use of factory methods, static creation methods, or builders instead of allowing direct constructor usage. In this article, let's learn about the Constrained Construction anti-pattern in .NET and understand why it hampers proper Dependency Injection.

Why we gonna do?

The Constrained Construction anti-pattern happens when classes artificially restrict how they can be constructed, typically by making constructors private or internal and forcing clients to use specific creation methods. While the intention might be to ensure proper object initialization or enforce creation patterns, it actually makes Dependency Injection much more difficult.

This anti-pattern often emerges from well-intentioned design decisions like wanting to validate inputs, ensure proper setup, or follow specific creation patterns. However, it creates significant obstacles when trying to use DI containers and makes unit testing considerably more challenging.

Why Constrained Construction is Problematic

Constrained Construction creates several issues:

  • DI container incompatibility - Most containers expect public constructors
  • Testing difficulties - Hard to create test instances with mocked dependencies
  • Reduced flexibility - Forces specific creation patterns on all consumers
  • Increased coupling - Clients become dependent on specific factory methods
  • Complex composition - Makes object graph construction more complicated
  • Violation of Open/Closed Principle - Prevents extension through inheritance

How we gonna do?

Example: EmailService with Constrained Construction

Here's a typical example of the Constrained Construction anti-pattern:


// Constrained Construction anti-pattern
public class EmailService
{
    private readonly IEmailConfiguration config;
    private readonly ILogger logger;
    
    // Private constructor - forces use of factory method
    private EmailService(IEmailConfiguration config, ILogger logger)
    {
        this.config = config ?? throw new ArgumentNullException(nameof(config));
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }
    
    // Static factory method - the "correct" way to create instances
    public static EmailService Create(IEmailConfiguration config, ILogger logger)
    {
        // Validation logic
        if (string.IsNullOrEmpty(config.SmtpServer))
            throw new ArgumentException("SMTP server must be configured", nameof(config));
            
        if (config.SmtpPort <= 0)
            throw new ArgumentException("SMTP port must be positive", nameof(config));
        
        return new EmailService(config, logger);
    }
    
    public async Task SendAsync(string to, string subject, string body)
    {
        logger.LogInformation($"Sending email to {to}");
        
        using var client = new SmtpClient(config.SmtpServer, config.SmtpPort);
        await client.SendMailAsync("noreply@company.com", to, subject, body);
        
        logger.LogInformation($"Email sent successfully to {to}");
    }
}
            

Another Example: Repository with Builder Pattern Constraint

Here's another common pattern that constrains construction:


// Repository with constrained construction via builder pattern
public class ProductRepository
{
    private readonly IDbConnection connection;
    private readonly ICacheService cache;
    private readonly ILogger logger;
    private readonly TimeSpan cacheTimeout;
    
    // Internal constructor - can't be used directly
    internal ProductRepository(
        IDbConnection connection, 
        ICacheService cache, 
        ILogger logger, 
        TimeSpan cacheTimeout)
    {
        this.connection = connection;
        this.cache = cache;
        this.logger = logger;
        this.cacheTimeout = cacheTimeout;
    }
    
    // Builder class to constrain construction
    public class Builder
    {
        private IDbConnection connection;
        private ICacheService cache;
        private ILogger logger;
        private TimeSpan cacheTimeout = TimeSpan.FromMinutes(5);
        
        public Builder WithConnection(IDbConnection connection)
        {
            this.connection = connection;
            return this;
        }
        
        public Builder WithCache(ICacheService cache)
        {
            this.cache = cache;
            return this;
        }
        
        public Builder WithLogger(ILogger logger)
        {
            this.logger = logger;
            return this;
        }
        
        public Builder WithCacheTimeout(TimeSpan timeout)
        {
            this.cacheTimeout = timeout;
            return this;
        }
        
        public ProductRepository Build()
        {
            if (connection == null)
                throw new InvalidOperationException("Database connection is required");
            if (cache == null)
                throw new InvalidOperationException("Cache service is required");
            if (logger == null)
                throw new InvalidOperationException("Logger is required");
                
            return new ProductRepository(connection, cache, logger, cacheTimeout);
        }
    }
    
    public async Task<Product> GetByIdAsync(int id)
    {
        var cacheKey = $"product:{id}";
        var cached = await cache.GetAsync<Product>(cacheKey);
        if (cached != null)
        {
            logger.LogDebug($"Product {id} found in cache");
            return cached;
        }
        
        const string sql = "SELECT * FROM Products WHERE Id = @id";
        var product = await connection.QuerySingleOrDefaultAsync<Product>(sql, new { id });
        
        if (product != null)
        {
            await cache.SetAsync(cacheKey, product, cacheTimeout);
            logger.LogDebug($"Product {id} cached for {cacheTimeout}");
        }
        
        return product;
    }
}
            

Problems with Constrained Construction

1. DI Container Incompatibility

Most DI containers expect public constructors and can't work with factory methods or builders:


// This won't work with most DI containers
public void ConfigureServices(IServiceCollection services)
{
    // Can't register EmailService because constructor is private
    // services.AddScoped<EmailService>(); // Compilation error or runtime exception
    
    // Must use workarounds with factory delegates
    services.AddScoped<EmailService>(provider =>
    {
        var config = provider.GetRequiredService<IEmailConfiguration>();
        var logger = provider.GetRequiredService<ILogger<EmailService>>();
        return EmailService.Create(config, logger);
    });
    
    // Builder pattern is even more complex to register
    services.AddScoped<ProductRepository>(provider =>
    {
        var connection = provider.GetRequiredService<IDbConnection>();
        var cache = provider.GetRequiredService<ICacheService>();
        var logger = provider.GetRequiredService<ILogger<ProductRepository>>();
        
        return new ProductRepository.Builder()
            .WithConnection(connection)
            .WithCache(cache)
            .WithLogger(logger)
            .WithCacheTimeout(TimeSpan.FromMinutes(10))
            .Build();
    });
}
            

2. Testing Difficulties

Unit testing becomes much more complex:


[Test]
public async Task SendAsync_WithValidInputs_SendsEmail()
{
    // Can't directly instantiate EmailService for testing
    // var emailService = new EmailService(mockConfig, mockLogger); // Won't compile
    
    // Must go through factory method
    var mockConfig = new Mock<IEmailConfiguration>();
    var mockLogger = new Mock<ILogger>();
    
    mockConfig.Setup(c => c.SmtpServer).Returns("smtp.test.com");
    mockConfig.Setup(c => c.SmtpPort).Returns(587);
    
    // Forced to use the factory method
    var emailService = EmailService.Create(mockConfig.Object, mockLogger.Object);
    
    // Test the actual functionality
    await emailService.SendAsync("test@example.com", "Test", "Body");
    
    // Verify logging
    mockLogger.Verify(
        l => l.LogInformation(It.Is<string>(s => s.Contains("Sending email to test@example.com"))),
        Times.Once
    );
}

[Test]
public async Task GetByIdAsync_WithValidId_ReturnsProduct()
{
    // Complex test setup due to builder pattern
    var mockConnection = new Mock<IDbConnection>();
    var mockCache = new Mock<ICacheService>();
    var mockLogger = new Mock<ILogger>();
    
    var repository = new ProductRepository.Builder()
        .WithConnection(mockConnection.Object)
        .WithCache(mockCache.Object)
        .WithLogger(mockLogger.Object)
        .Build();
    
    // Rest of test...
}
            

Refactoring to Remove Constrained Construction

The solution is to provide public constructors and move validation logic appropriately:


// Refactored EmailService with public constructor
public class EmailService
{
    private readonly IEmailConfiguration config;
    private readonly ILogger logger;
    
    // Public constructor for DI container and testing
    public EmailService(IEmailConfiguration config, ILogger logger)
    {
        this.config = ValidateConfig(config);
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }
    
    // Validation moved to private method
    private static IEmailConfiguration ValidateConfig(IEmailConfiguration config)
    {
        if (config == null)
            throw new ArgumentNullException(nameof(config));
            
        if (string.IsNullOrEmpty(config.SmtpServer))
            throw new ArgumentException("SMTP server must be configured", nameof(config));
            
        if (config.SmtpPort <= 0)
            throw new ArgumentException("SMTP port must be positive", nameof(config));
            
        return config;
    }
    
    public async Task SendAsync(string to, string subject, string body)
    {
        logger.LogInformation($"Sending email to {to}");
        
        using var client = new SmtpClient(config.SmtpServer, config.SmtpPort);
        await client.SendMailAsync("noreply@company.com", to, subject, body);
        
        logger.LogInformation($"Email sent successfully to {to}");
    }
}

// Refactored ProductRepository with public constructor
public class ProductRepository
{
    private readonly IDbConnection connection;
    private readonly ICacheService cache;
    private readonly ILogger logger;
    private readonly TimeSpan cacheTimeout;
    
    // Public constructor - simple and direct
    public ProductRepository(
        IDbConnection connection, 
        ICacheService cache, 
        ILogger logger,
        IOptions<CacheOptions> cacheOptions = null)
    {
        this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
        this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
        this.cacheTimeout = cacheOptions?.Value?.Timeout ?? TimeSpan.FromMinutes(5);
    }
    
    public async Task<Product> GetByIdAsync(int id)
    {
        var cacheKey = $"product:{id}";
        var cached = await cache.GetAsync<Product>(cacheKey);
        if (cached != null)
        {
            logger.LogDebug($"Product {id} found in cache");
            return cached;
        }
        
        const string sql = "SELECT * FROM Products WHERE Id = @id";
        var product = await connection.QuerySingleOrDefaultAsync<Product>(sql, new { id });
        
        if (product != null)
        {
            await cache.SetAsync(cacheKey, product, cacheTimeout);
            logger.LogDebug($"Product {id} cached for {cacheTimeout}");
        }
        
        return product;
    }
}

// Configuration class for cache options
public class CacheOptions
{
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
}
            

Simple DI Container Registration

With public constructors, DI registration becomes straightforward:


public void ConfigureServices(IServiceCollection services)
{
    // Simple registration - DI container handles construction
    services.AddScoped<EmailService>();
    services.AddScoped<ProductRepository>();
    
    // Configure cache options
    services.Configure<CacheOptions>(options =>
    {
        options.Timeout = TimeSpan.FromMinutes(10);
    });
    
    // Register dependencies
    services.AddScoped<IEmailConfiguration, EmailConfiguration>();
    services.AddScoped<IDbConnection, SqlConnection>();
    services.AddScoped<ICacheService, RedisCacheService>();
    services.AddLogging();
}
            

Easy Unit Testing

Testing becomes much simpler with public constructors:


[Test]
public async Task SendAsync_WithValidInputs_SendsEmail()
{
    // Arrange - Direct instantiation for testing
    var mockConfig = new Mock<IEmailConfiguration>();
    var mockLogger = new Mock<ILogger>();
    
    mockConfig.Setup(c => c.SmtpServer).Returns("smtp.test.com");
    mockConfig.Setup(c => c.SmtpPort).Returns(587);
    
    var emailService = new EmailService(mockConfig.Object, mockLogger.Object);
    
    // Act
    await emailService.SendAsync("test@example.com", "Test", "Body");
    
    // Assert
    mockLogger.Verify(
        l => l.LogInformation(It.Is<string>(s => s.Contains("Sending email to test@example.com"))),
        Times.Once
    );
}

[Test]
public async Task GetByIdAsync_WithValidId_ReturnsProduct()
{
    // Arrange - Simple and direct
    var mockConnection = new Mock<IDbConnection>();
    var mockCache = new Mock<ICacheService>();
    var mockLogger = new Mock<ILogger>();
    
    var repository = new ProductRepository(
        mockConnection.Object, 
        mockCache.Object, 
        mockLogger.Object
    );
    
    // Rest of test...
}
            

When Complex Construction is Actually Needed

If you truly need complex object creation, consider these alternatives:

  • Abstract Factory Pattern - Create a separate factory interface
  • Builder Pattern with Public Constructor - Keep constructor public, use builder as convenience
  • Configuration Objects - Use strongly-typed configuration classes
  • Factory Services - Register factory services with the DI container

// Example: Factory service pattern
public interface IEmailServiceFactory
{
    EmailService CreateEmailService();
}

public class EmailServiceFactory : IEmailServiceFactory
{
    private readonly IEmailConfiguration config;
    private readonly ILogger<EmailService> logger;
    
    public EmailServiceFactory(IEmailConfiguration config, ILogger<EmailService> logger)
    {
        this.config = config;
        this.logger = logger;
    }
    
    public EmailService CreateEmailService()
    {
        // Complex creation logic here
        return new EmailService(config, logger);
    }
}
            

Summary

The Constrained Construction anti-pattern makes dependency injection unnecessarily difficult by forcing specific creation patterns through private constructors, factory methods, or complex builders. While the intention might be to ensure proper object initialization, it creates significant obstacles for DI containers and makes unit testing much more complex.

The solution is to provide public constructors that accept all required dependencies, moving validation logic into private methods or constructor bodies. This approach enables seamless DI container integration, simplifies unit testing, and maintains flexibility. If complex object creation is truly needed, consider using factory patterns or configuration objects while keeping the constructor accessible for dependency injection.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Dependency Injection
  • Constrained Construction
  • Antipattern
  • DI
  • Factory Method
  • Object Creation