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