A delivery app shows a restaurant's pin on the map. A rider starts at that restaurant, rides away, and suddenly the restaurant's pin is moving too. Nobody wrote "move the restaurant" anywhere. The rider and the restaurant were simply holding the same location object, and the rider changed it. Bugs like this disappear when the object cannot be changed at all. That property is called immutability.
Immutable object = once it is created, its state never changes. To "change" it, you create a new object with the new values.
Real-world analogy: An exam hall ticket is printed once. If your exam centre changes, nobody takes a pen and overwrites the old ticket; a new hall ticket is issued. Anyone holding the old ticket still sees exactly what was printed on it, and the new ticket has the new centre. A printed ticket is immutable; a whiteboard is mutable.
Mutable: both owners share one point that changes. Immutable: a change creates a new point
1. What Is Immutability?
An object is immutable when all of its fields are set in the constructor and can never be changed afterwards. There is no setter, and no method edits the object in place. A method that looks like an update, such as moveTo() or withStatus(), returns a new object and leaves the original exactly as it was.
A mutable object is the opposite: setters or other methods change its fields after it is created. Most entity classes in an LLD (a User, a ParkingLot, a Cart) are mutable, and that is fine. Immutability is a design choice you make for specific classes, mostly value objects.
You already use immutable objects every day:
- Java:
String,Integer,LocalDate,BigDecimal, records,List.of(...). - Python:
str,int,tuple,frozenset, frozen dataclasses. - JavaScript: strings and numbers (primitives) are immutable.
- C++: any object you access through
const, and string literals.
For example, "save10".toUpperCase() does not change the original string. It returns a new string "SAVE10", and the old one is still "save10".
2. Why Immutability Matters in LLD
- No surprise changes. If an object cannot change, you can pass it anywhere without worrying that someone else will modify it. The restaurant bug above becomes impossible.
- Thread-safe for free. Many threads can read the same immutable object at the same time without locks, because nobody can write to it.
- Safe keys in maps and sets. If a key's fields change after it is put into a
HashMaporHashSet, its hash changes and the entry is "lost". Immutable keys never have this problem. - Validate once. The constructor checks the values. After that the object can never become invalid, so you never need to check it again.
- Easy history and undo. Old versions stay valid, so you can keep them for audit logs, undo or caching.
3. The Problem: A Shared Mutable Object
Here is the delivery app bug in code. GeoPoint is mutable: moveTo() changes its fields. The rider is created with the restaurant's point, so both objects hold a reference to the same GeoPoint.
Output
Before: Paradise Biryani at (17.4416, 78.4983)
Rider: Kiran at (17.4126, 78.4071)
After: Paradise Biryani at (17.4126, 78.4071) <-- the restaurant moved too!
The rider changed its own location, and the restaurant moved too. Nothing in Restaurant is wrong, and nothing in DeliveryPartner looks wrong on its own. The bug comes from sharing a mutable object. In a large codebase, finding out who changed a shared object is one of the hardest bugs to track down.
In C++, objects are copied by default, so the example uses shared_ptr to share one object. Java, Python and JavaScript share objects through references automatically, which is why this bug is so common in those languages.
4. How to Design an Immutable Class
Immutability is not one keyword. It is a short list of rules, and missing any one of them breaks it.
The steps to make a class immutable
- Stop subclasses from adding mutability. In Java, mark the class
final(C++ also hasfinal). Otherwise a subclass could add a setter. - Make every field private and assign-once. Java:
private final. C++: private fields and onlyconstmethods. Python: a frozen dataclass or read-only properties. JavaScript:#privatefields with getters andObject.freeze(this). - No setters, and no other method that changes a field.
- Validate and set everything in the constructor. After the constructor finishes, the object is complete and valid.
- Copy mutable inputs. If the constructor receives a list, array or other mutable object, store a copy, not the caller's object.
- Do not hand out mutable internals. Getters return a copy or a read-only view.
- "Changes" return new objects.
moveTo(),withStatus()andplus()build and return a new instance.
Here is the same delivery example with an immutable GeoPoint:
Output
Before: Paradise Biryani at (17.4416, 78.4983)
Rider: Kiran at (17.4126, 78.4071)
After: Paradise Biryani at (17.4416, 78.4983) <-- unchanged
Rejected: invalid coordinates (120.5, 78.4983)
What changed:
moveTo()returns a new point. The rider stores that new point; the restaurant still has the old one.- The rider's reference changes (
location = location.moveTo(...)), but noGeoPointobject is ever modified. Keep this difference in mind: an immutable object is not the same thing as a variable that cannot be reassigned. - Sharing the point between the restaurant and the rider is now completely safe.
- Invalid coordinates are rejected once, in the constructor.
Language notes: Python's @dataclass(frozen=True) raises FrozenInstanceError if anyone assigns a field. In C++, making the fields const would also block normal assignment of the whole object (location = ...), so the usual style is private fields, no setters and const methods.
5. Defensive Copies: The Hidden Leak
A class can look immutable (final, private fields, no setters) and still be mutable. This happens when it stores or returns a mutable object, such as a list. final fixes the reference; it does not freeze the list that the reference points to.
There are two leaks:
- Leak on the way in: the constructor keeps the caller's list, and the caller can still change that list later.
- Leak on the way out: the getter returns the internal list, and anyone who calls the getter can change it.
SafeResult copies the list on the way in and gives out a read-only view on the way out
Output
Safe: edit through getter did not reach the object
Leaky: Anjali [100, 72, 91, 99] total=362
Safe: Anjali [65, 72, 91] total=228
LeakyResult was changed twice from outside, and its total went from 228 to 362 without calling a single method that should change it. SafeResult closes both leaks:
| Language | Copy on the way in | Safe on the way out |
|---|---|---|
| Java | new ArrayList<>(marks) or List.copyOf(marks) | Collections.unmodifiableList(...) or List.copyOf(...) |
| C++ | store a std::vector by value | return a copy or a const& |
| Python | tuple(marks) | return the tuple (it has no append()) |
| JavaScript | [...marks] | Object.freeze(...) |
Immutable inputs such as strings, numbers and other immutable objects do not need copying. You only defend against things that can change.
Watch out: Object.freeze() in JavaScript and Collections.unmodifiableList() in Java are shallow. If the list holds mutable objects, those objects can still be changed. For full immutability, the elements must be immutable too. The next article, Deep Copy vs Shallow Copy, explains this in detail.
6. Immutability Support in Each Language
| Need | Java | C++ | Python | JavaScript |
|---|---|---|---|---|
| Field set only once | final | const member | frozen dataclass, @property without a setter | #private field + getter |
| Read-only access | no setters | const methods, const T& | no setters | Object.freeze |
| Ready-made value class | record GeoPoint(double lat, double lng) | const objects | @dataclass(frozen=True), NamedTuple | Object.freeze on a plain object |
| Immutable collection | List.of, List.copyOf | const std::vector | tuple, frozenset | Object.freeze([...]) (shallow) |
| Copy with one change | "with" methods | "with" methods | dataclasses.replace() | "with" methods, { ...obj, status: "PAID" } |
Java records give you final fields, a constructor, getters, equals(), hashCode() and toString() in one line, which makes them the easiest way to write a value object in modern Java. A record is still shallow, though: a record that holds a List needs a defensive copy in its constructor.
7. Practical Example: Order History with "With" Methods
If an object cannot change, how do you update an order? You use "with" methods: each one copies the current values, replaces one field and returns a new object. Because old versions are never modified, keeping a history and undoing a change become trivial.
Each change creates a new OrderState; old versions stay exactly as they were
Output
v1: ORD-1042 | PLACED | Rs.450
v2: ORD-1042 | CONFIRMED | Rs.450
v3: ORD-1042 | CONFIRMED | Rs.400
v4: ORD-1042 | DELIVERED | Rs.400
Current: ORD-1042 | DELIVERED | Rs.400
Undo -> ORD-1042 | CONFIRMED | Rs.400
Why This Design Works
- Free history. Every version is a separate object, so the list is a complete audit trail of the order.
- Undo is just "go back one version". No reverse logic is needed, because version v3 was never changed.
- Safe sharing. The payment service can hold v3 while the delivery service moves on to v4, and neither can disturb the other.
- Python keeps it short:
dataclasses.replace(self, status=new_status)creates the copy with one field changed.
8. When Not to Use Immutability
Immutability creates a new object for every change. For small value objects this costs almost nothing. It becomes a problem when an object is large and changes very often, for example when building a long string in a loop, or a game board that is updated many times per second.
A quick check for deciding whether a class should be immutable
In those cases, use a mutable helper and freeze the result at the end. Java's StringBuilder builds the text, and toString() then gives you an immutable String. The Builder pattern does the same for objects with many fields.
9. Common Mistakes
- Thinking that
finalorconston a variable makes the object immutable.final List<Integer> marksonly stops reassignment;marks.add(5)still works. - Storing the caller's list instead of a copy, or returning the internal list from a getter.
- Forgetting that
Object.freezeis shallow. Nested objects and arrays inside a frozen object can still change. - Leaving the class open for extension in Java, so a subclass can add mutable state and setters.
- Ignoring the return value.
point.moveTo(1, 2);on its own does nothing; you must use the returned object:point = point.moveTo(1, 2);. The same applies toname.toUpperCase(). - Making everything immutable. Entities with an identity and a lifecycle, such as a
ParkingSpotthat becomes free and occupied, are usually mutable. Use immutability for values.
Summary
- An immutable object never changes after construction; a "change" returns a new object.
- It prevents bugs caused by shared objects, is thread-safe without locks, works safely as a map key, and needs validation only once.
- The recipe: final class, private assign-once fields, no setters, validation in the constructor, defensive copies in and out, and "with" methods that return new objects.
final,constreferences,Object.freezeand unmodifiable views are shallow. Mutable fields still need copying.- Use immutability for value objects (money, coordinates, dates, settings, order snapshots). Use mutable objects or builders for large state that changes all the time.