Java OOP

What is Object-Oriented Programming (OOP)?

Object-Oriented Programming (OOP) is a programming paradigm that organizes software around objects rather than functions and logic. An object is a self-contained unit that bundles together data (fields) and the operations (methods) that work on that data. Java is an object-oriented language from the ground up — every piece of code you write lives inside a class, and almost everything you interact with is an object.

Think of it this way: in the real world, you interact with objects every day — a car, a bank account, a phone. Each object has state (a car has a color, speed, and fuel level) and behavior (a car can accelerate, brake, and turn). OOP lets you model software the same way, making code more intuitive, organized, and maintainable.

Why Does OOP Matter?

Before OOP, most programming was procedural — a list of instructions executed from top to bottom. This works fine for small programs, but as software grows to thousands of lines, procedural code becomes tangled, hard to debug, and nearly impossible to extend without breaking something else. OOP solves this by:

  • Modeling real-world concepts — Objects map naturally to business concepts (Customer, Order, Product), making code easier to understand.
  • Organizing code into manageable units — Each class has a single responsibility, so you know exactly where to look when something breaks.
  • Enabling code reuse — Inheritance and composition let you build on existing code instead of rewriting it.
  • Making code extensible — You can add new behavior without modifying existing, tested code.
  • Supporting team development — Different developers can work on different classes independently.

Procedural vs. Object-Oriented: A Quick Comparison

// === Procedural Approach ===
// Data and functions are separate. Any function can access and modify the data.
// As the program grows, tracking who changes what becomes a nightmare.

String employeeName = "Alice";
double employeeSalary = 75000;

static double calculateBonus(double salary) {
    return salary * 0.10;
}

static void printEmployee(String name, double salary) {
    System.out.println(name + " earns $" + salary);
}

// Nothing stops you from doing this anywhere in the code:
employeeSalary = -50000; // Invalid! But no guard against it.
// === Object-Oriented Approach ===
// Data and behavior live together. The object protects its own state.

public class Employee {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        setSalary(salary);
    }

    public void setSalary(double salary) {
        if (salary < 0) {
            throw new IllegalArgumentException("Salary cannot be negative");
        }
        this.salary = salary;
    }

    public double calculateBonus() {
        return this.salary * 0.10;
    }

    public void printInfo() {
        System.out.println(name + " earns $" + salary);
    }
}

// Now invalid data is impossible:
Employee alice = new Employee("Alice", 75000);
// alice.setSalary(-50000); // Throws IllegalArgumentException

Notice the difference: in the OOP version, the Employee object owns its data and controls how that data is accessed. No outside code can set the salary to a negative number. This is the essence of OOP — objects are responsible for their own state.

The Four Pillars of OOP

Java's OOP model rests on four fundamental principles, often called the four pillars. Every design decision you make in Java connects back to one or more of these:

Pillar What It Does Key Benefit Java Mechanism
Encapsulation Bundles data and methods together; hides internal state Data protection and controlled access private fields, getters/setters
Inheritance Creates parent-child relationships between classes Code reuse and hierarchical modeling extends, super
Polymorphism Allows one interface to represent multiple forms Flexibility and extensibility Overloading, overriding, upcasting
Abstraction Hides complex implementation, exposes only essentials Simplified interfaces and reduced complexity abstract classes, interface

We will explore each pillar in depth with practical, compilable examples. Let us start with Encapsulation, since it is the foundation that the other three pillars build on.

1. Encapsulation

Encapsulation means bundling the data (fields) and the methods that operate on that data into a single unit (a class), and then restricting direct access to the data from outside the class. The class controls how its data is read and modified through public methods, typically getters and setters.

Think of encapsulation like an ATM machine. You do not reach into the machine and grab cash directly. Instead, you interact through a controlled interface — you insert your card, enter your PIN, and request an amount. The ATM validates your request, checks your balance, and dispenses the cash. The internal mechanics are hidden from you.

The Core Idea: Private Fields, Public Methods

The most important rule of encapsulation is: make fields private and provide public methods to access them. This gives the class full control over its own data.

public class Person {
    // Fields are private -- no outside code can access them directly
    private String name;
    private int age;

    // Constructor
    public Person(String name, int age) {
        this.name = name;
        setAge(age); // Use the setter to enforce validation from day one
    }

    // Getter -- read access
    public String getName() {
        return name;
    }

    // Setter -- write access with validation
    public void setName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be null or blank");
        }
        this.name = name;
    }

    // Getter
    public int getAge() {
        return age;
    }

    // Setter with validation -- age must be between 0 and 150
    public void setAge(int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Age must be between 0 and 150, got: " + age);
        }
        this.age = age;
    }

    @Override
    public String toString() {
        return name + " (age " + age + ")";
    }
}

// Usage:
Person person = new Person("Alice", 30);
System.out.println(person.getName()); // Alice
System.out.println(person.getAge());  // 30

person.setAge(31);   // Works fine
// person.setAge(-5); // Throws IllegalArgumentException
// person.age = -5;   // Compile error -- age is private

Data Validation in Setters

One of the biggest advantages of encapsulation is the ability to validate data before accepting it. Without encapsulation, anyone can set a field to an invalid value. With it, the object protects its own invariants.

public class EmailAccount {
    private String email;
    private String password;

    public void setEmail(String email) {
        if (email == null || !email.contains("@") || !email.contains(".")) {
            throw new IllegalArgumentException("Invalid email format: " + email);
        }
        this.email = email.toLowerCase().trim();
    }

    public void setPassword(String password) {
        if (password == null || password.length() < 8) {
            throw new IllegalArgumentException("Password must be at least 8 characters");
        }
        if (password.equals(password.toLowerCase())) {
            throw new IllegalArgumentException("Password must contain at least one uppercase letter");
        }
        this.password = password; // In real code, you would hash this
    }

    public String getEmail() {
        return email;
    }

    // Notice: no getPassword() method -- we intentionally
    // prevent anyone from reading the password back out.
}

Real-World Example: BankAccount

A bank account is the classic encapsulation example. You cannot set the balance directly — you must use deposit() and withdraw() methods, which enforce business rules like minimum balance and overdraft protection.

public class BankAccount {
    private final String accountNumber; // final -- never changes after creation
    private final String ownerName;
    private double balance;

    public BankAccount(String accountNumber, String ownerName, double initialDeposit) {
        if (initialDeposit < 0) {
            throw new IllegalArgumentException("Initial deposit cannot be negative");
        }
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = initialDeposit;
    }

    // Read-only access to balance -- no setBalance() method exists
    public double getBalance() {
        return balance;
    }

    public String getAccountNumber() {
        return accountNumber;
    }

    public String getOwnerName() {
        return ownerName;
    }

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        balance += amount;
        System.out.println("Deposited $" + amount + ". New balance: $" + balance);
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        if (amount > balance) {
            throw new IllegalArgumentException("Insufficient funds. Balance: $" + balance);
        }
        balance -= amount;
        System.out.println("Withdrew $" + amount + ". New balance: $" + balance);
    }

    @Override
    public String toString() {
        return "Account " + accountNumber + " (" + ownerName + "): $" + balance;
    }
}

// Usage:
BankAccount account = new BankAccount("ACC-001", "Alice", 1000.00);
account.deposit(500.00);   // Deposited $500.0. New balance: $1500.0
account.withdraw(200.00);  // Withdrew $200.0. New balance: $1300.0
System.out.println(account.getBalance()); // 1300.0

// account.balance = 1000000; // Compile error -- balance is private
// account.withdraw(5000);    // Throws IllegalArgumentException: Insufficient funds

Immutable Objects

The strongest form of encapsulation is an immutable object — an object whose state cannot change after creation. All fields are final, there are no setters, and if any field holds a mutable object (like a List), the class returns a defensive copy.

Immutable objects are inherently thread-safe, simple to reason about, and safe to use as keys in HashMap or elements in HashSet.

import java.util.Collections;
import java.util.List;

public final class Address {
    private final String street;
    private final String city;
    private final String state;
    private final String zipCode;

    public Address(String street, String city, String state, String zipCode) {
        this.street = street;
        this.city = city;
        this.state = state;
        this.zipCode = zipCode;
    }

    public String getStreet()  { return street; }
    public String getCity()    { return city; }
    public String getState()   { return state; }
    public String getZipCode() { return zipCode; }

    // No setters -- the object cannot be changed after creation

    @Override
    public String toString() {
        return street + ", " + city + ", " + state + " " + zipCode;
    }
}

// Since Java 16, you can use records for simple immutable data carriers:
public record AddressRecord(String street, String city, String state, String zipCode) {
    // This generates the constructor, getters, equals(), hashCode(),
    // and toString() automatically -- all fields are final and private.
}

Access Modifiers Summary

Java provides four access levels that control visibility. Encapsulation relies on choosing the right level for each field and method:

Modifier Class Package Subclass World When to Use
private Yes No No No Fields (almost always), helper methods
(default/package) Yes Yes No No Package-internal utilities
protected Yes Yes Yes No Methods subclasses need to override
public Yes Yes Yes Yes API methods, constants

Rule of thumb: Start with private for everything. Only widen access when you have a specific reason. You can always make something more visible later, but making a public field private is a breaking change.

2. Inheritance

Inheritance allows a new class (the subclass or child) to inherit fields and methods from an existing class (the superclass or parent). This creates an IS-A relationship: a Dog IS-A Animal, a Car IS-A Vehicle, a Manager IS-A Employee.

Inheritance serves two purposes:

  • Code reuse — Common fields and methods live in the parent class. Subclasses inherit them without duplicating code.
  • Hierarchical modeling — You can model real-world "kind-of" relationships naturally.

The extends Keyword

In Java, a class uses the extends keyword to inherit from another class. Java supports single inheritance only — a class can extend exactly one parent class. (You can implement multiple interfaces, which we will cover in the Abstraction section.)

// Parent class (superclass)
public class Vehicle {
    private String make;
    private String model;
    private int year;
    private double fuelLevel; // percentage 0-100

    public Vehicle(String make, String model, int year) {
        this.make = make;
        this.model = model;
        this.year = year;
        this.fuelLevel = 100.0;
    }

    public void start() {
        System.out.println(year + " " + make + " " + model + " engine started.");
    }

    public void stop() {
        System.out.println(year + " " + make + " " + model + " engine stopped.");
    }

    public void refuel(double amount) {
        fuelLevel = Math.min(100.0, fuelLevel + amount);
        System.out.println("Fuel level: " + fuelLevel + "%");
    }

    // Getters
    public String getMake()    { return make; }
    public String getModel()   { return model; }
    public int getYear()       { return year; }
    public double getFuelLevel() { return fuelLevel; }

    @Override
    public String toString() {
        return year + " " + make + " " + model;
    }
}
// Child class (subclass) -- Car IS-A Vehicle
public class Car extends Vehicle {
    private int numberOfDoors;
    private boolean sunroof;

    public Car(String make, String model, int year, int numberOfDoors, boolean sunroof) {
        super(make, model, year); // Call parent constructor
        this.numberOfDoors = numberOfDoors;
        this.sunroof = sunroof;
    }

    // Car-specific method
    public void openSunroof() {
        if (sunroof) {
            System.out.println("Sunroof opened on " + this);
        } else {
            System.out.println(this + " does not have a sunroof.");
        }
    }

    public int getNumberOfDoors() { return numberOfDoors; }
    public boolean hasSunroof()   { return sunroof; }
}

// Child class -- Truck IS-A Vehicle
public class Truck extends Vehicle {
    private double payloadCapacity; // in tons
    private boolean fourWheelDrive;

    public Truck(String make, String model, int year, double payloadCapacity, boolean fourWheelDrive) {
        super(make, model, year); // Call parent constructor
        this.payloadCapacity = payloadCapacity;
        this.fourWheelDrive = fourWheelDrive;
    }

    public void loadCargo(double tons) {
        if (tons > payloadCapacity) {
            System.out.println("Cannot load " + tons + " tons. Max capacity: " + payloadCapacity + " tons.");
        } else {
            System.out.println("Loaded " + tons + " tons onto " + this);
        }
    }

    public double getPayloadCapacity() { return payloadCapacity; }
    public boolean isFourWheelDrive()  { return fourWheelDrive; }
}
// Usage:
Car myCar = new Car("Toyota", "Camry", 2024, 4, true);
myCar.start();        // Inherited from Vehicle: "2024 Toyota Camry engine started."
myCar.openSunroof();  // Car-specific: "Sunroof opened on 2024 Toyota Camry"
myCar.refuel(20);     // Inherited from Vehicle: "Fuel level: 100.0%"

Truck myTruck = new Truck("Ford", "F-150", 2024, 1.5, true);
myTruck.start();         // Inherited: "2024 Ford F-150 engine started."
myTruck.loadCargo(1.0);  // Truck-specific: "Loaded 1.0 tons onto 2024 Ford F-150"

The super Keyword

The super keyword is used inside a subclass to refer to its parent class. It has two primary uses:

  1. super(args) — Calls the parent class constructor. This must be the first statement in the subclass constructor. If you do not call super() explicitly, Java automatically inserts a call to the parent's no-argument constructor (and this will fail if the parent does not have one).
  2. super.methodName() — Calls the parent class version of an overridden method.
public class ElectricCar extends Car {
    private double batteryLevel; // percentage 0-100

    public ElectricCar(String make, String model, int year, int numberOfDoors) {
        super(make, model, year, numberOfDoors, true); // Call Car's constructor
        this.batteryLevel = 100.0;
    }

    // Override the start method
    @Override
    public void start() {
        // Call parent's start method first
        super.start();
        System.out.println("Electric motor engaged. Battery: " + batteryLevel + "%");
    }

    // Override refuel -- electric cars charge, they don't refuel
    @Override
    public void refuel(double amount) {
        batteryLevel = Math.min(100.0, batteryLevel + amount);
        System.out.println("Battery charged to " + batteryLevel + "%");
    }
}

// Usage:
ElectricCar tesla = new ElectricCar("Tesla", "Model 3", 2024, 4);
tesla.start();
// Output:
// 2024 Tesla Model 3 engine started.
// Electric motor engaged. Battery: 100.0%

Constructor Chaining

When you create an instance of a subclass, Java calls constructors from the top of the inheritance chain downward. This is called constructor chaining. Every object in Java ultimately starts with the Object class constructor.

public class A {
    public A() {
        System.out.println("A's constructor");
    }
}

public class B extends A {
    public B() {
        super(); // Calls A's constructor (Java inserts this even if you omit it)
        System.out.println("B's constructor");
    }
}

public class C extends B {
    public C() {
        super(); // Calls B's constructor
        System.out.println("C's constructor");
    }
}

// Creating an instance of C:
C obj = new C();
// Output:
// A's constructor
// B's constructor
// C's constructor

Method Overriding with @Override

A subclass can provide its own implementation of a method it inherited from its parent. This is called method overriding. The rules are:

  • The method must have the same name, return type, and parameter list as the parent method.
  • The access modifier can be the same or less restrictive (e.g., a protected method can be overridden as public, but not as private).
  • Always use the @Override annotation. It tells the compiler to verify you are actually overriding a parent method. If you misspell the method name, the compiler catches it.
public class Animal {
    public String speak() {
        return "...";
    }
}

public class Dog extends Animal {
    @Override
    public String speak() {
        return "Woof!";
    }
}

public class Cat extends Animal {
    @Override
    public String speak() {
        return "Meow!";
    }
}

// Without @Override, this typo would silently create a new method:
public class Bird extends Animal {
    @Override
    public String spaek() {  // Compile error! No method "spaek" in parent.
        return "Tweet!";
    }
}

The final Keyword in Inheritance

The final keyword can be used to restrict inheritance:

  • final class — The class cannot be extended. Example: String, Integer, and all wrapper classes are final.
  • final method — The method cannot be overridden by subclasses. Use this when a method's behavior is critical and should never change.
// final class -- cannot be extended
public final class MathUtils {
    public static double circleArea(double radius) {
        return Math.PI * radius * radius;
    }
}

// This would cause a compile error:
// public class AdvancedMathUtils extends MathUtils { } // Error: cannot extend final class

// final method -- cannot be overridden
public class Transaction {
    private double amount;

    // No subclass should ever change how the ID is generated
    public final String generateId() {
        return "TXN-" + System.currentTimeMillis();
    }

    // Subclasses CAN override this
    public void process() {
        System.out.println("Processing transaction: " + generateId());
    }
}

Single Inheritance Limitation

Java allows a class to extend only one parent class. This avoids the "diamond problem" found in languages like C++ that support multiple inheritance. If you need a class to inherit behavior from multiple sources, use interfaces (covered in the Abstraction section).

// This is NOT allowed in Java:
// public class FlyingCar extends Car, Airplane { } // Compile error

// Instead, use interfaces:
public interface Flyable {
    void fly();
}

public interface Drivable {
    void drive();
}

public class FlyingCar implements Flyable, Drivable {
    @Override
    public void fly() {
        System.out.println("Flying through the air");
    }

    @Override
    public void drive() {
        System.out.println("Driving on the road");
    }
}

3. Polymorphism

Polymorphism means "many forms." In Java, it allows you to use a single interface or parent type to represent objects of different classes, and each object responds to the same method call in its own way. This is one of the most powerful concepts in OOP because it lets you write flexible, extensible code.

There are two types of polymorphism in Java:

  • Compile-time polymorphism (Method Overloading) — Resolved by the compiler based on the method signature.
  • Runtime polymorphism (Method Overriding) — Resolved at runtime based on the actual object type.

Compile-Time Polymorphism: Method Overloading

Method overloading means defining multiple methods with the same name but different parameter lists (different number, types, or order of parameters). The compiler decides which version to call based on the arguments you pass.

public class Calculator {
    // Overloaded add() methods -- same name, different parameters

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

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

    public double add(double a, double b) {
        return a + b;
    }

    public String add(String a, String b) {
        return a + b; // String concatenation
    }
}

// Usage:
Calculator calc = new Calculator();
System.out.println(calc.add(5, 3));           // 8 (int version)
System.out.println(calc.add(5, 3, 2));        // 10 (three-int version)
System.out.println(calc.add(5.5, 3.2));       // 8.7 (double version)
System.out.println(calc.add("Hello, ", "World")); // "Hello, World" (String version)

Important: You cannot overload a method by changing only the return type. The parameter list must differ. The following would cause a compile error:

// This will NOT compile -- same parameters, different return type
public int getValue()    { return 42; }
public double getValue() { return 42.0; } // Compile error: duplicate method

Runtime Polymorphism: Method Overriding

Runtime polymorphism is where OOP really shines. When a parent reference variable points to a child object, and you call an overridden method, Java determines at runtime which version of the method to execute based on the actual object type, not the variable type. This is also called dynamic dispatch.

public class Shape {
    private String name;

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

    public double area() {
        return 0; // Default: unknown shape has zero area
    }

    public String getName() {
        return name;
    }
}

public class Circle extends Shape {
    private double radius;

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

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

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

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

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

public class Triangle extends Shape {
    private double base;
    private double height;

