Skip to main content

Object-Oriented Programming in Java

Object-Oriented Programming (OOP) is the foundation of Java. Everything in Java revolves around objects and classes, making it one of the most naturally OOP-friendly languages available.


Core Conceptsโ€‹

1. Class & Objectโ€‹

A class is a blueprint. An object is an instance of that blueprint.

public class Car {
String brand;
int year;

public Car(String brand, int year) {
this.brand = brand;
this.year = year;
}

public void drive() {
System.out.println(brand + " is driving!");
}
}

// Creating an object
Car myCar = new Car("Toyota", 2022);
myCar.drive(); // Toyota is driving!

Key terminology:

TermMeaning
ClassA template/blueprint defining fields and methods
ObjectA concrete instance created from a class (new Car(...))
FieldData stored inside an object (also called instance variable or attribute)
MethodBehavior/action an object can perform
ConstructorSpecial method called when creating an object (new)
thisReference to the current object instance

2. Encapsulationโ€‹

"Hide the data, expose the behavior."

Encapsulation means bundling data (fields) and behavior (methods) together, while restricting direct access to internal state using access modifiers.

Key Idea

Hide the data, expose the behavior.

public class BankAccount {
private double balance; // hidden from outside

public double getBalance() {
return balance;
}

public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}

public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
}
}
}

Access Modifiers:

ModifierSame ClassSame PackageSubclassEverywhere
privateโœ…โŒโŒโŒ
(default)โœ…โœ…โŒโŒ
protectedโœ…โœ…โœ…โŒ
publicโœ…โœ…โœ…โœ…
Spring Tip

Spring beans rely heavily on encapsulation. @Service, @Repository, and @Component classes expose only what's needed through public methods or interfaces.

๐ŸŽฏ Why Encapsulation Mattersโ€‹

Without encapsulation, any code can modify an object's internal state directly โ€” leading to unpredictable behavior:

// โŒ No encapsulation โ€” anyone can set invalid state
public class Order {
public double totalPrice;
public String status;
}

Order order = new Order();
order.totalPrice = -500; // Negative price? Allowed!
order.status = "BANANA"; // Nonsense status? Allowed!
// โœ… With encapsulation โ€” business rules enforced internally
public class Order {
private double totalPrice;
private OrderStatus status;

public void applyDiscount(double percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("Invalid discount");
}
this.totalPrice -= this.totalPrice * (percent / 100.0);
}

public void markAsShipped() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("Only paid orders can be shipped");
}
this.status = OrderStatus.SHIPPED;
}
}

The object protects its own invariants. Callers can't put it in an invalid state.

๐Ÿ” How to Spot Encapsulation Violationsโ€‹

SmellWhat It Means
Public fieldsAny code can set invalid values
Getters that expose mutable internalsgetList() returning the actual List โ€” callers can modify it directly
Validation scattered across callers5 different services all check if (amount > 0) before calling your class
Setter for everythingAuto-generated setters defeat the purpose of encapsulation
Domain logic in services, not objectsThe "Anemic Domain Model" anti-pattern

๐Ÿข Real-World Use Case: Protecting Domain Invariantsโ€‹

In a banking application, the Account class must ensure:

  • Balance never goes negative (for standard accounts)
  • Transactions are logged
  • Currency is consistent
public class Account {
private final String accountId;
private final Currency currency;
private BigDecimal balance;
private final List<Transaction> transactions = new ArrayList<>();

public Account(String accountId, Currency currency, BigDecimal initialBalance) {
this.accountId = accountId;
this.currency = currency;
this.balance = initialBalance;
}

public void transfer(Account target, BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Transfer amount must be positive");
}
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientFundsException(accountId, amount);
}
if (!this.currency.equals(target.currency)) {
throw new CurrencyMismatchException(this.currency, target.currency);
}

this.balance = this.balance.subtract(amount);
target.balance = target.balance.add(amount);

this.transactions.add(Transaction.debit(amount, target.accountId));
target.transactions.add(Transaction.credit(amount, this.accountId));
}

// Defensive copy โ€” don't expose the mutable list!
public List<Transaction> getTransactions() {
return Collections.unmodifiableList(transactions);
}

// No setter for balance โ€” it can only change through business operations
}

Key techniques:

  • final fields for immutable properties
  • No setters โ€” state changes only through business methods with validation
  • Defensive copy via Collections.unmodifiableList() for mutable collections
  • Invariants (positive balance, matching currency) enforced inside the class

๐Ÿ—๏ธ Architecture Deep Dive: Rich vs Anemic Domain Modelsโ€‹

