DEV Community

Cover image for Immutability - Not a Universal Law but a Trade-off
ivan.gavlik
ivan.gavlik

Posted on

Immutability - Not a Universal Law but a Trade-off

Introduction

Immutability is often presented as a best practice: once data is created, it is never changed. Instead of modifying existing data, you create new versions.

That sounds clean, predictable, and safe—and it often is. But treating immutability as a universal rule leads to overengineering and unnecessary complexity. Like most architectural decisions, immutability is a trade-off.

This post explores where immutability shines, where it hurts, and how to apply it pragmatically across different levels of a system.

Code Level

At the code level, immutability is usually a clear win.

Where it works well

Functions
Pure functions benefit directly from immutable data. No hidden side effects, easier reasoning, simpler testing.

Example: updating user’s balance using Clojure.

(defn add-balance [user amount]
  (update user :balance + amount))

(def user {:id 1 :balance 100})

(def updated-user (add-balance user 20))

;; user stays unchanged
;; updated-user is a new map
Enter fullscreen mode Exit fullscreen mode

Why this is strong

  • No mutation
  • Same input → same output
  • Original data untouched
  • Thread-safe by default

Concurrency (threads/services updating shared data)
With mutation: Problems are hidden inside objects
With immutability: Problems are visible at coordination points

Example: updating user's balance using Java

public class User {
    private int balance;

    public User(int balance) {
        this.balance = balance;
    }

    public void addBalance(int amount) {
        this.balance += amount;
    }

    public int getBalance() {
        return balance;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        User user = new User(0);

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                user.addBalance(1);
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final balance: " + user.getBalance());
/*
What should happen? Expected result: 2000
What actually happens? other number 
Problem is that this.balance += amount;
Two threads can interleave this -> classic race condition
*/
    }
}

/*
Fix 1 Synchronization (works, but costly)
*/ 

public class User {
    private int balance;

    public User(int balance) {
        this.balance = balance;
    }

    public synchronized void addBalance(int amount) {
        this.balance += amount;
    }

    public synchronized int getBalance() {
        return balance;
    }
}

/*
Fix 2 Atomic Types (better, but still mutable)
*/

public class User {
    private final AtomicInteger balance = new AtomicInteger(0);

    public void addBalance(int amount) {
        balance.addAndGet(amount);
    }

    public int getBalance() {
        return balance.get();
    }
}

/*
Fix 3 Immutability (In java still can be problem with overwrite references) 
*/ 

public final class User {
    private final int balance;

    public User(int balance) {
        this.balance = balance;
    }

    public User addBalance(int amount) {
        return new User(this.balance + amount);
    }

    public int getBalance() {
        return balance;
    }
}

/*
Fix 4: Use immutability + coordination

Concurrency is visible (not hidden)

In mutable code: this.balance += 1;

Concurrency problems are:
* invisible
* implicit
* easy to miss

*/

public class Main {
    public static void main(String[] args) throws InterruptedException {
        AtomicReference<User> userRef = new AtomicReference<>(new User(0));

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                userRef.updateAndGet(user -> user.addBalance(1));
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(userRef.get().getBalance());
    }
}

Enter fullscreen mode Exit fullscreen mode

Concurrency problems are not caused by threads—they are caused by shared mutable state.

Why this is strong ?

  • Mutable state fails silently under concurrency.
  • Immutable state forces you to be explicit about how change happens.

Conclusion

Immutability at the code level is not controversial—it’s cheap and effective.
The real trade-offs begin when you move beyond functions into data modeling and system design.

Data & Domain Level

This is where things get more complicated, because it becomes a design decision with consequences. Lets explore them, but first understand the problem:

At this level, the question changes from:

Should I mutate this object?

to:

What is my data model—state or history?

You are choosing between

  • Mutable (State-based model) You store the current truth: The system answers: “What is the state right now?”
  • Immutable (Event / history-based model) You store what happened: The system answers: “How did we get here?”

Example
Mutable Model

class User {
    int balance;
}
// operations
user.balance += 20;
user.balance -= 10;
Enter fullscreen mode Exit fullscreen mode

What you get is current balance

Example Immutable Model (event-based)

sealed interface Event {}

record BalanceIncreased(int amount) implements Event {}
record BalanceDecreased(int amount) implements Event {}

// state is derived
int apply(List<Event> events) {
    return events.stream()
        .mapToInt(event -> {
            if (event instanceof BalanceIncreased inc) {
                return inc.amount();
            } else if (event instanceof BalanceDecreased dec) {
                return -dec.amount();
            } else {
                return 0;
            }
        })
        .sum();
}
/*
 This works nicely for simple aggregations.
 For real domain logic, you almost always end up with a fold/reduce   over immutable state.
*/
Enter fullscreen mode Exit fullscreen mode

What you get is transactions.

How it works

Instead of updating a single record:

  • user.status = "active"

You model state transitions:

  • UserRegistered
  • UserActivated
  • UserSuspended

Or instead of overwriting:

  • balance = 120

You append:

  • +20
  • -10

Pros

  • Traceability
    You know what happened, not just what is.

  • Auditability
    Full history is preserved.

  • Debugging
    You can reconstruct past states.

  • Temporal queries
    “What was the value at time T?”

Cons

  • More storage
    You store every change, not just the latest state.

  • More complex data modeling
    Designing events or state transitions requires more thinking than simple CRUD. You must design: events invariants transitions.

  • Not always needed
    Do you really need to know when and why user.name changed?

When to use it

  • Shared state across systems
  • Domains where history matters (finance, auditing, workflows)

When not to use it

  • Large objects with frequent small updates
  • Domains where history has no business value (Data changes frequently with no meaning)
  • Simple data like user profiles where only the latest state matters Example CRUD systems: forms → tables → save

Conclusion

Conclusion here: make sure you understand pros and cons and when to use it.

Types of immutable models

  • Event-Based Model- domain events (just discussed)

  • Versioned State Instead of storing events, you store full versions of state.

  • Append-Only Log (State + History Hybrid) lightweight immutability because we track what changed from not why Example:
    json { "field": "status", "old": "PENDING", "new": "SHIPPED" }

  • Immutable Value Objects you keep immutability inside the domain, but store state

API Level

Traditional APIs are usually state-oriented. They ask the client to send the final desired state.

Instead of sending the final state, send the intent / command

Command-Based API

  • The client sends intent
  • API becomes business-driven

Example
POST /orders/123/ship

Instead of

PUT /orders/123
{
  "status": "SHIPPED"
}
Enter fullscreen mode Exit fullscreen mode

You end up with more endpoints
Instead of one CRUD endpoint, you may have many:

/ship
/cancel
/refund
Enter fullscreen mode Exit fullscreen mode

Client must know actions, not just state shape

Event-Style API

This answers what happened

Example

POST /orders/123/events
{
  "type": "OrderShipped",
  "timestamp": "2026-04-23T10:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Commands are requests
Events are facts

Clients usually should not publish domain events directly
Better:

  • clients send commands
  • server emits events

Use command/event style when

  • workflows matter
  • domain logic is rich
  • multiple systems react to actions
  • auditability matters

Avoid when

  • simple CRUD app (forms + tables) *no business workflows

Conclusion

CRUD APIs model data.
Command APIs model behavior.

Next steps

So far, we’ve looked at immutability in code, domain modeling, and APIs. At the code level, it’s mostly a win—but beyond that, once you move into domain and API design, trade-offs start to appear—and you need to be aware of them.

This is only the first part. In the next post, we’ll zoom out even further and explore what happens when these ideas hit persistence and system — where those trade-offs become even more apparent.

Top comments (0)