Loading learning content...
Understanding constructor injection mechanics is the foundation; applying it well in production systems is the art. The difference between amateur and professional usage lies in attention to nuance, recognition of edge cases, and consistent application of proven patterns.
This page synthesizes the best practices that distinguish clean, maintainable constructor injection from naive implementations. These practices emerge from decades of collective industry experience—codified principles that prevent common failures and enable scalable, testable architectures.
By the end of this page, you will know how to structure constructor parameters effectively, recognize and avoid common pitfalls, handle special cases appropriately, and apply constructor injection in ways that promote long-term maintainability. These are the practices that separate professional implementations from problematic ones.
When a class requires multiple dependencies, how those parameters are organized significantly affects readability and maintainability.
Recommended parameter ordering:
class PaymentProcessor {
constructor(
// 1. REQUIRED CORE DEPENDENCIES (most important first)
private readonly paymentGateway: PaymentGateway,
private readonly transactionRepository: TransactionRepository,
private readonly fraudDetector: FraudDetector,
// 2. REQUIRED CROSS-CUTTING CONCERNS
private readonly logger: Logger,
private readonly metrics: MetricsCollector,
// 3. REQUIRED CONFIGURATION
private readonly maxRetries: number,
private readonly timeoutMs: number,
// 4. OPTIONAL DEPENDENCIES (with defaults)
private readonly rateLimiter: RateLimiter = new NoOpRateLimiter(),
private readonly circuitBreaker: CircuitBreaker = new AlwaysClosedCircuitBreaker()
) {}
}
Why this ordering works:
Naming conventions:
// GOOD: Descriptive names matching the abstraction
constructor(
private readonly orderRepository: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly emailSender: EmailSender
) {}
// AVOID: Generic or abbreviated names
constructor(
private readonly repo: OrderRepository, // Too vague
private readonly pg: PaymentGateway, // Cryptic abbreviation
private readonly svc: EmailSender // Meaningless abbreviation
) {}
// AVOID: Implementation details in names
constructor(
private readonly postgresRepository: OrderRepository, // Leaks implementation
private readonly stripePayments: PaymentGateway // Concrete name for abstraction
) {}
Parameter names should match the interface type in style, clearly conveying purpose without revealing implementation.
Constructor injection makes dependency counts visible—which is intentional. A class with too many dependencies is doing too much, and the overwhelming constructor signature is the symptom that reveals the disease.
Guidelines for dependency counts:
| Count | Assessment | Recommended Action |
|---|---|---|
| 1-3 | Ideal | Well-focused class, proceed confidently |
| 4-5 | Acceptable | Review for Single Responsibility; likely fine |
| 6-7 | Warning zone | Seriously evaluate if class should be split |
| 8+ | Code smell | Almost certainly violates SRP; refactor urgently |
When you hit the limit—refactoring options:
Option 1: Split the class by responsibility
// BEFORE: One class doing too much
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly productRepo: ProductRepository,
private readonly inventoryService: InventoryService,
private readonly paymentGateway: PaymentGateway,
private readonly fraudChecker: FraudChecker,
private readonly taxCalculator: TaxCalculator,
private readonly emailSender: EmailSender,
private readonly smsNotifier: SmsNotifier,
private readonly logger: Logger
) {}
}
// AFTER: Split into focused services
class OrderProcessingService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly inventoryService: InventoryService,
private readonly paymentService: PaymentService, // Facade for payment concerns
private readonly logger: Logger
) {}
}
class PaymentService {
constructor(
private readonly paymentGateway: PaymentGateway,
private readonly fraudChecker: FraudChecker,
private readonly taxCalculator: TaxCalculator
) {}
}
class OrderNotificationService {
constructor(
private readonly emailSender: EmailSender,
private readonly smsNotifier: SmsNotifier
) {}
}
Option 2: Introduce parameter objects for related dependencies
// Group related dependencies
interface PaymentServices {
gateway: PaymentGateway;
fraudChecker: FraudChecker;
taxCalculator: TaxCalculator;
}
interface NotificationServices {
emailSender: EmailSender;
smsNotifier: SmsNotifier;
pushNotifier: PushNotifier;
}
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly paymentServices: PaymentServices,
private readonly notificationServices: NotificationServices,
private readonly logger: Logger
) {}
}
Caution: Parameter objects don't reduce actual complexity—they just hide it. Use them when dependencies are genuinely related, not just to shrink the signature.
Parameter objects and 'context' parameters can make constructors look simpler while leaving underlying complexity untouched. Before grouping dependencies, honestly assess whether the class should be split. Constructor injection intentionally exposes complexity—respect that signal.
The power of constructor injection comes from combining it with abstraction. Dependencies should be typed as interfaces or abstract classes, not concrete implementations.
Why abstractions matter:
// POOR: Concrete dependency
class UserService {
constructor(
private readonly repository: PostgresUserRepository // Concrete!
) {}
}
// BETTER: Abstract dependency
class UserService {
constructor(
private readonly repository: UserRepository // Interface!
) {}
}
// Now these are all valid:
const prodService = new UserService(new PostgresUserRepository());
const devService = new UserService(new SqliteUserRepository());
const testService = new UserService(new InMemoryUserRepository());
const mockService = new UserService(new MockUserRepository());
When concrete is acceptable:
Some dependencies are so stable and unlikely to change that abstraction adds unnecessary ceremony:
EmailAddress, Money, DateRangeLogger (though interface-based logging is common)// These concrete dependencies are acceptable:
class InvoiceService {
constructor(
// Abstract: core business dependencies
private readonly invoiceRepository: InvoiceRepository,
private readonly taxCalculator: TaxCalculator,
// Concrete: stable value types and configuration
private readonly companyAddress: Address,
private readonly defaultCurrency: Currency,
private readonly invoiceNumberFormat: string
) {}
}
Rule of thumb: Abstract dependencies that have behavior you might want to change or substitute. Accept concrete dependencies for value types and stable data structures.
Constructors should assign dependencies, not use them. Business logic, I/O operations, and complex computations don't belong in constructors.
What constructors should do:
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService,
private readonly logger: Logger
) {
// ✓ Simple assignments
// ✓ Guard clauses (null checks) if needed
// ✓ Basic object initialization
}
}
What constructors should NOT do:
class BadOrderService {
private cachedProducts: Product[];
constructor(
private readonly repository: OrderRepository,
private readonly productCatalog: ProductCatalog,
private readonly logger: Logger
) {
// ✗ I/O operations
this.logger.info('OrderService starting up...');
// ✗ Async operations (can't properly await)
this.cachedProducts = await this.productCatalog.getAllProducts();
// ✗ Complex business logic
if (this.repository.getConnectionState() === 'disconnected') {
this.repository.reconnect();
}
// ✗ Side effects
Analytics.trackServiceCreation('OrderService');
}
}
Handling initialization that requires work:
If initialization genuinely requires I/O or computation, use a factory or explicit initialization method:
// Factory approach: work done before construction
class OrderServiceFactory {
constructor(
private readonly repoFactory: OrderRepositoryFactory,
private readonly emailService: EmailService
) {}
async create(): Promise<OrderService> {
// Async initialization happens in factory
const repository = await this.repoFactory.createConnectedRepository();
// Constructor remains simple
return new OrderService(repository, this.emailService);
}
}
// Alternative: static factory method
class OrderService {
private constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService
) {}
static async create(
repoConfig: RepositoryConfig,
emailService: EmailService
): Promise<OrderService> {
const repository = await OrderRepository.connect(repoConfig);
return new OrderService(repository, emailService);
}
}
Think of constructors as assignment ceremonies—they take ready-made ingredients and assemble them. Factories are the kitchens where ingredients are prepared. Keep these roles separate for cleaner, more testable code.
If classes don't create their dependencies, something must. The composition root is where the object graph is assembled—typically near the application entry point.
Manual composition:
// src/main.ts - The composition root
async function bootstrap(): Promise<Application> {
// 1. Create infrastructure (low-level)
const config = loadConfiguration();
const dbConnection = await createDatabaseConnection(config.database);
const redisClient = await createRedisClient(config.redis);
// 2. Create repositories (data access)
const userRepository = new PostgresUserRepository(dbConnection);
const orderRepository = new PostgresOrderRepository(dbConnection);
const productRepository = new PostgresProductRepository(dbConnection);
// 3. Create infrastructure services
const emailService = new SendGridEmailService(config.sendgrid);
const cacheService = new RedisCacheService(redisClient);
const logger = new WinstonLogger(config.logging);
// 4. Create business services (compose lower layers)
const userService = new UserService(userRepository, emailService, logger);
const productService = new ProductService(productRepository, cacheService, logger);
const orderService = new OrderService(
orderRepository,
productService,
userService,
emailService,
logger
);
// 5. Create controllers/handlers (top layer)
const orderController = new OrderController(orderService, logger);
const userController = new UserController(userService, logger);
// 6. Return composed application
return new Application([orderController, userController]);
}
IoC container composition:
// NestJS example
@Module({
imports: [DatabaseModule, CacheModule],
providers: [
UserService,
OrderService,
ProductService,
{
provide: 'EMAIL_SERVICE',
useClass: SendGridEmailService,
},
],
controllers: [UserController, OrderController],
})
export class AppModule {}
// .NET Core example
public void ConfigureServices(IServiceCollection services)
{
// Infrastructure
services.AddDbContext<AppDbContext>();
services.AddStackExchangeRedisCache(options => { ... });
// Repositories
services.AddScoped<IUserRepository, PostgresUserRepository>();
services.AddScoped<IOrderRepository, PostgresOrderRepository>();
// Services
services.AddScoped<IUserService, UserService>();
services.AddScoped<IOrderService, OrderService>();
services.AddSingleton<IEmailService, SendGridEmailService>();
}
| Pattern | Pros | Cons | Best For |
|---|---|---|---|
| Manual (Pure DI) | Full control, no magic, debugging clear | Verbose, ordering requires care | Small apps, embedded systems |
| IoC Container | Less boilerplate, auto-resolution | Hidden logic, debugging harder | Large apps, web frameworks |
| Module-based | Organized by feature, scalable | Module coupling possible | Medium-large modular apps |
| Factory-based | Fine control, testable factories | More classes, more code | Complex initialization needs |
Most applications have one composition root in main.ts or bootstrap.ts. Multi-entry applications (CLI with subcommands, multi-tenant apps) might have multiple composition roots. But always centralize composition—don't scatter 'new' statements throughout business logic.
Not all dependencies are mandatory. Some enhance functionality without being essential. Handle these carefully to maintain clean interfaces.
Pattern 1: Default implementations (Null Object)
// Provide no-op defaults for optional functionality
class AnalyticsCollector implements Analytics {
// No-op implementation—does nothing
track(event: string, data: Record<string, unknown>): void {}
identify(userId: string): void {}
}
class ProductService {
constructor(
private readonly repository: ProductRepository,
private readonly cache: ProductCache,
// Optional with default no-op implementation
private readonly analytics: Analytics = new NoOpAnalytics()
) {}
async getProduct(id: string): Promise<Product> {
// No conditional logic needed—analytics.track always works
this.analytics.track('product_viewed', { id });
const cached = await this.cache.get(id);
if (cached) return cached;
return this.repository.findById(id);
}
}
Pattern 2: Explicit nullable (when no-op isn't appropriate)
class OrderService {
constructor(
private readonly repository: OrderRepository,
// Explicitly nullable—caller chose not to provide
private readonly promotionEngine: PromotionEngine | null = null
) {}
async calculateTotal(order: Order): Promise<Money> {
const subtotal = order.items.reduce(
(sum, item) => sum.add(item.price),
Money.zero()
);
// Conditional logic when no-op doesn't make sense
if (this.promotionEngine) {
const discount = await this.promotionEngine.calculateDiscount(order);
return subtotal.subtract(discount);
}
return subtotal;
}
}
Pattern 3: Builder pattern for complex optional configuration
class HttpClientBuilder {
private timeout = 30000;
private retries = 3;
private logger: Logger = new NoOpLogger();
private metrics: MetricsCollector = new NoOpMetrics();
private interceptors: HttpInterceptor[] = [];
withTimeout(ms: number): this {
this.timeout = ms;
return this;
}
withRetries(count: number): this {
this.retries = count;
return this;
}
withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
withMetrics(metrics: MetricsCollector): this {
this.metrics = metrics;
return this;
}
addInterceptor(interceptor: HttpInterceptor): this {
this.interceptors.push(interceptor);
return this;
}
build(): HttpClient {
return new HttpClient(
this.timeout,
this.retries,
this.logger,
this.metrics,
this.interceptors
);
}
}
// Usage: clean, flexible construction
const client = new HttpClientBuilder()
.withTimeout(5000)
.withLogger(productionLogger)
.addInterceptor(authInterceptor)
.build();
The Null Object pattern keeps consuming code cleaner—no conditionals needed. Use nullable only when the distinction between 'not provided' and 'do nothing' matters semantically. In most cases, a no-op implementation is cleaner.
Constructor injection and testability go hand in hand. Apply these practices to maximize the testing benefits.
Creating effective test doubles:
// Define interfaces that are easily mockable
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
// Create purpose-built test doubles
class FakeUserRepository implements UserRepository {
private users = new Map<string, User>();
// Pre-load test data
withUser(user: User): this {
this.users.set(user.id, user);
return this;
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
// Test inspection methods
getSavedUsers(): User[] {
return Array.from(this.users.values());
}
clear(): void {
this.users.clear();
}
}
Test setup patterns:
describe('UserService', () => {
// Shared test doubles
let userRepository: FakeUserRepository;
let emailService: MockEmailService;
let logger: SilentLogger;
let userService: UserService;
beforeEach(() => {
// Fresh instances for isolation
userRepository = new FakeUserRepository();
emailService = new MockEmailService();
logger = new SilentLogger();
// Construct with test doubles
userService = new UserService(
userRepository,
emailService,
logger
);
});
describe('registerUser', () => {
it('should save user and send welcome email', async () => {
// Arrange
const userData = { name: 'Alice', email: 'alice@example.com' };
// Act
const user = await userService.registerUser(userData);
// Assert - verify interactions
expect(userRepository.getSavedUsers()).toContainEqual(
expect.objectContaining({ email: 'alice@example.com' })
);
expect(emailService.getSentEmails()).toContainEqual(
expect.objectContaining({
to: 'alice@example.com',
template: 'welcome'
})
);
});
it('should not send email if save fails', async () => {
// Arrange - configure failure
userRepository.failOnSave(new DatabaseError('Connection lost'));
// Act & Assert
await expect(
userService.registerUser({ name: 'Bob', email: 'bob@example.com' })
).rejects.toThrow('Connection lost');
expect(emailService.getSentEmails()).toHaveLength(0);
});
});
});
Even with good intentions, developers make recurring mistakes with constructor injection. Recognizing these patterns helps avoid them.
| Mistake | Why It's Problematic | Remedy |
|---|---|---|
| Using 'new' inside classes | Creates hidden, untestable coupling | Inject dependencies; use factories for complex creation |
| Circular dependencies | A depends on B depends on A—deadlock | Introduce intermediary, use lazy loading, or redesign |
| God classes (too many deps) | Violates SRP, hard to understand | Split into focused classes with fewer responsibilities |
| Service locator in constructor | Hides true dependencies | Inject directly; list what you need |
| Constructor with business logic | Untestable initialization | Keep constructor simple; use factories for complex setup |
| Concrete instead of abstract deps | Prevents substitution | Program to interfaces; inject abstractions |
| Mutable dependency fields | Thread-unsafe, unpredictable | Use readonly/final; never reassign |
| Optional deps first in params | Breaks parameter defaulting | Required first, optional last with defaults |
Handling circular dependencies:
Circular dependencies are design problems, not DI problems. When A depends on B and B depends on A:
// PROBLEM: Circular
class OrderService {
constructor(private readonly paymentService: PaymentService) {}
}
class PaymentService {
constructor(private readonly orderService: OrderService) {} // Circular!
}
// SOLUTION 1: Extract shared interface
interface OrderPriceProvider {
getPrice(orderId: string): Money;
}
class OrderService implements OrderPriceProvider {
constructor(private readonly paymentService: PaymentService) {}
getPrice(orderId: string): Money { ... }
}
class PaymentService {
// Depends on narrow interface, not full OrderService
constructor(private readonly priceProvider: OrderPriceProvider) {}
}
// SOLUTION 2: Introduce mediator
class OrderPaymentMediator {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway
) {}
async processOrderPayment(orderId: string): Promise<void> {
const order = await this.orders.find(orderId);
await this.payments.charge(order.total);
// Coordinates both without circular dependency
}
}
If you're fighting circular dependencies, step back and question your design. Well-designed systems have clear dependency hierarchies. Cycles indicate tangled responsibilities that should be untangled, not hacked around.
Constructor injection is the preferred dependency injection technique for good reason—it provides explicit, compile-time-checked, immutable dependencies. But applying it well requires attention to detail and adherence to proven practices.
Module completion:
You've now completed the Constructor Injection module. You understand the mechanics of passing dependencies through constructors, how mandatory dependencies eliminate null-check proliferation, why immutability creates thread-safe and predictable objects, and the best practices that distinguish professional implementations.
You now have comprehensive knowledge of constructor injection—from fundamental mechanics to professional best practices. This is the most important DI technique and forms the foundation for everything else in dependency injection. The next module explores setter injection—when it's appropriate and how it complements constructor injection.