    public Triangle(double base, double height) {
        super("Triangle");
        this.base = base;
        this.height = height;
    }

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

Polymorphism with Collections

The real power of polymorphism is working with a collection of objects through a common parent type. You do not need to know the specific type of each object — you just call the method, and each object does the right thing.

import java.util.List;

public class ShapeDemo {
    public static void main(String[] args) {
        // A list of Shape references, but each element is a different subclass
        List shapes = List.of(
            new Circle(5.0),
            new Rectangle(4.0, 6.0),
            new Triangle(3.0, 8.0),
            new Circle(2.5),
            new Rectangle(10.0, 3.0)
        );

        // Polymorphism in action: each shape calculates its own area
        double totalArea = 0;
        for (Shape shape : shapes) {
            double area = shape.area(); // Dynamic dispatch -- calls the correct override
            System.out.printf("%s: area = %.2f%n", shape.getName(), area);
            totalArea += area;
        }
        System.out.printf("Total area: %.2f%n", totalArea);
    }
}

// Output:
// Circle: area = 78.54
// Rectangle: area = 24.00
// Triangle: area = 12.00
// Circle: area = 19.63
// Rectangle: area = 30.00
// Total area: 164.17

Notice that the loop code does not contain a single if or instanceof check. It does not care whether a shape is a Circle, Rectangle, or Triangle. It simply calls area(), and polymorphism ensures the correct version runs. This is what makes polymorphic code so clean and extensible — to add a new shape (Pentagon, Ellipse, etc.), you only need to create a new subclass. The existing loop works without any changes.

The instanceof Operator and Pattern Matching

Sometimes you need to check the actual type of an object at runtime. The instanceof operator returns true if an object is an instance of a specific class (or any of its subclasses). Since Java 16, pattern matching lets you combine the type check and the cast in one step.

// Traditional instanceof (pre-Java 16)
public static void describeShape(Shape shape) {
    if (shape instanceof Circle) {
        Circle circle = (Circle) shape; // Manual cast required
        System.out.println("Circle with radius: " + circle.getRadius());
    } else if (shape instanceof Rectangle) {
        Rectangle rect = (Rectangle) shape;
        System.out.println("Rectangle " + rect.getWidth() + " x " + rect.getHeight());
    } else {
        System.out.println("Unknown shape: " + shape.getName());
    }
}

// Pattern matching instanceof (Java 16+) -- cleaner syntax
public static void describeShapeModern(Shape shape) {
    if (shape instanceof Circle circle) {
        // 'circle' is automatically cast -- no explicit cast needed
        System.out.println("Circle with radius: " + circle.getRadius());
    } else if (shape instanceof Rectangle rect) {
        System.out.println("Rectangle " + rect.getWidth() + " x " + rect.getHeight());
    } else {
        System.out.println("Unknown shape: " + shape.getName());
    }
}

A word of caution: Heavy use of instanceof often signals a design problem. If you find yourself writing long instanceof chains, ask whether polymorphism could solve the problem more cleanly. Let each subclass define its own behavior through overriding rather than checking types externally.

4. Abstraction

Abstraction is the process of hiding complex implementation details and exposing only what is necessary. When you use a List in Java, you call add(), get(), and remove() without knowing whether the underlying implementation is an ArrayList (backed by an array) or a LinkedList (backed by nodes). That is abstraction at work.

Think of it like driving a car: you interact with the steering wheel, pedals, and gear shift (the abstraction). You do not need to understand how the engine combustion, transmission gearing, or electronic fuel injection works. The complexity is hidden behind a simple interface.

Java provides two mechanisms for abstraction: abstract classes and interfaces.

Abstract Classes

An abstract class is a class that cannot be instantiated directly. It serves as a blueprint for subclasses. It can contain:

  • Abstract methods (declared with abstract, no body) — Subclasses must implement these.
  • Concrete methods (with a body) — Subclasses inherit these and can optionally override them.
  • Fields, constructors, and static methods — Just like a regular class.

Use an abstract class when subclasses share common state (fields) or behavior (method implementations), but also have behavior that varies from subclass to subclass.

public abstract class PaymentMethod {
    private String ownerName;
    private double transactionFeePercent;

    public PaymentMethod(String ownerName, double transactionFeePercent) {
        this.ownerName = ownerName;
        this.transactionFeePercent = transactionFeePercent;
    }

    // Abstract method -- each payment type processes payments differently
    public abstract boolean processPayment(double amount);

    // Abstract method -- each payment type displays its info differently
    public abstract String getPaymentDetails();

    // Concrete method -- shared by all payment types
    public double calculateFee(double amount) {
        return amount * (transactionFeePercent / 100.0);
    }

    // Concrete method -- shared logic
    public void printReceipt(double amount) {
        double fee = calculateFee(amount);
        System.out.println("=== Payment Receipt ===");
        System.out.println("Paid by: " + ownerName);
        System.out.println("Method: " + getPaymentDetails());
        System.out.printf("Amount: $%.2f%n", amount);
        System.out.printf("Fee: $%.2f%n", fee);
        System.out.printf("Total: $%.2f%n", amount + fee);
        System.out.println("=======================");
    }

    public String getOwnerName() { return ownerName; }
}
public class CreditCard extends PaymentMethod {
    private String cardNumber;
    private String expirationDate;

    public CreditCard(String ownerName, String cardNumber, String expirationDate) {
        super(ownerName, 2.9); // Credit cards typically charge ~2.9% fee
        this.cardNumber = cardNumber;
        this.expirationDate = expirationDate;
    }

    @Override
    public boolean processPayment(double amount) {
        // In a real app, this would call a payment gateway API
        System.out.println("Charging $" + amount + " to credit card ending in "
            + cardNumber.substring(cardNumber.length() - 4));
        return true;
    }

    @Override
    public String getPaymentDetails() {
        return "Credit Card ending in " + cardNumber.substring(cardNumber.length() - 4)
            + " (exp: " + expirationDate + ")";
    }
}

public class BankTransfer extends PaymentMethod {
    private String bankName;
    private String accountNumber;

    public BankTransfer(String ownerName, String bankName, String accountNumber) {
        super(ownerName, 0.5); // Bank transfers have lower fees
        this.bankName = bankName;
        this.accountNumber = accountNumber;
    }

    @Override
    public boolean processPayment(double amount) {
        System.out.println("Initiating bank transfer of $" + amount
            + " from " + bankName + " account ending in "
            + accountNumber.substring(accountNumber.length() - 4));
        return true;
    }

    @Override
    public String getPaymentDetails() {
        return bankName + " account ending in "
            + accountNumber.substring(accountNumber.length() - 4);
    }
}

public class PayPal extends PaymentMethod {
    private String email;

    public PayPal(String ownerName, String email) {
        super(ownerName, 3.5); // PayPal fees
        this.email = email;
    }

    @Override
    public boolean processPayment(double amount) {
        System.out.println("Processing PayPal payment of $" + amount
            + " from " + email);
        return true;
    }

