Beginner Reading time: ~18 min

Core Principles

Encapsulation, inheritance, polymorphism and abstraction

OOP Principles

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:

  • EmailNotificationSender is a NotificationSender
  • SmsNotificationSender is a NotificationSender
  • PushNotificationSender is a NotificationSender

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 @Override annotation 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. Senior-level Insights.


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., NotificationSender above — has a shared auditLogger)
  • 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. Senior-level Insights

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. 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

9. Cheatsheet

  1. Encapsulation = data + operations in one unit, access restricted by modifiers
  2. Inheritance = IS-A relationship, extends keyword, single inheritance in Java
  3. Polymorphism = compile-time (overloading) + runtime (overriding, virtual dispatch)
  4. Abstraction = abstract class (state + partial impl.) vs interface (contract)
  5. Don't write getters/setters automatically — think in business operations
  6. Prefer composition over inheritance
  7. Always use the @Override annotation
  8. Use interfaces at module boundaries, abstract classes for shared implementation
  9. If the inheritance hierarchy is 3+ levels deep, refactor toward composition
  10. sealed (Java 17+) is the controlled form of inheritance — enables exhaustive pattern matching

🎼 Games

10 questions