Anemic Domain Model (anti-pattern)Rich Domain Model (proper encapsulation)
Objects are just data holders (DTOs with getters/setters)Objects contain both data and behavior
Business logic lives in service classesBusiness logic lives in domain objects
Validation scattered everywhereValidation centralized in the object
Easy to accidentally create invalid stateObject always in a valid state
// โŒ Anemic: Order is just a data bag
public class Order {
private double total;
private String status;
// getters and setters for everything...
}

// Logic is in the service โ€” not the object
public class OrderService {
public void cancel(Order order) {
if (!"PENDING".equals(order.getStatus())) {
throw new IllegalStateException("...");
}
order.setStatus("CANCELLED");
order.setTotal(0);
}
}
// โœ… Rich: Order owns its behavior
public class Order {
private BigDecimal total;
private OrderStatus status;

public void cancel() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Only pending orders can be cancelled");
}
this.status = OrderStatus.CANCELLED;
}
// No setters โ€” state transitions through behavior methods only
}

โš–๏ธ Trade-offsโ€‹

Over-encapsulationUnder-encapsulation
Too many tiny methods for simple data accessPublic fields everywhere
Wrapper classes for primitive values with no business rulesMutable internals exposed via getters
Forced builder pattern for 2-field objectsNo validation โ€” callers must check everything

Pragmatic guideline: Encapsulate where there are business rules to protect. Simple DTOs, configuration objects, and value objects with no invariants can use public fields or records.

// Records (Java 16+) are great for data without behavior
public record UserResponse(String name, String email, LocalDate joinDate) {}
// Immutable, transparent, no hidden state โ€” no need for heavy encapsulation

3. Inheritanceโ€‹

"Reuse behavior by specialization โ€” but think twice before you extend."

Inheritance allows a class to acquire properties and methods from a parent class using the extends keyword.

public class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public void makeSound() {
System.out.println(name + " makes a sound.");
}
}

public class Dog extends Animal {
public Dog(String name) {
super(name); // call parent constructor
}

@Override
public void makeSound() {
System.out.println(name + " barks!");
}
}

Animal dog = new Dog("Rex");
dog.makeSound(); // Rex barks!

Key rules:

  • Java supports single inheritance only (one parent class)
  • Use super to access parent class members
  • @Override annotation signals an intentional method override โ€” always use it!
Avoid deep inheritance chains (more than 2โ€“3 levels). They make code harder to understand and maintain. Prefer composition over inheritance when possible.

๐ŸŽฏ Why Inheritance Matters (And When It Hurts)โ€‹

Inheritance is the most powerful but most dangerous OOP tool. Used correctly, it eliminates duplication and enables polymorphism. Used incorrectly, it creates rigid, fragile hierarchies that are nearly impossible to refactor.

When inheritance helps:

// Template Method pattern โ€” shared algorithm, customizable steps
public abstract class DataImporter {
// The overall algorithm is fixed
public final void importData(String source) {
String rawData = readData(source);
List<Record> records = parseRecords(rawData);
validate(records);
saveAll(records);
}

protected abstract String readData(String source); // subclass customizes
protected abstract List<Record> parseRecords(String raw); // subclass customizes

protected void validate(List<Record> records) {
// shared validation โ€” can be overridden if needed
records.forEach(r -> {
if (r.isEmpty()) throw new ValidationException("Empty record");
});
}

private void saveAll(List<Record> records) {
// shared behavior โ€” NOT overridable
repository.saveAll(records);
}
}

public class CsvImporter extends DataImporter {
@Override
protected String readData(String source) { /* read CSV file */ }
@Override
protected List<Record> parseRecords(String raw) { /* parse CSV format */ }
}

public class JsonImporter extends DataImporter {
@Override
protected String readData(String source) { /* read JSON file */ }
@Override
protected List<Record> parseRecords(String raw) { /* parse JSON format */ }
}

When inheritance hurts:

// โŒ The Fragile Base Class problem
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // BUG: HashSet.addAll() calls add() internally!
// addCount is now DOUBLE the actual additions!
}
}

This is a famous example from Effective Java. The subclass breaks because it depends on the internal implementation of HashSet, not just its public contract.

๐Ÿ” How to Decide: Inheritance vs Compositionโ€‹

Ask these questions:

QuestionIf Yes โ†’If No โ†’
Is it a true "is-a" relationship?Consider inheritanceUse composition
Does the subclass need ALL parent behavior?Consider inheritanceUse composition
Will the hierarchy be stable (2โ€“3 levels max)?Consider inheritanceUse composition
Do you need to swap behavior at runtime?Use compositionConsider either
Is the parent class from a library you don't control?Use composition (wrap it)Consider either
// โœ… Composition: flexible, runtime-swappable
public class NotificationService {
private final MessageSender sender; // can be email, SMS, push โ€” swappable

public NotificationService(MessageSender sender) {
this.sender = sender;
}

public void notifyUser(User user, String message) {
sender.send(user.getContact(), message);
}
}

