Immutability


Introduction#

Immutability means that once created, an object cannot be changed. Instead of modifying existing data, you create new instances with the desired changes. This simple concept transforms how we reason about code, making programs more predictable, easier to debug, and naturally thread-safe. While it might seem counterintuitive at first, immutability often simplifies rather than complicates software design.

The Hidden Costs of Mutation#

Mutable state creates invisible dependencies that make code harder to understand and maintain. When objects can be modified in place, tracking how and when changes occur becomes a detective exercise that grows exponentially complex with system size.

Consider this seemingly innocent code:

class ShoppingCart {
  constructor(private items: CartItem[]) {}

  getItems(): CartItem[] {
    return this.items;
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

const cart = new ShoppingCart([...]);
const items = cart.getItems();
const originalTotal = cart.getTotal();

// Somewhere else in the codebase
items.push(new CartItem("surprise", 50));

// Total unexpectedly changed
const newTotal = cart.getTotal(); // Different value!

The cart’s total changed without any direct interaction with the cart itself. The returned array reference allows external code to mutate internal state, creating action-at-a-distance effects that violate encapsulation and make debugging nearly impossible in larger systems.

Immutable by Design#

Immutable objects eliminate these hidden dependencies by making change explicit and controlled. When you cannot modify an object, you must create a new one to represent the changed state. This forces you to be intentional about when and how data changes.

Here’s the same cart implemented immutably:

class ImmutableShoppingCart {
  constructor(private readonly items: readonly CartItem[]) {}

  getItems(): readonly CartItem[] {
    return this.items;
  }

  addItem(item: CartItem): ImmutableShoppingCart {
    return new ImmutableShoppingCart([...this.items, item]);
  }

  removeItem(index: number): ImmutableShoppingCart {
    const newItems = this.items.filter((_, i) => i !== index);
    return new ImmutableShoppingCart(newItems);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

Now changes are explicit and controlled. The original cart remains unchanged, and any modification creates a new cart instance. This eliminates surprise mutations and makes the flow of data changes visible and traceable.

Debugging and Reasoning Benefits#

Immutable objects make debugging fundamentally easier because they eliminate temporal coupling. With mutable objects, the same reference can represent different values at different times, making it difficult to reason about program state. With immutable objects, a reference always represents the same value.

This consistency enables powerful debugging techniques. You can safely pass immutable objects around without worrying about when they might change. You can cache them, store them in collections, and reason about them algebraically. The object you create is the object you get, always.

// With immutable objects, this is safe
const user = new User("John", "john@example.com");
const usersToEmail = [user, user, user]; // Same user instance

processUsers(usersToEmail);
sendEmails(usersToEmail);
logActivity(usersToEmail);

// user is still exactly what we created
// No function could have modified it

Concurrency and Thread Safety#

Immutable objects are inherently thread-safe. Since they cannot be modified after creation, multiple threads can access them simultaneously without synchronization concerns. This eliminates entire categories of concurrency bugs including race conditions, deadlocks caused by inconsistent locking orders, and data corruption from unsynchronized access.

// Immutable configuration - safe to share across threads
const config = new AppConfig({
  databaseUrl: "postgres://...",
  maxConnections: 100,
  timeout: 30000
});

// Multiple threads can safely access config
// No locks, no synchronization needed
const worker1 = new Worker(config);
const worker2 = new Worker(config);
const monitor = new Monitor(config);

The absence of shared mutable state eliminates the need for complex locking strategies and makes concurrent programs much easier to reason about and test. When objects cannot change, you cannot have race conditions.

Performance Considerations#

The primary concern developers raise about immutability is performance. Creating new objects instead of modifying existing ones seems wasteful, but modern systems often show that immutability improves rather than hinders performance.

Immutable objects enable aggressive optimization strategies. Since the objects never change, computations based on them can be cached safely. Hash codes can be computed once and stored. Expensive operations like sorting or searching can be done once and reused.

class ImmutableList<T> {
  private cached_length?: number;
  private cached_hash?: number;

  constructor(private items: T[]) {}

  length(): number {
    if (this.cached_length === undefined) {
      this.cached_length = this.items.length;
    }
    return this.cached_length;
  }

  hashCode(): number {
    if (this.cached_hash === undefined) {
      // Expensive computation done only once
      this.cached_hash = computeHash(this.items);
    }
    return this.cached_hash;
  }
}

Structural sharing takes this optimization further. When creating new immutable objects, you can reuse unchanged parts of the original object, minimizing both memory usage and creation time.

Implementation Strategies#

Implementing immutability effectively requires choosing the right approach for your context. Languages with built-in immutable collections like Clojure or Scala make this natural, but it can be achieved in any language with discipline and proper tools.

// TypeScript with readonly modifiers
interface ReadonlyUser {
  readonly id: string;
  readonly name: string;
  readonly email: string;
}

// C# with immutable records
public record User(string Id, string Name, string Email);

// C# with init-only properties
public class UserProfile {
  public string Id { get; init; }
  public string Name { get; init; }
  public string Email { get; init; }

  public UserProfile WithName(string newName) =>
    new() { Id = this.Id, Name = newName, Email = this.Email };
}

The key is consistency. Mixed mutable and immutable approaches often create more complexity than purely mutable code. Choose an approach and apply it consistently throughout your domain objects.

Conclusion#

Immutability transforms software design by eliminating hidden dependencies, making concurrent programming safe, and creating predictable state transitions. While it requires a different mindset initially, it often simplifies rather than complicates software architecture.

The key insight is that immutability makes change explicit and controlled. Instead of data that silently changes under you, you have clear transformation points where new data is created. This explicitness makes programs easier to understand, test, and maintain.

Start by making your domain objects immutable. Design your classes to return new instances rather than modify existing state. Your future self, debugging a complex system months later, will appreciate the predictability that immutability brings.