Engineering Atlas › Low-Level Design

Hiding state behind encapsulation

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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._total

Now 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:

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

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

  1. 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.
  2. The fewer places can write, the fewer places can break the rule, so access is narrowed to a type or a package.
  3. Narrowing costs nothing at runtime once calls are inlined, so most languages enforce it at compile time and leave escape hatches for tools.
  4. 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"] -.-> RD

Worked 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

OptionStrengthsWeaknessesChoose when
Public fieldsSimple, fast, no boilerplateAny caller can break rulesPlain data with no invariants
Private fields with domain operationsOne place guards each ruleMore API to designState with rules that span fields
Getters and setters for every fieldFamiliarSame exposure as public fields, more codeRarely; frameworks that demand it
Defensive copiesSimple, complete isolationAllocation per callSmall collections, cold paths
Read-only views or iteratorsNo copyCaller sees later changes; views can be cast away in some languagesLarge collections, hot paths
Immutable valuesSafe to share across threadsNew object per changeValue objects, concurrent readers

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. A Go method returns o.items where items is 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.

  3. Why does Python rewrite __balance to _Account__balance?

    Show answer

    To stop a subclass that also defines __balance from silently overwriting the parent's attribute. It is collision avoidance; the mangled name is still reachable.

  4. What does #token in obj tell you in JavaScript that "token" in obj cannot?

    Show answer

    Whether obj was constructed by this exact class (it carries this class's private name), not merely whether it has a property called token.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →