Home
Java from First Principles / Chapter 6 — The Four Pillars of OOP

The Four Pillars of OOP

Encapsulation, inheritance, polymorphism, abstraction — but with real examples, not the textbook version everyone has to memorise then forget.


The pillars everyone has to memorise

If you've ever interviewed for a Java job, you've been asked "What are the four pillars of OOP?" If you've ever taught a Java course, you've probably written them on a whiteboard: encapsulation, inheritance, polymorphism, abstraction.

The four pillars come up so often that they've ossified into something you memorise to pass interviews, then mostly ignore in real code. Which is a shame, because each pillar represents a real design lesson that took the industry years to figure out, and ignoring them produces real pain at scale.

This chapter goes through all four, but with the focus on *why each one exists* and *what it actually does for you in real code*. Not the textbook definitions — those you can find on a thousand other pages.

Inheritance hierarchy: Animal as parent, Dog and Cat as subclasses Inheritance — child classes specialise the parent Animal String name; int age; void eat() void makeSound() ← overridden Dog extends Animal String breed; // new field @Override void makeSound() { "Woof"; } void fetch() // new method Cat extends Animal boolean indoor; // new field @Override void makeSound() { "Meow"; } void purr() // new method
Children inherit everything from the parent (fields, methods) and can add new members or override behaviour. The hollow arrow points to the parent — the conventional UML notation for "extends".

Pillar 1: Encapsulation — hiding what shouldn't be visible

Encapsulation is the practice of bundling data with the methods that operate on it, while hiding the internal details from the outside world.

The "bundling" part is automatic — that's what classes do. The interesting part is **hiding**.

Suppose you build a BankAccount class:

Java
public class BankAccount {
    public double balance;
}

BankAccount a = new BankAccount();
a.balance = -50000;     // anyone can do this
a.balance += 1_000_000;  // and this

Now balance is just a number anyone can scribble on. There's no way to prevent invalid states (negative balances), no way to log transactions, no way to validate, no way to add interest calculations later. The class has no control over its own data.

Encapsulation says: keep the data private, expose carefully designed methods.

Java
public class BankAccount {
    private double balance;

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("must be positive");
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("must be positive");
        if (amount > balance) throw new IllegalStateException("insufficient funds");
        balance -= amount;
    }
}

Now invalid states are impossible from outside. The class is in control. You can later add logging, transaction history, currency support, audit trails — all without changing the API.

**The real benefit isn't access control — it's the freedom to change the implementation.** Today balance is a double. Tomorrow you switch to a BigDecimal for accuracy. With encapsulation, no caller breaks. Without it, every line that touched account.balance has to change.

This is why "make fields private" is so dogmatic. It's not paranoia about callers misusing data. It's about preserving your ability to refactor without coordinating with the entire codebase.


Pillar 2: Inheritance — "is-a" relationships

Inheritance lets one class extend another, picking up its fields and methods while adding or overriding behaviour. The classic example is the Animal hierarchy.

Java
public class Animal {
    String name;

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

    public void makeSound() {
        System.out.println("some generic sound");
    }
}

public class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println(name + " says Woof!");
    }

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

A Dog IS an Animal. It has a name (inherited), it can eat (inherited), it makes its own sound (overridden), and it can fetch (added). Inheritance models the "is-a" relationship.

Java
Dog rex = new Dog();
rex.name = "Rex";
rex.eat();         // "Rex is eating" — inherited method
rex.makeSound();   // "Rex says Woof!" — overridden
rex.fetch();       // "Rex fetches the ball" — Dog-specific

**Java is single-inheritance for classes** — you can extend exactly one parent. This is a deliberate choice. C++ allows multiple inheritance and the resulting "diamond problem" (when two parents define the same method, which wins?) caused decades of complexity. Java sidesteps it by allowing multiple inheritance only through interfaces, where the rules are simpler.

**@Override is your friend.** It's an annotation that tells the compiler "I'm intending to override a parent method." If you typo the method name (make_sound vs makeSound) or get the signature wrong, the compiler catches it. Without @Override, you'd silently add a new method, never override the parent's — a brutal bug to find. Always annotate overrides.