// vs

// โŒ Inheritance: rigid, locked at compile time
public class EmailNotificationService extends NotificationService {
// Can't easily switch to SMS at runtime
}

๐Ÿข Real-World Use Case: Framework Extension Pointsโ€‹

Most frameworks use inheritance for controlled extension points โ€” places where you're meant to customize behavior:

// Spring Security โ€” extending a base class to customize authentication
public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) {
// custom authentication logic (e.g., 2FA check)
}

@Override
protected UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication) {
return userDetailsService.loadUserByUsername(username);
}
}
// JPA โ€” extending a repository to add custom queries
public interface OrderRepository extends JpaRepository<Order, Long> {
// JpaRepository provides findById, save, delete, etc.
// You extend with custom methods:
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
}

Key insight: Framework inheritance is designed for it โ€” the abstract methods are explicit extension points. Business domain inheritance is where things get dangerous.

๐Ÿ—๏ธ Architecture Deep Dive: Types of Inheritanceโ€‹

TypeMechanismExampleRisk Level
Implementation inheritanceextends a classDog extends Animalโš ๏ธ Medium โ€” tight coupling to parent internals
Interface inheritanceimplements an interfaceDog implements Petโœ… Low โ€” only commits to a contract
Mixin inheritancedefault methods in interfacesComparable, Serializableโš ๏ธ Medium โ€” diamond problem with multiple defaults
Sealed inheritancesealed class (Java 17+)Shape permits Circle, Rectangleโœ… Low โ€” controlled, exhaustive

Java 17+: Sealed Classesโ€‹

Sealed classes give you controlled inheritance โ€” you explicitly list who can extend:

public sealed abstract class PaymentMethod
permits CreditCard, BankTransfer, DigitalWallet {

public abstract PaymentResult process(Money amount);
}

public final class CreditCard extends PaymentMethod {
@Override
public PaymentResult process(Money amount) { /* ... */ }
}

public final class BankTransfer extends PaymentMethod {
@Override
public PaymentResult process(Money amount) { /* ... */ }
}

public final class DigitalWallet extends PaymentMethod {
@Override
public PaymentResult process(Money amount) { /* ... */ }
}

Benefits:

  • Compiler knows all subtypes โ†’ exhaustive switch (pattern matching)
  • No rogue subclasses can appear unexpectedly
  • Combines the power of inheritance with the safety of enums
// Pattern matching with sealed classes (Java 21+)
public String describePayment(PaymentMethod method) {
return switch (method) {
case CreditCard cc -> "Card ending in " + cc.lastFour();
case BankTransfer bt -> "Transfer from " + bt.bankName();
case DigitalWallet dw -> "Wallet: " + dw.provider();
// No default needed โ€” compiler knows all cases!
};
}

โš–๏ธ Trade-offsโ€‹

Too Much InheritanceToo Little Inheritance
Deep hierarchies (5+ levels) nobody can followDuplicated code across similar classes
Fragile base class problem โ€” parent changes break childrenMissing polymorphism โ€” switch statements everywhere
God class that everything extendsNo shared contracts โ€” each class is an island
"Yo-yo problem" โ€” jumping up and down the hierarchy to understand behaviorCan't leverage framework extension points

The golden rule from Effective Java: "Favor composition over inheritance. If you must use inheritance, design and document for it โ€” or else prohibit it (use final)."


4. Polymorphismโ€‹

"One interface, many implementations."

Polymorphism means "many forms" โ€” the same interface can behave differently depending on the actual object.

Compile-time Polymorphism (Method Overloading)โ€‹

Same method name, different parameter lists โ€” resolved by the compiler:

public class Calculator {
public int add(int a, int b) {
return a + b;
}

public double add(double a, double b) { // same name, different params
return a + b;
}

public int add(int a, int b, int c) { // different number of params
return a + b + c;
}
}

Overloading rules:

  • Methods must differ in parameter types or count
  • Return type alone is NOT enough to distinguish overloaded methods
  • add(int, int) and add(long, long) are valid overloads

Runtime Polymorphism (Method Overriding)โ€‹

Same method signature, different behavior โ€” resolved at runtime based on the actual object type:

public class Shape {
public double area() {
return 0;
}
}

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double area() {
return Math.PI * radius * radius;
}
}

public class Rectangle extends Shape {
private double width, height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double area() {
return width * height;
}
}

// Polymorphic behavior
List<Shape> shapes = List.of(new Circle(5), new Rectangle(4, 6));
shapes.forEach(s -> System.out.println(s.area()));
Spring Tip

Polymorphism powers Spring's dependency injection. You program to an interface, and Spring injects the correct implementation at runtime.

๐ŸŽฏ Why Polymorphism Mattersโ€‹

Polymorphism is what makes OOP powerful, not just organized. Without it, you'd write code like this:

// โŒ Without polymorphism โ€” every new type requires modifying this code
public double calculateTotalArea(List<Object> shapes) {
double total = 0;
for (Object obj : shapes) {
if (obj instanceof Circle c) {
total += Math.PI * c.radius * c.radius;
} else if (obj instanceof Rectangle r) {
total += r.width * r.height;
} else if (obj instanceof Triangle t) {
total += 0.5 * t.base * t.height;
}
// Every new shape = add another branch here...
}
return total;
}
// โœ… With polymorphism โ€” adding a new shape requires ZERO changes here
public double calculateTotalArea(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::area)
.sum();
// New shapes just implement area() โ€” this code never changes!
}

๐Ÿ” The Dispatch Mechanism: How Java Does Itโ€‹

Understanding how polymorphism works internally helps you debug and optimize:

Animal animal = new Dog("Rex");
animal.makeSound(); // Which method runs?
StepWhat Happens
1. Compile timeCompiler checks: does Animal have makeSound()? Yes โ†’ compiles
2. RuntimeJVM looks at the actual object type (Dog), not the reference type (Animal)
3. Virtual method dispatchJVM finds Dog.makeSound() in the virtual method table (vtable)
4. ExecutionDog.makeSound() runs โ†’ "Rex barks!"

Key insight: The reference type determines which methods you can call (compile time). The object type determines which implementation runs (runtime).

Animal animal = new Dog("Rex");
animal.makeSound(); // โœ… Compiles โ€” Animal has makeSound()
animal.fetch(); // โŒ Compile error โ€” Animal doesn't have fetch()

Dog dog = new Dog("Rex");
dog.fetch(); // โœ… Compiles โ€” Dog has fetch()

๐Ÿข Real-World Use Casesโ€‹

1. Payment Processing with Strategy Patternโ€‹

public interface PaymentProcessor {
PaymentResult process(Order order);
boolean supports(PaymentMethod method);
}

@Component
public class CreditCardProcessor implements PaymentProcessor {
@Override
public PaymentResult process(Order order) {
// Stripe API call for credit cards
return stripeClient.charge(order.getTotal(), order.getCardToken());
}

@Override
public boolean supports(PaymentMethod method) {
return method == PaymentMethod.CREDIT_CARD;
}
}

@Component
public class PayPalProcessor implements PaymentProcessor {
@Override
public PaymentResult process(Order order) {
// PayPal API call
return paypalClient.executePayment(order.getPaypalOrderId());
}

@Override
public boolean supports(PaymentMethod method) {
return method == PaymentMethod.PAYPAL;
}
}

// The orchestrator doesn't know or care about specific processors
@Service
public class PaymentService {
private final List<PaymentProcessor> processors;

public PaymentService(List<PaymentProcessor> processors) {
this.processors = processors; // Spring injects ALL implementations
}

public PaymentResult pay(Order order) {
return processors.stream()
.filter(p -> p.supports(order.getPaymentMethod()))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentException(order.getPaymentMethod()))
.process(order);
}
}

Adding Apple Pay? Just create ApplePayProcessor implements PaymentProcessor. Zero changes to PaymentService.

2. Event Handling Systemโ€‹

public interface EventHandler<T extends DomainEvent> {
void handle(T event);
Class<T> getEventType();
}

@Component
public class OrderCreatedHandler implements EventHandler<OrderCreatedEvent> {
@Override
public void handle(OrderCreatedEvent event) {
// Send confirmation email, reserve inventory, etc.
}

@Override
public Class<OrderCreatedEvent> getEventType() {
return OrderCreatedEvent.class;
}
}

@Component
public class PaymentFailedHandler implements EventHandler<PaymentFailedEvent> {
@Override
public void handle(PaymentFailedEvent event) {
// Notify customer, retry payment, etc.
}

@Override
public Class<PaymentFailedEvent> getEventType() {
return PaymentFailedEvent.class;
}
}

3. Data Export Pipelineโ€‹

public interface DataExporter {
byte[] export(ReportData data);
String getContentType();
String getFileExtension();
}

@Component public class PdfExporter implements DataExporter {
public byte[] export(ReportData data) { /* generate PDF */ }
public String getContentType() { return "application/pdf"; }
public String getFileExtension() { return ".pdf"; }
}

@Component public class CsvExporter implements DataExporter {
public byte[] export(ReportData data) { /* generate CSV */ }
public String getContentType() { return "text/csv"; }
public String getFileExtension() { return ".csv"; }
}

@Component public class ExcelExporter implements DataExporter {
public byte[] export(ReportData data) { /* generate Excel */ }
public String getContentType() { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; }
public String getFileExtension() { return ".xlsx"; }
}

๐Ÿ—๏ธ Architecture Deep Dive: Polymorphism Patternsโ€‹

