Hiding state behind encapsulation
Open in the interactive app: practice, quiz, mock interview →In one minute
- Encapsulation means an object owns its data and every change goes through code that keeps it valid. The point is protecting invariants, not hiding fields.
- A private field with a public getter and setter is not encapsulated. Ask what rule must always hold, and whether any outside path can break it.
- The usual leaks are aliases: returning an internal list, or storing a list the caller still holds.
privatemeans different things at runtime. Java enforces it in the JVM, JavaScript#fieldsare truly inaccessible, Go hides lowercase names at package level, TypeScriptprivatedisappears at compile time, and Python only renames__xto_Class__x.- Agents often "fix" a compile error by making a field public, add a setter for every field, or have tests poke at
obj._items. Each one moves an invariant out of the class and into everyone's memory.
How it works
The problem: rules that live in callers
An order has line items and a total. The first version is a plain record:
class Order:
def __init__(self):
self.items = [] # list of (sku, qty, unit_cents)
self.total_cents = 0
self.status = "open"Checkout appends to items and adds to total_cents. The refund handler removes an item and forgets to subtract. A support tool sets status = "shipped" on an empty order. Every rule ("total equals the sum of the lines", "a shipped order cannot change") is enforced, or not, by whichever of 40 call sites touched the data last.
Move the rules next to the data
class Order:
def __init__(self):
self._lines = {}
self._total = 0
self._shipped = False
def add(self, sku, qty, unit_cents):
if self._shipped:
raise ValueError("order already shipped")
if qty <= 0:
raise ValueError("quantity must be positive")
old_qty, _ = self._lines.get(sku, (0, unit_cents))
self._lines[sku] = (old_qty + qty, unit_cents)
self._total += qty * unit_cents
def total_cents(self):
return self._totalNow each invariant has one guardian. Callers say what they want (add, ship) instead of pulling data out, deciding, and pushing new data back: the "tell, don't ask" style. The representation is also free to change: _total could be computed on demand without any caller noticing.
Getters and setters are not encapsulation
Generating get_total() and set_total() changes the syntax and keeps the problem: the setter is a public door to the same invariant. Expose domain operations (apply_discount(pct), ship()) and read access only where callers need it. A type with no rules, such as a DTO or config record, is honestly a plain struct.
Aliasing: the leak that passes review
func (o *Order) Lines() []Line { return o.lines }The returned slice shares the backing array, so lines[0].Qty = 100 changes the order without touching the total. The leak also runs inbound: NewOrder(lines) that stores the caller's slice lets the caller keep mutating it. Python lists, Java ArrayLists and JavaScript arrays behave the same way.
Fixes, from cheapest to strongest:
- Copy at the boundary on the way in and out (
slices.Clone,list(self._lines),[...this.lines]). - Read-only views:
types.MappingProxyTypein Python andCollections.unmodifiableListin Java are wrappers that refuse writes. TypeScriptReadonlyArrayis compile-time only. - Iteration instead of collections: offer
for_eachor an iterator, so no container escapes. - Immutable values: if
Lineis immutable and the collection is persistent, sharing is safe and copying is unnecessary.
Encapsulation beyond the class
A Go internal/ directory hides a package from code outside its parent tree, and a Java module exports only the packages it lists. A service whose tables other teams query directly is not encapsulated: its schema is frozen by people it has never met. The Coupling and Cohesion lesson covers measuring such dependencies.
When not to bother
- Plain data. Records, events and DTOs with no rules gain nothing from private fields and accessors.
- Inside a small module. Two functions in the same file sharing a struct do not need a defensive API between them.
- Hot paths where copying dominates. Return a read-only view or an iterator instead of copying a 100,000-element list per call.
Reviewing agent-written code
Agents optimise for compiling and passing. When code needs a private field, the quickest fix is a setter; when a test needs state, it is order._lines or (order as any).lines. In review, look for new setters, internal collections in return values, constructors storing caller-owned lists, and tests reaching into private state. Ask for domain operations, copies or views at the boundary, and tests through the public API.
The same shape elsewhere
Encapsulation is the multi-step consistency shape solved by giving one party exclusive write access. A database enforces CHECK constraints and foreign keys so no client can write an inconsistent row. A Kubernetes controller is the only writer of a resource's status. A mutex only works if every access goes through the lock, which is the same thing as all access going through methods.
Down the stack
Layer 1The contract
An encapsulated type promises that its invariants hold before and after every public operation, and that outside code reaches its representation only through those operations. The compiler, the runtime or only convention enforces this, depending on the language, and none makes it a security boundary inside a process: reflection, unsafe or a debugger gets through.
Layer 2The mechanism
Python. A single underscore (_lines) is convention only; tools such as linters and from module import * respect it. A double underscore (__lines) inside a class body is rewritten by the compiler to _Order__lines in the bytecode, so a subclass's __lines does not collide with the parent's. It is name mangling for collision avoidance, not access control: order._Order__lines works. A frozen dataclass blocks assignment by overriding __setattr__, which object.__setattr__(obj, "x", 1) bypasses.
TypeScript. private and readonly are type-checker rules. tsc emits an ordinary JavaScript property, so (o as any).lines, Object.keys(o) and JSON.stringify(o) all see it.
JavaScript #private. A #lines field is not a property at all. Each evaluation of a class body creates a unique private name, and the field is stored on the instance under that name, where only code lexically inside the class can name it. Object.keys, Reflect.ownKeys and JSON.stringify do not see it, reading it on the wrong object throws TypeError, and #lines in obj is a brand check that tells you whether an object came from this class. One consequence: a Proxy around such an object breaks methods that read #lines, because the proxy itself does not have the field.
Go. Visibility is decided by the first letter of the identifier and enforced per package by the compiler. Every file in the package sees everything, so a package, not a struct, is Go's unit of encapsulation. The reflect package can read an unexported field's value, but CanSet reports false and calling Interface() on it panics; only unsafe pointer arithmetic gets past that.
Java. private is checked by the compiler and again by the JVM's access control when a call or field reference is resolved. setAccessible(true) used to open anything; since JDK 16 and 17 (JEP 396 and JEP 403), reflection into packages a module does not open fails unless the application is launched with --add-opens.
Layer 3The machine
Encapsulation through methods is usually free after compilation. A Go or JVM getter that returns a field is inlined to a single load at a fixed offset from the object pointer, the same instruction as a public field access. V8 implements #private fields as properties keyed by private symbols, so they use the same hidden classes and inline caches as public fields and cost the same. CPython is the exception: a property is a descriptor that runs a Python function, typically two to three times the cost of a plain attribute load.
The real costs are copies. Returning a defensive copy of a 10,000-element slice of 24-byte structs allocates 240 KB and does a memmove of the same size, about 10 to 30 us plus garbage collector work. Called once per request that is invisible; called inside a loop it dominates the profile. Views and iterators avoid the allocation.
Encapsulation is also what makes a lock mean anything. A mutex protects only the paths that take it: if Lines() returns the internal slice, a caller reads it while another goroutine appends under the lock, a race the lock never sees. go test -race catches it because it tracks memory accesses, not methods.
Why it's built this way
- Rules that span several fields (total equals the sum of lines) can only be kept if every write goes through code that knows the rule.
- The fewer places can write, the fewer places can break the rule, so access is narrowed to a type or a package.
- Narrowing costs nothing at runtime once calls are inlined, so most languages enforce it at compile time and leave escape hatches for tools.
- References make sharing cheap but let state escape the boundary, so data crossing it is copied, viewed read-only, or made immutable.
Diagram
flowchart LR
C1["checkout"] --> API["Order.add / remove / ship"]
C2["refunds"] --> API
C3["support tool"] --> API
API --> CHK{"invariants hold?"}
CHK -- "no" --> ERR["reject with error"]
CHK -- "yes" --> ST["private state: lines, total, shipped"]
ST --> RD["lines() returns a copy"]
RD --> C1
X["caller mutates its copy"] -.-> RDWorked example
A subscription billing service had a Plan object with a public prices map (currency to cents) and a cached display_price string. Eleven code paths wrote to prices, three of which also refreshed display_price. In one quarter, support logged 230 tickets where the checkout page showed a different price from the invoice. Every ticket traced to a path that updated prices and not the cache.
The fix made prices private, added set_price(currency, cents) that validated the currency and refreshed the display string, and made prices() return a read-only view. Moving the 11 call sites took two days. Tickets of that kind fell to zero, and a later change that computed display_price on demand instead of caching it touched one file.
The one performance surprise was a reporting job that called prices() for each of 2 million plans. The first version returned a copied dict, adding 2 million small allocations and about 4 seconds to a 40-second job. Switching to MappingProxyType, which wraps the dict without copying, removed the overhead.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Public fields | Simple, fast, no boilerplate | Any caller can break rules | Plain data with no invariants |
| Private fields with domain operations | One place guards each rule | More API to design | State with rules that span fields |
| Getters and setters for every field | Familiar | Same exposure as public fields, more code | Rarely; frameworks that demand it |
| Defensive copies | Simple, complete isolation | Allocation per call | Small collections, cold paths |
| Read-only views or iterators | No copy | Caller sees later changes; views can be cast away in some languages | Large collections, hot paths |
| Immutable values | Safe to share across threads | New object per change | Value objects, concurrent readers |
Pitfalls in production
- Returning internal mutable collections, so callers change state without passing the rules. Fix: copy, view or iterate at the boundary.
- Storing caller-owned lists or slices in constructors. Fix: copy on the way in.
- A setter for every field "for flexibility". Fix: replace with operations named after domain actions, each checking its rule.
- Treating TypeScript
privateor Python_xas a security boundary, for example for secrets in objects that get logged. Fix: keep secrets out of serialised objects; use#privateor a separate holder. - Tests that read private state and break on every refactor. Fix: assert through public behaviour; if that is hard, the class is probably missing an operation.
- A mutex inside a struct whose methods leak references to protected data. Fix: never return references to guarded state; run tests with
-race.
Leadership lens
- Ask "what are the invariants and who can break them?" in design review. If the answer is "anyone with the object", the design is not done.
- Treat schema access the same way: a service whose tables other teams read directly cannot change its own representation. Budget for an API instead.
- Watch for setter sprawl in agent-written diffs; it is a cheap signal that domain logic is leaking into callers.
- Do not mandate getters and setters as a style rule. It produces boilerplate without protecting anything and teaches the wrong lesson.
Check yourself
A class has private fields and a public getter and setter for each. In what sense is it not encapsulated?
Show answer
Any caller can still set any field to any value, so the class enforces no rules. Encapsulation protects invariants through meaningful operations, not through access syntax.
A Go method returns
o.itemswhereitemsis a slice. What can a caller do with it that breaks the object?Show answer
Overwrite elements in place, since the returned slice shares the backing array; the object's derived state, such as a cached total, no longer matches.
Why does Python rewrite
__balanceto_Account__balance?Show answer
To stop a subclass that also defines
__balancefrom silently overwriting the parent's attribute. It is collision avoidance; the mangled name is still reachable.What does
#token in objtell you in JavaScript that"token" in objcannot?Show answer
Whether
objwas constructed by this exact class (it carries this class's private name), not merely whether it has a property calledtoken.
Watch
- Core Design Principles for Software Developers (Venkat Subramaniam, Devoxx): a practical talk on keeping design simple, cohesive and well encapsulated.
- Object-Oriented Programming is Bad (Brian Will): a strong critique of encapsulation applied at the wrong granularity, worth watching to sharpen your own position.
Free resources
- TellDontAsk (Martin Fowler): moving behaviour next to the data it guards, and where the idea stops being useful.
- Private properties (MDN): the exact semantics of
#privatefields, brand checks and their interaction with proxies. - Private Variables (Python tutorial): name mangling explained by the language's own documentation.
- JEP 403: Strongly Encapsulate JDK Internals: how Java closed reflective access to private internals and what
--add-opensdoes. - On the Criteria To Be Used in Decomposing Systems into Modules (David Parnas, 1972): the paper that introduced information hiding.
- Internal directories (Go command docs): package-level encapsulation enforced by the Go toolchain.