Skip to main content

The Four Pillars of OOP: Abstraction vs. Encapsulation

Easy

Interview Question: "What are the four pillars of OOP? More specifically, I hear people mix up abstraction and encapsulation all the time. How do you draw the line between them?"

Abstraction and encapsulation sound similar, so people mix them up. Here's a simple way to tell all four pillars apart, using one example: your bank account.


1. Abstraction - show the "what", hide the "how"​

Think about an ATM. You press Withdraw, type an amount, and get cash. You don't see the fraud checks or the database updates happening behind the screen - you don't need to. The screen shows a simple menu. Everything else stays hidden.

WHAT YOU SEE (INTERFACE)WHAT YOU DO NOT SEE (IMPLEMENTATION)ATM PANELWithdrawDepositBalanceCALL1. verify available funds2. evaluate fraud heuristics3. post ledger adjustment4. trigger physical dispense
You press one button. Everything behind the interface boundary stays completely encapsulated.

That's abstraction: show only the actions someone needs (deposit, withdraw, getBalance), and hide how they work. In code, this is why we write an interface or abstract class - it's the menu a caller is allowed to see.

2. Encapsulation - lock the data, control the door​

Now look at the bank's side. Your balance isn't an open number that anyone can change directly - it's locked inside the account. The only way to change it is through deposit() and withdraw(), and both check the rules first: no negative deposits, no withdrawing more than you have.

SAVINGSACCOUNT CAPSULEdeposit(100)CALLSavingsAccountpublic deposit(amount)PRIVATE STATEbalance = 500balance = -999Direct access deniedBLOCKEDREADgetBalance()
The object guards its own data. Internal state can only be accessed or mutated through verified public methods.

That's encapsulation: keep the data (balance) together with the methods allowed to touch it, and lock out everything else. This keeps the object from ever reaching an invalid state, like a negative balance.

So what's actually different?​

AbstractionEncapsulation
Question it answers"What can the caller do?""Who's allowed to touch the data?"
What it hidesHow the operation works internally (the logic)The raw data itself (the variables)
Main goalKeep things simple for whoever is using the classKeep the object's data safe and valid
Built withInterfaces, abstract classesprivate/protected fields, getters/setters

One-liner to say out loud in an interview: "Abstraction hides the complexity you don't need to see. Encapsulation hides the data you're not allowed to touch."

3. Inheritance - share a blueprint, specialize from it​

SavingsAccount and CurrentAccount are both still "an account" - both have a balance, both can report it. They share one base blueprint, Account, and each adds its own rule: Savings blocks the balance from going below zero. Current allows it to dip into an overdraft limit.

AccountBASEbalancegetBalance()SavingsAccountSUBCLASSbalancegetBalance()withdraw()min: 0CurrentAccountSUBCLASSbalancegetBalance()withdraw()min: -500
The child classes automatically inherit balance and getBalance(). Only withdraw() is overridden to enforce specialized rules.

That's inheritance: a child class reuses a parent class's fields and methods, and only changes what's different. This is called an "IS-A" relationship - a SavingsAccount is an Account.

💡 Going deeper: Inheritance can be overused. If two classes aren't truly an "IS-A" fit, forcing one into a parent/child shape makes the code fragile. See Composition vs. Inheritance for when to use one over the other.

4. Polymorphism - same call, different behavior​

Say your app has a list of accounts - some SavingsAccount, some CurrentAccount, each holding ₹50. It calls .withdraw(100) on every one of them, the same line of code, without ever checking the type:

DYNAMIC METHOD DISPATCHPOLYMORPHIC CALLacc.withdraw(100)declared as AccountDISPATCHSavingsAccountbalance = 50RefusedCurrentAccountbalance = 50Approved
The caller writes one uniform line. The runtime dispatches dynamically to whichever method body belongs to the actual instance.

That's polymorphism: the same method call produces different behavior depending on the actual object it's called on. Two forms show up in interviews:

  • Overloading (compile-time): same method name, different parameters, chosen by the compiler - e.g. deposit(amount) vs deposit(amount, note).
  • Overriding (runtime): a child class replaces a parent's method with its own version, chosen automatically based on the real object - what SavingsAccount.withdraw() and CurrentAccount.withdraw() do above.

Putting it all together​

  • Abstraction: the ATM screen only shows deposit, withdraw, getBalance - nothing about how they work.
  • Encapsulation: the balance is private; only those three methods can touch it.
  • Inheritance: SavingsAccount and CurrentAccount both reuse the same Account blueprint.
  • Polymorphism: calling .withdraw() on either one runs different logic automatically.

One bank account. Four pillars.


Clean Code Example​

Here's the full picture - interface, private state, two subclasses, and a polymorphic call - in one place:

// 1. ABSTRACTION: the menu of actions a caller is allowed to see.
interface Account {
void deposit(double amount);
void withdraw(double amount);
double getBalance();
}

// 2. ENCAPSULATION + the shared blueprint used by inheritance.
abstract class BaseAccount implements Account {
private double balance; // locked away - only this class can touch it directly

protected BaseAccount(double initialBalance) {
this.balance = Math.max(initialBalance, 0);
}

@Override
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive.");
balance += amount;
}

@Override
public double getBalance() {
return balance;
}

protected void setBalance(double newBalance) {
this.balance = newBalance;
}
}

// 3. INHERITANCE: reuses deposit()/getBalance(), only customizes withdraw().
class SavingsAccount extends BaseAccount {
public SavingsAccount(double initialBalance) { super(initialBalance); }

@Override
public void withdraw(double amount) {
if (amount > getBalance()) throw new IllegalStateException("Insufficient funds.");
setBalance(getBalance() - amount);
}
}

class CurrentAccount extends BaseAccount {
private final double overdraftLimit;

public CurrentAccount(double initialBalance, double overdraftLimit) {
super(initialBalance);
this.overdraftLimit = overdraftLimit;
}

@Override
public void withdraw(double amount) {
if (getBalance() - amount < -overdraftLimit) throw new IllegalStateException("Over overdraft limit.");
setBalance(getBalance() - amount);
}
}

// 4. POLYMORPHISM: same method call, different behavior per actual object.
public class Demo {
public static void main(String[] args) {
List<Account> allAccounts = List.of(
new SavingsAccount(200),
new CurrentAccount(200, 500)
);
for (Account acc : allAccounts) {
acc.withdraw(100); // each object runs its own withdraw() rules
}
}
}