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.
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:
// === 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.
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.
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 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
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.
}
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
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.
}
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.
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:
extends KeywordIn 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"
super KeywordThe super keyword is used inside a subclass to refer to its parent class. It has two primary uses:
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).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%
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
@OverrideA subclass can provide its own implementation of a method it inherited from its parent. This is called method overriding. The rules are:
protected method can be overridden as public, but not as private).@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!";
}
}
final Keyword in InheritanceThe 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());
}
}
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");
}
}
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:
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 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;
}
}
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.
instanceof Operator and Pattern MatchingSometimes 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.
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.
An abstract class is a class that cannot be instantiated directly. It serves as a blueprint for subclasses. It can contain:
abstract, no body) — Subclasses must implement these.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
// =======================
An interface defines a contract — a set of methods that implementing classes must provide. Unlike abstract classes, interfaces:
public static final constants).default methods (methods with a body) and static methods.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
| 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.
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."
// 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
| 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 |
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. |
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.
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.
Even experienced developers fall into these traps. Being aware of them will save you hours of debugging and refactoring.
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 Stackextends 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 }
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
}
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.
// 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.
// 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
}
}
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:
// === 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
| 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 |
| 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. |
private. Only add getters and setters that your code actually needs. Validate data in setters.