Clean Code: Encapsulation, The Protective Shield
Think about driving a car. You interact with a simple interface: a steering wheel, pedals, and a gear stick. You don't need to know about the complex machinery under the hood to drive the car. The car's internal workings are encapsulated.
Encapsulation in object-oriented programming is the same idea. An object should hide its internal state and complexity and expose a clear, simple interface for other parts of the code to interact with. This prevents other parts of your system from becoming dependent on implementation details that might change.
Protecting Your Data
Let's look at a simple BankAccount
example.
// Bad: The internal state is exposed. Anyone can reach in and change the balance.
class BankAccount {
public balance: number = 0;
public accountNumber: string;
constructor(accountNumber: string) {
this.accountNumber = accountNumber;
}
}
const account = new BankAccount('12345');
account.balance = 1_000_000; // Uh oh. We just gave ourselves a million dollars.
// Good: The internal state is encapsulated. Balance can only be changed via public methods.
class BankAccount {
private _balance: number = 0;
private readonly _accountNumber: string;
constructor(accountNumber: string) {
this._accountNumber = accountNumber;
}
public getBalance(): number {
return this._balance;
}
public deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Deposit amount must be positive.');
}
this._balance += amount;
}
public withdraw(amount: number): void {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive.');
}
if (amount > this._balance) {
throw new Error('Insufficient funds.');
}
this._balance -= amount;
}
}
In the "good" example, the _balance
is private
. The only way to interact with it is through the deposit
and withdraw
methods. This allows the BankAccount
object to enforce rules (e.g., you can't deposit a negative amount) and protect its own state.
Hiding Implementation Details
Encapsulation also means hiding the "how" of your object's work. Let's consider a UserService
that fetches user data. The rest of the application shouldn't have to know if the data is coming from a cache or a database.
// Bad: The implementation details (database and cache) are public.
class UserService {
public database: Database;
public cache: Cache;
public async getUser(id: string) {
// Other parts of the code are now coupled to how this service works.
const cached = this.cache.get(id);
if (cached) return cached;
const user = await this.database.query(`SELECT * FROM users WHERE id = ${id}`);
this.cache.set(id, user);
return user;
}
}
// Good: The implementation is hidden behind a simple public method.
class UserService {
private database: Database;
private cache: Cache;
constructor(database: Database, cache: Cache) {
this.database = database;
this.cache = cache;
}
public async getUser(id: string): Promise<User | null> {
const cachedUser = await this.getUserFromCache(id);
if(cachedUser) return cachedUser;
return this.getUserFromDatabase(id);
}
private async getUserFromCache(id: string): Promise<User | null> {
console.log('Checking cache...');
return this.cache.get(id);
}
private async getUserFromDatabase(id: string): Promise<User | null> {
console.log('Fetching from database...');
const user = await this.database.query('SELECT * FROM users WHERE id = ?', [id]);
if (user) {
this.cache.set(id, user);
}
return user;
}
}
In the "good" example, we can completely change how the UserService
works internally (e.g., change the caching strategy) without affecting any of the code that uses it. That's the power of encapsulation.
Encapsulation is a fundamental principle that helps you create modular, maintainable, and robust object-oriented systems.