
Constrained Construction Antipattern in Dependency Injection
Author - Abdul Rahman (Bhai)
Dependency Injection
7 Articles
Table of Contents
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.