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
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());
}
}
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;
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.
*/
What you get is transactions.
How it works
Instead of updating a single record:
user.status = "active"
You model state transitions:
UserRegisteredUserActivatedUserSuspended
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 whyuser.namechanged?
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"
}
You end up with more endpoints
Instead of one CRUD endpoint, you may have many:
/ship
/cancel
/refund
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"
}
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)