    @Override
    public String getPaymentDetails() {
        return "PayPal (" + email + ")";
    }
}
// Usage -- polymorphism + abstraction working together:
public class PaymentDemo {
    public static void main(String[] args) {
        PaymentMethod creditCard = new CreditCard("Alice", "4111111111111234", "12/26");
        PaymentMethod bankTransfer = new BankTransfer("Bob", "Chase", "9876543210");
        PaymentMethod paypal = new PayPal("Charlie", "charlie@email.com");

        // Process payments polymorphically
        List payments = List.of(creditCard, bankTransfer, paypal);
        for (PaymentMethod payment : payments) {
            payment.processPayment(100.00);
            payment.printReceipt(100.00);
            System.out.println();
        }

        // You cannot instantiate an abstract class:
        // PaymentMethod pm = new PaymentMethod("Test", 1.0); // Compile error
    }
}

// Output:
// Charging $100.0 to credit card ending in 1234
// === Payment Receipt ===
// Paid by: Alice
// Method: Credit Card ending in 1234 (exp: 12/26)
// Amount: $100.00
// Fee: $2.90
// Total: $102.90
// =======================
//
// Initiating bank transfer of $100.0 from Chase account ending in 3210
// === Payment Receipt ===
// Paid by: Bob
// Method: Chase account ending in 3210
// Amount: $100.00
// Fee: $0.50
// Total: $100.50
// =======================
//
// Processing PayPal payment of $100.0 from charlie@email.com
// === Payment Receipt ===
// Paid by: Charlie
// Method: PayPal (charlie@email.com)
// Amount: $100.00
// Fee: $3.50
// Total: $103.50
// =======================

Interfaces

An interface defines a contract — a set of methods that implementing classes must provide. Unlike abstract classes, interfaces:

  • Cannot have instance fields (only public static final constants).
  • Cannot have constructors.
  • Support multiple implementation — a class can implement many interfaces.
  • Since Java 8, can have default methods (methods with a body) and static methods.
  • Since Java 9, can have private methods for internal code reuse.

Use an interface when you want to define what a class can do without dictating how it does it or what state it holds.

// Interface defines a contract: any Sortable object can be sorted
public interface Sortable {
    int compareTo(Object other);
}

// Interface with default method (Java 8+)
public interface Loggable {
    String getLogPrefix();

    // Default method -- provides a default implementation
    default void log(String message) {
        System.out.println("[" + getLogPrefix() + "] " + message);
    }
}

// Interface with static method
public interface Identifiable {
    String getId();

    static boolean isValidId(String id) {
        return id != null && id.length() >= 3;
    }
}

// A class can implement multiple interfaces
public class Product implements Loggable, Identifiable {
    private String id;
    private String name;
    private double price;

    public Product(String id, String name, double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    @Override
    public String getLogPrefix() {
        return "Product-" + id;
    }

    @Override
    public String getId() {
        return id;
    }

    public String getName() { return name; }
    public double getPrice() { return price; }
}

// Usage:
Product laptop = new Product("P001", "Laptop", 999.99);
laptop.log("Created successfully"); // [Product-P001] Created successfully
System.out.println(Identifiable.isValidId("P001")); // true
System.out.println(Identifiable.isValidId("AB"));    // false

Abstract Class vs. Interface: When to Use Which

Feature Abstract Class Interface
Instance fields Yes — can have instance variables No — only public static final constants
Constructors Yes No
Method bodies Can have both abstract and concrete methods Abstract by default; default and static methods since Java 8
Multiple inheritance No — single extends only Yes — a class can implement many interfaces
Access modifiers Any (private, protected, public) public (or private since Java 9 for helper methods)
When to use Subclasses share common state and behavior Unrelated classes need to share a capability
Relationship "IS-A" with shared implementation "CAN-DO" capability contract
Example abstract class Animal — Dog, Cat share fields interface Serializable — any class can be serializable

Rule of thumb: Use an interface to define what an object can do. Use an abstract class when you also want to provide shared state or partial implementation that subclasses can build on.

5. Composition vs. Inheritance

Inheritance represents an IS-A relationship (a Dog IS-A Animal). Composition represents a HAS-A relationship (a Car HAS-A Engine). Both are tools for code reuse, but they serve different purposes and have different trade-offs.

A common mistake among beginners is reaching for inheritance when composition would be more appropriate. The famous advice from the Gang of Four (GoF) design patterns book is: "Favor composition over inheritance."

Why Composition Is Often Preferred

  • Loose coupling — With composition, you can swap components at runtime. With inheritance, the parent-child relationship is fixed at compile time.
  • No fragile base class problem — Changing a parent class can break all subclasses. Changing a composed object only affects that object.
  • More flexible — A class can be composed of many objects (has-a Engine, has-a Transmission, has-a GPS), but can only extend one parent.
// BAD: Using inheritance where composition is more appropriate
// A Car is NOT an Engine -- it HAS an Engine
public class Engine {
    public void start() {
        System.out.println("Engine started");
    }
}

// This is wrong -- a Car is not a kind of Engine
// public class Car extends Engine { }

// GOOD: Using composition -- Car HAS-A Engine
public class Engine {
    private int horsepower;

    public Engine(int horsepower) {
        this.horsepower = horsepower;
    }

    public void start() {
        System.out.println(horsepower + "HP engine started");
    }

    public void stop() {
        System.out.println("Engine stopped");
    }
}

public class Transmission {
    private String type; // "automatic" or "manual"

    public Transmission(String type) {
        this.type = type;
    }

    public void shift(int gear) {
        System.out.println(type + " transmission shifted to gear " + gear);
    }
}

public class GPS {
    public void navigate(String destination) {
        System.out.println("Navigating to " + destination);
    }
}

public class Car {
    // Composition: Car HAS-A Engine, Transmission, and GPS
    private final Engine engine;
    private final Transmission transmission;
    private GPS gps; // Optional -- can be added or removed

    public Car(Engine engine, Transmission transmission) {
        this.engine = engine;
        this.transmission = transmission;
    }

    public void installGPS(GPS gps) {
        this.gps = gps; // Components can be swapped at runtime
    }

