Immutability Strategies - Tuples, Frozen Dataclasses, and Value Objects
Reading time: ~28 minutes | Level: Intermediate → Engineering
Before reading further, predict the output of this program:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
tags: list # mutable!
p = Point(1.0, 2.0, ["origin"])
p.tags.append("modified") # raises? or succeeds?
print(p.tags) # ?
Show Answer
['origin', 'modified']
p.tags.append("modified") succeeds. No exception is raised. frozen=True prevents rebinding - you cannot do p.tags = ["something_else"] (that would raise FrozenInstanceError). But it does not prevent mutating an object that p.tags already points to. The list object itself is mutable; frozen=True only freezes the reference. This is shallow immutability. The Point is frozen at the attribute-reference level, but the objects those attributes point to can still be mutated. Understanding this distinction is the foundation of every immutability strategy in Python.
This lesson maps every tool in Python's immutability toolkit, explains exactly what each one does and does not guarantee, and shows you how to build genuinely immutable structures for use in functional programming, concurrent systems, and domain-driven design.
What You Will Learn
- Python's mutability model: which built-in types are mutable and which are immutable
- Why immutability matters: predictability, hashability, thread safety, caching, debugging
- The shallow vs deep immutability distinction - the key to understanding
frozen=True tuple,frozenset, andbytesas truly immutable built-ins (with caveats)collections.namedtupleandtyping.NamedTupleas immutable named recordsdataclasses.dataclass(frozen=True): what it does and what it does not dotypes.MappingProxyType: read-only views of dicts- Strategies for "modifying" immutable structures:
dataclasses.replace(),_replace()for namedtuples - Deep immutability: the frozen dataclass + tuple of tuples pattern
- Real-world: value objects in Domain-Driven Design, Redux-style state in Python
Prerequisites
- Lesson 07 (Pure Functions) - immutability is the structural enforcement of purity at the data level
- Basic familiarity with
dataclassesandcollections.namedtuple - Understanding of Python's object model: names are references, not boxes
Part 1 - Python's Mutability Model
The Core Distinction
Every Python object is either mutable (its internal state can change after creation) or immutable (its internal state is fixed at creation and can never change).
Why Mutability Matters in Practice
# Mutable default: sharing a reference is dangerous
a = [1, 2, 3]
b = a # b and a point to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] - a was modified through b
# Immutable: sharing a reference is safe
x = (1, 2, 3)
y = x # y and x point to the same tuple
# There is no operation that can modify x through y - sharing is safe
Mutability creates action at a distance: changes through one name affect all other names bound to the same object. Immutability eliminates this class of bug entirely.
Hashability: The Dict Key Requirement
Immutable objects are generally hashable; mutable objects generally are not.
# Immutable - hashable - can be used as dict keys and set members
d = {}
d[(1, 2)] = "point" # tuple key: works
d[frozenset({1, 2})] = "set" # frozenset key: works
d["name"] = "value" # str key: works
# Mutable - not hashable - cannot be used as dict keys
try:
d[[1, 2]] = "list key" # TypeError: unhashable type: 'list'
except TypeError as e:
print(e)
try:
d[{"a": 1}] = "dict key" # TypeError: unhashable type: 'dict'
except TypeError as e:
print(e)
This matters whenever you need composite keys - for example, indexing a grid by (row, col), or caching results keyed by a set of tags.
Part 2 - Tuples: Immutable Sequences (With a Caveat)
Tuples are the most commonly used immutable sequence in Python. They are immutable in the sense that you cannot add, remove, or replace their elements. But they can contain mutable objects.
# Tuple is immutable at the reference level
t = (1, [2, 3], "hello")
# Cannot rebind elements:
try:
t[0] = 99 # TypeError: 'tuple' object does not support item assignment
except TypeError as e:
print(e)
# CAN mutate objects inside:
t[1].append(4)
print(t) # (1, [2, 3, 4], 'hello') - the tuple changed in content!
This is the same shallow immutability you saw in the opening puzzle. The tuple guarantees that t[0], t[1], and t[2] will always refer to the same objects. It does not guarantee that those objects are themselves immutable.
Hashability of Tuples
A tuple is hashable only if all its elements are hashable.
hash((1, 2, 3)) # works - all ints
hash((1, "hello", 3.14)) # works - all hashable types
hash((1, [2, 3])) # TypeError: unhashable type: 'list'
This is why the opening puzzle's Point.tags: list breaks deep immutability even with frozen=True: a list is unhashable, so Point itself would not be hashable.
Python strings are immutable. str.replace(), str.upper(), str.strip() - every string method that appears to "modify" a string actually returns a new string object. The original string is never modified. This is why you must always capture the return value: name = name.strip(), not just name.strip().
Part 3 - frozenset: The Truly Immutable Set
frozenset is the immutable counterpart to set. It supports all read operations (in, len, iteration, set algebra) but not write operations (add, remove, discard, update).
tags = frozenset({"python", "functional", "immutable"})
# Read operations work:
print("python" in tags) # True
print(len(tags)) # 3
print(tags | frozenset({"new"})) # frozenset({'python', 'functional', 'immutable', 'new'})
# Write operations raise AttributeError:
try:
tags.add("another")
except AttributeError as e:
print(e) # 'frozenset' object has no attribute 'add'
# frozenset is hashable - usable as dict key:
cache = {}
cache[frozenset({"a", "b"})] = "result"
print(cache[frozenset({"b", "a"})]) # "result" - frozensets are equal regardless of order
Use frozenset and tuple as dict keys when you need composite or collection-valued keys. For example, caching results of a function that takes a set of feature flags: cache[frozenset(active_flags)] = result. A regular set cannot be used as a dict key; frozenset solves this.
Part 4 - namedtuple and typing.NamedTuple
Named tuples provide immutable records with named fields. They are tuples under the hood - all tuple guarantees apply.
collections.namedtuple (Classic API)
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(x=3.0, y=4.0)
print(p.x) # 3.0
print(p[0]) # 3.0 - indexable like a tuple
print(p._fields) # ('x', 'y')
# _replace returns a NEW namedtuple with specified fields changed:
p2 = p._replace(x=10.0)
print(p) # Point(x=3.0, y=4.0) - original unchanged
print(p2) # Point(x=10.0, y=4.0) - new object
typing.NamedTuple (Modern API, Preferred)
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
label: str = "unnamed" # default value supported
p = Point(x=1.0, y=2.0)
print(p) # Point(x=1.0, y=2.0, label='unnamed')
print(p.label) # 'unnamed'
# Supports type annotations, defaults, and is inspectable:
p2 = p._replace(label="origin")
print(p2) # Point(x=1.0, y=2.0, label='origin')
# Is a tuple:
print(isinstance(p, tuple)) # True
print(hash(p)) # hashable - can be used as dict key
typing.NamedTuple is preferred over collections.namedtuple because it supports type annotations, IDE auto-completion, and mypy type checking natively.
Part 5 - dataclasses.dataclass(frozen=True)
frozen=True converts a dataclass into an immutable-ish record. Python generates __hash__ automatically and replaces __setattr__ and __delattr__ with versions that raise FrozenInstanceError.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Config:
host: str
port: int
timeout: float = 30.0
tags: tuple[str, ...] = field(default_factory=tuple)
cfg = Config(host="localhost", port=5432)
# Rebinding attributes raises FrozenInstanceError:
try:
cfg.host = "remotehost"
except Exception as e:
print(type(e).__name__, e)
# FrozenInstanceError: cannot assign to field 'host'
# Hashing works (if all fields are hashable):
print(hash(cfg)) # a stable integer hash
print({cfg: "cached result"}) # usable as dict key
The Shallow Immutability Trap
As the opening puzzle showed, frozen=True is shallow. If a field holds a mutable object, that object can still be mutated.
@dataclass(frozen=True)
class ShallowConfig:
name: str
allowed_hosts: list[str] # DANGER: mutable field
cfg = ShallowConfig(name="prod", allowed_hosts=["a.com", "b.com"])
# This raises FrozenInstanceError - cannot rebind:
# cfg.allowed_hosts = ["c.com"]
# This SUCCEEDS - mutating the list object:
cfg.allowed_hosts.append("evil.com")
print(cfg.allowed_hosts) # ['a.com', 'b.com', 'evil.com']
# And now it is no longer hashable:
try:
hash(cfg)
except TypeError as e:
print(e) # unhashable type: 'list'
frozen=True is shallow immutability. It prevents rebinding of attributes but does not prevent mutation of mutable objects stored in those attributes. If you put a list, dict, or set inside a frozen=True dataclass, the field can still be mutated through its reference. For genuinely immutable data structures, use tuple instead of list, frozenset instead of set, and types.MappingProxyType instead of dict for nested fields.
Part 6 - types.MappingProxyType: Read-Only Dict Views
types.MappingProxyType wraps a dict and returns a read-only view. You cannot add, modify, or delete keys through the proxy. The underlying dict can still be modified if you have a reference to it - the proxy is a read-only view, not a deep copy.
from types import MappingProxyType
original = {"host": "localhost", "port": 5432}
proxy = MappingProxyType(original)
# Read operations work:
print(proxy["host"]) # "localhost"
print(len(proxy)) # 2
print("port" in proxy) # True
# Write operations raise TypeError:
try:
proxy["host"] = "remote"
except TypeError as e:
print(e) # 'mappingproxy' object does not support item assignment
try:
proxy["new_key"] = "value"
except TypeError as e:
print(e) # 'mappingproxy' object does not support item assignment
# But the underlying dict can still be modified:
original["host"] = "remote"
print(proxy["host"]) # "remote" - proxy reflects the change
# Creating a fully independent frozen dict (snapshot):
snapshot = MappingProxyType(dict(original)) # copy first, then proxy
Where Python Uses MappingProxyType Internally
MappingProxyType is used throughout the Python runtime:
class MyClass:
x = 10
# Class __dict__ is exposed as a MappingProxyType:
print(type(MyClass.__dict__)) # <class 'mappingproxy'>
print(MyClass.__dict__["x"]) # 10
This prevents external code from modifying class __dict__ directly, while still allowing it to be read. The same pattern is useful for configuration objects that should be readable but not writable by consumers.
Part 7 - Deep Immutability: Making It All the Way Down
Shallow immutability stops at one level. Deep immutability means every object in the entire structure is immutable. Python's type system does not enforce this automatically - you must construct it carefully.
Frozen Dataclass with All-Immutable Fields
from dataclasses import dataclass
from typing import Sequence
@dataclass(frozen=True)
class Address:
street: str
city: str
country: str
@dataclass(frozen=True)
class User:
user_id: int
name: str
email: str
address: Address # immutable - also a frozen dataclass
roles: tuple[str, ...] # immutable - tuple of strings (strings are immutable)
tags: frozenset[str] # immutable - frozenset of strings
user = User(
user_id=1,
name="Alice",
address=Address(street="123 Main St", city="London", country="GB"),
roles=("admin", "editor"),
tags=frozenset({"active", "verified"}),
)
# user is deeply immutable:
# - user.name is a str (immutable)
# - user.address is a frozen dataclass (immutable at its level)
# - user.address.city is a str (immutable)
# - user.roles is a tuple of strs (all immutable)
# - user.tags is a frozenset of strs (all immutable)
print(hash(user)) # hashable - all fields are hashable
The Tuple of Tuples Pattern
For grid or matrix data that must be immutable:
# Mutable - vulnerable to mutation
grid_mutable = [[0, 1, 0], [1, 0, 1], [0, 1, 0]]
grid_mutable[0][1] = 99 # silently mutates
# Immutable - cannot be mutated
grid_frozen = tuple(tuple(row) for row in [[0, 1, 0], [1, 0, 1], [0, 1, 0]])
try:
grid_frozen[0][1] = 99
except TypeError as e:
print(e) # 'tuple' object does not support item assignment
# And it is hashable:
print(hash(grid_frozen)) # works
Never use mutable default arguments. They are created once at function definition time and shared across all calls. This is one of the oldest and most common Python bugs:
# BUG: default list is shared across ALL calls
def add_user(name: str, roles: list = []) -> dict:
roles.append("user") # mutates the shared default!
return {"name": name, "roles": roles}
print(add_user("Alice")) # {'name': 'Alice', 'roles': ['user']}
print(add_user("Bob")) # {'name': 'Bob', 'roles': ['user', 'user']} - wrong!
# FIX: use None sentinel
def add_user_fixed(name: str, roles: list | None = None) -> dict:
if roles is None:
roles = []
return {"name": name, "roles": roles + ["user"]}
The same applies to mutable default dicts, sets, and any other mutable default argument.
Part 8 - Strategies for "Modifying" Immutable Structures
Immutability means you never change an existing object - you create a new one with the desired changes. Python provides helpers for this.
dataclasses.replace() - Functional Updates for Frozen Dataclasses
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Config:
host: str
port: int
debug: bool = False
cfg = Config(host="localhost", port=5432)
print(cfg) # Config(host='localhost', port=5432, debug=False)
# Create a new Config with debug=True - original is untouched:
debug_cfg = replace(cfg, debug=True)
print(cfg) # Config(host='localhost', port=5432, debug=False)
print(debug_cfg) # Config(host='localhost', port=5432, debug=True)
# Multiple fields:
prod_cfg = replace(cfg, host="db.prod.example.com", port=5433)
print(prod_cfg) # Config(host='db.prod.example.com', port=5433, debug=False)
namedtuple._replace() - Functional Updates for NamedTuples
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
z: float = 0.0
p = Point(x=1.0, y=2.0)
p_elevated = p._replace(z=5.0)
print(p) # Point(x=1.0, y=2.0, z=0.0)
print(p_elevated) # Point(x=1.0, y=2.0, z=5.0)
Composing Updates: The Functional Update Pipeline
from dataclasses import dataclass, replace
from typing import Callable
@dataclass(frozen=True)
class AppState:
user_id: int | None
authenticated: bool
page: str
cart_items: tuple[str, ...]
def login(state: AppState, user_id: int) -> AppState:
return replace(state, user_id=user_id, authenticated=True)
def navigate(state: AppState, page: str) -> AppState:
return replace(state, page=page)
def add_to_cart(state: AppState, item: str) -> AppState:
return replace(state, cart_items=state.cart_items + (item,))
# Initial state
initial = AppState(user_id=None, authenticated=False, page="home", cart_items=())
# Each operation returns a new state; previous states are preserved
after_login = login(initial, user_id=42)
after_navigate = navigate(after_login, page="shop")
after_add = add_to_cart(after_navigate, item="SKU-001")
print(initial.authenticated) # False - original unchanged
print(after_login.authenticated) # True
print(after_add.cart_items) # ('SKU-001',)
print(after_navigate.cart_items) # () - the navigate state has no cart item yet
This is the Python equivalent of Redux-style state management: each state is immutable, and "mutations" are expressed as functions that return new states.
Part 9 - Real-World Patterns
Value Objects in Domain-Driven Design
In DDD, a value object is an object that has no identity - it is defined entirely by its attributes. Two value objects with the same attributes are equal. Value objects must be immutable: you never "change" a value object, you replace it with a new one.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError(f"Money amount cannot be negative: {self.amount}")
if len(self.currency) != 3:
raise ValueError(f"Currency must be a 3-letter code: {self.currency}")
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(amount=self.amount + other.amount, currency=self.currency)
def scale(self, factor: Decimal) -> "Money":
return Money(amount=(self.amount * factor).quantize(Decimal("0.01")), currency=self.currency)
price = Money(amount=Decimal("99.99"), currency="GBP")
tax = price.scale(Decimal("0.20"))
total = price.add(tax)
print(price) # Money(amount=Decimal('99.99'), currency='GBP')
print(tax) # Money(amount=Decimal('20.00'), currency='GBP')
print(total) # Money(amount=Decimal('119.99'), currency='GBP')
# Value equality - two Money objects with the same values are equal:
m1 = Money(Decimal("10.00"), "USD")
m2 = Money(Decimal("10.00"), "USD")
print(m1 == m2) # True
print(hash(m1) == hash(m2)) # True - same hash
Configuration Objects
from dataclasses import dataclass, replace
from types import MappingProxyType
@dataclass(frozen=True)
class DatabaseConfig:
host: str
port: int
database: str
pool_size: int = 5
ssl: bool = True
@classmethod
def for_development(cls) -> "DatabaseConfig":
return cls(host="localhost", port=5432, database="dev_db", ssl=False)
@classmethod
def for_production(cls, host: str, database: str) -> "DatabaseConfig":
return cls(host=host, port=5432, database=database, pool_size=20, ssl=True)
dev_config = DatabaseConfig.for_development()
test_config = replace(dev_config, database="test_db")
print(dev_config.database) # dev_db
print(test_config.database) # test_db - original unchanged
# Expose as a read-only mapping for consumers that need dict-like access:
import dataclasses
config_view = MappingProxyType(dataclasses.asdict(dev_config))
print(config_view["host"]) # localhost
Engineering Checklist
Before moving on, verify you can answer these without looking:
- What is the difference between shallow and deep immutability?
- What does
frozen=Trueon a dataclass actually prevent? What does it NOT prevent? - When is a
tuplehashable? When is it not? - What is
frozensetand why is it useful as a dict key? - How do you "modify" a frozen dataclass? What function do you use?
- What is
types.MappingProxyTypeand where does Python use it internally? - Why is a mutable default argument a bug? What is the correct pattern?
- What is a value object in DDD and why must it be immutable?
Key Takeaways
- Python's mutable types (
list,dict,set) allow in-place modification; immutable types (int,str,tuple,frozenset,bytes) do not. Shared references to mutable objects create action at a distance - changes through one name affect all other names bound to the same object. frozen=Trueon a dataclass is shallow immutability: it prevents rebinding of attributes (raisesFrozenInstanceError) but does not prevent mutation of mutable objects stored in those attributes. Alistinside a frozen dataclass can still be appended to.- A
tupleis hashable only if all its elements are hashable. Atuplecontaining alistis not hashable. For deep immutability, usetupleof immutable elements andfrozensetinstead ofset. frozensetis the hashable, immutable counterpart toset. Use it as a dict key whenever you need a set-valued key - for example, caching results keyed by a combination of feature flags.typing.NamedTupleis preferred overcollections.namedtuple. It supports type annotations, IDE completion, andmypy. It is a tuple subclass - all tuple guarantees (hashability of hashable-element tuples, immutability at the reference level) apply.dataclasses.replace(obj, field=new_value)returns a new dataclass instance with specified fields changed. The original is untouched. This is the functional update pattern - the Python equivalent of Redux-style state management.types.MappingProxyTypeprovides a read-only view of a dict. It is used by Python itself to expose class__dict__. Use it to expose configuration or settings dicts to consumers that should read but not write.- Never use mutable default arguments (
def f(x, lst=[])) - the default object is created once and shared across all calls. UseNoneas the sentinel and create a new object inside the function body. - Value objects in DDD are defined entirely by their attributes, have no identity, and must be immutable. Frozen dataclasses with all-immutable fields are the idiomatic Python implementation.
Graded Practice
Level 1 - Predict the Output
Question 1
t = (1, 2, [3, 4])
t[2].append(5)
print(t)
print(len(t))
Show Answer
(1, 2, [3, 4, 5])
3
t[2] is a reference to the list [3, 4]. The tuple t is immutable at the reference level - you cannot do t[2] = something_else. But the list object that t[2] refers to is mutable, and t[2].append(5) mutates it. The tuple now "contains" [3, 4, 5], even though the tuple itself was never "modified" - the reference is unchanged, but the object it points to is different. The tuple's length is still 3 because no references were added or removed.
Question 2
from dataclasses import dataclass
@dataclass(frozen=True)
class Tag:
name: str
value: str
t1 = Tag(name="env", value="prod")
t2 = Tag(name="env", value="prod")
t3 = Tag(name="env", value="dev")
print(t1 == t2)
print(t1 is t2)
print(hash(t1) == hash(t2))
print({t1, t2, t3})
Show Answer
True
False
True
{Tag(name='env', value='prod'), Tag(name='env', value='dev')}
frozen=True generates __eq__ (compares field values) and __hash__ (based on field values). t1 == t2 is True because both have the same name and value. t1 is t2 is False because they are separate objects in memory. hash(t1) == hash(t2) is True because equal objects must have equal hashes (required by Python's hash contract). The set {t1, t2, t3} contains two elements because t1 and t2 are considered equal and t3 is different.
Question 3
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(1.0, 2.0)
p2 = p._replace(x=10.0)
print(p)
print(p2)
print(p == (1.0, 2.0))
print(isinstance(p, tuple))
Show Answer
Point(x=1.0, y=2.0)
Point(x=10.0, y=2.0)
True
True
_replace returns a new Point with x=10.0; the original p is untouched. p == (1.0, 2.0) is True because NamedTuple subclasses tuple and tuple equality compares element-by-element. isinstance(p, tuple) is True for the same reason - a NamedTuple IS a tuple. This means named tuples can be used anywhere a tuple is expected, including as dict keys (if all elements are hashable).
Question 4
from types import MappingProxyType
d = {"a": 1, "b": 2}
proxy = MappingProxyType(d)
d["c"] = 3
print(proxy.get("c"))
try:
proxy["d"] = 4
except TypeError as e:
print(f"Error: {e}")
print(len(proxy))
Show Answer
3
Error: 'mappingproxy' object does not support item assignment
3
proxy is a read-only view of d, not a copy. When d["c"] = 3 is added to the underlying dict, proxy.get("c") returns 3 - the proxy reflects the live state of d. Writing through the proxy (proxy["d"] = 4) raises TypeError. len(proxy) is 3 because the proxy now sees d's three keys: "a", "b", and "c".
Question 5
def make_point(x, y, metadata={}):
metadata["created"] = True
return (x, y, metadata)
p1 = make_point(1, 2)
p2 = make_point(3, 4)
print(p1)
print(p2)
print(p1[2] is p2[2])
Show Answer
(1, 2, {'created': True})
(3, 4, {'created': True})
True
metadata={} is a mutable default argument. The dict {} is created once, at function definition time, and reused across all calls. Both p1[2] and p2[2] reference the exact same dict object (p1[2] is p2[2] is True). The dict already has {'created': True} from the first call when the second call runs - but since the key is set to the same value again, the output looks identical. The bug becomes obvious if you do something like metadata[x] = y - data from all previous calls accumulates in the same dict.
Level 2 - Debug Challenge
The following class is supposed to represent an immutable shopping cart where adding an item returns a new cart. Find why it is not actually immutable and fix it.
from dataclasses import dataclass
@dataclass(frozen=True)
class Cart:
user_id: int
items: list[str] # BUG
def add_item(self, item: str) -> "Cart":
self.items.append(item) # BUG
return self
cart1 = Cart(user_id=1, items=[])
cart2 = cart1.add_item("book")
cart3 = cart2.add_item("pen")
print(cart1.items)
print(cart2.items)
print(cart3 is cart2)
Show Answer
There are two bugs:
Bug 1 - Mutable field: items: list[str] stores a mutable list. Even with frozen=True, the list can be mutated through self.items.append(...). frozen=True only prevents self.items = new_list (rebinding), not self.items.append(...) (mutating the list object).
Bug 2 - Returns self: add_item returns self after mutating it. This means cart1, cart2, and cart3 all refer to the same object - there is no new cart, and all "carts" share the same mutated state.
Current output (buggy):
['book', 'pen'] # cart1 was mutated - supposed to be []
['book', 'pen'] # cart2 was mutated - supposed to be ['book']
True # cart3 is cart2 - supposed to be a new object
Fixed version:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Cart:
user_id: int
items: tuple[str, ...] # FIX 1: use tuple instead of list
def add_item(self, item: str) -> "Cart":
return replace(self, items=self.items + (item,)) # FIX 2: return new Cart
cart1 = Cart(user_id=1, items=())
cart2 = cart1.add_item("book")
cart3 = cart2.add_item("pen")
print(cart1.items) # () - original unchanged
print(cart2.items) # ('book',) - new cart
print(cart3.items) # ('book', 'pen') - new cart
print(cart3 is cart2) # False - distinct objects
The combination of tuple fields and dataclasses.replace() achieves true shallow immutability. Each add_item creates a new Cart with a new tuple; the original Cart is never modified.
Level 3 - Design Challenge
Design an immutable event sourcing system for a bank account in Python. Requirements:
- An
Accountis a value object: immutable, identified by its state, not by a mutable object reference deposit(amount)andwithdraw(amount)return newAccountobjects- Every state transition produces an event (a record of what happened)
- The full history of events can be replayed to reconstruct any past account state
- The account balance can never be negative (enforce this in the pure core)
All data structures must be genuinely immutable (no list, no dict as fields in immutable objects).
Show Answer
from dataclasses import dataclass, replace
from decimal import Decimal
from typing import Literal
# ============================================================
# IMMUTABLE VALUE OBJECTS
# ============================================================
@dataclass(frozen=True)
class Money:
"""Immutable value object representing an amount in a currency."""
amount: Decimal
currency: str
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
return Money(self.amount - other.amount, self.currency)
def is_negative(self) -> bool:
return self.amount < Decimal("0")
def __str__(self) -> str:
return f"{self.currency} {self.amount:.2f}"
EventType = Literal["account_opened", "deposited", "withdrawn"]
@dataclass(frozen=True)
class AccountEvent:
"""Immutable record of a single state transition."""
event_type: EventType
amount: Money
balance_after: Money
@dataclass(frozen=True)
class Account:
"""
Immutable value object representing an account state.
History is stored as a tuple of AccountEvents - immutable sequence.
"""
account_id: str
owner: str
balance: Money
history: tuple[AccountEvent, ...]
@classmethod
def open(cls, account_id: str, owner: str, currency: str = "GBP") -> "Account":
"""Pure factory - creates a new account with zero balance."""
zero = Money(Decimal("0"), currency)
event = AccountEvent(
event_type="account_opened",
amount=zero,
balance_after=zero,
)
return cls(
account_id=account_id,
owner=owner,
balance=zero,
history=(event,),
)
def deposit(self, amount: Money) -> "Account":
"""Pure: returns a new Account with increased balance."""
if amount.is_negative():
raise ValueError(f"Deposit amount must be positive: {amount}")
new_balance = self.balance + amount
event = AccountEvent(
event_type="deposited",
amount=amount,
balance_after=new_balance,
)
return replace(
self,
balance=new_balance,
history=self.history + (event,),
)
def withdraw(self, amount: Money) -> "Account":
"""Pure: returns a new Account with decreased balance; raises if insufficient."""
if amount.is_negative():
raise ValueError(f"Withdrawal amount must be positive: {amount}")
new_balance = self.balance - amount
if new_balance.is_negative():
raise ValueError(
f"Insufficient funds: balance {self.balance}, requested {amount}"
)
event = AccountEvent(
event_type="withdrawn",
amount=amount,
balance_after=new_balance,
)
return replace(
self,
balance=new_balance,
history=self.history + (event,),
)
# ============================================================
# PURE REPLAY FUNCTION
# ============================================================
def replay_to_event(events: tuple[AccountEvent, ...], n: int) -> Money:
"""Pure: replays the first n events and returns the balance at that point."""
if n <= 0 or not events:
return events[0].balance_after if events else Money(Decimal("0"), "GBP")
return events[min(n, len(events)) - 1].balance_after
# ============================================================
# DEMO - everything is pure, immutable, and reproducible
# ============================================================
gbp = lambda amount: Money(Decimal(str(amount)), "GBP")
# Open account - pure factory
acc0 = Account.open("ACC-001", "Alice")
print(f"Opened: {acc0.balance}") # GBP 0.00
# Deposit - returns new account, original untouched
acc1 = acc0.deposit(gbp(500))
print(f"After deposit: {acc1.balance}") # GBP 500.00
print(f"Original: {acc0.balance}") # GBP 0.00 - unchanged
# Withdraw
acc2 = acc1.withdraw(gbp(120))
print(f"After withdrawal: {acc2.balance}") # GBP 380.00
# Another deposit
acc3 = acc2.deposit(gbp(50))
print(f"Final balance: {acc3.balance}") # GBP 430.00
# Full history
print(f"\nEvent history ({len(acc3.history)} events):")
for i, event in enumerate(acc3.history):
print(f" [{i}] {event.event_type}: {event.amount} → balance {event.balance_after}")
# Replay: what was the balance after the first 2 events?
balance_at_2 = replay_to_event(acc3.history, 2)
print(f"\nBalance after event 2: {balance_at_2}") # GBP 500.00
# Overdraft protection (pure core enforces the invariant)
try:
acc3.withdraw(gbp(9999))
except ValueError as e:
print(f"\nOverdraft prevented: {e}")
# Account states are hashable - can be used as dict keys, stored in sets
states = {acc0, acc1, acc2, acc3}
print(f"\nDistinct account states: {len(states)}") # 4
Output:
Opened: GBP 0.00
After deposit: GBP 500.00
Original: GBP 0.00 - unchanged
After withdrawal: GBP 380.00
Final balance: GBP 430.00
Event history (4 events):
[0] account_opened: GBP 0.00 → balance GBP 0.00
[1] deposited: GBP 500.00 → balance GBP 500.00
[2] withdrawn: GBP 120.00 → balance GBP 380.00
[3] deposited: GBP 50.00 → balance GBP 430.00
Balance after event 2: GBP 500.00
Overdraft prevented: Insufficient funds: balance GBP 430.00, requested GBP 9999.00
Distinct account states: 4
Design decisions:
MoneyandAccountEventandAccountare allfrozen=Truedataclasses with all-immutable fields (str,Decimal,tuple)- No
listordictappears as a field in any immutable class depositandwithdrawreturn newAccountobjects viareplace()- the original is always preserved- Business invariants (no negative deposits, no overdraft) are enforced in the pure core
- The
replay_to_eventfunction is pure - it reconstructs historical state from an immutable event log without any I/O - Every
Accountstate is hashable and can be stored in asetor used as a dict key
What's Next
Lesson 09 covers the functools module in full - lru_cache, cache, wraps, partial, reduce, total_ordering, cached_property, and singledispatch. You now understand why lru_cache requires pure functions (from this lesson and Lesson 07). The next lesson shows you how to use every tool in functools at engineering depth, including the subtle memory leak caused by applying lru_cache to instance methods.
