OOP Principles

The four pillars β€” and how they actually work in production Java

← Back to Index

What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a way of organising code around objects β€” entities that combine related data (fields) and behaviour (methods) in a single unit. Instead of writing a program as a long sequence of instructions that manipulate separate variables, OOP lets you model the real world: a BankAccount knows its own balance and knows how to deposit or withdraw. A User knows its own credentials and knows how to authenticate.

The problem OOP solves: as programs grow, procedural code β€” variables here, functions there, no clear ownership of data β€” becomes impossible to reason about. Who is allowed to change this value? Which function is responsible for this logic? OOP answers both questions by keeping data and the code that operates on it together, in one place, with controlled access.

// Procedural style: data and logic are separate, no ownership
            String  accountId = "ACC-001";
            double balance    = 500.0;
            
            // Anyone can corrupt state β€” no rules, no guarantees
            balance = -9999;
            
            double calculateInterest(double bal, double rate) { return bal * rate; }
            void   printBalance(double bal)              { System.out.println(bal); }
            
            // OOP style: data + behaviour bundled, access controlled
            public class BankAccount {
                private final String id;
                private       double balance;
            
                public void deposit(double amount) {
                    if (amount <= 0) throw new IllegalArgumentException("Must be positive");
                    balance += amount;  // only THIS class can modify balance
                }
            
                public double calculateInterest(double rate) { return balance * rate; }
                public void   printBalance() { System.out.println(balance); }
            }

Java is OOP-first: every piece of code lives inside a class. There are no standalone functions, no global variables. The four fundamental principles that make OOP work β€” encapsulation, inheritance, polymorphism, and abstraction β€” are not just theoretical concepts. They are the design rules that separate maintainable production code from a codebase nobody wants to touch.

OOP vs procedural β€” not a competition

OOP is not always the right tool. Simple scripts, data pipelines, and mathematical algorithms are often cleaner in a procedural or functional style. Java itself has embraced functional features since Java 8 (lambdas, streams, records). Modern Java is OOP as the structural backbone, with functional style for data transformations β€” both coexisting in the same codebase.

The Four Pillars at a Glance

OOP organises code around objects β€” entities that bundle state (fields) and behaviour (methods). Java is OOP-first: every piece of code lives inside a class, and the JVM runs nothing that isn't part of an object graph.

Pillar Core idea Java mechanism
Encapsulation Hide state, expose behaviour private fields + public methods
Inheritance Build on existing types (is-a) extends, super
Polymorphism Same call, different behaviour Method overriding + dynamic dispatch
Abstraction Define what, hide how interface, abstract class

1. Encapsulation

Encapsulation means an object controls its own state. External code cannot put the object into an invalid state because it has no direct access to the fields β€” only to the methods that enforce the rules.

// ❌ No encapsulation β€” anyone can corrupt the state
public class BankAccountBad {
    public double balance;
    public String accountNumber;
}
account.balance = -1_000_000;  // perfectly legal, completely wrong
account.accountNumber = null;  // now the account has no identity

// βœ… Encapsulated β€” object enforces its own invariants
public class BankAccount {
    private final String accountNumber;
    private       double balance;
    private final List<String> history = new ArrayList<>();

    public BankAccount(String number) {
        if (number == null || number.length() != 10)
            throw new IllegalArgumentException("Invalid account number");
        this.accountNumber = number;
    }

    public double getBalance() { return balance; }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Must be positive");
        balance += amount;
        history.add("DEPOSIT $" + amount);
    }

    public void withdraw(double amount) {
        if (amount > balance) throw new IllegalStateException("Insufficient funds");
        balance -= amount;
        history.add("WITHDRAW $" + amount);
    }

    // Defensive copy β€” caller cannot mutate our internal list
    public List<String> getHistory() { return List.copyOf(history); }
}
Encapsulation in modern Java: prefer records and immutability

For data-carrying objects with no business logic, a record (Java 16+) gives you encapsulation for free β€” fields are private and final, getters are generated, and the object is immutable by default:

