Appearance
9.2.2 — Encapsulation
The delivery app from 9.2.1 now has a wallet. Customers top it up, spend from it, and get refunds into it. There is exactly one rule that the business cares about more than any other: the balance must never go below zero.
Here is how that rule usually gets written first:
typescript
class Wallet {
balance = 0; // (1) anyone can read and write this
}
class WalletService {
spend(wallet: Wallet, amount: number): void {
if (wallet.balance >= amount) { // (2) the rule lives here…
wallet.balance -= amount;
}
}
}Line (1) is a public field, so any code anywhere in the program can assign to it. Line (2) is the rule, and it is sitting in a completely different class from the data it protects.
Now count the ways this fails. The refund flow is in RefundService, and it writes wallet.balance += amount directly, because that seemed obvious and there is no rule about refunds. The promotions team adds a bonus credit in PromoService. Somebody writes a nightly reconciliation job that fixes rounding drift by assigning the balance straight from a database sum. Six months later there are eleven places in the codebase that write to wallet.balance, and exactly one of them checks the rule.
Then a customer with 200 rupees in the wallet places two orders in the same second from two devices. Both requests read the balance, both see 200, both pass the check, both subtract. The wallet is now at minus 100 and the company has given away food.
Nothing in that story is exotic. It is what happens to every piece of state that is public.
1. What encapsulation actually is
The usual definition is "hiding data" or "making fields private", and that definition is why so many people apply it and get no benefit. Here is the useful one:
Encapsulation means there is exactly one way to change a piece of state, and that way enforces the rules about it.
The hiding is a means. The end is that a rule becomes impossible to bypass rather than merely written down somewhere. Those are wildly different things. A rule written down somewhere gets violated by the next engineer who has not read that file. A rule that is structurally impossible to bypass does not get violated, because there is no path to violate it through.
Here is the wallet with a real boundary around it:
typescript
class Wallet {
#balanceCents = 0; // (1)
constructor(private readonly ownerId: CustomerId) {}
get balance(): Money { // (2)
return Money.of(this.#balanceCents, "INR");
}
topUp(amount: Money): void {
if (!amount.isPositive()) throw new InvalidAmountError(amount); // (3)
this.#balanceCents += amount.cents;
}
spend(amount: Money): void {
if (!amount.isPositive()) throw new InvalidAmountError(amount);
if (amount.cents > this.#balanceCents) { // (4)
throw new InsufficientFundsError(this.ownerId, amount, this.balance);
}
this.#balanceCents -= amount.cents; // (5)
}
}Line (1) uses the # prefix, which is JavaScript's real private field. This matters and it is not the same as TypeScript's private keyword, which is explained in section 3. A # field cannot be read or written from outside the class by any means: not by wallet["balanceCents"], not by Object.keys, not by JSON.stringify, not by a debugger-driven hack in a test. The door does not exist.
Line (2) is a getter that exposes the balance for reading, as a Money value object rather than a raw number. Reading is safe. What is unsafe is writing, and there is no setter, so writing is not offered.
Line (3) rejects a negative top-up. Without it, topUp(Money.of(-50000)) is a way to spend money through the deposit function, which is exactly the kind of gap that shows up in security reviews.
Line (4) is the rule the business cares about, and now it lives in the same object as the data. There is no way to reduce the balance except through this method, so there is no way to reduce it past the check.
Line (5) is the only line in the entire program that subtracts from a balance. When something eventually goes wrong with money — and it will — this is the one place you have to read, and one breakpoint catches every path.
The refund flow, the promotions job and the reconciliation script all have to come through topUp and spend now. They cannot write the field, because the field is not reachable. The rule went from "documented" to "enforced by the language".
2. The anemic domain model, and why it is the most common OOP failure
The first version of the wallet has a name: the anemic domain model. Objects are bags of data with getters and setters and no behaviour, while all the actual logic lives in classes called SomethingService that reach into those bags and manipulate them.
It is worth being precise about why this is bad, because it looks organised. There are classes. There are interfaces. There is a service layer. It passes every superficial test for "we do OOP here".
The problem is that every invariant is homeless. A rule about the balance has to live somewhere, and if it does not live with the balance then it lives in whichever service happened to need it first. The second service that touches the balance does not know the rule exists. The third one copies the check from the second and gets it slightly wrong. Now you have the 9.1 shotgun surgery smell at its purest: one business rule, scattered across many files, nothing linking them.
The second problem is that services grow without limit. Because no logic lives in the domain objects, every workflow's logic funnels into services, so services accumulate dependencies. The end state is a class with twenty-two constructor parameters that no test can set up.
There is an honest exception, and stating it protects you from applying this dogmatically. When there are genuinely no rules, anemia is fine. A DTO carrying JSON from an HTTP request to a handler has no invariants and needs no behaviour. A read model built specifically to render one screen is data, and wrapping it in methods is ceremony. Anemia is a defect precisely and only when rules exist and have nowhere to live.
3. Four ways to hide something in TypeScript, and which to use
TypeScript gives you several mechanisms that look similar and behave very differently at runtime. This trips people up in interviews and in production.
typescript
class Wallet {
public ownerId: CustomerId; // (1) the default — anyone
private auditLog: Entry[] = []; // (2) TypeScript only
protected feeRate = 0.02; // (3) this class and subclasses
readonly currency = "INR"; // (4) public to read, never assignable
#balanceCents = 0; // (5) real, enforced by the engine
}(1) public is what you get when you write nothing. The field is part of your public contract, and changing it later breaks callers you cannot see.
(2) private is enforced by the TypeScript compiler and disappears entirely when the code is compiled to JavaScript. This is the one that surprises people. At runtime the field is a completely ordinary property:
typescript
const w = new Wallet(id);
w.auditLog; // compile error: Property is private
(w as any).auditLog; // → works fine at runtime. The cast silences the compiler.
w["auditLog"]; // → also works. Nothing is actually hidden.
console.log(JSON.stringify(w)); // → the private field appears in the outputSo private protects you from your own team's honest mistakes, which is genuinely most of the value, and protects you from nothing else.
(3) protected means this class and any class extending it can see the field. It is a compile-time promise like private. Treat protected as a real design decision rather than a convenience, because a protected field is part of your contract with every subclass forever, and section 9.2.4 shows what that costs.
(4) readonly means the field can be read from anywhere but assigned only in the constructor. Also compile-time only. It is the right choice for values that are public knowledge but must not change, like a currency code or an id.
(5) #name is a JavaScript private field, part of the language itself rather than the type system. It survives compilation, it is enforced by the engine, and there is no cast or bracket trick that reaches it. It is also invisible to JSON.stringify, Object.keys and most serialisers, which is usually what you want for a secret and occasionally a nuisance for a value you did want serialised.
The rule to follow: use # for state that carries a real invariant or a real secret, such as balances, tokens and keys. Use private for ordinary internal helpers where the type checker is enough. Use readonly liberally, because it costs nothing and documents intent. Use protected rarely and deliberately.
4. Getters and setters do not encapsulate anything
This is the most widespread misunderstanding of the whole topic, so it gets its own section.
typescript
class Wallet {
private balance = 0;
getBalance(): number { return this.balance; } // (1)
setBalance(v: number): void { this.balance = v; } // (2)
}Lines (1) and (2) together are exactly equivalent to a public field, with extra typing. Anybody can read it and anybody can set it to anything, including minus one million. The field is technically private and the state is completely exposed. Adding a getter and a setter for every field is a mechanical habit, often generated by an IDE, that produces the appearance of encapsulation with none of the substance.
The way out is to stop thinking in terms of fields and start thinking in terms of what callers actually need to do. There are only two kinds of thing a caller ever wants:
- A question, which reads state and changes nothing:
wallet.balance,order.total,subscription.isActive. - A command, which does something meaningful in the business and enforces its own rules:
wallet.spend(amount),order.cancel(reason),subscription.renew().
Notice that commands are named after what the business calls the operation, not after which field they touch. spend is a business event, setBalance is a field assignment. That difference is what makes the code searchable, testable and explainable. When somebody asks "where can money leave a wallet", the answer is "the spend method", and it is a complete answer.
This separation between questions and commands has a formal name, Command Query Separation, which says that a method should either change state or return an answer, but not both. It is developed in 9.3.10. The practical benefit is that you can call a question as many times as you like with no consequences, which makes debugging and logging safe.
Setters are not always wrong. If a field genuinely can be set to anything, and the business really does have an operation that just sets it, a setter is honest. profile.displayName = newName with validation inside the setter is fine. The test is whether the operation has a name in the business. "Change your display name" does. "Set balance" does not.
5. Tell, do not ask
There is a design habit that follows directly from all of this, and once you see it you cannot unsee it.
typescript
// ❌ ask: pull the data out, decide outside
if (wallet.balance.cents >= order.total.cents) {
wallet.balance.cents -= order.total.cents;
order.markPaid();
}
// ✅ tell: hand the decision to whoever owns the data
wallet.spend(order.total);
order.markPaid();The first version asks the wallet for its data and then makes the wallet's decision on its behalf, outside the wallet. Every if written about another object's data is a decision that has escaped its home. And once it has escaped, it gets copied — the next feature that needs to check funds copies those three lines, and now the rule exists twice.
The second version tells the wallet what you want and lets it decide. If the wallet cannot do it, it throws, and the caller handles that. The rule stays in one place forever.
A useful review question that catches this instantly: who owns this decision? If the answer is "the object whose data we are reading", the code is in the wrong house.
There is a related smell worth naming. When you see a chain like order.customer.address.country.code, the calling code now knows the shape of four objects, and any change to any of them breaks it. That is the Law of Demeter, sometimes phrased as "only talk to your immediate friends", and 9.3.10 gives it a full treatment with the cases where chaining is actually fine.
6. The leak nobody notices: handing out your internals
Encapsulation fails silently when a method returns a reference to mutable internal state. This one is worth burning into memory because it defeats every private field on the page.
typescript
class Order {
#lines: OrderLine[] = [];
getLines(): OrderLine[] { return this.#lines; } // (1)
}
const order = Order.place([line]);
order.getLines().length = 0; // (2) the order is now empty
order.getLines().push(anythingAtAll); // (3) no validation ranLine (1) returns the actual array, not a copy. Lines (2) and (3) then modify the order's internal state from outside, having never called a single method on the order. The # did nothing, because you handed the caller the key.
Three fixes, in increasing order of strength.
typescript
// fix 1: return a copy — callers get a real array they can do anything to, harmlessly
getLines(): OrderLine[] { return [...this.#lines]; }
// fix 2: return a read-only view — no copy cost, compiler blocks mutation
get lines(): readonly OrderLine[] { return this.#lines; } // (1)
// fix 3: do not hand out the collection at all — offer the operations callers need
lineCount(): number { return this.#lines.length; }
totalFor(sku: Sku): Money { /* … */ }
addLine(line: OrderLine): void { /* validates, then pushes */ } // (2)Fix 2's line (1) uses readonly OrderLine[], a TypeScript type that has no push, pop, splice or assignable length. It costs nothing at runtime because it is the same array. The limitation is that it is compile-time only, so a caller writing plain JavaScript, or one who casts, can still mutate it.
Fix 3's line (2) is the strongest and usually the right answer for anything with rules. Nobody gets the collection; they get the operations. Every change goes through validation, and the object stays in charge of itself. It is also the option that most reduces coupling, because callers no longer know that lines are stored in an array at all. Swap it for a Map next year and nothing outside changes.
The same trap applies to returning a mutable object, a Date, or a nested config. Date is especially sharp because it is mutable in JavaScript, so returning this.#createdAt lets a caller do order.createdAt.setFullYear(1970).
7. Where the boundary goes: encapsulation above the class
Everything so far has been about one class, but the same idea works at every scale, and using it at the larger scale is what separates a tidy codebase from a genuinely maintainable one.
Module level. In a JavaScript or TypeScript module, anything you do not export is private to that file (3.6.5). A folder with an index.ts that re-exports only three things has an encapsulated interface, no matter how many files sit behind it. This is the cheapest encapsulation available and the most under-used. If ten files inside billing/ are only ever used by each other, export none of them.
Group level, the aggregate. Some rules span several objects. "An order's total must equal the sum of its lines" is not about one object, it is about an order and its lines together. So the boundary has to be drawn around the group: outside code holds a reference to the Order and never to an OrderLine, and every change to a line goes through a method on the order. Domain-Driven Design calls that group an aggregate and the object you are allowed to hold the aggregate root. The practical rule is simple: draw the boundary around whatever set of objects a single rule needs in order to be checkable. This idea returns in 9.7 when designing whole machines and again in Part 10 when a boundary becomes a service.
Service level. The same instinct, one scale up, is why a service owning a database table should be the only thing that writes it. When two services write one table, the table is a public field on a shared object, and everything in section 1 applies with a much bigger blast radius.
8. What this costs, and when to skip it
Encapsulation is not free, and pretending otherwise makes it easy for a sceptic to dismiss the whole idea.
It costs indirection. Reading wallet.spend(amount) requires opening the wallet to see what happens. A public field requires opening nothing. For code with no rules, that indirection is pure cost.
It costs friction when the boundary is wrong. If you encapsulate at the wrong granularity, callers constantly need something the object does not offer, so methods get added one by one until the class has forty of them and hides nothing. When you notice yourself adding a method per caller request, the boundary is in the wrong place, not the callers.
It can be faked. A class whose entire public surface is getters and setters is a public data bag with paperwork, as section 4 showed.
Skip it when: the data has no rules (DTOs, config, read models, plain parsed JSON), the object is a short-lived local inside one function, or the type is a small immutable value where the fields are the meaning. An immutable value object can expose its fields publicly and lose nothing, because nobody can change them, which is why readonly public fields on a value object are perfectly good design.
One thing not to do: never make something public because a test needs it. If a test needs to see internal state, the test is checking the wrong thing. Test the behaviour through the public surface — spend more than the balance and assert that it throws — because that is what the rest of the program can actually observe, and a test coupled to internals breaks every time you refactor even when nothing is wrong. Chapter 9.8 covers this properly.
What the next page adds. Encapsulation hides how one object works. The next question is how callers can depend on what it does without depending on which class is doing it, which is 9.2.3 on abstraction and roles.
Recall
- Encapsulation means there is exactly one way to change a piece of state, and that way enforces the rules. The hiding is a means; making a rule impossible to bypass is the end.
- The anemic domain model — data bags plus
SomethingServiceclasses holding all logic — leaves every rule homeless, so it gets copied, half-copied and missed. It is fine only when there really are no rules (DTOs, read models). - TypeScript's
private,protectedandreadonlyare compile-time only and vanish in the emitted JavaScript;#fieldis enforced by the engine and cannot be reached by casts, brackets orJSON.stringify. Use#for real invariants and secrets. - A getter plus a setter for every field is a public field with paperwork. Offer questions (read, no effect) and commands named after business operations (
spend,cancel), not field assignments. - Tell, do not ask: every
ifwritten about another object's data is a decision in the wrong house. The review question is who owns this decision? - Returning internal mutable state (an array, a
Map, aDate) defeats every private field. Return a copy, returnreadonly, or best, do not hand out the collection at all — expose the operations instead. - The same boundary idea scales up: unexported module members, the aggregate drawn around whatever objects one rule needs to be checkable, and a service owning its own tables.
Self-test: State encapsulation in one sentence without using the word "hide". Why is private in TypeScript weaker than #, and when does that difference actually matter? Show how a getter returning an array undoes encapsulation, and give the three fixes in order of strength. When is an anemic model the right answer? Why should a test never reach into private state?
Quiz Bank
FoundationalWhat is encapsulation for, beyond making fields private?
Protecting invariants. An invariant is a rule about state that must always be true: a wallet balance is never negative, an order always has at least one line, a booking's end date is never before its start. Every such rule needs exactly one guardian, and encapsulation makes it structurally impossible to change the state except through code that checks the rule.
The mechanism is private state, but the design content is choosing which operations exist and what each one promises. Making a field private and then adding a setter for it protects nothing, because the setter is a second door with no guard. What actually protects the rule is that the only ways to move money are topUp and spend, and both check.
The failure mode this prevents is the anemic domain model, where objects are data bags and the rules live in service classes. There the rule is scattered across every caller that remembers it and enforced in none of them, which is the shotgun surgery smell from 9.1.
The bound worth stating: with genuinely no invariants — a DTO, a config object, a read model built for one screen — records and functions are fine, and adding methods is ceremony. Anemia is a defect when rules exist and have nowhere to live, not whenever a class lacks methods.
FoundationalTypeScript private versus the hash private field — what is the real difference and when does it matter?
private is a compile-time check by TypeScript. It disappears when the code is emitted, so at runtime the property is completely ordinary. All of these reach it: (obj as any).field, obj["field"], Object.keys(obj), JSON.stringify(obj), and any plain JavaScript caller who never saw your types.
#field is a JavaScript language feature. It survives compilation and is enforced by the engine. There is no cast, no bracket access and no reflection trick that reads it from outside the class. It is also skipped by JSON.stringify and Object.keys.
When the difference matters. For secrets and security-relevant state — API keys, tokens, raw card data, balances — use #, because "the compiler asked you not to" is not a security property, and because a private field that silently appears in JSON.stringify output is how secrets end up in log files. For ordinary internal helpers where you only need to stop your own team from depending on an implementation detail, private is enough and reads more familiarly.
Two costs of # to know. It is invisible to serialisers, so a class using it needs an explicit toJSON if instances get serialised. And it is genuinely inaccessible from tests, which is a feature — a test that needed to read internals was testing the wrong thing.
AppliedA code review shows a class with private fields and a getter plus setter for every one of them. What do you say, and what do you propose instead?
What to say: this class has the shape of encapsulation and none of the effect. A private field with a public setter is a public field that took more typing, since any caller can still put any value in. The state is fully exposed, so no rule about it can hold.
The diagnosis to give the author: the class was designed by listing its fields and then mechanically producing accessors, usually with an IDE shortcut. Design should start from the other end — what do callers actually need to do.
The proposal. Go through the call sites and sort every use into a question or a command. Questions read and change nothing, and can stay as getters, ideally returning value objects rather than raw numbers so the units cannot be confused. Commands should be renamed after the business operation they perform and should contain the rules for it. setStatus("cancelled") becomes cancel(reason), which can check that the order is not already shipped. setBalance(n) splits into topUp(amount) and spend(amount), each with its own rule.
Then delete every accessor that no call site needed, which is usually most of them. A field with no reader outside the class does not need a getter, and adding one in advance widens the public surface for free.
The line to leave in the review: every setter you keep should have a name the business would recognise, and every setter that does not is state escaping.
InterviewShow how a class can be fully private and still not encapsulated.
By handing out a reference to mutable internal state:
typescript
class Order {
#lines: OrderLine[] = [];
getLines(): OrderLine[] { return this.#lines; } // returns the real array
}
order.getLines().push(unvalidatedLine); // no method on Order ran; no rule was checked
order.getLines().length = 0; // the order is now empty and thinks it is validThe field is a genuine JavaScript private field and it made no difference, because the reference escaped. This is the same aliasing problem as a constructor storing a caller's array (9.2.1), running in the opposite direction.
The three fixes, weakest to strongest. Return a copy with [...this.#lines], which is safe but allocates on every call and quietly discards changes callers make, which can confuse them. Return readonly OrderLine[], which has no runtime cost and stops mutation at compile time, but only for TypeScript callers who do not cast. Best, do not expose the collection at all: offer lineCount(), addLine(line) and whatever queries callers actually need. That keeps every change inside the validating methods and also removes the caller's knowledge that lines are stored in an array, so you can change the storage later without touching anything outside.
The same trap in other clothes: returning a Date, which is mutable in JavaScript, or a Map, or a nested config object. Any time a getter returns something a caller can modify, ask what happens if they do.
StaffA review shows OrderService with 22 injected dependencies and methods like applyDiscount(order, discount), while Order, Cart and Discount are field bags. The team says they follow OOP because everything is classes with interfaces. Assess and redirect.
The assessment. The classes and interfaces are present and the object orientation is not. This is an anemic domain model with a god service: all behaviour centralised in one class that therefore has twenty-two reasons to change, which is minimum cohesion by 9.1's definition, while the objects that should be guarding rules — an order's total matching its lines, a discount's eligibility conditions — are defenceless bags any code path can corrupt.
The twenty-two dependencies are the symptom rather than the disease. Because no logic lives in the domain, every workflow's collaborators have to be funnelled into the one class that holds all logic. You cannot fix the dependency count without moving the logic.
The redirect, in order. First, push each decision to the object that owns the data it needs. applyDiscount(order, discount) becomes order.apply(discount), with Order enforcing its own rules about what may be discounted. Every such move deletes a branch from the service and often a dependency with it. Second, mint value objects for the concepts currently floating around as primitives — Money, Percentage, CouponCode — immutable and validated at construction, so illegal values stop existing rather than being checked for repeatedly. This is the runtime twin of making illegal states unrepresentable in the type system (3.7.2). Third, keep in services only what genuinely belongs there: orchestration across several aggregates, transactions, and calls to the outside world. Those services shrink to a handful of dependencies, and if one is still fat, split it by use case.
What changes in testing, which is the argument that usually wins the team over. Today, testing anything requires constructing twenty-two mocks. After the move, domain rules are tested with plain objects and no mocks at all, because the logic is pure, and the thin orchestrators need only two or three fakes. Test setup shrinking is a measurable proxy for coupling dropping.
The framing to leave them with: object orientation is measured by where decisions live relative to the state they govern, not by the presence of classes. A service full of if statements about other objects' data is procedural code wearing OOP's clothes.