PatternHow It Uses PolymorphismWhen to Use
StrategySwap algorithms at runtimePayment processing, discount calculation, sorting
Template MethodOverride specific steps in a fixed algorithmData import, report generation, lifecycle hooks
ObserverNotify multiple listeners through a common interfaceEvent handling, notifications, pub/sub
DecoratorWrap objects to add behavior transparentlyLogging, caching, retry, compression
CommandEncapsulate actions as objectsUndo/redo, task queues, macro recording

Polymorphism with Genericsโ€‹

Java generics add type-safe polymorphism without casting:

// Without generics โ€” unsafe, requires casting
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // runtime ClassCastException risk

// With generics โ€” compile-time safety
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // no cast needed, type-safe

Bounded type parameters combine generics with polymorphism:

// Only accepts types that are Comparable
public <T extends Comparable<T>> T findMax(List<T> items) {
return items.stream()
.max(Comparator.naturalOrder())
.orElseThrow();
}

// Works with any Comparable type
findMax(List.of(3, 1, 4, 1, 5)); // Integer
findMax(List.of("apple", "banana", "cherry")); // String
findMax(List.of(LocalDate.now(), LocalDate.of(2020, 1, 1))); // LocalDate

โš–๏ธ Trade-offsโ€‹

Too Much PolymorphismToo Little Polymorphism
Simple operations wrapped in unnecessary interfacesif/else chains that grow with every new type
"Astronaut architecture" โ€” 5 layers of abstraction for a CRUD endpointinstanceof checks scattered through the codebase
Hard to debug โ€” "what implementation is actually running?"Duplicated logic across similar types
Performance overhead from virtual dispatch (rare in practice)Can't extend without modifying existing code

When to use a simple switch instead of polymorphism:

  • The set of types is small (3โ€“5) and stable
  • Each branch is a single line
  • No team needs to add new types independently
  • It's internal code, not a public API
// This is FINE โ€” 3 enum values, stable, one-liners
public String formatCurrency(Currency currency, double amount) {
return switch (currency) {
case USD -> "$%.2f".formatted(amount);
case EUR -> "โ‚ฌ%.2f".formatted(amount);
case GBP -> "ยฃ%.2f".formatted(amount);
};
}

๐Ÿงช Testing Implicationsโ€‹

Polymorphism makes testing easier by enabling mock/fake substitution:

// Test with a fake implementation โ€” no real payment processing!
@Test
void shouldProcessOrder() {
PaymentProcessor fakeProcessor = new PaymentProcessor() {
@Override
public PaymentResult process(Order order) {
return PaymentResult.success(); // always succeeds in tests
}

@Override
public boolean supports(PaymentMethod method) {
return true; // supports everything in tests
}
};

PaymentService service = new PaymentService(List.of(fakeProcessor));
PaymentResult result = service.pay(testOrder);
assertTrue(result.isSuccess());
}

5. Abstractionโ€‹

"Show only what matters, hide the complexity."

Abstraction hides implementation details and exposes only the essential features. Java achieves this through abstract classes and interfaces.

Abstract Classโ€‹

public abstract class Vehicle {
protected String model;

public Vehicle(String model) {
this.model = model;
}

public abstract void fuelUp(); // must be implemented by subclass

public void startEngine() { // shared behavior
System.out.println(model + " engine started.");
}
}

public class ElectricCar extends Vehicle {
public ElectricCar(String model) {
super(model);
}

@Override
public void fuelUp() {
System.out.println(model + " is charging...");
}
}

Interfaceโ€‹

public interface Payable {
void processPayment(double amount); // implicitly public & abstract

default void printReceipt() { // default method (Java 8+)
System.out.println("Payment processed.");
}
}

public class CreditCardPayment implements Payable {
@Override
public void processPayment(double amount) {
System.out.println("Charging $" + amount + " to credit card.");
}
}

Abstract Class vs Interface:

FeatureAbstract ClassInterface
InstantiationโŒ CannotโŒ Cannot
Multiple inheritanceโŒ Noโœ… Yes (implements A, B)
Constructorโœ… YesโŒ No
FieldsAny typepublic static final only
MethodsAbstract + concreteAbstract + default/static
Spring Tip

Interfaces are everywhere in Spring. JpaRepository, ApplicationContext, BeanFactory โ€” you always code to the interface, letting Spring provide the implementation.

๐ŸŽฏ Why Abstraction Mattersโ€‹

Abstraction lets you work with concepts instead of implementation details. Consider sending a notification:

// โŒ Without abstraction โ€” caller must know EVERY detail
public class OrderService {
public void notifyCustomer(Order order) {
// Must know SMTP details
Properties props = new Properties();
props.put("mail.smtp.host", "smtp.gmail.com");
props.put("mail.smtp.port", "587");
Session session = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("[email protected]", "password");
}
});
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("[email protected]"));
message.setRecipients(Message.RecipientType.TO, order.getCustomerEmail());
message.setSubject("Order Confirmation");
message.setText("Your order #" + order.getId() + " is confirmed.");
Transport.send(message);
}
}
// โœ… With abstraction โ€” caller only knows "send notification"
public class OrderService {
private final NotificationSender notifier;

public void notifyCustomer(Order order) {
notifier.send(
order.getCustomerEmail(),
"Order Confirmation",
"Your order #" + order.getId() + " is confirmed."
);
}
}

The OrderService doesn't know or care about SMTP, Twilio, Firebase, or any implementation detail. It just says "send a notification" โ€” that's abstraction.

๐Ÿ” When to Use Abstract Class vs Interfaceโ€‹

Use an Abstract Class WhenUse an Interface When
You want to share state (fields) across subclassesYou want to define a contract without state
You have a template method pattern (fixed algorithm, customizable steps)You need multiple inheritance of types
Subclasses share 60%+ of their behaviorImplementations are fundamentally different
You want to provide a default constructor or initialization logicYou want maximum flexibility and decoupling
The hierarchy is stable and well-understoodYou're designing a public API

A common combined pattern:

// Interface defines the contract
public interface MessageSender {
void send(String to, String subject, String body);
boolean supports(String channel);
}

// Abstract class provides shared behavior for similar implementations
public abstract class AbstractEmailSender implements MessageSender {
protected final EmailConfig config;

protected AbstractEmailSender(EmailConfig config) {
this.config = config;
}

@Override
public boolean supports(String channel) {
return "email".equals(channel);
}

// Template method โ€” shared email structure, customizable sending
@Override
public final void send(String to, String subject, String body) {
validateAddress(to);
String formattedBody = formatBody(body);
doSend(to, subject, formattedBody);
logSent(to, subject);
}

protected abstract void doSend(String to, String subject, String body);

protected String formatBody(String body) {
return wrapInHtmlTemplate(body); // shared default
}

private void validateAddress(String to) { /* shared validation */ }
private void logSent(String to, String subject) { /* shared logging */ }
}

// Concrete implementations only implement the unique part
public class SmtpEmailSender extends AbstractEmailSender {
@Override
protected void doSend(String to, String subject, String body) {
// SMTP-specific sending logic
}
}

public class SendGridEmailSender extends AbstractEmailSender {
@Override
protected void doSend(String to, String subject, String body) {
// SendGrid API-specific logic
}
}

๐Ÿข Real-World Use Casesโ€‹

1. Repository Abstraction (Spring Data)โ€‹

Spring Data is a masterclass in abstraction:

// You write this:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByActiveTrue();
}

// Spring generates the implementation at runtime!
// You never write SQL, manage connections, or handle ResultSets.
// That's abstraction โ€” hiding ALL persistence complexity behind a simple interface.

What Spring abstracts away:

  • Connection pool management
  • SQL generation
  • Transaction handling
  • Result mapping
  • Pagination

2. Cloud Storage Abstractionโ€‹

public interface StorageService {
String upload(String fileName, byte[] data);
byte[] download(String fileId);
void delete(String fileId);
String getPublicUrl(String fileId);
}

@Component @Profile("aws")
public class S3StorageService implements StorageService {
// 200 lines of AWS S3 SDK complexity hidden behind 4 simple methods
}

@Component @Profile("local")
public class LocalStorageService implements StorageService {
// Simple file system operations for local development
}

3. Caching Abstraction (Spring Cache)โ€‹

// Spring's @Cacheable is abstraction in action
@Service
public class ProductService {

@Cacheable("products") // Abstraction! Don't care if it's Redis, Caffeine, or Hazelcast
public Product getProduct(Long id) {
return repository.findById(id).orElseThrow();
}
}

Behind @Cacheable, Spring abstracts away:

  • Cache provider selection (Redis, Caffeine, EhCache)
  • Cache key generation
  • Serialization/deserialization
  • TTL management
  • Cache eviction policies

๐Ÿ—๏ธ Architecture Deep Dive: Levels of Abstractionโ€‹

Abstraction operates at multiple scales:

Level 1 โ€” Method Abstraction
calculateTax(order) // hides tax calculation details

Level 2 โ€” Class Abstraction
TaxCalculator // hides which tax strategy is used

Level 3 โ€” Interface Abstraction
TaxService interface // hides the entire tax subsystem

Level 4 โ€” Module Abstraction
tax-service module // hides internal classes, exposes only API

Level 5 โ€” Service Abstraction
Tax Microservice // hides technology stack, database, deployment

Each level hides more complexity. A well-architected system uses the right level of abstraction at each boundary.