// All fields private+final, constructor, accessors, equals, hashCode, toString β€” generated
record Money(BigDecimal amount, Currency currency) {
    // Compact constructor for validation
    Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Negative money");
    }
}

2. Inheritance

Inheritance lets a class reuse and specialise the behaviour of another class via extends. Java allows only single class inheritance (to avoid the diamond problem) but unlimited interface implementation.

/*
 *  Inheritance hierarchy:
 *
 *           Animal (base)
 *          /      \
 *        Dog      Cat
 *        |
 *     Labrador (most specific)
 *
 *  Every Labrador IS-A Dog IS-A Animal.
 *  Each level adds/overrides behaviour.
 */

public class Animal {
    protected final String name;

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

    public void eat() { System.out.println(name + " eating"); }

    // Override hook β€” subclasses provide the sound
    public void makeSound() { System.out.println(name + " makes a sound"); }
}

public class Dog extends Animal {
    private final String breed;

    public Dog(String name, String breed) {
        super(name);   // super() must be the first call
        this.breed = breed;
    }

    @Override               // Always annotate β€” compiler catches typos
    public void makeSound() { System.out.println(name + " barks"); }

    public void fetch() { System.out.println(name + " fetches!"); }
}

Dog dog = new Dog("Rex", "Labrador");
dog.eat();        // inherited from Animal
dog.makeSound();  // Dog's override: "Rex barks"
dog.fetch();      // Dog's own method
Favour composition over inheritance

Inheritance creates tight coupling. The subclass depends on the superclass internals β€” any change to the parent can silently break children (the fragile base class problem). Use inheritance only when the is-a relationship is genuine and permanent.

// ❌ Stack "is not really a List" β€” extending ArrayList exposes add(index, e),
// remove(index), subList()… none of which a stack should have
public class Stack extends ArrayList { ... }

// βœ… Stack "has a" list internally β€” only exposes push/pop/peek
public class Stack<T> {
    private final Deque<T> elements = new ArrayDeque<>();
    public void push(T item) { elements.push(item); }
    public    T pop()        { return elements.pop(); }
    public    T peek()       { return elements.peek(); }
}

3. Polymorphism

Polymorphism means the same method call produces different behaviour depending on the actual runtime type of the object. It comes in two flavours:

  • Runtime (dynamic dispatch) β€” method overriding, resolved by the JVM via vtable lookup at runtime. This is the powerful one.
  • Compile-time (static dispatch) β€” method overloading, resolved by the compiler based on argument types.

Runtime polymorphism β€” the vtable

public abstract class Shape {
    public abstract double area();       // each subclass provides its own
    public String describe() {
        return String.format("Area: %.2f", area());
    }
}

public class Circle    extends Shape {
    private final double r;
    public Circle(double r) { this.r = r; }
    @Override public double area() { return Math.PI * r * r; }
}

public class Rectangle extends Shape {
    private final double w, h;
    public Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override public double area() { return w * h; }
}

// This method works for Circle, Rectangle, and any future Shape β€”
// no changes required when new subclasses are added (Open/Closed Principle)
public double totalArea(List<Shape> shapes) {
    return shapes.stream()
                 .mapToDouble(Shape::area)  // runtime dispatch for each element
                 .sum();
}
/*
 *  HOW the JVM picks the right method (vtable):
 *
 *  Shape s = new Circle(5.0);
 *  s.area();  ← which implementation?
 *
 *  Circle object on heap:
 *  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 *  β”‚ class pointer β†’ Circle ───┼──► Circle vtable:
 *  β”‚ r: 5.0                    β”‚      area()     ─► Circle.area()   βœ…
 *  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      describe() ─► Shape.describe()
 *
 *  The reference type (Shape) is irrelevant at runtime.
 *  The OBJECT's class determines which method runs.
 */

Polymorphism in real production code

// Interface = the contract. Implementations can change without touching callers.
public interface PaymentProcessor {
    void process(BigDecimal amount);
}

// Spring injects the right implementation based on profile/config
@Service
public class OrderService {
    private final PaymentProcessor processor;  // program to interface

    public OrderService(PaymentProcessor processor) {
        this.processor = processor;
    }