    public void drive(String destination) {
        engine.start();
        transmission.shift(1);
        if (gps != null) {
            gps.navigate(destination);
        }
    }
}

// Usage:
Engine v6 = new Engine(300);
Transmission auto = new Transmission("automatic");
Car myCar = new Car(v6, auto);
myCar.installGPS(new GPS());
myCar.drive("New York");

// Output:
// 300HP engine started
// automatic transmission shifted to gear 1
// Navigating to New York

When to Use Inheritance vs. Composition

Use Inheritance When Use Composition When
There is a genuine IS-A relationship There is a HAS-A relationship
Subclass is a specialization of the parent You want to reuse behavior from multiple sources
You want polymorphic behavior You want to swap components at runtime
Example: Dog extends Animal Example: Car has Engine, Wheels, GPS

6. SOLID Principles

The SOLID principles are five design guidelines that help you write OOP code that is maintainable, flexible, and resilient to change. They were popularized by Robert C. Martin (Uncle Bob) and are considered essential knowledge for any professional Java developer.

Letter Principle In Plain English
S Single Responsibility A class should have only one reason to change.
O Open/Closed Classes should be open for extension but closed for modification.
L Liskov Substitution Subclasses should be usable wherever their parent class is expected.
I Interface Segregation Prefer many small interfaces over one large, general-purpose interface.
D Dependency Inversion Depend on abstractions (interfaces), not concrete implementations.

Practical Example: Single Responsibility Principle

The most commonly violated SOLID principle is the Single Responsibility Principle (SRP). Here is what a violation looks like and how to fix it:

// BAD: This class has too many responsibilities
// It handles user data, validation, database operations, AND email sending.
// If any of those concerns change, this class must change.
public class UserService {
    public void registerUser(String name, String email, String password) {
        // 1. Validation
        if (name == null || name.isBlank()) throw new IllegalArgumentException("Invalid name");
        if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
        if (password.length() < 8) throw new IllegalArgumentException("Password too short");

        // 2. Database operation
        String sql = "INSERT INTO users (name, email, password) VALUES (?, ?, ?)";
        // ... execute SQL ...

        // 3. Email sending
        // ... connect to SMTP server, build email, send ...
        System.out.println("Welcome email sent to " + email);

        // 4. Logging
        System.out.println("[LOG] User registered: " + email);
    }
}
// GOOD: Each class has a single responsibility

public class UserValidator {
    public void validate(String name, String email, String password) {
        if (name == null || name.isBlank()) throw new IllegalArgumentException("Invalid name");
        if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
        if (password.length() < 8) throw new IllegalArgumentException("Password too short");
    }
}

public class UserRepository {
    public void save(User user) {
        String sql = "INSERT INTO users (name, email, password) VALUES (?, ?, ?)";
        // ... execute SQL ...
    }
}

public class EmailService {
    public void sendWelcomeEmail(String email) {
        // ... connect to SMTP server, build email, send ...
        System.out.println("Welcome email sent to " + email);
    }
}

// The service class now coordinates (orchestrates) -- it does not do the actual work
public class UserService {
    private final UserValidator validator;
    private final UserRepository repository;
    private final EmailService emailService;

    public UserService(UserValidator validator, UserRepository repository, EmailService emailService) {
        this.validator = validator;
        this.repository = repository;
        this.emailService = emailService;
    }

    public void registerUser(String name, String email, String password) {
        validator.validate(name, email, password);
        User user = new User(name, email, password);
        repository.save(user);
        emailService.sendWelcomeEmail(email);
    }
}

Now each class has exactly one reason to change: UserValidator changes when validation rules change, UserRepository changes when the database schema changes, EmailService changes when the email provider changes. The UserService only changes when the overall registration workflow changes.

Quick Summary of Remaining SOLID Principles

Open/Closed Principle: You should be able to add new behavior (new shapes, new payment methods) by creating new classes, not by modifying existing ones. The Shape and PaymentMethod examples earlier in this tutorial follow this principle — adding a Pentagon class requires no changes to existing code.

Liskov Substitution Principle: If your code works with a Vehicle reference, it should work correctly regardless of whether the actual object is a Car, Truck, or ElectricCar. Subclasses must not violate the expectations set by the parent class.

Interface Segregation Principle: Instead of one large Worker interface with code(), manage(), test(), and design(), create separate Coder, Manager, Tester, and Designer interfaces. A class should not be forced to implement methods it does not use.

Dependency Inversion Principle: High-level modules should depend on abstractions (interfaces), not concrete classes. Notice how the UserService constructor accepts interfaces/abstractions rather than creating concrete instances with new. This makes the code testable and flexible.

7. Common OOP Mistakes

Even experienced developers fall into these traps. Being aware of them will save you hours of debugging and refactoring.

Mistake 1: Overusing Inheritance

Not every code-reuse scenario calls for inheritance. If the relationship is not genuinely IS-A, use composition instead.

// BAD: Stack is NOT an ArrayList -- it just uses one internally
// By extending ArrayList, Stack exposes methods like add(index, element)
// and remove(index) that violate stack behavior (LIFO).
public class Stack extends ArrayList {
    public void push(T item) { add(item); }
    public T pop() { return remove(size() - 1); }
}

// Users can bypass the stack behavior:
Stack stack = new Stack<>();
stack.push("A");
stack.push("B");
stack.add(0, "WRONG"); // This should not be allowed on a stack!

// GOOD: Use composition -- Stack HAS-A List
public class Stack {
    private final List items = new ArrayList<>();

    public void push(T item)  { items.add(item); }
    public T pop()            { return items.remove(items.size() - 1); }
    public T peek()           { return items.get(items.size() - 1); }
    public boolean isEmpty()  { return items.isEmpty(); }
    public int size()         { return items.size(); }
    // No add(index, element), remove(index), or other List methods exposed
}

Mistake 2: Breaking Encapsulation

Creating a getter and setter for every field defeats the purpose of encapsulation. Only expose what outside code genuinely needs.

// BAD: Every field has a public getter and setter -- this is barely
// better than making the fields public.
public class Order {
    private double subtotal;
    private double tax;
    private double total;

    public void setSubtotal(double subtotal) { this.subtotal = subtotal; }
    public void setTax(double tax)           { this.tax = tax; }
    public void setTotal(double total)       { this.total = total; }
    // Nothing stops someone from setting total to a value that
    // does not equal subtotal + tax.
}

// GOOD: The object manages its own derived state.
public class Order {
    private double subtotal;
    private double taxRate;

    public Order(double subtotal, double taxRate) {
        this.subtotal = subtotal;
        this.taxRate = taxRate;
    }

    public double getSubtotal() { return subtotal; }
    public double getTax()      { return subtotal * taxRate; }
    public double getTotal()    { return subtotal + getTax(); }
    // total is always consistent -- it cannot be set to a wrong value
}

Mistake 3: God Classes

A "God class" is a single class that tries to do everything. It violates the Single Responsibility Principle and quickly becomes unmaintainable. If your class has more than 300-500 lines or the word "Manager" or "Utility" in the name with dozens of methods, it is probably doing too much. Break it up into focused classes.

Mistake 4: Violating the Liskov Substitution Principle

// Classic LSP violation: Square extends Rectangle

public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width)   { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea()              { return width * height; }
}

public class Square extends Rectangle {
    // A square must maintain width == height
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Unexpected side effect!
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height; // Unexpected side effect!
    }
}

// This code works correctly for Rectangle but breaks for Square:
public void resize(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    // Expected area: 50
    System.out.println(rect.getArea());
    // For Rectangle: 50 (correct)
    // For Square: 100 (WRONG -- setHeight changed width too)
}

// The fix: Square should NOT extend Rectangle.
// Instead, create a Shape interface that both implement independently.

Mistake 5: Exposing Mutable Internal State

// BAD: Returning the internal list directly
public class Team {
    private List members = new ArrayList<>();