Abstraction and the Dependency Ruleโ€‹

In Clean Architecture, abstraction enforces the Dependency Rule โ€” outer layers depend on inner layers through abstractions:

Controller โ†’ UseCase (interface) โ† UseCaseImpl
โ†“
Repository (interface) โ† JpaRepositoryImpl

Inner layers define the interfaces. Outer layers implement them. This is abstraction + DIP working together.

Java's Evolution of Abstractionโ€‹

Java VersionAbstraction FeatureImpact
Java 1.0abstract class, interfaceBasic abstraction tools
Java 8default methods in interfacesInterfaces can evolve without breaking implementors
Java 8static methods in interfacesUtility methods belong with the abstraction
Java 9private methods in interfacesShare code between default methods
Java 14record classesTransparent data carriers (abstraction of boilerplate)
Java 17sealed classesControlled abstraction โ€” known set of implementations
Java 21Pattern matchingWork with abstractions more expressively

โš–๏ธ Trade-offsโ€‹

Over-AbstractionUnder-Abstraction
Too many interfaces with single implementationsBusiness logic coupled to specific libraries/frameworks
Can't trace code flow โ€” too many layers of indirectionChanging one technology requires rewriting business code
"Lasagna code" โ€” too many layers for simple operationsCan't swap implementations for testing
Ctrl+Click leads through 5 interfaces before reaching real codeInfrastructure details leak into domain logic

When to abstract:

  • You have (or expect) multiple implementations
  • You need to mock/fake in tests
  • The implementation involves an external system (DB, API, file system)
  • You're building a library or framework for others

When NOT to abstract:

  • The class is simple, stable, and has no variants
  • It's a utility/helper with pure functions
  • You're adding an interface just for the sake of having one (UserService โ†’ UserServiceImpl)
  • YAGNI โ€” "You Ain't Gonna Need It"

๐Ÿงช Testing Implicationsโ€‹

Abstraction is the key enabler of testable code:

// Without abstraction โ€” can't test without a real database
public class OrderService {
private final DataSource dataSource = new MySQLDataSource();

public Order getOrder(Long id) {
Connection conn = dataSource.getConnection();
// ... SQL queries, result mapping, etc.
}
}

// With abstraction โ€” easily testable
public class OrderService {
private final OrderRepository repository; // abstraction!

@Test
void shouldFindOrder() {
// Use a simple in-memory fake โ€” no database needed!
OrderRepository fakeRepo = new InMemoryOrderRepository();
fakeRepo.save(new Order(1L, "Test Order"));

OrderService service = new OrderService(fakeRepo);
Order result = service.getOrder(1L);

assertEquals("Test Order", result.getName());
}
}
Without AbstractionWith Abstraction
Tests need real infrastructure (DB, Redis, S3)Tests use in-memory fakes
Tests take seconds (I/O bound)Tests take milliseconds
Tests are flaky (network issues, state leakage)Tests are deterministic
Can't run tests offline or in CI easilyTests run anywhere

๐Ÿ”— How the 4 Pillars Work Togetherโ€‹

The four OOP pillars aren't isolated concepts โ€” they reinforce each other:

Encapsulation
โ†“ protects internal state
Abstraction
โ†“ hides complexity behind interfaces
Inheritance / Composition
โ†“ enables code reuse and specialization
Polymorphism
โ†“ allows runtime flexibility and extensibility

A real example showing all four:

// ABSTRACTION โ€” defines the contract
public interface NotificationChannel {
void send(Notification notification);
boolean supports(NotificationType type);
}

// ENCAPSULATION โ€” hides Twilio API details and configuration
public class SmsChannel implements NotificationChannel {
private final TwilioClient client; // private โ€” encapsulated
private final String fromNumber; // private โ€” encapsulated
private final RateLimiter rateLimiter; // private โ€” encapsulated

public SmsChannel(TwilioConfig config) {
this.client = new TwilioClient(config.getAccountSid(), config.getAuthToken());
this.fromNumber = config.getFromNumber();
this.rateLimiter = new RateLimiter(config.getMaxPerMinute());
}

// POLYMORPHISM โ€” same send() method, different behavior per channel
@Override
public void send(Notification notification) {
rateLimiter.acquire();
client.sendSms(fromNumber, notification.getRecipient(), notification.getMessage());
}

@Override
public boolean supports(NotificationType type) {
return type == NotificationType.URGENT;
}
}

// INHERITANCE โ€” shared base behavior (optional, when useful)
public abstract class AbstractEmailChannel implements NotificationChannel {
protected final EmailConfig config;

protected AbstractEmailChannel(EmailConfig config) {
this.config = config;
}

@Override
public final void send(Notification notification) {
String html = renderTemplate(notification);
doSend(notification.getRecipient(), notification.getSubject(), html);
}

protected abstract void doSend(String to, String subject, String html);
protected abstract String renderTemplate(Notification notification);
}

