Loading content...
You now understand the mechanics of Constructor, Setter, and Interface Injection. But knowing how each pattern works is different from knowing when to apply each one. This page bridges that gap—transforming theoretical knowledge into practical engineering judgment.
In real projects, you'll face nuanced situations: circular dependencies that seem to require setter injection, optional features that could go either way, framework constraints that limit your options, and legacy code with embedded assumptions. The ability to navigate these situations with confidence separates junior developers from seasoned engineers.
By the end of this page, you will have a systematic decision framework for choosing injection styles, understand how to evaluate trade-offs in real scenarios, recognize anti-patterns that masquerade as valid choices, and develop the judgment to make confident DI decisions in production systems.
Before any decision framework, internalize this principle:
Constructor Injection is the default choice. Use it unless you have a specific, compelling reason not to.
This isn't arbitrary preference—it's based on the fundamental properties Constructor Injection provides:
readonly/finalThese properties align with good object-oriented design principles. When you're unsure which injection method to use, ask: "Why can't I use Constructor Injection here?" If you can't answer that question, use Constructor Injection.
Think of Constructor Injection as the "secure" default. Like defaulting to immutable data structures or private visibility, it's the safe starting point. You deviate only when requirements force you to, and you document why.
123456789101112131415161718192021222324252627
// THE DEFAULT: Constructor Injection for all dependenciesclass OrderProcessingService { constructor( private readonly orderRepository: OrderRepository, private readonly paymentGateway: PaymentGateway, private readonly inventoryService: InventoryService, private readonly notificationService: NotificationService, private readonly auditLogger: AuditLogger ) { // All dependencies required, immutable, visible } // Object is ready to use immediately async processOrder(order: Order): Promise<ProcessingResult> { // No null checks, no "is initialized" checks // Dependencies are guaranteed present }} // Tests are straightforwardconst service = new OrderProcessingService( mockRepository, mockPayment, mockInventory, mockNotification, mockAudit);When deciding on injection style, work through these questions in order:
Let's apply the decision framework to realistic scenarios:
InvoiceService needs InvoiceRepository, TaxCalculator, PdfGenerator123456789
// Scenario A: Core business serviceclass InvoiceService { constructor( private readonly repository: InvoiceRepository, private readonly taxCalculator: TaxCalculator, private readonly pdfGenerator: PdfGenerator ) { } // ✅ All required, all in constructor}ProductService always needs ProductRepository, optionally uses ProductCacheNullCache123456789101112131415161718192021222324
// Scenario B: Optional enhancement // Option 1 (Preferred): Null Object patternclass ProductService { constructor( private readonly repository: ProductRepository, private readonly cache: ProductCache = new NullProductCache() ) { } // ✅ Cache defaults to no-op, code stays clean} // Option 2: Setter for optionalclass ProductService { private cache?: ProductCache; constructor( private readonly repository: ProductRepository ) { } setCache(cache: ProductCache): void { this.cache = cache; } // ⚠️ Must check cache before use}OrderService needs CustomerService, CustomerService needs OrderService12345678910111213141516171819202122232425262728
// Scenario C: Circular dependency (temporary solution) class OrderService { private customerService?: CustomerService; constructor( private readonly repository: OrderRepository ) { } // Setter breaks the cycle setCustomerService(service: CustomerService): void { this.customerService = service; }} class CustomerService { constructor( private readonly repository: CustomerRepository, private readonly orderService: OrderService // Constructor here ) { }} // Wiringconst orderService = new OrderService(orderRepo);const customerService = new CustomerService(customerRepo, orderService);orderService.setCustomerService(customerService); // ⚠️ TODO: Refactor to eliminate circular dependencyApplicationContext, Environment*Aware interfaces1234567891011121314151617181920212223242526
// Scenario D: Framework-provided services@Servicepublic class DynamicBeanLoader implements ApplicationContextAware, EnvironmentAware { private ApplicationContext context; private Environment environment; // Constructor for business dependencies public DynamicBeanLoader(ConfigRepository configRepo) { this.configRepo = configRepo; } // Interface injection for framework services @Override public void setApplicationContext(ApplicationContext context) { this.context = context; } @Override public void setEnvironment(Environment environment) { this.environment = environment; } // ✅ Hybrid: Constructor for business, Interface for framework}PaymentProcessor can switch between payment gateways at runtime12345678910111213141516171819202122
// Scenario E: Reconfigurable strategyclass PaymentProcessor { private gateway: PaymentGateway; constructor( private readonly repository: PaymentRepository, initialGateway: PaymentGateway // Initial via constructor ) { this.gateway = initialGateway; } // Setter for runtime reconfiguration setGateway(gateway: PaymentGateway): void { if (!gateway) { throw new Error("PaymentGateway cannot be null"); } this.gateway = gateway; this.logGatewayChange(gateway); } // ⚠️ Consider thread-safety in concurrent environments}Some injection choices are disguised anti-patterns. Recognize and avoid these:
123456789101112131415161718192021222324252627
// ❌ ANTI-PATTERN: Field injection (common in Spring without constructor)@Servicepublic class OrderService { @Autowired private OrderRepository repository; // No constructor! @Autowired private PaymentGateway gateway; // Hidden dependency // How do you test this without Spring? // You need reflection to inject mocks.} // ✅ CORRECT: Constructor injection@Servicepublic class OrderService { private final OrderRepository repository; private final PaymentGateway gateway; @Autowired // Or omit - Spring autowires single constructor public OrderService(OrderRepository repository, PaymentGateway gateway) { this.repository = repository; this.gateway = gateway; } // Testing: just call constructor with mocks}123456789101112131415161718192021222324252627
// ❌ ANTI-PATTERN: Service Locator (NOT dependency injection)class OrderService { constructor(private container: Container) { } async processOrder(order: Order): Promise<void> { // Pulls dependencies from container - hides requirements! const repo = this.container.get<OrderRepository>("OrderRepository"); const gateway = this.container.get<PaymentGateway>("PaymentGateway"); await gateway.charge(order); await repo.save(order); }} // ✅ CORRECT: Inject actual dependenciesclass OrderService { constructor( private readonly repository: OrderRepository, private readonly gateway: PaymentGateway ) { } async processOrder(order: Order): Promise<void> { await this.gateway.charge(order); await this.repository.save(order); } // Dependencies are VISIBLE, not hidden in container queries}When you find yourself reaching for setter injection or service locator, pause and ask: "Am I solving a real problem, or escaping from a design issue?" Constructor explosion, circular dependencies, and 'flexibility needs' are often symptoms of design problems, not reasons to abandon Constructor Injection.
Real-world classes often use multiple injection styles appropriately. The key is using each style for its intended purpose:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// HYBRID INJECTION STRATEGY interface LoggerAware { setLogger(logger: Logger): void;} class EnterpriseOrderService implements LoggerAware { // 1. CONSTRUCTOR: Required business dependencies private readonly repository: OrderRepository; private readonly paymentGateway: PaymentGateway; private readonly inventoryService: InventoryService; // 2. SETTER: Optional enhancements private cache?: OrderCache; private metrics?: MetricsCollector; // 3. INTERFACE: Framework/cross-cutting services private logger!: Logger; constructor( repository: OrderRepository, paymentGateway: PaymentGateway, inventoryService: InventoryService ) { // Validate required dependencies if (!repository) throw new Error("OrderRepository required"); if (!paymentGateway) throw new Error("PaymentGateway required"); if (!inventoryService) throw new Error("InventoryService required"); this.repository = repository; this.paymentGateway = paymentGateway; this.inventoryService = inventoryService; } // Optional setter injection setCache(cache: OrderCache): void { this.cache = cache; } setMetrics(metrics: MetricsCollector): void { this.metrics = metrics; } // Interface injection (LoggerAware) setLogger(logger: Logger): void { this.logger = logger; } async processOrder(order: Order): Promise<ProcessingResult> { const startTime = Date.now(); // Use logger (must be injected) this.logger.info(`Processing order ${order.id}`); // Business logic uses constructor-injected deps await this.inventoryService.reserve(order.items); await this.paymentGateway.charge(order); await this.repository.save(order); // Use optional cache if available if (this.cache) { await this.cache.invalidate(order.customerId); } // Use optional metrics if available this.metrics?.recordTiming("order.process", Date.now() - startTime); return ProcessingResult.success(order); }}| Dependency Type | Injection Style | Example |
|---|---|---|
| Core business dependencies | Constructor | Repository, PaymentGateway |
| Optional enhancements | Setter | Cache, Metrics, Audit |
| Framework services | Interface (*Aware) | Logger, Config, Context |
| Configuration objects | Constructor | ServiceConfig, Options |
| Swappable strategies | Constructor (or Setter if truly dynamic) | PricingStrategy |
Different frameworks have different conventions. Here are best practices for major frameworks:
Use this checklist when deciding on injection style for a dependency:
When none of the checklists clearly apply, default to Constructor Injection. You can always refactor to setter or interface injection later if requirements emerge. Going the other direction (setter to constructor) is harder because it changes the construction contract.
You now have the knowledge and framework to make confident injection style decisions. Let's consolidate:
Module Complete:
You've now completed the Dependency Injection Fundamentals module. You understand:
The next module explores Inversion of Control Containers—frameworks that automate dependency resolution and lifecycle management, taking DI from manual wiring to industrial-scale configuration.
You now have the theoretical knowledge and practical judgment to make informed DI decisions. Remember: good engineering isn't about blindly following rules—it's about understanding trade-offs and making conscious choices. When you choose an injection style, you should be able to explain why it's the right choice for that situation.