Core Principles
Encapsulation, inheritance, polymorphism and abstraction
The four pillars of object-oriented programming: encapsulation, inheritance, polymorphism, and abstraction â illustrated through real-world business domain examples.
1. Definition
What is OOP?
Object-Oriented Programming (OOP) is a programming paradigm that organizes data and the operations on it into objects. It is the foundational design of the Java language â java.lang.Object is the root of every class hierarchy.
Why does it exist?
The procedural approach quickly becomes unmanageable in large codebases: data structures and the logic that operates on them become scattered. OOP solves this problem:
- Modularity â independent units that can be developed in isolation
- Reusability â extracting common behavior instead of copy-pasting
- Maintainability â the scope of a change is clearly bounded
- Modeling â natural mapping of business domain concepts to code
Where does it fit?
OOP sits among the main programming paradigms:
- Procedural â example: C
- Object-Oriented â Java, C#, Kotlin â this topic
- Functional â Haskell, Scala
- Hybrid â Java 8+ and Kotlin combine OOP with FP elements
Modern Java (8+) combines OOP with functional elements â lambda expressions, the Stream API, Optional â but its foundation remains object-oriented.
2. Core Concepts
2.1 Encapsulation
Encapsulation is the practice of bundling data and the operations that act on it into a single unit, while restricting direct external access to that data.
Access modifiers
| Modifier | Within class | Package | Subclass | Everywhere |
|---|---|---|---|---|
private |
â | â | â | â |
| (default) | â | â | â | â |
protected |
â | â | â | â |
public |
â | â | â | â |
Why does it matter?
- Invariant protection â the object's internal state always stays consistent
- Change management â the internal implementation can be swapped without changing client code
- Minimal API surface â only expose what actually needs to be used
// An encapsulated model of a payment transaction
public class PaymentTransaction {
// Internal state â cannot be modified directly from outside
private final String transactionId;
private BigDecimal amount;
private Currency currency;
private TransactionStatus status;
public PaymentTransaction(String transactionId, BigDecimal amount, Currency currency) {
// Invariant: amount must be positive
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive: " + amount);
}
this.transactionId = transactionId;
this.amount = amount;
this.currency = currency;
this.status = TransactionStatus.PENDING;
}
// State transition protected by business rules
public void approve() {
if (this.status != TransactionStatus.PENDING) {
throw new IllegalStateException(
"Only a PENDING transaction can be approved, current status: " + this.status
);
}
this.status = TransactionStatus.APPROVED;
}
// Getter â amount is readable but cannot be overwritten directly
public BigDecimal getAmount() {
return amount;
}
// No setAmount()! Amount changes go through a business operation.
public void applyDiscount(BigDecimal percentage) {
if (this.status != TransactionStatus.PENDING) {
throw new IllegalStateException("Discount can only be applied in PENDING status");
}
this.amount = this.amount.multiply(BigDecimal.ONE.subtract(percentage));
}
}
Key takeaway: Don't just write getters/setters â think about which business operations should drive state changes.
2.2 Inheritance
Inheritance allows a class to inherit the fields and methods of another class. In Java it is expressed with the extends keyword â single inheritance only (a class can have at most one parent class).
IS-A relationship
Inheritance expresses an IS-A (is a kind of) relationship:
Examples of an IS-A relationship:
EmailNotificationSenderis aNotificationSenderSmsNotificationSenderis aNotificationSenderPushNotificationSenderis aNotificationSender
Method overriding
A subclass can override a parent method if:
- The method signature is the same (name + parameters)
- The return type is identical or narrower (covariant return)
- The access modifier is the same or wider (cannot be narrowed)
- The
@Overrideannotation is not mandatory, but strongly recommended â the compiler will catch typos
// Common logic in the parent class
public abstract class NotificationSender {
private final AuditLogger auditLogger;
protected NotificationSender(AuditLogger auditLogger) {
this.auditLogger = auditLogger;
}
// Template Method pattern â the common flow is here; the specific part is supplied by subclasses
public final void send(Notification notification) {
validate(notification);
doSend(notification); // abstract â implemented by subclass
auditLogger.log(notification, channel()); // shared audit log
}
protected void validate(Notification notification) {
if (notification.getRecipient() == null || notification.getRecipient().isBlank()) {
throw new IllegalArgumentException("Recipient must not be empty");
}
}
// Subclasses provide the actual sending logic
protected abstract void doSend(Notification notification);
protected abstract String channel();
}
public class EmailNotificationSender extends NotificationSender {
private final EmailGateway emailGateway;
public EmailNotificationSender(AuditLogger auditLogger, EmailGateway emailGateway) {
super(auditLogger);
this.emailGateway = emailGateway;
}
@Override
protected void doSend(Notification notification) {
emailGateway.sendEmail(
notification.getRecipient(),
notification.getSubject(),
notification.getBody()
);
}
@Override
protected String channel() {
return "EMAIL";
}
}
Warning: Inheritance creates tight coupling between parent and subclass. Use it deliberately â â see: 7. Deep Dive.
2.3 Polymorphism
Polymorphism means that the same interface can hide different implementations. Java has two forms:
Compile-time (static) polymorphism â Method Overloading
The compiler decides which method to call based on the number and types of parameters:
public class RiskScoreCalculator {
// Default risk calculation
public RiskScore calculate(Transaction transaction) {
return evaluate(transaction, RiskProfile.DEFAULT);
}
// Profile-based risk calculation â overloaded version
public RiskScore calculate(Transaction transaction, RiskProfile profile) {
return evaluate(transaction, profile);
}
// Batch risk calculation â another overload
public List<RiskScore> calculate(List<Transaction> transactions) {
return transactions.stream()
.map(this::calculate)
.toList();
}
private RiskScore evaluate(Transaction tx, RiskProfile profile) {
// ... risk evaluation logic
return new RiskScore(tx.getId(), computeScore(tx, profile));
}
}
Runtime (dynamic) polymorphism â Method Overriding
The JVM decides at runtime which implementation to invoke (virtual method dispatch):
// Interface â the contract
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
boolean supports(PaymentMethod method);
}
// Implementation: card payment
public class CardPaymentProcessor implements PaymentProcessor {
@Override
public PaymentResult process(PaymentRequest request) {
// Validate card details, tokenize, call acquirer
CardDetails card = request.getCardDetails();
AuthResponse auth = acquirerGateway.authorize(card, request.getAmount());
return new PaymentResult(auth.isSuccessful(), auth.getAuthCode());
}
@Override
public boolean supports(PaymentMethod method) {
return method == PaymentMethod.CREDIT_CARD || method == PaymentMethod.DEBIT_CARD;
}
}
// Implementation: bank transfer
public class BankTransferProcessor implements PaymentProcessor {
@Override
public PaymentResult process(PaymentRequest request) {
// Validate IBAN, call SEPA/ACH
BankAccount account = request.getBankAccount();
TransferResult result = bankingGateway.initiateTransfer(account, request.getAmount());
return new PaymentResult(result.isAccepted(), result.getReferenceId());
}
@Override
public boolean supports(PaymentMethod method) {
return method == PaymentMethod.BANK_TRANSFER;
}
}
// Usage â the calling code doesn't know (or care) which implementation runs
public class PaymentService {
private final List<PaymentProcessor> processors;
public PaymentService(List<PaymentProcessor> processors) {
this.processors = processors;
}
public PaymentResult processPayment(PaymentRequest request) {
// Decided at runtime which processor runs
return processors.stream()
.filter(p -> p.supports(request.getMethod()))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentMethodException(request.getMethod()))
.process(request);
}
}
2.4 Abstraction
Abstraction is the practice of exposing essential details while hiding irrelevant ones. Java provides two tools for this:
Abstract class vs Interface
| Aspect | abstract class |
interface |
|---|---|---|
| Instantiation | â No | â No |
| Constructor | â Yes | â No |
| State (fields) | â Instance fields | â ïž Only static final |
| Multiple inheritance | â One parent | â Multiple interfaces |
default method |
â | â Java 8+ |
| When to use | Shared state + partial implementation | Contract, capability |
When to use which?
- Abstract class â when there is shared state or shared logic that subclasses will reuse (e.g.,
NotificationSenderabove â has a sharedauditLogger) - Interface â when defining a behavioral contract that any class â even unrelated ones â can implement (e.g.,
PaymentProcessorâ card and bank transfer are completely different classes)
// Interface â pure behavioral contract
public interface RiskEvaluator {
RiskLevel evaluate(Transaction transaction);
String evaluatorName();
}
// Abstract class â shared logic and state
public abstract class AbstractRiskEvaluator implements RiskEvaluator {
private final RiskThresholdConfig config;
private final MetricsCollector metrics;
protected AbstractRiskEvaluator(RiskThresholdConfig config, MetricsCollector metrics) {
this.config = config;
this.metrics = metrics;
}
@Override
public final RiskLevel evaluate(Transaction transaction) {
long start = System.nanoTime();
try {
double score = computeScore(transaction); // subclass logic
return mapToLevel(score); // shared mapping
} finally {
metrics.record(evaluatorName(), System.nanoTime() - start);
}
}
// Subclasses provide their own scoring logic
protected abstract double computeScore(Transaction transaction);
// Shared mapping â converts a score into a RiskLevel based on config
private RiskLevel mapToLevel(double score) {
if (score >= config.getHighThreshold()) return RiskLevel.HIGH;
if (score >= config.getMediumThreshold()) return RiskLevel.MEDIUM;
return RiskLevel.LOW;
}
protected RiskThresholdConfig getConfig() {
return config;
}
}
// Concrete implementation â velocity-based risk
public class VelocityRiskEvaluator extends AbstractRiskEvaluator {
private final TransactionHistoryRepository historyRepo;
public VelocityRiskEvaluator(RiskThresholdConfig config, MetricsCollector metrics,
TransactionHistoryRepository historyRepo) {
super(config, metrics);
this.historyRepo = historyRepo;
}
@Override
protected double computeScore(Transaction transaction) {
// Based on the number of transactions in the last 24 hours
int recentCount = historyRepo.countRecent(transaction.getUserId(), Duration.ofHours(24));
int limit = getConfig().getVelocityLimit();
return Math.min((double) recentCount / limit, 1.0);
}
@Override
public String evaluatorName() {
return "VELOCITY";
}
}
3. Practical Usage
When to use each pillar
| Pillar | Use case |
|---|---|
| Encapsulation | Whenever business invariants must be protected (e.g., a transaction amount must never be negative) |
| Inheritance | Template Method pattern, shared state + partial implementation, framework hooks |
| Polymorphism | Strategy pattern, plugin architectures, testability (mocking) |
| Abstraction | API design, defining module boundaries, dependency inversion |
When NOT to use
| Anti-pattern | Problem |
|---|---|
| Getters/Setters everywhere | Creates an illusion of encapsulation â the object is effectively an open data structure |
| Deep inheritance hierarchy (3+ levels) | Fragile Base Class problem â changes to the parent break subclasses unexpectedly |
| Marker interface with state | If you want to share state, an interface is the wrong tool |
| Polymorphism with a single implementation | Over-engineering â if you know there will only ever be one payment processor, don't make it an interface |
| Abstract class with one method and no state | Use an interface instead â simpler and more flexible |
4. Code Examples
4.1 Encapsulation â Immutable value object
/**
* Immutable monetary amount â fully encapsulated.
* Once created it cannot be modified; thread-safe.
*/
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
Objects.requireNonNull(amount, "Amount must not be null");
Objects.requireNonNull(currency, "Currency must not be null");
// Two decimal places, banker's rounding
this.amount = amount.setScale(2, RoundingMode.HALF_EVEN);
this.currency = currency;
}
// Business operation â returns a new object (immutable)
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
public boolean isGreaterThan(Money other) {
ensureSameCurrency(other);
return this.amount.compareTo(other.amount) > 0;
}
private void ensureSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
}
}
// Read-only â no setters
public BigDecimal getAmount() { return amount; }
public Currency getCurrency() { return currency; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money money)) return false;
return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount.stripTrailingZeros(), currency);
}
}
4.2 Inheritance â Template Method for notification sending
â See the NotificationSender example in Section 2.2 Inheritance.
4.3 Polymorphism â Strategy pattern for risk evaluation
// Multiple evaluators â each examines a different aspect
public class CompositeRiskEvaluator implements RiskEvaluator {
private final List<RiskEvaluator> evaluators;
public CompositeRiskEvaluator(List<RiskEvaluator> evaluators) {
this.evaluators = List.copyOf(evaluators); // defensive copy
}
@Override
public RiskLevel evaluate(Transaction transaction) {
// Return the highest risk level found
return evaluators.stream()
.map(e -> e.evaluate(transaction))
.max(Comparator.comparingInt(RiskLevel::severity))
.orElse(RiskLevel.LOW);
}
@Override
public String evaluatorName() {
return "COMPOSITE";
}
}
4.4 Common mistake â â / â
// â BAD: Violation of encapsulation â public mutable fields
public class PaymentOrder {
public List<LineItem> items = new ArrayList<>(); // anyone can modify!
public BigDecimal totalAmount; // can be overwritten directly!
}
// Usage â invariants are easily violated:
PaymentOrder order = new PaymentOrder();
order.items.add(new LineItem("Widget", new BigDecimal("10.00")));
order.totalAmount = new BigDecimal("-999.99"); // â ïž Negative amount!
// â
GOOD: Modifiable only through business operations; invariants protected
public class PaymentOrder {
private final List<LineItem> items = new ArrayList<>();
private BigDecimal totalAmount = BigDecimal.ZERO;
public void addItem(LineItem item) {
Objects.requireNonNull(item, "Item must not be null");
if (item.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Item price must be positive");
}
this.items.add(item);
this.totalAmount = this.totalAmount.add(item.getPrice());
}
// Defensive copy â callers cannot modify the internal list
public List<LineItem> getItems() {
return Collections.unmodifiableList(items);
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
}
5. Trade-offs
| Aspect | Details |
|---|---|
| ⥠Performance | Virtual method dispatch (polymorphism) has minimal overhead â the JIT compiler inlines hot paths. However, too many abstraction layers can cause cache misses if there is excessive indirection. |
| đŸ Memory | Every object carries a 12â16 byte header (mark word + class pointer). Deep inheritance hierarchies increase object size because all parent fields are included. |
| đ§ Maintainability | OOP produces well-structured code when applied correctly. Excessive inheritance and too many abstract layers hurt readability â creating a "trace hell" where a single call must be followed through 8 classes. |
| đ Flexibility | Interface-based design (programming to interfaces) gives excellent flexibility â adding a new implementation requires no modification to existing code (Open/Closed Principle). Inheritance, by contrast, creates tight coupling. |
6. Common Mistakes
6.1 â Anemic Domain Model
// â The object is just a data holder; logic lives in a "service"
public class Transaction {
private String id;
private BigDecimal amount;
private TransactionStatus status;
// Getters + Setters everywhere...
public void setStatus(TransactionStatus status) { this.status = status; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
public class TransactionService {
public void approve(Transaction tx) {
if (tx.getStatus() == TransactionStatus.PENDING) {
tx.setStatus(TransactionStatus.APPROVED); // poking state from outside
}
}
}
// â
Rich Domain Model â the object is responsible for its own state
public class Transaction {
private final String id;
private BigDecimal amount;
private TransactionStatus status;
public void approve() {
if (this.status != TransactionStatus.PENDING) {
throw new IllegalStateException("Only a PENDING transaction can be approved");
}
this.status = TransactionStatus.APPROVED;
}
// No setStatus()! State transitions happen through business operations.
}
6.2 â Inheritance for code reuse (HAS-A instead of IS-A)
// â BAD: PaymentLogger is NOT "a kind of" ArrayList!
public class PaymentLogger extends ArrayList<String> {
public void logPayment(String message) {
this.add("[PAYMENT] " + message);
}
}
// Problem: PaymentLogger inherits ALL public ArrayList methods
// â clear(), remove(), set() â which are irrelevant for a logger!
// â
GOOD: Composition â the logger CONTAINS a list, it does not EXTEND one
public class PaymentLogger {
private final List<String> logs = new ArrayList<>();
public void logPayment(String message) {
logs.add("[PAYMENT] " + message);
}
// Only expose what is actually needed
public List<String> getRecentLogs(int count) {
int start = Math.max(0, logs.size() - count);
return Collections.unmodifiableList(logs.subList(start, logs.size()));
}
}
6.3 â Leaky Abstraction (internal implementation leaking out)
// â The internal list reference leaks out
public class RiskAssessmentResult {
private final List<RiskFactor> factors;
public RiskAssessmentResult(List<RiskFactor> factors) {
this.factors = factors; // â ïž No defensive copy!
}
public List<RiskFactor> getFactors() {
return factors; // â ïž Mutable reference â callers can modify it!
}
}
// Caller accidentally (or intentionally) modifies internal state:
List<RiskFactor> factors = result.getFactors();
factors.clear(); // đ„ The original object's internal state is now corrupted!
// â
Defensive copy + unmodifiable view
public class RiskAssessmentResult {
private final List<RiskFactor> factors;
public RiskAssessmentResult(List<RiskFactor> factors) {
this.factors = List.copyOf(factors); // Defensive copy in constructor
}
public List<RiskFactor> getFactors() {
return factors; // List.copyOf() already returns an unmodifiable list
}
}
6.4 â Missing @Override annotation
// â Typo in method name â creates a new method instead of overriding
public class CustomRiskEvaluator extends AbstractRiskEvaluator {
// Should be computeScore, not komputScore!
// Without @Override the compiler says nothing
protected double komputScore(Transaction transaction) {
return 0.5;
}
// Result: AbstractRiskEvaluator's abstract computeScore() runs instead
// â or if a default implementation exists, the bug is silent
}
// â
The @Override annotation causes a compile error if the method doesn't exist in the parent
public class CustomRiskEvaluator extends AbstractRiskEvaluator {
@Override // â The compiler verifies this is actually an override
protected double computeScore(Transaction transaction) {
return 0.5;
}
}
7. Deep Dive
Composition over Inheritance
A principle known since the Gang of Four (GoF) book: prefer composition over inheritance. Inheritance is the strongest form of coupling in Java â if the parent changes, every subclass is potentially affected.
// â Inheritance-based approach â rigid, hard to extend
public class FraudCheckingPaymentService extends PaymentService {
@Override
public PaymentResult process(PaymentRequest request) {
fraudChecker.check(request);
return super.process(request);
}
}
// What if I also want rate limiting? Another extends?
// FraudCheckingRateLimitedPaymentService extends FraudCheckingPaymentService? đ€ź
// â
Composition + Decorator pattern â flexible and extensible
public class FraudCheckingPaymentProcessor implements PaymentProcessor {
private final PaymentProcessor delegate;
private final FraudChecker fraudChecker;
public FraudCheckingPaymentProcessor(PaymentProcessor delegate, FraudChecker fraudChecker) {
this.delegate = delegate;
this.fraudChecker = fraudChecker;
}
@Override
public PaymentResult process(PaymentRequest request) {
fraudChecker.check(request); // precondition
return delegate.process(request); // delegation
}
@Override
public boolean supports(PaymentMethod method) {
return delegate.supports(method);
}
}
// Rate limiting? Create a RateLimitingPaymentProcessor that also delegates.
// They can be freely combined and nested.
SOLID connection
The OOP pillars alone are not enough â the SOLID principles define how to apply them correctly:
| SOLID | Relationship to OOP |
|---|---|
| S â Single Responsibility | One class changes for one reason â focused encapsulation |
| O â Open/Closed | Open for extension, closed for modification â polymorphism + abstraction |
| L â Liskov Substitution | A subclass must be substitutable for its parent â correct inheritance |
| I â Interface Segregation | Small, focused interfaces â better abstraction |
| D â Dependency Inversion | Depend on abstractions, not concrete implementations â use interfaces |
The dangers of over-abstraction
Codebase size: ~5,000 lines
Abstract layers: 8 (Controller â Service â Facade â Strategy â Handler â Processor â Adapter â Gateway)
One request trace: 15 classes, 3 interfaces
New developer onboarding: 3 weeks đ±
Rule of thumb: Ask yourself â "If I need to change this tomorrow, how many files do I have to open to understand the flow?" If the answer is 6+, you probably have too much abstraction.
Sealed classes (Java 17+)
Modern Java's sealed keyword restricts the inheritance hierarchy â only permitted subclasses can exist:
// Exactly three kinds of notification outcome are possible â no others
public sealed interface NotificationOutcome
permits Delivered, Failed, Retrying {
}
public record Delivered(Instant timestamp) implements NotificationOutcome {}
public record Failed(String reason, Instant timestamp) implements NotificationOutcome {}
public record Retrying(int attempt, Instant nextRetry) implements NotificationOutcome {}
// Pattern matching â the compiler verifies exhaustiveness
public String describe(NotificationOutcome outcome) {
return switch (outcome) {
case Delivered d -> "Delivered at: " + d.timestamp();
case Failed f -> "Failed: " + f.reason();
case Retrying r -> "Retrying, attempt #" + r.attempt();
// No default branch needed â sealed ensures exhaustiveness
};
}
Interview tip
Question: "When would you use an abstract class vs an interface?"
Good answer: "If there is shared state or a shared implementation that subclasses will reuse â abstract class. If it's a purely behavioral contract that unrelated classes can implement â interface. Since Java 8, interfaces can also have default methods, which blurs the line. But the key distinction is that an interface has no state (no instance fields)."
8. Interview Questions
How do you explain encapsulation vs abstraction without mixing them up?
Encapsulation hides internal state behind a controlled API, while abstraction hides unnecessary detail so the caller can work with a simpler mental model.
Why is composition usually safer than inheritance?
Because composition keeps coupling weaker. Inheritance makes subclasses sensitive to parent behavior and evolution, while composition allows behavior to be assembled and replaced more safely.
When is an abstract class better than an interface?
When several closely related classes need shared implementation or shared state. If you only need a contract across unrelated types, an interface is usually better.
What is the best way to talk about the four OOP pillars in an interview?
Define each pillar briefly, connect it to a Java construct, and mention a trade-off. Strong answers combine definition, language feature, and judgment.
9. Glossary
| Term | Definition |
|---|---|
| Virtual method dispatch | The JVM mechanism that determines at runtime which overridden method to invoke â using the vtable (virtual method table) |
| Covariant return type | An overriding method's return type may be narrower (more specific) than the original (Java 5+) |
| Fragile Base Class | A problem where modifying the parent class unexpectedly breaks its subclasses |
| Tight coupling | Two components depend on each other so closely that changing one forces a change in the other |
| Defensive copy | Making a copy of an incoming or outgoing mutable object to protect internal state |
| Anemic Domain Model | An anti-pattern where objects only hold data and business logic is scattered across "service" classes |
| Diamond problem | An ambiguity that arises with multiple inheritance â Java handles it through interface default method rules |
| Marker interface | An empty interface (e.g., Serializable) used for tagging, not for defining behavior |
10. Cheatsheet
- Encapsulation = data + operations in one unit, access restricted by modifiers
- Inheritance = IS-A relationship,
extendskeyword, single inheritance in Java - Polymorphism = compile-time (overloading) + runtime (overriding, virtual dispatch)
- Abstraction =
abstract class(state + partial impl.) vsinterface(contract) - Don't write getters/setters automatically â think in business operations
- Prefer composition over inheritance
- Always use the
@Overrideannotation - Use interfaces at module boundaries, abstract classes for shared implementation
- If the inheritance hierarchy is 3+ levels deep, refactor toward composition
sealed(Java 17+) is the controlled form of inheritance â enables exhaustive pattern matching
đź Games
10 questions