Loading content...
Not all dependencies are created equal. Some collaborators are essential—without them, the class simply cannot perform its core function. A UserRepository needs a database connection to fetch users. An EmailSender needs SMTP credentials to send mail. These are mandatory dependencies, and they belong in the constructor.
But other dependencies enhance functionality without being essential. A logging framework improves observability but isn't required for business logic execution. A caching layer improves performance but the system works without it. A metrics collector enables monitoring but operations proceed without metrics.
These are optional dependencies, and they represent the sweet spot for setter injection. This page explores the Optional Dependencies Pattern—how to design classes that work correctly without their optional collaborators while gracefully enhancing functionality when those collaborators are provided.
By the end of this page, you will be able to distinguish truly optional dependencies from mandatory ones, design classes that degrade gracefully when optional dependencies are absent, implement the Optional Dependencies Pattern correctly, and understand the patterns that make optional dependency handling clean and maintainable.
The first challenge is correctly identifying which dependencies are optional. This seems straightforward, but developers frequently misclassify dependencies—either treating mandatory dependencies as optional (leading to runtime failures) or treating optional dependencies as mandatory (forcing unnecessary configuration).
The Litmus Test: Can the Class Fulfill Its Contract Without This Dependency?
Ask yourself: If this dependency is null, can the class still perform its core responsibility? Not at full capability—just its essential function.
| Class | Dependency | Mandatory? | Reasoning |
|---|---|---|---|
| UserRepository | Database Connection | Mandatory | Cannot fetch users without database access |
| UserRepository | Query Logger | Optional | Can fetch users without logging queries |
| PaymentProcessor | Payment Gateway | Mandatory | Cannot process payments without a gateway |
| PaymentProcessor | Fraud Detection Service | Often Optional | Can process payments, but with higher risk |
| EmailService | SMTP Connection | Mandatory | Cannot send emails without SMTP |
| EmailService | Template Engine | It Depends | Depends on whether raw text mode is acceptable |
| WebController | Request Handler | Mandatory | Cannot handle requests without a handler |
| WebController | Rate Limiter | Optional | Can handle requests without rate limiting |
| CacheManager | Primary Cache | Mandatory | A cache manager needs something to manage |
| CacheManager | Fallback Cache | Optional | Primary cache is sufficient; fallback enhances resilience |
Some dependencies seem optional but actually aren't. A TemplateEngine might seem optional for an EmailService, but if your application only sends templated emails, then it's effectively mandatory. Always consider your actual use cases, not theoretical flexibility.
Common Categories of Optional Dependencies
Through experience, several categories of dependencies consistently qualify as optional:
Let's implement a complete example that demonstrates the Optional Dependencies Pattern. We'll build an OrderService that has both mandatory dependencies (injected via constructor) and optional dependencies (injected via setters).
This realistic example shows how the patterns interact in production code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
// =====================================// Interfaces for Dependencies// ===================================== interface IOrderRepository { save(order: Order): Promise<Order>; findById(id: string): Promise<Order | null>;} interface IPaymentGateway { charge(amount: number, paymentMethod: PaymentMethod): Promise<PaymentResult>;} interface ILogger { debug(message: string, context?: Record<string, any>): void; info(message: string, context?: Record<string, any>): void; error(message: string, context?: Record<string, any>): void;} interface IMetricsCollector { increment(metric: string, tags?: Record<string, string>): void; timing(metric: string, durationMs: number, tags?: Record<string, string>): void;} interface IEventPublisher { publish(event: DomainEvent): Promise<void>;} interface IInventoryCache { getStock(productId: string): number | null; setStock(productId: string, quantity: number): void;} // =====================================// The Order Service// ===================================== class OrderService { // MANDATORY dependencies - injected via constructor private readonly orderRepository: IOrderRepository; private readonly paymentGateway: IPaymentGateway; // OPTIONAL dependencies - injected via setters private logger: ILogger | null = null; private metrics: IMetricsCollector | null = null; private eventPublisher: IEventPublisher | null = null; private inventoryCache: IInventoryCache | null = null; // Constructor requires mandatory dependencies constructor( orderRepository: IOrderRepository, paymentGateway: IPaymentGateway ) { // Validate mandatory dependencies if (!orderRepository) { throw new Error("OrderRepository is required"); } if (!paymentGateway) { throw new Error("PaymentGateway is required"); } this.orderRepository = orderRepository; this.paymentGateway = paymentGateway; } // Setters for optional dependencies setLogger(logger: ILogger): this { this.logger = logger; return this; } setMetrics(metrics: IMetricsCollector): this { this.metrics = metrics; return this; } setEventPublisher(eventPublisher: IEventPublisher): this { this.eventPublisher = eventPublisher; return this; } setInventoryCache(cache: IInventoryCache): this { this.inventoryCache = cache; return this; } // Core business method demonstrating graceful degradation async placeOrder(request: PlaceOrderRequest): Promise<OrderResult> { const startTime = Date.now(); const orderId = this.generateOrderId(); // Logging: Optional - skip gracefully if not configured this.logger?.info("Placing order", { orderId, customerId: request.customerId }); try { // Step 1: Validate and calculate (uses optional cache for stock check) const orderItems = await this.validateOrderItems(request.items); const total = this.calculateTotal(orderItems); // Step 2: Process payment (uses mandatory payment gateway) this.logger?.debug("Processing payment", { orderId, amount: total }); const paymentResult = await this.paymentGateway.charge( total, request.paymentMethod ); if (!paymentResult.success) { // Metrics: Optional - record failure if configured this.metrics?.increment("orders.payment_failed", { reason: paymentResult.errorCode }); return { success: false, error: `Payment failed: ${paymentResult.errorMessage}` }; } // Step 3: Create and save order (uses mandatory repository) const order = new Order( orderId, request.customerId, orderItems, total, paymentResult.transactionId ); await this.orderRepository.save(order); this.logger?.info("Order saved", { orderId }); // Step 4: Publish event (optional - non-critical operation) if (this.eventPublisher) { try { await this.eventPublisher.publish( new OrderPlacedEvent(order) ); this.logger?.debug("Order event published", { orderId }); } catch (publishError) { // Event publishing failure is non-fatal this.logger?.error("Failed to publish order event", { orderId, error: String(publishError) }); } } // Step 5: Record metrics (optional) const duration = Date.now() - startTime; this.metrics?.timing("orders.place_duration", duration); this.metrics?.increment("orders.placed", { status: "success" }); return { success: true, orderId: order.id, totalCharged: total }; } catch (error) { this.logger?.error("Order placement failed", { orderId, error: String(error) }); this.metrics?.increment("orders.placed", { status: "error" }); throw error; } } // Helper that uses optional inventory cache private async validateOrderItems( items: OrderItemRequest[] ): Promise<ValidatedOrderItem[]> { const validated: ValidatedOrderItem[] = []; for (const item of items) { // Try cache first if available let stockLevel = this.inventoryCache?.getStock(item.productId); if (stockLevel === null || stockLevel === undefined) { // Cache miss or no cache - fall back to direct check this.logger?.debug("Cache miss for inventory", { productId: item.productId }); stockLevel = await this.fetchStockFromDatabase(item.productId); // Update cache if available this.inventoryCache?.setStock(item.productId, stockLevel); } if (stockLevel < item.quantity) { throw new InsufficientStockError(item.productId, item.quantity, stockLevel); } validated.push({ productId: item.productId, quantity: item.quantity, unitPrice: await this.getProductPrice(item.productId) }); } return validated; } private calculateTotal(items: ValidatedOrderItem[]): number { return items.reduce( (sum, item) => sum + (item.unitPrice * item.quantity), 0 ); } private generateOrderId(): string { return `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } private async fetchStockFromDatabase(productId: string): Promise<number> { // Simulated database call return 100; } private async getProductPrice(productId: string): Promise<number> { // Simulated price lookup return 29.99; }}Key Observations from the Implementation:
Constructor for mandatory, setters for optional — The constructor signature clearly documents what's required. Setters indicate what's configurable.
Fluent setters — Returning this enables chained configuration for better readability.
Graceful degradation everywhere — Optional usage is wrapped in null-safe access (?.) or explicit checks.
No silent failures for critical paths — While optional dependencies are skipped gracefully, errors in core operations (payment, persistence) are propagated.
Logging errors in optional operations — When event publishing fails, we log the error but don't fail the order placement.
When an optional dependency is absent, the class must degrade gracefully—continuing to function while simply lacking the enhanced capability. Let's explore the strategies for achieving this gracefully:
Strategy 1: Silent Skip
The simplest approach: if the dependency isn't there, don't perform the operation. Best for truly fire-and-forget operations like logging or metrics.
1234567891011121314151617181920
class DocumentProcessor { private auditLogger: IAuditLogger | null = null; setAuditLogger(logger: IAuditLogger): void { this.auditLogger = logger; } processDocument(doc: Document): ProcessResult { // Silent skip - uses optional chaining this.auditLogger?.logAccess(doc.id, "process"); // Core processing - always happens const result = this.doProcessing(doc); // Silent skip - no indication that auditing didn't happen this.auditLogger?.logCompletion(doc.id, "process", result.status); return result; }}Strategy 2: Default/Fallback Behavior
When the optional dependency would provide a value or make a decision, use a sensible default when it's absent:
12345678910111213141516171819202122232425262728293031323334353637
interface IPricingStrategy { calculatePrice(basePrice: number, customer: Customer): number;} class ProductService { private pricingStrategy: IPricingStrategy | null = null; setPricingStrategy(strategy: IPricingStrategy): void { this.pricingStrategy = strategy; } getPrice(productId: string, customer: Customer): number { const basePrice = this.getBasePrice(productId); // Use strategy if available, otherwise use default (no discount) if (this.pricingStrategy) { return this.pricingStrategy.calculatePrice(basePrice, customer); } // Default behavior: return base price unchanged return basePrice; } getBasePrice(productId: string): number { // Fetch from database return 99.99; }} // Without pricing strategy: all customers pay base priceconst basicService = new ProductService();console.log(basicService.getPrice("PROD-1", vipCustomer)); // 99.99 // With pricing strategy: VIP customers get discountsconst advancedService = new ProductService();advancedService.setPricingStrategy(new TieredPricingStrategy());console.log(advancedService.getPrice("PROD-1", vipCustomer)); // 79.99Strategy 3: Try-Catch Isolation
For optional operations that might fail, wrap them in try-catch to prevent failures from affecting the main operation:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
interface INotificationService { sendNotification(userId: string, message: string): Promise<void>;} class OrderCompletionHandler { private notificationService: INotificationService | null = null; private analyticsService: IAnalyticsService | null = null; setNotificationService(service: INotificationService): void { this.notificationService = service; } setAnalyticsService(service: IAnalyticsService): void { this.analyticsService = service; } async handleOrderCompleted(order: Order): Promise<void> { // Critical: Update order status (will throw if it fails) await this.updateOrderStatus(order, "completed"); // Optional: Send notification // Isolated in try-catch - failure doesn't affect order completion if (this.notificationService) { try { await this.notificationService.sendNotification( order.customerId, `Your order ${order.id} has been completed!` ); } catch (error) { // Log but don't fail the operation console.error("Failed to send notification:", error); // Optionally: queue for retry, increment failure metric, etc. } } // Optional: Record analytics if (this.analyticsService) { try { await this.analyticsService.recordEvent("order_completed", { orderId: order.id, total: order.total, itemCount: order.items.length }); } catch (error) { console.error("Failed to record analytics:", error); } } } private async updateOrderStatus(order: Order, status: string): Promise<void> { // Critical database update }}Strategy 4: Null Object Default
Pre-initialize with a null object implementation so the class never needs null checks. The null object does nothing but satisfies the interface:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Null Object implementationsclass NullLogger implements ILogger { debug(message: string, context?: any): void { /* no-op */ } info(message: string, context?: any): void { /* no-op */ } error(message: string, context?: any): void { /* no-op */ }} class NullMetrics implements IMetricsCollector { increment(metric: string, tags?: any): void { /* no-op */ } timing(metric: string, ms: number, tags?: any): void { /* no-op */ }} class NullCache<T> implements ICache<T> { get(key: string): T | undefined { return undefined; } set(key: string, value: T): void { /* no-op */ } delete(key: string): void { /* no-op */ }} // Service uses null objects as defaultsclass ReportGenerator { // Pre-initialized with null objects - NEVER null private logger: ILogger = new NullLogger(); private metrics: IMetricsCollector = new NullMetrics(); private cache: ICache<Report> = new NullCache(); // Setters replace the null objects with real implementations setLogger(logger: ILogger): void { this.logger = logger; } setMetrics(metrics: IMetricsCollector): void { this.metrics = metrics; } setCache(cache: ICache<Report>): void { this.cache = cache; } async generateReport(params: ReportParams): Promise<Report> { const start = Date.now(); // No null checks needed - null objects handle calls safely this.logger.info("Generating report", params); // Check cache (null cache always returns undefined) const cached = this.cache.get(params.cacheKey); if (cached) { this.logger.debug("Cache hit"); this.metrics.increment("reports.cache_hit"); return cached; } // Generate report this.metrics.increment("reports.cache_miss"); const report = await this.buildReport(params); // Cache result (null cache ignores the set) this.cache.set(params.cacheKey, report); // Record timing this.metrics.timing("reports.generation_time", Date.now() - start); this.logger.info("Report generated", { id: report.id }); return report; } private async buildReport(params: ReportParams): Promise<Report> { // Report generation logic return new Report(params); }} // Clean usage - no optional dependencies cluttering the codeconst generator = new ReportGenerator();const report = await generator.generateReport(params); // Works with all null objects // Production usage - inject real implementationsgenerator.setLogger(new WinstonLogger());generator.setMetrics(new DatadogMetrics());generator.setCache(new RedisCache());Using null objects as defaults completely eliminates null checks from the class's business logic. The code reads as if all dependencies are always present, making it cleaner and less error-prone. This is the recommended approach when you have multiple optional dependencies.
How optional dependencies get injected varies by architecture. Let's explore common configuration patterns:
Pattern 1: Manual Configuration in Composition Root
Explicitly configure optional dependencies during application startup:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// composition-root.ts// This is where you wire up your application function configureApplication(config: AppConfig): AppContainer { // Create mandatory dependencies const database = new PostgresDatabase(config.databaseUrl); const paymentGateway = new StripeGateway(config.stripeApiKey); // Create the core service with mandatory dependencies const orderService = new OrderService( new OrderRepository(database), paymentGateway ); // Configure optional dependencies based on environment/config if (config.logging.enabled) { const logger = new WinstonLogger(config.logging.level); orderService.setLogger(logger); } if (config.metrics.enabled) { const metrics = new DatadogMetrics(config.metrics.apiKey); orderService.setMetrics(metrics); } if (config.events.enabled) { const eventPublisher = new KafkaEventPublisher(config.events.brokers); orderService.setEventPublisher(eventPublisher); } if (config.cache.enabled) { const cache = new RedisCache(config.cache.url); orderService.setInventoryCache(new InventoryCacheAdapter(cache)); } return { orderService };} // Different configurations for different environmentsconst devConfig: AppConfig = { logging: { enabled: true, level: "debug" }, metrics: { enabled: false }, // No metrics in dev events: { enabled: false }, // No events in dev cache: { enabled: false }, // No cache in dev // ...}; const prodConfig: AppConfig = { logging: { enabled: true, level: "info" }, metrics: { enabled: true, apiKey: "xxx" }, events: { enabled: true, brokers: ["kafka:9092"] }, cache: { enabled: true, url: "redis://cache:6379" }, // ...};Pattern 2: Framework-Based Configuration
DI frameworks often support optional injection natively:
1234567891011121314151617181920212223242526272829303132333435363738394041
// NestJS example with optional injectionimport { Injectable, Optional, Inject } from '@nestjs/common'; @Injectable()export class OrderService { constructor( // Required - will throw if not registered private readonly orderRepository: OrderRepository, private readonly paymentGateway: PaymentGateway, // Optional - null if not registered @Optional() @Inject('LOGGER') private readonly logger?: ILogger, @Optional() @Inject('METRICS') private readonly metrics?: IMetricsCollector, ) {} async placeOrder(request: PlaceOrderRequest): Promise<OrderResult> { this.logger?.info("Placing order"); // ... business logic ... this.metrics?.increment("orders.placed"); return result; }} // Module registration@Module({ providers: [ OrderRepository, PaymentGateway, // These may or may not be registered // { // provide: 'LOGGER', // useClass: WinstonLogger // }, ],})export class OrderModule {}Pattern 3: Builder Pattern for Complex Configuration
When many optional dependencies exist, a builder provides a clean configuration API:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
class OrderServiceBuilder { private orderRepository: IOrderRepository | null = null; private paymentGateway: IPaymentGateway | null = null; private logger: ILogger | null = null; private metrics: IMetricsCollector | null = null; private eventPublisher: IEventPublisher | null = null; private cache: IInventoryCache | null = null; // Required dependencies (must be called) withOrderRepository(repo: IOrderRepository): this { this.orderRepository = repo; return this; } withPaymentGateway(gateway: IPaymentGateway): this { this.paymentGateway = gateway; return this; } // Optional dependencies (may be called) withLogger(logger: ILogger): this { this.logger = logger; return this; } withMetrics(metrics: IMetricsCollector): this { this.metrics = metrics; return this; } withEventPublisher(publisher: IEventPublisher): this { this.eventPublisher = publisher; return this; } withCache(cache: IInventoryCache): this { this.cache = cache; return this; } build(): OrderService { // Validate required dependencies if (!this.orderRepository) { throw new Error("OrderRepository is required"); } if (!this.paymentGateway) { throw new Error("PaymentGateway is required"); } // Create service with required dependencies const service = new OrderService( this.orderRepository, this.paymentGateway ); // Inject optional dependencies if (this.logger) { service.setLogger(this.logger); } if (this.metrics) { service.setMetrics(this.metrics); } if (this.eventPublisher) { service.setEventPublisher(this.eventPublisher); } if (this.cache) { service.setCache(this.cache); } return service; }} // Clean, readable configurationconst orderService = new OrderServiceBuilder() .withOrderRepository(new OrderRepository(db)) .withPaymentGateway(new StripeGateway(apiKey)) .withLogger(new ConsoleLogger()) .withMetrics(new DatadogMetrics()) // Note: Event publisher and cache are not configured .build();Optional dependencies create interesting testing considerations. You need to test that the class works correctly in multiple configurations:
Let's see how to write comprehensive tests:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
describe("OrderService", () => { // Mock mandatory dependencies (always needed) let mockOrderRepo: jest.Mocked<IOrderRepository>; let mockPaymentGateway: jest.Mocked<IPaymentGateway>; // Mock optional dependencies (sometimes needed) let mockLogger: jest.Mocked<ILogger>; let mockMetrics: jest.Mocked<IMetricsCollector>; let mockEventPublisher: jest.Mocked<IEventPublisher>; beforeEach(() => { // Set up mandatory mocks mockOrderRepo = { save: jest.fn().mockResolvedValue(/* saved order */), findById: jest.fn() }; mockPaymentGateway = { charge: jest.fn().mockResolvedValue({ success: true, transactionId: "tx-123" }) }; // Set up optional mocks mockLogger = { debug: jest.fn(), info: jest.fn(), error: jest.fn() }; mockMetrics = { increment: jest.fn(), timing: jest.fn() }; mockEventPublisher = { publish: jest.fn().mockResolvedValue(undefined) }; }); describe("with minimal configuration (no optional dependencies)", () => { let service: OrderService; beforeEach(() => { // Only mandatory dependencies service = new OrderService(mockOrderRepo, mockPaymentGateway); }); it("should place order successfully", async () => { const result = await service.placeOrder(validOrderRequest); expect(result.success).toBe(true); expect(mockPaymentGateway.charge).toHaveBeenCalled(); expect(mockOrderRepo.save).toHaveBeenCalled(); }); it("should not throw when logging is not configured", async () => { await expect(service.placeOrder(validOrderRequest)) .resolves.not.toThrow(); }); it("should not throw when metrics are not configured", async () => { await expect(service.placeOrder(validOrderRequest)) .resolves.not.toThrow(); }); }); describe("with full configuration (all optional dependencies)", () => { let service: OrderService; beforeEach(() => { service = new OrderService(mockOrderRepo, mockPaymentGateway); service.setLogger(mockLogger); service.setMetrics(mockMetrics); service.setEventPublisher(mockEventPublisher); }); it("should log order placement", async () => { await service.placeOrder(validOrderRequest); expect(mockLogger.info).toHaveBeenCalledWith( "Placing order", expect.any(Object) ); }); it("should record metrics", async () => { await service.placeOrder(validOrderRequest); expect(mockMetrics.increment).toHaveBeenCalledWith( "orders.placed", expect.objectContaining({ status: "success" }) ); }); it("should publish domain event", async () => { await service.placeOrder(validOrderRequest); expect(mockEventPublisher.publish).toHaveBeenCalledWith( expect.any(OrderPlacedEvent) ); }); }); describe("resilience with optional dependencies", () => { it("should succeed even if event publishing fails", async () => { const service = new OrderService(mockOrderRepo, mockPaymentGateway); const failingPublisher = { publish: jest.fn().mockRejectedValue(new Error("Kafka unavailable")) }; service.setEventPublisher(failingPublisher); service.setLogger(mockLogger); const result = await service.placeOrder(validOrderRequest); // Order should still succeed expect(result.success).toBe(true); // Error should be logged expect(mockLogger.error).toHaveBeenCalledWith( "Failed to publish order event", expect.any(Object) ); }); it("should work when logger is set to null after being configured", async () => { const service = new OrderService(mockOrderRepo, mockPaymentGateway); service.setLogger(mockLogger); // Some frameworks might set dependencies to null (service as any).logger = null; // Should not throw await expect(service.placeOrder(validOrderRequest)) .resolves.not.toThrow(); }); });});The key insight is to test the service works correctly in EVERY valid configuration. This includes testing that optional dependencies truly are optional—the service doesn't fail when they're absent.
We've explored how setter injection enables the Optional Dependencies Pattern—allowing classes to work with or without certain collaborators while enhancing functionality when those collaborators are available.
What's Next:
Now that we understand how to work with optional dependencies, the next page examines the trade-offs of setter injection—the costs, risks, and architectural implications that inform when setter injection is appropriate and when it should be avoided.
You now understand the Optional Dependencies Pattern—how to design classes that gracefully degrade when optional collaborators are absent while enhancing functionality when they're present. Next, we'll examine the trade-offs that determine when this flexibility is worth the cost.