    public void checkout(Order order) {
        processor.process(order.total());  // no idea WHICH processor β€” doesn't need to know
    }
}

@Profile("production") @Component
public class StripeProcessor implements PaymentProcessor { ... }

@Profile("test")       @Component
public class MockProcessor  implements PaymentProcessor { ... }

// Adding a new payment provider = new class, zero changes to OrderService

4. Abstraction

Abstraction defines what something does without revealing how. Java provides two tools: interfaces (pure contracts) and abstract classes (partial implementation + contract).

Interface vs abstract class β€” when to use each

Use interface when… Use abstract class when…
Unrelated classes need the same capability Sharing code among closely related classes
Multiple inheritance of type is needed You need constructors or instance state
Defining a pure contract (Comparable, Runnable) Template Method pattern (fixed algorithm, variable steps)
Default behaviour via default methods Subclasses share substantial common code

Template Method pattern with abstract class

// Abstract class defines the ALGORITHM SKELETON
public abstract class DataExporter {

    // Template method β€” final so subclasses can't break the flow
    public final void export(List<?> data) {
        validate(data);
        String formatted = format(data);   // abstract β€” subclass decides format
        write(formatted);                  // abstract β€” subclass decides destination
    }

    protected void validate(List<?> data) {
        if (data == null || data.isEmpty())
            throw new IllegalArgumentException("No data to export");
    }

    protected abstract String format(List<?> data);
    protected abstract void   write(String content);
}

// Concrete subclasses provide the HOW
public class CsvExporter extends DataExporter {
    @Override
    protected String format(List<?> data) { return /* CSV logic */ ""; }
    @Override
    protected void   write(String content)  { /* write to file */ }
}

public class JsonApiExporter extends DataExporter {
    @Override
    protected String format(List<?> data) { return /* JSON logic */ ""; }
    @Override
    protected void   write(String content)  { /* POST to API */ }
}

OOP Under the JVM Hood

Understanding the JVM's object model helps you reason about performance and debug subtle bugs.

Object memory layout

/*
 *  Dog dog = new Dog("Rex", "Labrador");  (Dog extends Animal)
 *
 *  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 *  β”‚          Dog Object (heap)           β”‚
 *  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
 *  β”‚ Object header (12-16 bytes)          β”‚
 *  β”‚   mark word  β€” hashCode, GC, locks  β”‚
 *  β”‚   class ptr  β†’ Dog.class (vtable)   β”‚
 *  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
 *  β”‚ Animal fields (inherited, laid first)β”‚
 *  β”‚   name: ref β†’ "Rex" (String on heap) β”‚
 *  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
 *  β”‚ Dog fields                           β”‚
 *  β”‚   breed: ref β†’ "Labrador"           β”‚
 *  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 *
 *  Field access is O(1) regardless of inheritance depth β€”
 *  offsets are computed at class-load time, not runtime.
 */

The Collections Framework as a masterclass in OOP

// ABSTRACTION: interface defines the contract
List<String> names;            // declare as interface type β€” always

// POLYMORPHISM: swap implementations without touching callers
names = new ArrayList<>();   // O(1) random access
names = new LinkedList<>();  // O(1) head/tail insertion
names = new CopyOnWriteArrayList<>(); // thread-safe reads

// ENCAPSULATION: internal array is hidden; you interact via the API
names.add("Alice");  // no idea if the array is resizing right now β€” don't care

// INHERITANCE: AbstractList provides shared implementation (iterator, indexOf, etc.)
// ArrayList extends AbstractList implements List β€” 2-level hierarchy, flat and clear

Common Pitfalls

Violating LSP β€” the Square/Rectangle trap
// "A Square is a Rectangle" β€” sounds right, breaks code
class Square extends Rectangle {
    @Override public void setWidth(int w)  { super.width = w; super.height = w; }
    @Override public void setHeight(int h) { super.width = h; super.height = h; }
}

Rectangle r = new Square();
r.setWidth(5); r.setHeight(10);
r.getArea();   // Expected 50, got 100 β€” violated caller's assumptions