// POLYMORPHISM โ€” the orchestrator doesn't care which channel handles it
@Service
public class NotificationService {
private final List<NotificationChannel> channels;

public void notify(Notification notification) {
channels.stream()
.filter(ch -> ch.supports(notification.getType()))
.forEach(ch -> ch.send(notification));
}
}

SOLID Principlesโ€‹

These 5 principles guide writing clean, maintainable OOP code. Each principle is covered in depth in its own page.

PrincipleDescriptionDeep Dive
Single ResponsibilityA class should have only one reason to changeRead more โ†’
Open/ClosedOpen for extension, closed for modificationRead more โ†’
Liskov SubstitutionSubclasses must be substitutable for their base classRead more โ†’
Interface SegregationPrefer small, specific interfaces over large, general onesRead more โ†’
Dependency InversionDepend on abstractions, not concrete implementationsRead more โ†’
// โœ… Dependency Inversion in Spring
@Service
public class OrderService {
private final PaymentGateway paymentGateway; // interface, not implementation

public OrderService(PaymentGateway paymentGateway) { // injected by Spring
this.paymentGateway = paymentGateway;
}
}
Following SOLID principles naturally leads to better Spring application design โ€” especially Dependency Inversion, which is the backbone of Spring's IoC container.

Quick Referenceโ€‹

OOP in Java
โ”œโ”€โ”€ Encapsulation โ†’ private fields + public getters/setters + business methods
โ”œโ”€โ”€ Inheritance โ†’ extends (single), implements (multiple), sealed (Java 17+)
โ”œโ”€โ”€ Polymorphism โ†’ overloading (compile-time), overriding (runtime), generics
โ””โ”€โ”€ Abstraction โ†’ abstract class, interface, default methods, records

Further Readingโ€‹


Interview Questionsโ€‹

Encapsulationโ€‹

Q: How do you decide between a getter/setter and a business method?โ€‹

A: Use getters for read access. Replace setters with business methods that enforce invariants โ€” e.g., withdraw(amount) instead of setBalance(newBalance).

Q: What is a practical sign of poor encapsulation?โ€‹

A: Business invariants are enforced in many callers instead of inside the domain object. If 5 services all check if (amount > 0) before calling your class, that validation belongs inside the class.

Q: How do you handle encapsulation with mutable collections?โ€‹

A: Return defensive copies (Collections.unmodifiableList()) or use immutable collection types. Never expose internal mutable state through getters.

Inheritanceโ€‹

Q: How do you decide between inheritance and composition in service design?โ€‹

A: Prefer composition for runtime variability and lower coupling; use inheritance only for true semantic substitution (is-a relationships) where the subclass genuinely specializes ALL parent behavior.

Q: What is the Fragile Base Class problem?โ€‹

A: When a change to a parent class breaks subclasses because they depend on internal implementation details (like HashSet.addAll() internally calling add()). This is why Effective Java says: design for inheritance or prohibit it.

Q: When would you use sealed classes over regular inheritance?โ€‹

A: When you want a fixed, known set of subtypes โ€” e.g., PaymentMethod permits CreditCard, BankTransfer, DigitalWallet. It enables exhaustive pattern matching and prevents rogue subclasses.

Polymorphismโ€‹

Q: Why is polymorphism useful in payment or notification domains?โ€‹

A: It lets teams add providers without modifying core orchestration logic. A new ApplePayProcessor implements PaymentProcessor just works โ€” the PaymentService never changes.

Q: What is the difference between overloading and overriding?โ€‹

A: Overloading is compile-time polymorphism (same method name, different parameters). Overriding is runtime polymorphism (subclass provides a different implementation of a parent method). Overloading is resolved by the compiler; overriding is resolved by the JVM at runtime via the vtable.

Q: How does Java resolve method calls on an interface reference?โ€‹

A: The JVM uses the actual object's class at runtime (dynamic dispatch). The interface reference determines which methods can be called (compile-time check), but the object's class determines which implementation runs.

Abstractionโ€‹

Q: When can abstraction harm maintainability?โ€‹

A: When abstractions are introduced before real variation exists, increasing indirection with no payoff. The UserService/UserServiceImpl 1:1 pattern is a common symptom.

Q: How do SOLID principles influence API stability?โ€‹

A: They reduce ripple effects so extensions and internal refactors do not break consumers. Abstraction hides implementation changes; DIP ensures consumers depend on stable interfaces.

Q: How would you refactor a god service using OOP principles?โ€‹

A: Split responsibilities (SRP), extract domain behaviors into rich objects (encapsulation), introduce interfaces for dependencies (abstraction + DIP), and use polymorphism to eliminate type-switching code.