**Inheritance is often overused.** When you reach for extends, ask first: is this really an "is-a" relationship, or am I just trying to reuse some code? If it's just code reuse, prefer **composition** — embedding one object inside another:

Java
// Composition: a Car HAS an Engine, not IS an Engine
public class Car {
    private Engine engine;     // composed
    
    public void start() {
        engine.start();         // delegate
    }
}

The general Java community wisdom — "favour composition over inheritance" — exists because deep inheritance hierarchies are notoriously hard to refactor. Composition is more flexible: you can swap the engine, you can have multiple engines, the relationship is explicit. Reach for inheritance when the "is-a" relationship is fundamental and stable; reach for composition the rest of the time.


Pillar 3: Polymorphism — one interface, many forms

Polymorphism is the ability to treat objects of different concrete types as instances of a common type, while still calling the right behaviour for each.

The most common form is **subtype polymorphism**:

Java
public class Animal {
    public void makeSound() { System.out.println("..."); }
}

public class Dog extends Animal {
    @Override public void makeSound() { System.out.println("Woof!"); }
}

public class Cat extends Animal {
    @Override public void makeSound() { System.out.println("Meow!"); }
}

List<Animal> animals = List.of(new Dog(), new Cat(), new Dog());
for (Animal a : animals) {
    a.makeSound();    // calls Dog.makeSound() or Cat.makeSound() — Java picks the right one
}
// Output:
//   Woof!
//   Meow!
//   Woof!

The variable a is typed as Animal. But at runtime, when a.makeSound() is called, Java looks at the *actual object* (a Dog or a Cat) and calls THAT class's overridden method. This is called **dynamic dispatch** or **virtual method dispatch**.

This is the engine that makes the strategy pattern, plugin architectures, callback handlers, and dependency injection work. You write code against an abstract type; at runtime, any concrete subtype slots in and the right behaviour fires.

**Why this matters in real code.** A web framework writes:

Java
public abstract class HttpController {
    public abstract Response handle(Request request);
}

Your code writes:

Java
public class UserController extends HttpController {
    @Override
    public Response handle(Request request) { ... }
}

The framework holds a HttpController reference. When a request arrives, it calls controller.handle(request). Polymorphism dispatches to your UserController.handle(). The framework knows nothing about UserController; it just calls the abstract method, and Java routes it to the right implementation.

This is also why type signatures should usually use the **most general type** that satisfies the contract. Take List instead of ArrayList. Take Map instead of HashMap. The caller can pass any subtype, and your method works with all of them. The flip side: return the most *specific* useful type. If your method always returns an ArrayList, declaring the return type as List keeps your options open later without restricting callers.


Pillar 4: Abstraction — exposing what matters, hiding the rest

Abstraction is closely related to encapsulation, but at a different level. Encapsulation is about hiding implementation details *inside* a class. Abstraction is about defining what a thing *is* in terms of what it can do, without committing to how.

The cleanest example is the List interface in the Java standard library:

Java
public interface List<E> {
    void add(E element);
    E get(int index);
    int size();
    void remove(int index);
    // ... and a few dozen more methods
}

List is an **abstraction** — it describes the contract of "an ordered collection where you can add, get, and remove elements." It doesn't say *how* those operations work. There are several concrete implementations:

When you write code that takes a List<String>, you're working at the abstraction level. You don't care which concrete implementation came in. Your code works with any of them.

**Why this matters.** Abstraction lets you write code that survives implementation changes. Suppose you wrote:

Java
public void process(ArrayList<String> items) { ... }

Now you can only pass an ArrayList. If someone has a LinkedList, they have to copy it first. If the framework gives them an unmodifiable view, they have to copy again. You've coupled to an implementation choice.

Java
public void process(List<String> items) { ... }

Now everything fits. The caller has freedom to pick the right concrete type for their context, and your method doesn't care.

**Java's two main tools for abstraction:**

The next chapter is dedicated to comparing these two — they're constantly confused in interviews and constantly misused in practice.


Putting them together: a small real example

Here's a small example that uses all four pillars. A payment processing system.

Java
// Abstraction: define what a PaymentMethod can do, regardless of how
public interface PaymentMethod {
    boolean charge(double amount);
    void refund(double amount);
}