// βœ… Fix: don't use inheritance here; both implement a common interface
interface Shape { int getArea(); }
class Rectangle implements Shape { ... }
class Square    implements Shape { ... }
Forgetting @Override β€” silent overload instead of override
class Dog extends Animal {
    public void eats() { ... }  // typo! creates a NEW method, doesn't override eat()
}
Animal dog = new Dog();
dog.eat();  // calls Animal.eat() β€” our method is never reached

// βœ… @Override makes the compiler catch this immediately
@Override
public void eats() { }  // compile error: does not override any method
instanceof abuse β€” usually a design smell
// ❌ Type-checking in a loop means the type hierarchy isn't doing its job
for (Shape s : shapes) {
    if (s instanceof Circle)    { render((Circle) s); }
    else if (s instanceof Rectangle) { render((Rectangle) s); }
}

// βœ… Polymorphism: move render() to the Shape hierarchy
for (Shape s : shapes) { s.render(); }  // each shape knows how to render itself

// βœ… OR β€” Java 21 pattern matching in switch (sealed types)
String desc = switch (shape) {
    case Circle    c -> "circle r="  + c.radius();
    case Rectangle r -> "rect "     + r.w() + "x" + r.h();
};

Interview Questions

πŸŽ“ Junior level

Q: What are the four pillars of OOP?
Encapsulation (hide state, expose behaviour via methods), Inheritance (is-a relationship, code reuse via extends), Polymorphism (same call, different behaviour via method overriding and dynamic dispatch), Abstraction (define what, not how, via interfaces and abstract classes).

Q: What is the difference between method overloading and overriding?
Overloading: same name, different parameter list, resolved at compile time (static dispatch). Overriding: subclass redefines a parent method with the same signature, resolved at runtime (dynamic dispatch). They look similar but are fundamentally different mechanisms.

Q: Can you override a static method?
No. Static methods belong to the class, not instances β€” there is no dynamic dispatch. If a subclass defines a static method with the same signature, it's method hiding, not overriding. The method called depends on the reference type, not the object type.

Q: What is the diamond problem and how does Java solve it?
If two classes A and B both extend C and define the same method, and D extends both A and B, which method does D inherit? Java avoids this by allowing only single class inheritance. For interfaces with conflicting default methods, the implementing class must override and explicitly choose: A.super.method() vs B.super.method().

πŸ”₯ Senior level

Q: Explain the Liskov Substitution Principle with a real example.
LSP: a subtype must be fully substitutable for its supertype without altering program correctness. The Square/Rectangle example is the canonical violation β€” code that calls setWidth(5); setHeight(10) and then checks getArea() == 50 will silently fail with a Square. The fix is not to inherit, but to implement a common interface independently. In Spring, LSP is what makes DI work: you can substitute MockPaymentProcessor for StripePaymentProcessor in tests because both honour the PaymentProcessor contract.

Q: Why favour composition over inheritance?
Inheritance is a white-box relationship β€” the subclass depends on protected internals of the parent. Change the parent and you may silently break children (fragile base class). Composition is black-box β€” you only depend on the public API of the composed object, which can be swapped at runtime. In practice: if you can express the relationship as "has-a" rather than "is-a", prefer composition. The JDK itself got this wrong with Stack extends Vector β€” a cautionary tale.

Q: How does the JVM implement dynamic dispatch?
Each class has a vtable (virtual method table) β€” an array of pointers to method implementations. Every object on the heap holds a class pointer. When you call a virtual method, the JVM follows the class pointer to the vtable and invokes the correct implementation. The JIT compiler optimises this: for monomorphic call sites (one concrete type at runtime), it inlines the method entirely β€” zero dispatch overhead.

Q: What problem do sealed classes (Java 17) solve for OOP?
Sealed classes restrict which classes can extend or implement a type. Combined with pattern matching in switch (Java 21), they restore the closed-world assumption that OOP lost with open inheritance hierarchies. When the compiler knows all possible subtypes, it can verify switch exhaustiveness without a default branch. This is the modern Java answer to the expression problem: clean polymorphism without runtime instanceof chains.