    public List getMembers() {
        return members; // Caller can modify the internal list!
    }
}

Team team = new Team();
team.getMembers().add("Alice");
team.getMembers().clear(); // Oops -- cleared the team's internal data!

// GOOD: Return a defensive copy or an unmodifiable view
public class Team {
    private final List members = new ArrayList<>();

    public void addMember(String name) {
        members.add(name);
    }

    public List getMembers() {
        return Collections.unmodifiableList(members); // Read-only view
    }
}

8. Complete Practical Example: Employee Management System

Let us tie everything together with a complete, compilable example that demonstrates all four pillars of OOP working in concert. We will build a simple employee management system with:

  • Encapsulation — Private fields with validation
  • Inheritance — Manager and Developer extend Employee
  • Polymorphism — Each employee type calculates bonus differently
  • Abstraction — Abstract Employee class and Reviewable interface
  • Composition — Department HAS employees
// === Interface: defines a capability contract ===

public interface Reviewable {
    String performReview();
    int getPerformanceScore(); // 1-10
}

public interface Promotable {
    boolean isEligibleForPromotion();
    void promote(String newTitle);
}
// === Abstract base class: shared state and behavior ===

public abstract class Employee implements Reviewable, Promotable {
    // Encapsulation: all fields are private
    private final String employeeId;
    private String name;
    private String title;
    private double baseSalary;
    private int yearsOfExperience;

    public Employee(String employeeId, String name, String title,
                    double baseSalary, int yearsOfExperience) {
        this.employeeId = employeeId;
        this.name = name;
        this.title = title;
        setBaseSalary(baseSalary);
        this.yearsOfExperience = yearsOfExperience;
    }

    // Abstract methods -- each employee type implements differently
    public abstract double calculateBonus();
    public abstract String getRole();

    // Concrete method -- shared by all employees
    public double getTotalCompensation() {
        return baseSalary + calculateBonus();
    }

    // Encapsulation: validation in setter
    public void setBaseSalary(double baseSalary) {
        if (baseSalary < 30000) {
            throw new IllegalArgumentException("Salary must be at least $30,000");
        }
        this.baseSalary = baseSalary;
    }

    // Promotable interface implementation
    @Override
    public boolean isEligibleForPromotion() {
        return yearsOfExperience >= 2 && getPerformanceScore() >= 7;
    }

    @Override
    public void promote(String newTitle) {
        if (!isEligibleForPromotion()) {
            System.out.println(name + " is not eligible for promotion yet.");
            return;
        }
        this.title = newTitle;
        this.baseSalary *= 1.15; // 15% raise on promotion
        System.out.println(name + " promoted to " + newTitle
            + "! New salary: $" + String.format("%.2f", baseSalary));
    }

    // Getters
    public String getEmployeeId()     { return employeeId; }
    public String getName()           { return name; }
    public String getTitle()          { return title; }
    public double getBaseSalary()     { return baseSalary; }
    public int getYearsOfExperience() { return yearsOfExperience; }

    @Override
    public String toString() {
        return String.format("[%s] %s - %s (%s) | Salary: $%.2f | Bonus: $%.2f",
            employeeId, name, title, getRole(), baseSalary, calculateBonus());
    }
}
// === Concrete subclass: Developer ===

public class Developer extends Employee {
    private String programmingLanguage;
    private int pullRequestsPerMonth;
    private int bugFixesPerMonth;

    public Developer(String employeeId, String name, String title,
                     double baseSalary, int yearsOfExperience,
                     String programmingLanguage) {
        super(employeeId, name, title, baseSalary, yearsOfExperience);
        this.programmingLanguage = programmingLanguage;
        this.pullRequestsPerMonth = 0;
        this.bugFixesPerMonth = 0;
    }

    public void logWork(int pullRequests, int bugFixes) {
        this.pullRequestsPerMonth += pullRequests;
        this.bugFixesPerMonth += bugFixes;
    }

    // Polymorphism: Developer's bonus is based on output metrics
    @Override
    public double calculateBonus() {
        double baseBonus = getBaseSalary() * 0.10; // 10% base bonus
        double outputBonus = (pullRequestsPerMonth * 200) + (bugFixesPerMonth * 150);
        return baseBonus + outputBonus;
    }

    @Override
    public String getRole() {
        return "Developer (" + programmingLanguage + ")";
    }

    // Reviewable interface
    @Override
    public String performReview() {
        return getName() + " review: " + pullRequestsPerMonth + " PRs, "
            + bugFixesPerMonth + " bug fixes this month.";
    }

    @Override
    public int getPerformanceScore() {
        if (pullRequestsPerMonth >= 15 && bugFixesPerMonth >= 5) return 10;
        if (pullRequestsPerMonth >= 10) return 8;
        if (pullRequestsPerMonth >= 5) return 6;
        return 4;
    }

    public String getProgrammingLanguage() { return programmingLanguage; }
}
// === Concrete subclass: Manager ===

public class Manager extends Employee {
    private List directReports;
    private double teamPerformanceRating; // 1.0 - 5.0

    public Manager(String employeeId, String name, String title,
                   double baseSalary, int yearsOfExperience) {
        super(employeeId, name, title, baseSalary, yearsOfExperience);
        this.directReports = new ArrayList<>();
        this.teamPerformanceRating = 3.0;
    }

    public void addDirectReport(Employee employee) {
        directReports.add(employee);
        System.out.println(employee.getName() + " now reports to " + getName());
    }

    public void setTeamPerformanceRating(double rating) {
        if (rating < 1.0 || rating > 5.0) {
            throw new IllegalArgumentException("Rating must be between 1.0 and 5.0");
        }
        this.teamPerformanceRating = rating;
    }

    // Polymorphism: Manager's bonus depends on team size and performance
    @Override
    public double calculateBonus() {
        double baseBonus = getBaseSalary() * 0.15; // 15% base bonus (higher than developer)
        double teamBonus = directReports.size() * 1000 * teamPerformanceRating;
        return baseBonus + teamBonus;
    }

    @Override
    public String getRole() {
        return "Manager (" + directReports.size() + " reports)";
    }

    // Reviewable interface
    @Override
    public String performReview() {
        return getName() + " review: Managing " + directReports.size()
            + " employees, team rating: " + teamPerformanceRating;
    }

    @Override
    public int getPerformanceScore() {
        if (teamPerformanceRating >= 4.5) return 10;
        if (teamPerformanceRating >= 3.5) return 8;
        if (teamPerformanceRating >= 2.5) return 6;
        return 4;
    }