// Inheritance + Polymorphism: each subclass implements the contract differently
public class CreditCard implements PaymentMethod {
    private String cardNumber;
    private String cvv;

    @Override
    public boolean charge(double amount) {
        // contact card network, return success/failure
        return true;
    }

    @Override
    public void refund(double amount) { ... }
}

public class PayPal implements PaymentMethod {
    private String email;

    @Override
    public boolean charge(double amount) {
        // call PayPal API
        return true;
    }

    @Override
    public void refund(double amount) { ... }
}

// Encapsulation: the Order class controls its own data
public class Order {
    private List<Item> items;
    private double total;       // computed, never directly settable

    public boolean checkout(PaymentMethod payment) {
        boolean ok = payment.charge(total);
        if (ok) markPaid();
        return ok;
    }

    private void markPaid() { ... }    // private — internal concern
}

// Usage
Order order = new Order(...);
PaymentMethod pm = pickPaymentMethod();   // returns CreditCard or PayPal — caller doesn't care
order.checkout(pm);

Look at each pillar in action:

This kind of design is what makes adding a new payment method easy: write class ApplePay implements PaymentMethod, implement the two methods, and the rest of the code works unchanged. Nothing in Order or the checkout flow needs to know that ApplePay exists. That's the payoff of OOP done well.


When OOP isn't the answer

An honest section. OOP is a tool, not a religion. Some problems don't benefit from it.

**Pure data transformations.** If you're parsing a CSV, computing some statistics, and writing JSON output, you don't need objects with state and identity. A series of static methods operating on simple data structures (List<Map<String, Object>> or records) is often cleaner and easier to test.

**Mathematical computations.** Numerical code, algorithms, parsers — these are usually clearer as functions, not as object hierarchies. Java's stream API and modern functional features (lambdas, method references) are a better fit.

**Domain-modelling overkill.** Not every concept needs a class. If you have a User and a Password, do you really need a PasswordValidator interface, a PasswordValidationStrategy enum, a PasswordValidationResult record, and a PasswordValidationContext builder? Or could it be one method on User that returns a boolean?

Senior Java developers know when to reach for OOP and when to keep things flat. The trap is using inheritance and abstraction *because Java rewards verbosity*, not because the problem needs it.

A useful heuristic: if your class names start including words like "Manager", "Handler", "Processor", "Service" — that's often a sign you've turned a verb into a noun. Sometimes that's the right move (Spring has thousands of "Service" classes). Sometimes it's a hint that the operation could just be a function on simpler data. Be deliberate.


Common interview confusions

The pillars come up in interviews constantly, and certain confusions repeat. Worth getting straight.

**"Is encapsulation just private?"** No. Encapsulation is the *practice* of bundling data with its methods and hiding implementation. private is one of the *tools* you use to achieve it. You can have private fields and still leak implementation by, say, returning a mutable internal collection that callers can modify.

**"Inheritance vs composition — which is better?"** Composition is usually better when in doubt. Inheritance creates a tight coupling between parent and child: changes in the parent ripple to the child. Composition lets you swap parts at runtime. The "Effective Java" rule: favour composition over inheritance, but use inheritance when the relationship really is "is-a" and the parent class was designed to be extended.

**"Polymorphism vs overloading."** Different things. Polymorphism (dynamic dispatch) decides which method to call at *runtime* based on the actual object type. Overloading (multiple methods with the same name but different parameter types) is resolved at *compile time* based on the declared types of the arguments.

Java
public void log(String s) { ... }
public void log(int n) { ... }     // overloading

Object o = "hello";
log(o);    // calls log(Object) — overload resolved at compile time
           // (or fails to compile if no log(Object) exists)

**"Abstraction vs encapsulation."** Often used interchangeably in casual speech. Strictly: encapsulation is about hiding *details* (inside a class). Abstraction is about exposing *contracts* (between classes). You encapsulate to enable abstraction; abstraction is the public-facing result.

**"Does Java support multiple inheritance?"** Yes through interfaces (a class can implement many interfaces), no through classes (a class extends exactly one). Since Java 8, interfaces can have default method implementations, which gets closer to multiple inheritance — but interfaces still can't hold state, so the diamond problem is much less painful.


⁂ Back to all modules