Encapsulation and Invariants: Keep Objects in a Valid State
8 min readBytePatterns
Encapsulation and invariants explained in Python: one validating door per change, all-or-nothing updates, no leaked lists, and a seeded test of every refusal.
"Encapsulation" is usually taught as "make your fields private", which makes it sound like etiquette. The real reason is stronger: an object that controls every change to its own state can promise something about that state, and keep the promise no matter what callers do. That promise is called an invariant. This article shows how to keep one, the two quiet ways objects break theirs, and how to test that they never do.
The problem it solves
A seat booking with more attendees than seats, a bank balance below its overdraft limit, a date range that ends before it starts: these are illegal states. If any code anywhere can write the fields, then every caller has to remember every rule, and the one that forgets corrupts the object for everyone else. The bug shows up far from where it was written.
Encapsulation moves the rules to one place. Callers can no longer put the object into an illegal state, because the only way in is through methods that check.
The intuition
An invariant is a rule about an object's state that holds before and after every public call. Not "a field that never changes": the values move freely, but the rule always holds. Three habits keep it:
- The constructor establishes it. An object is never visible in an illegal state, not even right after creation.
- Every public method preserves it, validating before writing. Check everything first, then mutate. A refused call must leave the object exactly as it was, so the caller can catch the error and carry on with a valid object. Writing first and checking afterwards, or writing item by item, leaves half-done changes behind.
- No back doors. Returning an internal list hands the caller a way to mutate state without passing through any check. Return a copy or an immutable view, such as a tuple.
In Python, privacy is a convention: a leading underscore means "not yours to write", and nothing enforces it. A double underscore triggers name mangling, which renames the attribute to include the class name, and still does not truly hide it. @property gives a read-only view of a field. Encapsulation in Python protects against mistakes, not against someone determined.
Watch it run
The animation uses the lesson's Thermostat. An invariant is a promise that holds before and after every public call; this one says the target is always between 5 and 30 degrees, never anything else. The field is private, and the leading underscore says it is not yours to write. So there is exactly one door in, and it validates first. set(24) is inside the range, so the check passes and the field moves to 24. Reading is a property: the value comes out, the field stays in. Then a call the object must not honour: set it to eighty. The rule is checked before anything is written, so it raises instead, and the console prints the refusal. The object is untouched, still 24 and still legal. A setter that stored the value and logged a warning would have broken the promise; one that clamps keeps it, but silently sets 30 when 80 was asked. One door in means one place to enforce the rule, and two doors means one of them drifts.
Encapsulation and Invariants
Step 1 of 11
An invariant is a promise that holds before and after every public call.
The same interactive animation as the lesson — step through it with the controls.
The code
A room with a capacity and a list of attendees. The invariant has three parts, and every method checks before it writes:
class Room:
"""Invariant: capacity >= 1, at most capacity attendees, no name twice."""
def __init__(self, capacity):
if capacity < 1:
raise ValueError("capacity must be at least 1")
self._capacity = capacity
self._attendees = []
@property
def capacity(self):
return self._capacity
@property
def attendees(self):
return tuple(self._attendees) # a copy: nobody can append to ours
def add_many(self, names):
names = list(names)
if len(set(names)) < len(names) or set(names) & set(self._attendees):
raise ValueError("already booked")
if len(self._attendees) + len(names) > self._capacity:
raise ValueError("not enough seats")
self._attendees.extend(names) # every check passed: now, and only now, write
def remove(self, name):
if name not in self._attendees:
raise ValueError("not booked")
self._attendees.remove(name)
def resize(self, capacity):
if capacity < max(1, len(self._attendees)):
raise ValueError("would strand attendees")
self._capacity = capacity
room = Room(3)
room.add_many(["ana", "bo"])
for attempt in (lambda: room.add_many(["cy", "dee"]), lambda: room.resize(1), lambda: room.remove("zed")):
try:
attempt()
except ValueError as e:
print("refused:", e, room.attendees, room.capacity)
# refused: not enough seats ('ana', 'bo') 3
# refused: would strand attendees ('ana', 'bo') 3
# refused: not booked ('ana', 'bo') 3
try:
room.attendees.append("eve")
except AttributeError:
print("read-only view") # read-only view
Three refusals, and each leaves the room exactly as it was. Now the two quiet ways to break an invariant. A property that returns the real list, and an add_many that checks and writes one name at a time:
import random
class LeakyRoom(Room):
@property
def attendees(self):
return self._attendees # hands out the real list
class EagerRoom(Room):
def add_many(self, names):
for name in names: # checks and writes one name at a time
if name in self._attendees or len(self._attendees) == self._capacity:
raise ValueError("cannot add " + name)
self._attendees.append(name)
leaky = LeakyRoom(3)
leaky.attendees.extend(["ana", "bo", "cy", "dee"])
print(len(leaky.attendees), leaky.capacity) # 4 3
eager = EagerRoom(3)
eager.add_many(["ana", "bo"])
try:
eager.add_many(["cy", "dee"])
except ValueError as e:
print(e, eager.attendees) # cannot add dee ('ana', 'bo', 'cy')
room = Room(3)
room._attendees += ["x"] * 9 # the underscore is a convention, not a lock
print(len(room.attendees)) # 9
The leaky room holds four people in three seats without any method being called. The eager room raised an error and still booked cy: the invariant survived, but the caller was told "no" while the object said "partly yes". And nothing stops a determined caller from writing _attendees directly.
The seeded check writes the rules a second time, as a pure function, and replays 1,000 random sequences of 40 calls, valid and invalid. After every call the object must match the reference, satisfy the invariant, and be unchanged after any refusal. The eager version fails most sequences:
def reference(state, op, arg):
"""The rules written as a pure function: the new state, or None for a refusal."""
cap, people = state
if op == "add":
ok = len(set(arg)) == len(arg) and not set(arg) & set(people) and len(people) + len(arg) <= cap
return (cap, people + tuple(arg)) if ok else None
if op == "remove":
return (cap, tuple(p for p in people if p != arg)) if arg in people else None
return (arg, people) if arg >= max(1, len(people)) else None
def run(cls, seed):
r = random.Random(seed)
obj = cls(r.randint(1, 5))
state, good = (obj.capacity, ()), True
for _ in range(40):
op = r.choice(["add", "add", "remove", "resize"])
arg = ([r.choice("abcdefg") for _ in range(r.randint(0, 3))] if op == "add"
else r.choice("abcdefg") if op == "remove" else r.randint(-1, 6))
want = reference(state, op, arg)
try:
{"add": obj.add_many, "remove": obj.remove, "resize": obj.resize}[op](arg)
good &= want is not None
except ValueError:
good &= want is None
state = want or state # a refusal must leave everything as it was
now = (obj.capacity, tuple(obj.attendees))
good &= now == state and 1 <= now[0] and len(now[1]) <= now[0] and len(set(now[1])) == len(now[1])
return good
print(all(run(Room, s) for s in range(1000))) # True
print(sum(not run(EagerRoom, s) for s in range(1000))) # 964
The complexity
Validation adds work to each call, here O(n + k) set building for k new names against n booked ones. That cost buys a guarantee every reader of the class can rely on without re-checking, and a smaller search space when something does go wrong: only the methods can be guilty.
Where it goes wrong
- Validating after writing. The refusal leaves the change behind.
- Partial updates. Check the whole batch, then apply it.
- Leaking mutable internals. Return tuples or copies.
- Setters for everything. A setter per field is a back door per field; expose operations with meaning, such as
add_manyorresize. - Silent clamping. It keeps the rule but hides the caller's mistake; raise instead.
- Invariants across threads. Check-then-write is two steps; another thread can slip between them, so shared objects need a lock, as in the thread safety article.
When it shows up in interviews
In low-level design rounds, where the design is judged on whether its objects can reach an illegal state: a parking lot over capacity, an account below its limit. The LLD overview lists the classic prompts. It also appears as "what is encapsulation?" in OOP screening questions, where naming the invariant is what lifts the answer above "private fields".
How to say it in an interview
"Encapsulation is how an object keeps its invariants, the rules about its state that hold before and after every public call. The constructor establishes them, and every public method validates before it writes, so a refused call leaves the object unchanged. I don't expose setters for raw fields or return mutable internals; callers get operations with meaning and read-only views. In Python that's an underscore convention plus properties, so it guards against mistakes rather than malice, and I'd test it by replaying random calls against a reference."