Loading learning content...
Every well-designed module keeps secrets.
When you use your smartphone, you tap icons and type messages. You don't manipulate the electrical signals directly. You don't access memory addresses or manage battery voltage levels. These implementation details are hidden from you—and that's precisely why you can use the phone at all.
If every internal detail were exposed, the cognitive load would be unbearable. You'd need to understand electromagnetic signals just to send a text. The hiding of details is what makes complex systems usable.
In software, the principle is identical. Classes that expose their internal implementation details become impossible to change, difficult to understand, and dangerous to use. Classes that hide their internals behind stable interfaces become resilient, maintainable, and safe.
This page explores the second pillar of encapsulation: information hiding through access control. We'll learn what to hide, how to hide it, and why secrets are the foundation of maintainable software.
By the end of this page, you will understand access modifiers and their semantics, apply the principle of least privilege to class design, identify what should be hidden and what should be exposed, understand how hiding enables change and reduces coupling, and recognize the symptoms of insufficient hiding.
Most object-oriented languages provide access modifiers—keywords that control the visibility of class members. While syntax varies, the concepts are universal:
| Visibility | Java/C# | Python | TypeScript | C++ | Who Can Access |
|---|---|---|---|---|---|
| Public | public | No prefix | public | public: | Anyone, anywhere |
| Protected | protected | _prefix | protected | protected: | Class + subclasses |
| Package/Internal | (default)/internal | N/A | N/A | N/A | Same package/module |
| Private | private | __prefix | private | private: | Only the class itself |
The spectrum of visibility:
Access modifiers create a spectrum from fully hidden to fully exposed:
Most Hidden ←――――――――――――――――――――――→ Most Exposed
private → protected → package → public
Each step toward public exposure:
1234567891011121314151617181920212223242526272829303132333435363738
class BankAccount { // PRIVATE: Deepest secrets - internal data representation private balance: number; private transactionLog: Transaction[]; private overdraftLimit: number; // PROTECTED: Secrets shared with subclasses protected accountType: AccountType; protected interestRate: number; // PUBLIC: The contract this class offers to the world public readonly accountNumber: string; public deposit(amount: number): DepositResult { // External code calls this... this.validateAmount(amount); // ...but can't call this this.balance += amount; // ...or access this this.recordTransaction('deposit', amount); // ...or modify this return new DepositResult(true, this.getBalance()); } public getBalance(): number { // Public accessor - controlled window into private state // Could apply transformations, caching, access logging, etc. return this.balance; } // PRIVATE: Implementation details hidden from everyone private validateAmount(amount: number): void { if (amount <= 0) { throw new InvalidAmountError("Amount must be positive"); } } private recordTransaction(type: string, amount: number): void { this.transactionLog.push(new Transaction(type, amount, new Date())); }}When designing a class, start with all members private. Only increase visibility when there's a proven need. This "private by default" approach ensures you never accidentally expose internals. Making something public later is easy; making something private later is often impossible without breaking clients.
Not everything needs hiding, but some things absolutely must be hidden. Here's a systematic guide:
balance; it should use deposit(), withdraw(), getBalance().deposit(), withdraw(), transfer(). These form the behavioral contract.getBalance(), isActive(), getOwner().accountNumber, id, type (when truly immutable).The core question:
For every member, ask: "If I expose this, what assumptions will clients make? What happens if I need to change this?"
If changing it would break clients, consider whether they should be depending on it at all. Often the answer is no—and hiding is the solution.
The deepest reason to hide implementation details is to preserve your freedom to change. Every detail you expose becomes a constraint you cannot violate without breaking clients.
Consider this evolution:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// VERSION 1: Simple implementationclass PriceCalculator { private basePrice: number; private taxRate: number; getTotal(): number { return this.basePrice * (1 + this.taxRate); }} // VERSION 2: Added caching (internal optimization)class PriceCalculator { private basePrice: number; private taxRate: number; private cachedTotal: number | null = null; // NEW! getTotal(): number { if (this.cachedTotal === null) { this.cachedTotal = this.basePrice * (1 + this.taxRate); } return this.cachedTotal; } private invalidateCache(): void { // NEW! this.cachedTotal = null; }} // VERSION 3: Changed to use BigDecimal for precision (internal change)class PriceCalculator { private basePrice: BigDecimal; // CHANGED type private taxRate: BigDecimal; // CHANGED type private cachedTotal: BigDecimal | null = null; getTotal(): number { // Same signature! if (this.cachedTotal === null) { this.cachedTotal = this.basePrice.multiply( BigDecimal.ONE.add(this.taxRate) ); } return this.cachedTotal.toNumber(); // Convert for backwards compat }} // ✅ All versions are drop-in compatible!// Clients only call getTotal() - they never knew or cared about internalsBecause the internal representation was hidden, we could:
All without any client code knowing or caring.
Now imagine if basePrice had been public. Clients would be accessing it directly. The moment you change its type from number to BigDecimal, every client breaks. You've lost the freedom to evolve.
"With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody." — Hyrum Wright, Google. This is why hiding is so critical. Any exposed detail WILL be depended on, regardless of whether you intended it to be part of the contract.
Coupling is the degree of interdependence between software modules. High coupling means changes in one module ripple through many others. Low coupling means modules can change independently.
Hiding reduces coupling by limiting what external code can depend on.
In the exposed design:
In the hidden design:
| Change Type | Exposed Internals | Hidden Internals |
|---|---|---|
| Rename internal field | Break all clients using that field | Zero impact - change one file |
| Change field type | Break all clients reading/writing that field | Zero impact - internal only |
| Add caching | Potentially break clients expecting fresh computation | Zero impact - optimization is internal |
| Change algorithm | Might break clients depending on intermediate states | Zero impact - result is same |
| Add validation | Might break clients setting invalid values directly | Zero impact - API enforces rules |
A quick coupling test: if you wanted to change your class's internal representation, how many other files would need updating? If the answer is more than one (the class itself), you have coupling through exposed internals. Proper hiding brings this number to exactly one.
A common misconception: "If I make fields private and add getters and setters, I've encapsulated."
This is often false. Blindly adding getters and setters can provide almost no encapsulation benefit. Let's analyze:
12345678910111213141516171819202122232425
// ❌ FAKE ENCAPSULATION: Getters/setters for everythingclass Order { private items: OrderItem[]; private status: string; private total: number; // These provide ZERO protection getItems(): OrderItem[] { return this.items; } // Exposes mutable array! setItems(items: OrderItem[]) { this.items = items; } // No validation! getStatus(): string { return this.status; } setStatus(status: string) { this.status = status; } // Any string! getTotal(): number { return this.total; } setTotal(total: number) { this.total = total; } // Can break invariant!} // External code can do all of this:order.setStatus("banana"); // Invalid status!order.setTotal(-500); // Negative total!order.getItems().push(maliciousItem); // Modified internal array!order.getItems().length = 0; // Cleared items! // This is worse than public fields because it's LONGER CODE// with the same vulnerability.When getters are acceptable:
return [...this.items] or return Object.freeze({...this.data}).getTotal() that sums items rather than returning a stored field.When setters are rarely acceptable:
setTotal() can create an Order where total doesn't match items.setStatus('cancelled') hides the business operation. cancel() method is clearer and can enforce cancellation rules.Instead of setters, provide domain-meaningful methods: replace setStatus() with confirm(), ship(), cancel(), return(). Replace setTotal() with calculateTotal() that's called internally. Replace setItems() with addItem(), removeItem(), clearItems(). Each method enforces its own rules.
Beyond access modifiers, several techniques reinforce information hiding:
Collections.unmodifiableList() in Java, Object.freeze() in JavaScript, ReadonlyArray<T> in TypeScript.getX() and getY(), return a Point object.ReadableAccount interface exposes only getBalance(), while the full BankAccount class includes deposit() and withdraw().12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
class Employee { private name: string; private skills: Set<string>; private startDate: Date; private salary: Money; // Value object instead of number // ✅ Defensive copy on input constructor(name: string, skills: string[], startDate: Date) { this.name = name; this.skills = new Set(skills); // Copy the array into a Set this.startDate = new Date(startDate.getTime()); // Copy the Date this.salary = Money.zero(); } // ✅ Defensive copy on output getSkills(): ReadonlySet<string> { return this.skills; // TypeScript ReadonlySet prevents mutations } getStartDate(): Date { return new Date(this.startDate.getTime()); // Return a copy } // ✅ Value object return - immutable by design getSalary(): Money { return this.salary; // Money is immutable, safe to return } // ✅ Command method instead of setter adjustSalary(adjustment: SalaryAdjustment): void { if (!adjustment.isApproved()) { throw new UnauthorizedError("Salary adjustment not approved"); } this.salary = adjustment.apply(this.salary); this.recordAudit(adjustment); } private recordAudit(adjustment: SalaryAdjustment): void { // Internal auditing logic }} // Value object: immutable, no setters, equality by valueclass Money { private readonly amount: number; private readonly currency: string; constructor(amount: number, currency: string) { if (amount < 0) throw new Error("Money cannot be negative"); this.amount = amount; this.currency = currency; } add(other: Money): Money { if (this.currency !== other.currency) { throw new CurrencyMismatchError(); } return new Money(this.amount + other.amount, this.currency); } static zero(): Money { return new Money(0, 'USD'); }}How do you know if a codebase has hiding problems? Look for these symptoms:
order.getCustomer().getAddress().getCity().toUpperCase() — reaching deep into object graphs because internals are exposed.int, string) instead of meaningful types, making it easy to misuse values.123456789101112131415161718192021222324252627
// ❌ TRAIN WRECK: Chain of exposed internalsfunction calculateShipping(order: Order): number { // We're reaching into customer, into address, into country, into region... const region = order.getCustomer() .getAddress() .getCountry() .getRegion(); // And into items, into first item, into weight... const weight = order.getItems()[0].getWeight(); return this.shippingRates.get(region) * weight;} // ✅ PROPER HIDING: Ask the object that knowsfunction calculateShipping(order: Order): number { // Order knows its shipping destination and weight // We don't need to know how it knows return order.calculateShippingCost(this.shippingRates);} // Inside Order:calculateShippingCost(rates: ShippingRates): number { const region = this.customer.getShippingRegion(); // Customer knows its region const weight = this.getTotalWeight(); // Order knows its weight return rates.calculate(region, weight);}The 'Law of Demeter' (or 'Principle of Least Knowledge') formalizes train wreck avoidance: a method should only call methods on (1) itself, (2) its parameters, (3) objects it creates, (4) its direct components. Never call methods on objects returned by other methods—that's reaching into someone else's internals.
We've explored the second pillar of encapsulation in depth. Here are the essential takeaways:
What's next:
We've covered the definition of encapsulation, bundling data with behavior, and hiding implementation details. The next page brings everything together by examining why encapsulation matters at the system level—its impact on maintainability, testability, security, and team scalability.
You now understand how to hide implementation details effectively—not just through access modifiers, but through thoughtful API design, defensive techniques, and recognition of encapsulation violations. Your classes can now evolve independently, protected from the chaos of uncontrolled access.