Loading content...
Not all dependencies are created equal. Some dependencies are nice to have—if they're absent, the system can still function, perhaps with reduced capabilities. But other dependencies are essential—without them, the class cannot perform its fundamental responsibilities.
Consider an OrderService without an OrderRepository. What could it possibly do? It cannot save orders. It cannot retrieve orders. It cannot check order status. The very concept of an OrderService is meaningless without storage capabilities.
These are mandatory dependencies—collaborators without which an object cannot fulfill its contract. Constructor injection provides a natural, elegant mechanism for declaring and enforcing mandatory dependencies.
By the end of this page, you will understand how constructor injection enforces mandatory dependencies through compile-time guarantees. You'll learn to distinguish mandatory from optional dependencies, handle validation appropriately, and eliminate defensive null-checking throughout your codebase.
Without proper dependency guarantees, codebases become littered with defensive programming—null checks, undefined guards, and fallback behaviors that obscure the real logic:
class OrderService {
private repository?: OrderRepository;
private emailService?: EmailService;
setRepository(repo: OrderRepository): void {
this.repository = repo;
}
setEmailService(service: EmailService): void {
this.emailService = service;
}
async placeOrder(order: Order): Promise<void> {
// Defensive checks everywhere
if (!this.repository) {
throw new Error('Repository not configured');
}
if (!this.emailService) {
throw new Error('Email service not configured');
}
await this.repository.save(order);
await this.emailService.sendConfirmation(order);
}
async getOrder(id: string): Promise<Order> {
// Same checks repeated in every method
if (!this.repository) {
throw new Error('Repository not configured');
}
return this.repository.findById(id);
}
}
This code has several severe problems:
OrderService instance can exist in an unusable state. Code must constantly check if the object is 'ready'.NULL references were famously called the 'billion dollar mistake' by their inventor. When dependencies can be null, that cost multiplies across every method, every class, every system that must handle the possibility of missing collaborators.
Constructor injection solves the mandatory dependency problem through a simple principle: objects cannot be created without providing all required dependencies.
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService
) {
// Dependencies are guaranteed to be provided by the constructor signature
}
async placeOrder(order: Order): Promise<void> {
// No null checks needed—dependencies are guaranteed present
await this.repository.save(order);
await this.emailService.sendConfirmation(order);
}
async getOrder(id: string): Promise<Order> {
// Clean, focused business logic
return this.repository.findById(id);
}
}
What changed:
The enforcement mechanism:
Let's trace the enforcement from type system to runtime:
// Compile-time: TypeScript catches missing arguments
const service = new OrderService(); // Error: Expected 2 arguments, got 0
const service = new OrderService(repository); // Error: Expected 2 arguments, got 1
const service = new OrderService(repository, null); // Error: null is not assignable to EmailService
const service = new OrderService(repository, emailService); // ✓ Compiles successfully
The type system guarantees that every OrderService instance has both a repository and an email service. This guarantee is verified at compile time—before any code runs.
| Aspect | Setter Injection | Constructor Injection |
|---|---|---|
| Missing dependency caught | At runtime, when method called | At compile time, when instantiating |
| Partial initialization | Possible—object can exist without dependencies | Impossible—all dependencies required for creation |
| Null checks required | Every method using dependency | None—dependencies guaranteed present |
| Error timing | Late—potentially in production | Early—during development/build |
| Temporal coupling | Methods must be called in order | No ordering constraints—object ready immediately |
| Type safety | Weaker—nullable types needed | Stronger—non-nullable types throughout |
Constructor injection with mandatory dependencies creates null-safe classes by design—not through runtime checks or nullable types, but through structural guarantees enforced at construction time.
The null-free class pattern:
class PaymentProcessor {
// No optionals, no nullables—all dependencies are definite
constructor(
private readonly gateway: PaymentGateway,
private readonly fraudChecker: FraudChecker,
private readonly ledger: TransactionLedger,
private readonly logger: Logger
) {}
async processPayment(payment: Payment): Promise<ProcessingResult> {
// Every dependency is guaranteed to exist
this.logger.info('Processing payment', { amount: payment.amount });
const fraudResult = await this.fraudChecker.check(payment);
if (fraudResult.isRisky) {
return ProcessingResult.declined('Fraud risk detected');
}
const chargeResult = await this.gateway.charge(payment);
await this.ledger.record(chargeResult.transaction);
return ProcessingResult.success(chargeResult.transaction);
}
}
Notice what's absent: no ?. operators, no ?? fallbacks, no if (this.dependency) guards. The code reads as pure business logic because the plumbing—ensuring dependencies exist—was handled at construction.
In software development, 'shifting left' means catching problems earlier in the development cycle. Constructor injection shifts dependency problems from runtime failures to compile-time errors—the earliest possible moment. Problems caught at compile time cost a fraction of those found in production.
Making impossible states unrepresentable:
One of the most powerful principles in type-safe programming is making invalid states impossible to represent. Constructor injection applies this principle to dependencies:
// Without constructor injection: invalid states are possible
class BadOrderService {
repository?: OrderRepository; // Could be undefined
// Object can exist in unusable state
}
// With constructor injection: invalid states are impossible
class GoodOrderService {
constructor(private readonly repository: OrderRepository) {}
// Object cannot exist without repository
}
A GoodOrderService without a repository literally cannot exist in your program. The type system prevents its creation. This is fundamentally different from runtime validation—it's structural impossibility.
While type systems prevent null from being passed in typed code, there are scenarios where additional validation is appropriate—particularly at system boundaries or when integrating with dynamically-typed code.
Defensive validation in the constructor:
class OrderService {
private readonly repository: OrderRepository;
private readonly emailService: EmailService;
constructor(
repository: OrderRepository,
emailService: EmailService
) {
// Guard clauses for extra safety at boundaries
if (!repository) {
throw new ArgumentNullError('repository');
}
if (!emailService) {
throw new ArgumentNullError('emailService');
}
this.repository = repository;
this.emailService = emailService;
}
// Methods can safely assume dependencies exist
}
class ArgumentNullError extends Error {
constructor(parameterName: string) {
super(`Parameter '${parameterName}' cannot be null or undefined`);
this.name = 'ArgumentNullError';
}
}
When to add guard clauses:
| Scenario | Guard Recommended? | Reasoning |
|---|---|---|
| Internal domain classes | Not typically | Type system provides sufficient guarantee |
| Public library APIs | Yes | Can't control how external callers invoke your code |
| System boundaries | Yes | Data from external systems may not match types |
| JavaScript interop | Yes | TypeScript types erased at runtime |
| Deserialization points | Yes | Parsed JSON may not match expected types |
| IoC container configuration | Container handles | Modern containers validate wiring automatically |
A cleaner pattern for guard clauses:
import { Guard } from './validation';
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService,
private readonly paymentGateway: PaymentGateway
) {
Guard.againstNull('repository', repository);
Guard.againstNull('emailService', emailService);
Guard.againstNull('paymentGateway', paymentGateway);
}
}
// Guard utility
class Guard {
static againstNull<T>(paramName: string, value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new ArgumentNullError(paramName);
}
}
static againstNullOrEmpty(paramName: string, value: string): void {
Guard.againstNull(paramName, value);
if (value.trim().length === 0) {
throw new ArgumentEmptyError(paramName);
}
}
}
Guard clauses, when used, should be in the constructor only. Once past construction, dependencies are guaranteed valid, and no further checks are needed in methods.
In strictly-typed environments with disciplined teams, constructor guards may be redundant—the type system provides the guarantee. In mixed environments or public APIs, guards provide defense in depth. Choose based on your context, but never let guards substitute for proper typing.
Not every dependency a class uses is mandatory. Some enhance functionality without being essential. Understanding the difference is crucial for designing clean APIs.
Mandatory dependencies:
Optional dependencies:
Example: Mandatory vs Optional:
class ProductSearchService {
private readonly logger?: Logger;
constructor(
// Mandatory: Cannot search without an index
private readonly searchIndex: SearchIndex,
// Mandatory: Cannot function without product data
private readonly productRepository: ProductRepository,
// Optional: Caching enhances performance but isn't required
private readonly cache?: ProductCache,
// Optional: Logging is helpful but not essential
logger?: Logger
) {
this.logger = logger;
}
async search(query: string): Promise<Product[]> {
this.logger?.debug('Searching for:', query);
// Try cache first if available
if (this.cache) {
const cached = await this.cache.get(query);
if (cached) return cached;
}
// Core functionality—uses mandatory dependencies
const productIds = await this.searchIndex.search(query);
const products = await this.productRepository.findByIds(productIds);
// Cache results if caching is available
if (this.cache) {
await this.cache.set(query, products);
}
return products;
}
}
Rather than making dependencies optional (nullable), consider using the Null Object pattern. Provide a no-op implementation as a default, keeping the dependency mandatory but allowing callers to request 'do nothing' behavior. This eliminates conditional checks in the consuming code.
The mandatory dependency pattern, enforced through constructor injection, creates what we call complete objects—instances that are fully configured and ready to use from the moment they're created.
Properties of complete objects:
// Complete object: ready immediately
class EmailCampaignService {
constructor(
private readonly templateEngine: TemplateEngine,
private readonly emailSender: EmailSender,
private readonly recipientList: RecipientRepository,
private readonly analytics: CampaignAnalytics
) {
// Object is complete. All methods work immediately.
}
// All methods can be called in any order
async sendCampaign(campaignId: string): Promise<CampaignResult> { ... }
async previewTemplate(templateId: string): Promise<string> { ... }
async getRecipientCount(listId: string): Promise<number> { ... }
}
// Usage: create and immediately use
const service = new EmailCampaignService(engine, sender, recipients, analytics);
await service.sendCampaign('spring-sale'); // Works immediately
Contrast with incomplete objects:
// Incomplete object: requires setup after construction
class IncompleteEmailService {
private templateEngine?: TemplateEngine;
private emailSender?: EmailSender;
private isInitialized = false;
async initialize(): Promise<void> {
this.templateEngine = await TemplateEngine.create();
this.emailSender = await EmailSender.connect();
this.isInitialized = true;
}
async sendEmail(template: string, to: string): Promise<void> {
if (!this.isInitialized) {
throw new Error('Service not initialized. Call initialize() first.');
}
// Now we can work
}
}
// Usage: create, then remember to initialize, then use
const service = new IncompleteEmailService();
await service.initialize(); // Easy to forget!
await service.sendEmail('welcome', 'user@example.com');
Incomplete objects create temporal coupling—the requirement that methods be called in a specific order. This coupling is invisible and easy to violate.
Some dependencies require async initialization (database connections, service discovery). The elegant solution isn't making the object incomplete—it's creating dependencies asynchronously BEFORE constructing the object, then injecting them fully initialized. Factories or async composition roots handle this pattern.
Complete objects with mandatory dependencies are remarkably easy to test. The construction process itself tells you exactly what test doubles you need.
Test setup pattern:
describe('OrderService', () => {
// Declare dependencies as let—they're created fresh for each test
let mockRepository: MockOrderRepository;
let mockPaymentGateway: MockPaymentGateway;
let mockEmailService: MockEmailService;
let orderService: OrderService;
beforeEach(() => {
// Create fresh mocks
mockRepository = new MockOrderRepository();
mockPaymentGateway = new MockPaymentGateway();
mockEmailService = new MockEmailService();
// Construct the service with all mandatory dependencies
orderService = new OrderService(
mockRepository,
mockPaymentGateway,
mockEmailService
);
});
it('should save order when payment succeeds', async () => {
// Arrange
mockPaymentGateway.willSucceed();
const order = createTestOrder();
// Act
await orderService.placeOrder(order);
// Assert
expect(mockRepository.savedOrders).toContain(order);
expect(mockEmailService.sentEmails).toHaveLength(1);
});
it('should not save order when payment fails', async () => {
// Arrange
mockPaymentGateway.willFail();
const order = createTestOrder();
// Act & Assert
await expect(orderService.placeOrder(order)).rejects.toThrow();
expect(mockRepository.savedOrders).toHaveLength(0);
});
});
Why complete objects simplify testing:
No null checks in tests — Every dependency is provided; no need to stub null-handling paths
Constructor is the test setup — The constructor signature documents exactly which mocks are needed
Immediate usability — No initialization methods to call in test setup
Focused assertions — Tests verify behavior, not that the object is properly initialized
Refactoring safety — Adding a dependency updates the constructor; tests won't compile until updated
The mandatory dependencies pattern, enabled by constructor injection, fundamentally changes how we think about object validity. Objects are either fully configured and ready, or they don't exist at all.
What's next:
Having established that constructor injection creates complete, mandatory-dependency objects, we'll explore another crucial property: immutable dependencies. Once injected, should dependencies be changeable? The answer shapes both correctness and thread-safety.
You now understand how constructor injection enforces mandatory dependencies, creating complete objects that are valid from birth. This pattern eliminates defensive null-checking and shifts dependency errors from runtime to compile-time. Next, we'll explore immutable dependencies.