    public List getDirectReports() {
        return Collections.unmodifiableList(directReports);
    }
}
// === Composition: Department HAS employees ===

public class Department {
    private final String name;
    private final List employees;

    public Department(String name) {
        this.name = name;
        this.employees = new ArrayList<>();
    }

    public void addEmployee(Employee employee) {
        employees.add(employee);
    }

    // Polymorphism: works with any Employee subclass
    public void printDepartmentReport() {
        System.out.println("\n=== " + name + " Department Report ===");
        System.out.println("Total employees: " + employees.size());

        double totalSalary = 0;
        double totalBonus = 0;

        for (Employee emp : employees) {
            System.out.println(emp);               // Calls toString() -- polymorphism
            System.out.println("  " + emp.performReview()); // Interface method -- polymorphism
            totalSalary += emp.getBaseSalary();
            totalBonus += emp.calculateBonus();     // Each type calculates differently
        }

        System.out.printf("%nDepartment totals -- Salaries: $%.2f | Bonuses: $%.2f | Total: $%.2f%n",
            totalSalary, totalBonus, totalSalary + totalBonus);
    }

    // Find highest performer using polymorphism
    public Employee getTopPerformer() {
        return employees.stream()
            .max((a, b) -> Integer.compare(a.getPerformanceScore(), b.getPerformanceScore()))
            .orElse(null);
    }

    public String getName() { return name; }
    public List getEmployees() {
        return Collections.unmodifiableList(employees);
    }
}
// === Putting it all together ===

import java.util.*;

public class EmployeeManagementDemo {
    public static void main(String[] args) {
        // Create employees (Abstraction: we use Employee references for polymorphism)
        Developer dev1 = new Developer("D001", "Alice", "Senior Developer",
            95000, 5, "Java");
        Developer dev2 = new Developer("D002", "Bob", "Junior Developer",
            65000, 1, "Python");
        Developer dev3 = new Developer("D003", "Charlie", "Mid Developer",
            80000, 3, "Java");

        Manager mgr1 = new Manager("M001", "Diana", "Engineering Manager",
            120000, 8);

        // Log some work for developers
        dev1.logWork(18, 7);  // 18 PRs, 7 bug fixes
        dev2.logWork(6, 2);   // 6 PRs, 2 bug fixes
        dev3.logWork(12, 4);  // 12 PRs, 4 bug fixes

        // Set up manager's team
        mgr1.addDirectReport(dev1);
        mgr1.addDirectReport(dev2);
        mgr1.addDirectReport(dev3);
        mgr1.setTeamPerformanceRating(4.2);

        // Composition: create a department that HAS employees
        Department engineering = new Department("Engineering");
        engineering.addEmployee(dev1);
        engineering.addEmployee(dev2);
        engineering.addEmployee(dev3);
        engineering.addEmployee(mgr1);

        // Polymorphism: the department works with all employee types uniformly
        engineering.printDepartmentReport();

        // Find and promote top performer
        Employee topPerformer = engineering.getTopPerformer();
        if (topPerformer != null) {
            System.out.println("\nTop performer: " + topPerformer.getName()
                + " (score: " + topPerformer.getPerformanceScore() + ")");
            topPerformer.promote("Lead " + topPerformer.getTitle());
        }
    }
}

// Output:
//
// Alice now reports to Diana
// Bob now reports to Diana
// Charlie now reports to Diana
//
// === Engineering Department Report ===
// Total employees: 4
// [D001] Alice - Senior Developer (Developer (Java)) | Salary: $95000.00 | Bonus: $14150.00
//   Alice review: 18 PRs, 7 bug fixes this month.
// [D002] Bob - Junior Developer (Developer (Python)) | Salary: $65000.00 | Bonus: $7700.00
//   Bob review: 6 PRs, 2 bug fixes this month.
// [D003] Charlie - Mid Developer (Developer (Java)) | Salary: $80000.00 | Bonus: $10600.00
//   Charlie review: 12 PRs, 4 bug fixes this month.
// [M001] Diana - Engineering Manager (Manager (3 reports)) | Salary: $120000.00 | Bonus: $30600.00
//   Diana review: Managing 3 employees, team rating: 4.2
//
// Department totals -- Salaries: $360000.00 | Bonuses: $63050.00 | Total: $423050.00
//
// Top performer: Alice (score: 10)
// Alice promoted to Lead Senior Developer! New salary: $109250.00

What This Example Demonstrates

OOP Concept Where It Appears
Encapsulation All fields are private; salary has validation in the setter; internal lists are returned as unmodifiable views
Inheritance Developer and Manager extend Employee; shared fields and methods (name, salary, getTotalCompensation()) live in the parent
Polymorphism Department.printDepartmentReport() calls calculateBonus() and performReview() on each employee — each type responds differently
Abstraction Employee is abstract with abstract methods; Reviewable and Promotable interfaces define capability contracts
Composition Department HAS-A list of employees; Manager HAS-A list of direct reports
SRP Each class has one responsibility: Employee holds employee data, Department manages a group of employees

9. Summary and Quick Reference

Concept Definition Java Keywords Remember
Encapsulation Hide internal state; control access through methods private, getters/setters Make fields private. Validate in setters. Expose only what is needed.
Inheritance Create IS-A relationships; reuse parent class code extends, super, @Override Only use for genuine IS-A relationships. Prefer composition for HAS-A.
Polymorphism One type, many forms; same method call, different behavior Overloading, overriding, upcasting Parent references can hold child objects. Method calls resolve at runtime.
Abstraction Hide complexity; expose only essential features abstract, interface Abstract class for shared state. Interface for shared capability.
Composition Build complex objects by combining simpler ones Instance fields of other class types "Favor composition over inheritance." Use HAS-A when IS-A does not apply.
SOLID Five principles for maintainable OOP design Design principles, not keywords One responsibility per class. Depend on abstractions, not concrete types.

Key Takeaways

  1. Start with encapsulation. Make every field private. Only add getters and setters that your code actually needs. Validate data in setters.
  2. Use inheritance sparingly. Only extend a class when there is a genuine IS-A relationship. Otherwise, use composition.
  3. Design for polymorphism. Write code that works with parent types and interfaces. This makes your system extensible without modifying existing code.
  4. Abstract the right things. Use abstract classes for shared state and behavior. Use interfaces for shared capabilities across unrelated classes.
  5. Follow SOLID principles. Keep classes small and focused. Depend on abstractions. Make sure subclasses are true substitutes for their parents.
  6. Think before you code. Ask yourself: "What objects exist in this problem? What does each one know (state)? What can each one do (behavior)? How do they relate to each other?" This is the OOP mindset.



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *