Loading learning content...
Consider a simple question: once an object receives its dependencies through the constructor, should those dependencies ever change?
The answer, refined through decades of engineering practice, is almost universally no. Dependencies injected at construction time should remain fixed for the lifetime of the object. This principle—immutable dependencies—creates predictable, thread-safe, and debuggable software.
But why? What problems arise from mutable dependencies, and what benefits does immutability provide? This page explores these questions in depth, establishing immutability as a fundamental property of well-constructed dependency injection.
By the end of this page, you will understand why dependencies should be immutable after injection, the technical mechanisms for enforcing immutability, the problems mutable dependencies create, and the profound implications for correctness, concurrency, and reasoning about code.
Immutable dependencies provide guarantees that mutable ones cannot. When dependencies never change, entire classes of bugs become impossible, and reasoning about code becomes dramatically simpler.
What immutable dependencies guarantee:
class OrderProcessor {
constructor(
private readonly repository: OrderRepository, // 'readonly' enforces immutability
private readonly validator: OrderValidator,
private readonly notifier: CustomerNotifier
) {}
async processOrder(order: Order): Promise<void> {
// Throughout this method—and the object's entire lifetime:
// - this.repository is ALWAYS the same repository
// - this.validator is ALWAYS the same validator
// - this.notifier is ALWAYS the same notifier
await this.validator.validate(order);
await this.repository.save(order);
await this.notifier.notify(order.customerId, 'Order confirmed');
}
}
The readonly modifier (TypeScript) or equivalent constructs in other languages prevents assignment after construction. The dependencies are fixed—frozen in place from the moment the object comes into existence.
Immutability doesn't just prevent certain bugs—it removes entire categories of failure modes from consideration. When debugging, you never have to ask 'Did something swap the repository?' You know it didn't. That saved mental cycle multiplies across every debugging session.
To appreciate immutability, let's examine what happens when dependencies can change after construction:
// ANTI-PATTERN: Mutable dependencies
class UnsafeOrderService {
private repository: OrderRepository; // Not readonly—can be reassigned
private emailService: EmailService;
constructor(repository: OrderRepository, emailService: EmailService) {
this.repository = repository;
this.emailService = emailService;
}
// Dangerous: allows dependency swapping at runtime
setRepository(newRepository: OrderRepository): void {
this.repository = newRepository;
}
setEmailService(newEmailService: EmailService): void {
this.emailService = newEmailService;
}
async processOrder(order: Order): Promise<void> {
// PROBLEM: Which repository? Which email service?
// Depends on what setters were called and when
await this.repository.save(order);
await this.emailService.sendConfirmation(order);
}
}
What can go wrong:
A concrete failure scenario:
// Thread 1
async function handleWebRequest(orderService: UnsafeOrderService) {
const order = parseOrderFromRequest();
// At this point, repository is PostgresRepository
await orderService.processOrder(order);
// Order saved to Postgres
}
// Thread 2 (configuration reload)
function onConfigChange(orderService: UnsafeOrderService) {
// Config says switch to new database
orderService.setRepository(new MySqlRepository());
}
// What happens if Thread 2 runs DURING Thread 1's processOrder?
// Order validation might happen with Postgres connection
// Order save might happen with MySQL connection
// Complete corruption of transactional integrity
Mutable dependencies create race condition windows that don't exist with immutable dependencies.
In concurrent environments—which is most production software—mutable dependencies are inherently dangerous. The window between 'check' and 'use' becomes an opportunity for another thread to swap the dependency. This class of bug is notoriously difficult to reproduce and debug.
Different languages provide different mechanisms for enforcing dependency immutability. Understanding these mechanisms ensures your guarantees are enforced, not just intended.
TypeScript: readonly modifier
class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly emailService: EmailService
) {}
// This would be a compile error:
// changeRepository(repo: UserRepository) {
// this.userRepository = repo; // Error: Cannot assign to 'userRepository' because it is a read-only property
// }
}
The readonly modifier in TypeScript prevents reassignment at compile time. It's the primary mechanism for immutable dependencies.
| Language | Mechanism | Syntax Example | Enforcement Level |
|---|---|---|---|
| TypeScript | readonly modifier | private readonly repo: Repo | Compile-time |
| Java | final keyword | private final Repo repo | Compile-time |
| C# | readonly keyword | private readonly Repo _repo | Compile-time |
| Kotlin | val (immutable) | private val repo: Repo | Compile-time |
| Python | Convention + property | @property (no setter) | Runtime (convention) |
| Rust | Default immutability | repo: Repo (not mut) | Compile-time |
| C++ | const member | const Repo& repo | Compile-time |
Java: final fields
public class UserService {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
private final EmailService emailService;
public UserService(
UserRepository userRepository,
PasswordHasher passwordHasher,
EmailService emailService) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.emailService = emailService;
}
// Reassignment is a compile error:
// void changeRepo(UserRepository repo) {
// this.userRepository = repo; // Error: cannot assign a value to final variable
// }
}
C#: readonly fields
public class UserService
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
public UserService(
IUserRepository userRepository,
IPasswordHasher passwordHasher)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
}
}
readonly/final makes the reference immutable—the field always points to the same object. But the object itself might still be mutable. For dependencies, this is usually acceptable: we care that the same EmailService is used, not that the EmailService's internal state never changes. Dependencies provide behavior, not data.
Immutable dependencies have profound implications for thread safety. When dependencies cannot change, objects become inherently safer in concurrent environments.
Why immutability enables thread safety:
Thread safety issues arise when multiple threads access shared mutable state. For dependency references:
class ThreadSafeOrderProcessor {
constructor(
private readonly repository: OrderRepository, // Immutable reference
private readonly validator: OrderValidator // Immutable reference
) {}
// Can be safely called from multiple threads simultaneously
async processOrder(order: Order): Promise<void> {
// Both threads will ALWAYS use the same repository and validator
// No race conditions on dependency access
await this.validator.validate(order);
await this.repository.save(order);
}
}
// In a concurrent web server:
const processor = new ThreadSafeOrderProcessor(repo, validator);
// Thread 1
processor.processOrder(order1);
// Thread 2 (completely safe—no conflict)
processor.processOrder(order2);
Important nuance:
Immutable references guarantee thread safety for accessing dependencies. However, the dependencies themselves may have mutable internal state (a repository maintains connections, a cache stores entries). Thread safety of the dependency's operations depends on how the dependency itself is implemented—that's a separate concern.
// The reference to 'cache' is immutable—always the same cache
// But cache.get() and cache.set() might modify internal state
// Thread safety of those operations depends on the Cache implementation
class ProductService {
constructor(
private readonly cache: ProductCache,
private readonly repository: ProductRepository
) {}
async getProduct(id: string): Promise<Product> {
// Reference access is thread-safe
// cache.get() thread safety depends on ProductCache implementation
const cached = await this.cache.get(id);
if (cached) return cached;
const product = await this.repository.findById(id);
await this.cache.set(id, product); // Cache implementation handles concurrency
return product;
}
}
Constructor injection with immutable references handles thread safety at the dependency-access level. Thread safety within dependencies is the dependency's responsibility. This separation keeps concerns cleanly divided—objects don't worry about implementation details of their collaborators.
Immutable dependencies preserve something philosophically important: the identity of an object. When dependencies can change, we must ask uncomfortable questions about what an object really is.
The Ship of Theseus problem:
Classical philosophy poses a puzzle: if a ship has all its planks replaced over time, is it still the same ship? This question applies directly to objects with mutable dependencies.
// Initial setup
const orderService = new MutableOrderService(postgresRepo, sendgridEmail);
// Later...
orderService.setRepository(mongoRepo);
orderService.setEmailService(twilioSms);
// Is this the 'same' OrderService?
// It has different behavior, different side effects, different characteristics.
// The identity is compromised.
With immutable dependencies, this confusion disappears:
// This OrderService is THIS OrderService, forever
const orderService = new ImmutableOrderService(postgresRepo, sendgridEmail);
// If you want different behavior, create a different object
const alternateService = new ImmutableOrderService(mongoRepo, twilioSms);
Behavioral consistency over time:
Immutable dependencies guarantee that an object's behavior is consistent throughout its lifetime. This consistency enables:
class ConsistentOrderProcessor {
constructor(
private readonly shippingCalculator: ShippingCalculator
) {}
// This method's behavior is stable over time
// Because shippingCalculator never changes
calculateShipping(order: Order): Money {
return this.shippingCalculator.calculate(
order.items,
order.destination
);
}
}
// If I call calculateShipping twice with the same order,
// I get the same result (assuming ShippingCalculator is deterministic)
// This would NOT be guaranteed if shippingCalculator could be swapped
In well-designed DI systems, an object's identity is fully defined at construction. What it does, who it collaborates with, how it behaves—all fixed. To create different behavior, create a different object. This clear delineation makes systems understandable.
In rare cases, systems genuinely need to change which implementations are used at runtime. How do we handle this without violating immutability?
Pattern 1: Create new objects instead of mutating existing ones
// Instead of mutating an existing service:
// orderService.setRepository(newRepo); // BAD
// Create a new service with new dependencies:
const newOrderService = new OrderService(newRepo, existingEmailService);
// If using IoC container, trigger re-resolution:
container.rebind(OrderRepository).to(NewOrderRepository);
const newOrderService = container.get(OrderService);
Pattern 2: Use a strategy/provider that encapsulates variability
// The dependency itself decides which implementation to use
interface RepositoryProvider {
getRepository(): OrderRepository;
}
class DynamicRepositoryProvider implements RepositoryProvider {
private currentRepository: OrderRepository;
constructor(initialRepository: OrderRepository) {
this.currentRepository = initialRepository;
}
getRepository(): OrderRepository {
return this.currentRepository;
}
// Thread-safe switching handled internally
switchTo(newRepository: OrderRepository): void {
this.currentRepository = newRepository;
}
}
class OrderService {
constructor(
private readonly repositoryProvider: RepositoryProvider // Immutable reference to provider
) {}
async getOrder(id: string): Promise<Order> {
// Provider decides which repository—service doesn't know or care
const repo = this.repositoryProvider.getRepository();
return repo.findById(id);
}
}
Pattern 3: Use a decorator that can be reconfigured
class ReconfigurableRepository implements OrderRepository {
private delegate: OrderRepository;
constructor(initialDelegate: OrderRepository) {
this.delegate = initialDelegate;
}
// Thread-safe delegation switching
setDelegate(newDelegate: OrderRepository): void {
this.delegate = newDelegate;
}
async save(order: Order): Promise<void> {
return this.delegate.save(order);
}
async findById(id: string): Promise<Order> {
return this.delegate.findById(id);
}
}
// OrderService has immutable reference to ReconfigurableRepository
// But ReconfigurableRepository internally manages variability
class OrderService {
constructor(
private readonly repository: OrderRepository // Actually a ReconfigurableRepository
) {}
}
In all these patterns, the consuming class maintains immutable dependencies. Variability is pushed into specialized components designed to handle it safely.
Every pattern for dynamic dependencies adds complexity and potential failure modes. Before implementing any of these, ask: Do we really need runtime switching? Often, the answer is no—application restart or deployment is acceptable. Dynamic switching should be the exception, not the norm.
Immutable dependencies simplify lifecycle management. When dependencies don't change, the lifecycle model is straightforward: construction → use → disposal. No intermediate states, no reconfiguration phases.
Simple lifecycle model:
// Construction: dependencies injected and fixed
const service = new OrderService(repository, emailService);
// Use: service operates with fixed dependencies
await service.processOrder(order1);
await service.processOrder(order2);
await service.processOrder(order3);
// Disposal: cleanup (if needed) is straightforward
await service.dispose(); // Or let GC handle it
Contrast with mutable lifecycle:
// Construction: incomplete
const service = new MutableOrderService();
// Configuration phase: set dependencies
service.setRepository(repository);
service.setEmailService(emailService);
service.initialize();
// Use phase: but dependencies might change!
await service.processOrder(order1);
// Reconfiguration phase (why?)
service.setRepository(newRepository);
// More use (with different behavior now!)
await service.processOrder(order2);
// Disposal: which dependencies to clean up? Old or new?
await service.dispose();
The mutable lifecycle has more phases, more opportunities for error, and more complexity.
| Lifecycle Aspect | Immutable Dependencies | Mutable Dependencies |
|---|---|---|
| States | 2 (constructed, disposed) | 4+ (empty, configuring, ready, reconfiguring...) |
| Transitions | 1 (use → dispose) | Many (configure, reconfigure, initialize...) |
| Error opportunities | Construction failure only | Every state transition |
| Testing coverage | Minimal states to cover | Combinatorial explosion |
| Documentation | Simple: construct and use | Complex: lifecycle diagram needed |
| Mental model | Create once, use forever | Track current configuration |
Disposal and resource management:
When dependencies are immutable, disposal is clear: dispose the object and its dependencies together (if the object owns them) or leave dependency disposal to whoever created them (if dependencies are shared).
// Dependencies are injected—disposal is caller's responsibility
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService
) {}
// No dispose() needed—dependencies came from outside, cleanup is external
}
// Usage:
const repo = new PostgresOrderRepository(connectionString);
const email = new SmtpEmailService(smtpConfig);
const service = new OrderService(repo, email);
// Use service...
// Cleanup: whoever created dependencies cleans them up
await repo.close();
await email.close();
// service can be garbage collected
The general rule: if you construct it, you dispose it. Classes receiving dependencies via constructor injection typically don't own those dependencies—they use but don't manage their lifecycle. This clear ownership model prevents double-disposal bugs and resource leaks.
Immutable dependencies are more than a coding convention—they're a constraint that eliminates entire categories of bugs while simplifying reasoning, testing, and concurrent programming.
What's next:
With the core principles of constructor injection established—passing dependencies through constructors, enforcing mandatory dependencies, and maintaining immutability—the final page presents comprehensive best practices for constructor injection in production systems.
You now understand why dependencies should be immutable after injection—creating predictable, thread-safe, and maintainable objects. Combined with mandatory dependencies and constructor delivery, this forms the foundation of robust dependency injection. Next, we'll synthesize these principles into best practices.