Java OOP

June 29, 20265 min readUpdated 8/20/2026

Object-oriented programming is four ideas. Most explanations define them; this post shows each one in code and says what it is actually for — because the fourth idea, inheritance, is the one beginners reach for constantly and professionals reach for rarely.

1. Encapsulation — hide the data, expose the rules

class BankAccount {
    private double balance;                    // nobody outside can touch this

    public void withdraw(double amount) {
        if (amount > balance) {
            throw new IllegalStateException("insufficient funds");
        }
        balance -= amount;
    }

    public double getBalance() { return balance; }
}

If balance were public, that overdraft check would be a suggestion. Because it is private, the check is the only road in. Encapsulation is not about hiding for its own sake — it is about there being exactly one place where a rule lives.

2. Abstraction — a simple front on a complicated thing

interface PaymentProcessor {
    boolean charge(String customerId, double amount);
}

class StripeProcessor implements PaymentProcessor {
    public boolean charge(String customerId, double amount) {
        // HTTP call, retries, signature verification, error mapping...
        return true;
    }
}

The caller writes processor.charge(id, 20.00) and knows nothing about HTTP. That is abstraction: the interface names what, the class handles how. Interfaces covers interfaces properly.

3. Inheritance — a subclass gets everything the parent has

class Animal {
    protected final String name;

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

    void breathe() { System.out.println(name + " breathes"); }

    String speak() { return "..."; }
}

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

    @Override
    String speak() { return "Woof"; }    // replace the parent's version

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

Dog gets breathe() for free, replaces speak(), and adds fetch(). A class can extend exactly one other class — Java has no multiple inheritance of state.

super(name) is mandatory here and must be the first statement: the parent must be fully initialised before the subclass touches anything.

4. Polymorphism — one call, whichever implementation applies

class Animal {
    String speak() { return "..."; }
}
class Dog extends Animal {
    @Override String speak() { return "Woof"; }
}
class Cat extends Animal {
    @Override String speak() { return "Meow"; }
}

class Demo {
    void run() {
        List<Animal> animals = List.of(new Dog(), new Cat());

        for (Animal animal : animals) {
            System.out.println(animal.speak());   // Woof, then Meow
        }
    }
}

The variable's type is Animal, but the method that runs is the one on the actual object. That decision happens at runtime, and it is the mechanism behind most flexible design in Java: you can add a Bird class later and that loop does not change.

Overriding versus overloading

Similar names, unrelated ideas. This trips people in interviews constantly:

OverridingOverloading
WhatA subclass replaces an inherited methodSame name, different parameters, same class
SignatureMust match exactlyMust differ
ResolvedAt runtime, by the object's typeAt compile time, by the argument types

Abstract classes

An abstract class cannot be instantiated. It exists to hold shared code and to declare methods subclasses must supply:

abstract class Shape {
    abstract double area();                      // no body — subclasses must provide one

    void describe() {                            // shared, inherited as-is
        System.out.println(getClass().getSimpleName() + " area " + area());
    }
}

class Circle extends Shape {
    private final double radius;
    Circle(double radius) { this.radius = radius; }

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

class Square extends Shape {
    private final double side;
    Square(double side) { this.side = side; }

    @Override double area() { return side * side; }
}

new Shape() does not compile, which is the point: a shape with no area is not a thing. Use an abstract class when subclasses genuinely share implementation; use an interface when they only share a contract.

Prefer composition to inheritance

This is the most valuable thing in the post. Inheritance looks like the answer to "I want to reuse this code", and it usually is not. It binds the subclass to the parent's implementation permanently — a change in the parent silently changes every subclass.

// Inheritance: a Car IS-A Engine? No. This is wrong, and it also exposes
// every Engine method on Car.
class Engine {
    void start() { System.out.println("engine starts"); }
}

// Composition: a Car HAS-A Engine. Car chooses what to expose.
class Car {
    private final Engine engine = new Engine();

    void start() {
        engine.start();
        System.out.println("car ready");
    }
}

The test is a sentence. IS-A means inheritance may fit: a Dog is an Animal. HAS-A means composition: a Car has an Engine. If you are inheriting to get access to a few useful methods, that is HAS-A wearing a disguise.

Composition also survives change better. A field can be swapped for a different implementation, injected for a test, or changed at runtime. A superclass is fixed at compile time forever.

Enums are objects too

An enum is a class with a fixed set of instances, so it can carry fields and behaviour. This is often a better answer than a hierarchy. From the console bank app this site uses for examples:

public enum TransactionType {

    DEPOSIT("Deposit", true),
    WITHDRAWAL("Withdrawal", false),
    TRANSFER_IN("Transfer in", true),
    TRANSFER_OUT("Transfer out", false);

    private final String label;
    private final boolean credit;

    TransactionType(String label, boolean credit) {
        this.label = label;
        this.credit = credit;
    }

    public BigDecimal signed(BigDecimal amount) {
        return credit ? amount : amount.negate();
    }
}

The alternative — four subclasses of an abstract TransactionType, or an if chain deciding the sign — is more code and easier to get wrong. Here the knowledge that a withdrawal is negative lives on the constant itself, so nothing else has to remember it.

The final escape hatch

If a class is not designed to be extended, say so. Marking it final means nobody can subclass it, which removes an entire category of future surprise:

final class Money {                      // nobody can extend this
    private final long cents;
    Money(long cents) { this.cents = cents; }
}

The reasoning is the same as preferring composition. Every class that can be extended is a class whose internals are effectively public to its subclasses, and whose behaviour someone may have overridden in a way you did not anticipate. Designing for inheritance means documenting which methods may be overridden and what they may assume — real work. If you have not done that work, final is the honest declaration.

Next

Interfaces have appeared three times now without a proper explanation. Interfaces is next — contracts without state, why a class can implement many of them, and when to choose one over an abstract class.