Definition
Object-Oriented Programming is built on four fundamental pillars.
- Encapsulation bundles data (fields) and methods into a single class and restricts direct access using access modifiers (
privatefields +publicmethods). - Abstraction hides complex implementation details and exposes only the essential interface so the caller knows WHAT a method does, not HOW it does it internally.
- Inheritance allows a child class to acquire the fields and methods of a parent class, promoting code reuse and establishing an IS-A relationship (
SavingsAccountIS-AAccount). - Polymorphism means many forms: the same method call behaves differently depending on the actual object type at runtime.
account.calculateInterest()computes 4% forSavingsAccountand 0% forCurrentAccount, decided at runtime via dynamic dispatch.
Think of National Bank of India. Encapsulation: Rajesh's account balance is private and can only change through deposit() and withdraw() methods that validate every modification. Abstraction: Rajesh calls transfer(toAccount, amount) without knowing the 15 internal steps (debit, credit, audit, notification, compliance check). Inheritance: SavingsAccount and CurrentAccount both extend Account, inheriting common features like balance and deposit(). Polymorphism: the month-end batch calls account.calculateInterest() on each account, and the JVM dispatches to the correct override at runtime.
Understanding the Concepts
What are the four pillars of OOP?
Object-Oriented Programming is built on four pillars that work together to produce modular, maintainable, and extensible software. Each pillar addresses a different concern: encapsulation controls access, abstraction simplifies interfaces, inheritance promotes reuse, and polymorphism enables flexibility. In a banking system these four pillars appear in every class, every service, and every interaction.
What is encapsulation?
Encapsulation is the most fundamental pillar. It combines data and behavior into a class and controls access. The private fields cannot be accessed directly from outside the class. Only public getter and setter methods (or domain methods like deposit and withdraw) can read or modify the data. This is not just about hiding data: it is about controlling how data changes. A bank account balance should never be set to a negative value. Without encapsulation, any code could write account.balance = -5000. With encapsulation, withdraw(amount) validates the amount, checks minimum balance, and throws InsufficientFundsException if the balance would go below zero.
package com.bank.core;
import java.math.BigDecimal;
public class Account {
private BigDecimal balance; // private: cannot be accessed directly
private final String accountId; // private + final: immutable after creation
public Account(String accountId, BigDecimal initialBalance) {
this.accountId = accountId;
this.balance = initialBalance;
}
public void withdraw(BigDecimal amount) {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (balance.subtract(amount).compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalStateException("Balance insufficient for " + amount);
}
balance = balance.subtract(amount);
}
public BigDecimal getBalance() { return balance; }
// No setBalance() method: balance can only change through deposit() or withdraw().
}
What is abstraction?
Abstraction hides the implementation details behind a clean interface. The caller interacts with what the system does, not how it does it. In Java, abstraction is achieved through abstract classes and interfaces. An interface PaymentService declares transfer(from, to, amount). The caller does not know whether it uses NEFT, RTGS, or UPI internally. The implementation can change without affecting callers.
package com.bank.service;
import java.math.BigDecimal;
public interface PaymentService {
TransferResult transfer(String fromAccount, String toAccount, BigDecimal amount);
}
public class NeftPaymentService implements PaymentService {
@Override
public TransferResult transfer(String from, String to, BigDecimal amount) {
// 1. Validate accounts
// 2. Check balance
// 3. Debit source via RBI clearing
// 4. Credit destination (next-day settlement)
// 5. Generate UTR number
// 6. Send SMS notification
// 7. Write audit log
// Caller sees NONE of this complexity.
return new TransferResult("UTR12345", "SUCCESS");
}
}
public class UpiPaymentService implements PaymentService {
@Override
public TransferResult transfer(String from, String to, BigDecimal amount) {
// Completely different internals: VPA resolution, NPCI switch, instant credit.
// But the caller code does not change.
return new TransferResult("UPI98765", "SUCCESS");
}
}
What is inheritance?
Inheritance establishes an IS-A relationship where a child class extends a parent class, inheriting all non-private fields and methods. The child can add new behavior and override existing behavior. In banking: SavingsAccount IS-A Account, CurrentAccount IS-A Account. Both share accountId, balance, and deposit(). SavingsAccount adds interestRate and calculateInterest(). CurrentAccount adds overdraftLimit and overrides withdraw() to allow overdraft.
package com.bank.core;
import java.math.BigDecimal;
public class SavingsAccount extends Account {
private BigDecimal interestRate;
public SavingsAccount(String accountId, BigDecimal balance, BigDecimal rate) {
super(accountId, balance);
this.interestRate = rate;
}
public BigDecimal calculateInterest() {
return getBalance().multiply(interestRate);
}
}
public class CurrentAccount extends Account {
private BigDecimal overdraftLimit;
public CurrentAccount(String accountId, BigDecimal balance, BigDecimal limit) {
super(accountId, balance);
this.overdraftLimit = limit;
}
@Override
public void withdraw(BigDecimal amount) {
BigDecimal effective = getBalance().add(overdraftLimit);
if (effective.compareTo(amount) < 0) {
throw new IllegalStateException("Exceeds overdraft limit");
}
// CurrentAccount allows withdrawal up to balance + overdraft.
}
}
What is polymorphism?
Polymorphism means the same method call executes different code depending on the actual object type at runtime. The variable type is the parent (Account), but the actual object is the child (SavingsAccount or CurrentAccount). When account.withdraw(amount) is called, the JVM looks at the actual object type and calls the correct version. This is called dynamic dispatch: the method resolution happens at runtime, not compile time.
package com.bank.service;
import java.math.BigDecimal;
import java.util.List;
public class InterestBatchJob {
public void processMonthEnd(List<Account> allAccounts) {
for (Account account : allAccounts) {
if (account instanceof SavingsAccount sa) {
BigDecimal interest = sa.calculateInterest();
account.deposit(interest);
}
// No need to know all possible account types at compile time.
}
}
public void processWithdrawal(Account account, BigDecimal amount) {
account.withdraw(amount);
// If account is SavingsAccount: calls Account.withdraw() (strict balance check)
// If account is CurrentAccount: calls CurrentAccount.withdraw() (allows overdraft)
// Same code, different behavior at runtime.
}
}
How do the four pillars work together?
In a real banking system the pillars never operate in isolation. Encapsulation protects data inside each class. Abstraction hides the complexity behind a clean service interface. Inheritance lets new account types reuse existing code. Polymorphism lets existing code handle new account types without modification.
// Encapsulation: Account.balance is private, changed only through deposit/withdraw
// Abstraction: PaymentService.transfer() hides NEFT/UPI/RTGS complexity
// Inheritance: SavingsAccount extends Account, reuses deposit/withdraw
// Polymorphism: account.withdraw(amount) behaves differently for each account type
// The real power: adding a NEW account type requires ZERO changes to existing code.
public class NRIAccount extends Account {
private String foreignCurrency;
private BigDecimal exchangeRate;
@Override
public void withdraw(BigDecimal amountInINR) {
// Custom validation: check FEMA compliance, check repatriation limits
super.withdraw(amountInINR);
}
}
// InterestBatchJob, PaymentService, TransferController: NONE of them need changes.
How is encapsulation different from abstraction?
Encapsulation and abstraction are different but complementary. Encapsulation is about data protection: making fields private and controlling how they change. Abstraction is about complexity hiding: exposing a simple interface and concealing how it works internally.
| Aspect | Encapsulation | Abstraction |
|--------|--------------|-------------|
| Focus | Protect internal state | Simplify external interface |
| Question answered | Who can access my data? | What does the user need to know? |
| Direction | Inward-facing (protect my internals) | Outward-facing (simplify for callers) |
| Java mechanism | private fields, validated setters | interface, abstract class |
| Banking example | balance is private, only withdraw() can change it | transfer() hides 15 internal steps |
What are common mistakes with OOP pillars?
The most common mistake is using inheritance for code reuse when composition is more appropriate. Another mistake is exposing a live internal collection from a getter, which breaks encapsulation.
// WRONG: using inheritance just for code reuse
public class AccountReport extends Account {
// AccountReport IS-NOT-A Account. It just needs account data.
}
// CORRECT: use composition
public class AccountReport {
private final Account account; // HAS-A Account
public AccountReport(Account account) { this.account = account; }
}
// WRONG: leaking internal collection
public List<Transaction> getTransactions() {
return transactions; // caller can call .clear()!
}
// CORRECT: return unmodifiable view
public List<Transaction> getTransactions() {
return List.copyOf(transactions);
}
When should you use each pillar?
Encapsulation should be applied to every class: make fields private and provide validated access methods. Abstraction should be applied whenever a caller does not need to know the implementation details: use interfaces for service contracts. Inheritance should be applied only when there is a true IS-A relationship with shared state: SavingsAccount IS-A Account. Polymorphism enables the Open/Closed Principle: existing code works with new types without modification.
What should a senior engineer know?
- The four pillars are not design patterns you apply. They are fundamental properties of every Java class. Even a single class with no explicit
extendsdemonstrates all four pillars (private fields, public methods, overloaded methods, implicitextends Object). - The Open/Closed Principle depends on polymorphism: code is open for extension (new subclasses) and closed for modification (existing code unchanged).
- In Spring Boot, loose coupling is achieved through interfaces (abstraction) + constructor injection (DI). Services depend on interfaces, not concrete classes.
- Effective Java recommends "favor composition over inheritance" because inheritance creates tight coupling (fragile base class problem).
- Records (Java 16) enforce encapsulation through immutability: all fields are private final with no setters.
- Sealed classes (Java 17) give the parent control over which children can exist, enabling exhaustive pattern matching in switch.
Visual Overview
┌─────────────────────────────────────────────┐
│ FOUR PILLARS OF OOP │
├─────────────────────────────────────────────┤
│ │
│ ENCAPSULATION ABSTRACTION │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ private data │ │ public API │ │
│ │ + validated │ │ hides HOW │ │
│ │ methods │ │ exposes WHAT │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ INHERITANCE POLYMORPHISM │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ parent → child│ │ same call │ │
│ │ code reuse │ │ different │ │
│ │ IS-A │ │ behavior │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────┘
Account (abstract)
│
├── SavingsAccount
│ calculateInterest() → balance x 4%
│
├── CurrentAccount
│ calculateInterest() → 0
│ withdraw() → allows overdraft
│
└── NRIAccount
withdraw() → FEMA compliance check
InterestBatchJob iterates List<Account>
│
▼
account.calculateInterest() ← polymorphic call
│
├── SavingsAccount → balance x 4% / 12
├── CurrentAccount → 0
└── NRIAccount → balance x 3.5% / 12
Key Takeaway
The four pillars of OOP are encapsulation, abstraction, inheritance, and polymorphism, and they work together, not in isolation. Encapsulation bundles data with methods and restricts access: the Account class keeps balance private and changeable only through deposit() and withdraw(), which validate every modification. Abstraction hides implementation complexity: the caller calls transfer() without knowing the 15 internal steps. Inheritance enables code reuse: SavingsAccount, CurrentAccount, and NRIAccount all extend Account, inheriting deposit() and getBalance() without duplication. Polymorphism enables flexibility: account.calculateInterest() automatically applies the correct formula for each account type at runtime.
In Pankaj's banking platform at National Bank of India, the month-end interest batch iterates over List
The real test of OOP understanding is extensibility: when you add a new type (NRIAccount), existing code (InterestBatchService, AccountController) should require zero modifications. This is achieved by encapsulating behavior in each class, abstracting common behavior in the parent, inheriting shared functionality, and using polymorphism so callers work with the parent type.
Remember one sentence: encapsulation protects, abstraction simplifies, inheritance reuses, and polymorphism flexes, and together they let you add new types without changing existing code.
---