☕ Java
OOP Concepts
Object-Oriented Programming (OOP) is a programming paradigm that organises software around objects — self-contained units that combine data (fields) and behaviour (methods). Java is a class-based, object-oriented language where almost everything is an object. OOP provides four foundational principles: encapsulation, inheritance, polymorphism, and abstraction. Together they produce software that is modular, reusable, maintainable, and easier to reason about as systems grow in complexity.
What Is Object-Oriented Programming?
Before OOP, programs were written as sequences of procedures — functions that operated on data stored in separate structures. As programs grew larger, managing the relationship between data and the functions that manipulated it became increasingly difficult. A change to a data structure required finding and updating every function that used it.
OOP solved this by binding data and behaviour together into a single unit called an object. An object knows things (its fields represent its state) and it can do things (its methods represent its behaviour). The outside world interacts with an object only through its defined interface — the public methods it exposes — and has no direct access to its internal data unless the object chooses to allow it.
This shift in thinking maps naturally to the real world. A bank account object knows its balance and owner, and it can perform operations like deposit, withdraw, and getBalance. Nothing outside the account can directly alter the balance field — it must go through the account's methods, which can enforce rules like "balance cannot go below zero". This is fundamentally different from procedural code where the balance is just a variable that any function can modify at will.
Java enforces OOP at the language level. Every piece of executable code must be inside a class. Classes define the blueprint for objects — they describe what fields an object will have and what methods it will respond to. Creating an object from a class is called instantiation, and the created object is called an instance of that class.
Java
// ── Procedural style (what OOP replaces): ────────────────────────────
// Data and behaviour are separate — any code can access any data.
double balance = 1000.0;
String owner = "Alice";
// Any function can modify balance directly — no control:
balance = balance - 5000.0; // now negative — no protection!
System.out.println(balance); // -4000.0
// ── Object-oriented style: ────────────────────────────────────────────
// Data and behaviour are bound together inside the class.
// The object controls access to its own state.
public class BankAccount {
private double balance; // hidden — cannot be accessed directly
private String owner;
public BankAccount(String owner, double initialBalance) {
this.owner = owner;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException(
"Deposit amount must be positive");
balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException(
"Withdrawal amount must be positive");
if (amount > balance) throw new IllegalStateException(
"Insufficient funds");
balance -= amount;
}
public double getBalance() { return balance; }
public String getOwner() { return owner; }
}
// Usage — must go through the object's methods:
BankAccount account = new BankAccount("Alice", 1000.0);
account.deposit(500.0);
// account.balance = -5000.0; // COMPILE ERROR — private field
account.withdraw(200.0); // enforces rules internally
System.out.println(account.getBalance()); // 1300.0
// ── Class as blueprint, object as instance: ───────────────────────────
// One class — many independent objects:
BankAccount aliceAccount = new BankAccount("Alice", 1000.0);
BankAccount bobAccount = new BankAccount("Bob", 2500.0);
BankAccount corpAccount = new BankAccount("Corp", 50000.0);
// Each object has its own independent copy of balance and owner.The Four Pillars of OOP
OOP is built on four foundational principles that together define how objects should be designed and how they interact. These principles are not independent features — they work together and reinforce each other. Encapsulation hides implementation details. Inheritance allows code reuse across related types. Polymorphism allows one interface to work with many types. Abstraction simplifies complex systems by exposing only what is necessary.
Every professional Java developer must internalise these four principles because they shape every design decision — from naming a method to deciding which class should own a piece of data. Understanding why these principles exist, not just what they mean, is the difference between writing code that merely compiles and writing code that is genuinely maintainable.
The four pillars are often listed as encapsulation, inheritance, polymorphism, and abstraction, but they are not equal in importance or frequency of use. Encapsulation is used in every class. Polymorphism is the most powerful for writing flexible systems. Inheritance is useful but often overused. Abstraction is the design goal that the other three serve.
Java
// ── The four pillars at a glance: ────────────────────────────────────
//
// 1. ENCAPSULATION
// Bind data and behaviour together. Hide internal details.
// Expose only a controlled public interface.
// Mechanism: private fields + public getter/setter methods.
// Benefit: protects state, reduces coupling, simplifies change.
//
// 2. INHERITANCE
// A class (subclass) acquires the fields and methods of another
// class (superclass). Enables code reuse and "is-a" relationships.
// Mechanism: extends keyword.
// Benefit: avoids duplication of shared behaviour.
// Warning: overuse of inheritance creates brittle hierarchies.
// Prefer composition over deep inheritance.
//
// 3. POLYMORPHISM
// One interface, many implementations. The same method call
// behaves differently depending on the actual object type.
// Mechanisms: method overriding (runtime) + overloading (compile time).
// Benefit: write code that works with any compatible type.
// Add new types without changing existing code.
//
// 4. ABSTRACTION
// Show only what is necessary. Hide complexity behind simple interfaces.
// Mechanisms: abstract classes + interfaces.
// Benefit: reduces cognitive load, decouples interface from implementation.
// Code depends on what something does, not how it does it.
// ── All four in one example: ──────────────────────────────────────────
// ABSTRACTION — define the interface (what a shape can do):
public abstract class Shape {
private String colour; // ENCAPSULATION — private field
public Shape(String colour) {
this.colour = colour;
}
public String getColour() { return colour; } // controlled access
public abstract double area(); // abstraction — what, not how
public abstract double perimeter(); // each subclass defines how
// Template method — uses abstract methods (polymorphism):
public void describe() {
System.out.printf("%s %s: area=%.2f perimeter=%.2f%n",
colour, getClass().getSimpleName(), area(), perimeter());
}
}
// INHERITANCE — subclasses inherit colour and describe():
public class Circle extends Shape {
private double radius; // ENCAPSULATION
public Circle(String colour, double radius) {
super(colour);
this.radius = radius;
}
@Override public double area() { return Math.PI * radius * radius; }
@Override public double perimeter() { return 2 * Math.PI * radius; }
}
public class Rectangle extends Shape {
private double width, height;
public Rectangle(String colour, double width, double height) {
super(colour);
this.width = width;
this.height = height;
}
@Override public double area() { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}
// POLYMORPHISM — one loop handles any Shape subtype:
List<Shape> shapes = List.of(
new Circle("Red", 5.0),
new Rectangle("Blue", 4.0, 6.0),
new Circle("Green", 3.0)
);
for (Shape shape : shapes) {
shape.describe(); // calls the correct area()/perimeter() at runtime
}
// Output:
// Red Circle: area=78.54 perimeter=31.42
// Blue Rectangle: area=24.00 perimeter=20.00
// Green Circle: area=28.27 perimeter=18.85Encapsulation
Encapsulation is the practice of keeping an object's internal state private and exposing it only through carefully controlled public methods. The word comes from "encapsulate" — to enclose something in a capsule. An object's fields are the capsule's contents; the public methods are the capsule's controlled openings.
The primary motivation is protection of invariants. An invariant is a rule about the state of an object that must always be true — for example, a Person's age must be non-negative, a Circle's radius must be positive, an OrderItem's quantity must be at least one. When fields are public, any code anywhere can violate these invariants. When fields are private, only the class's own methods can change them, and those methods enforce the invariants.
A secondary motivation is flexibility. If a field is private, the class can change its internal representation without affecting any outside code. If you later decide to store a Person's name as separate firstName and lastName fields rather than a single name string, only the Person class needs to change — all callers still use the same getName() method. If the field had been public, every caller that accessed person.name directly would break.
Encapsulation also enables lazy initialisation, caching, logging, validation on write, and computed properties on read — none of which are possible with raw public field access.
Java
// ── Encapsulation protects invariants: ───────────────────────────────
public class Person {
private String name; // private — hidden from outside
private int age; // private — protected by setter
public Person(String name, int age) {
setName(name); // use setter — enforces validation
setAge(age);
}
// Getter — controlled read access:
public String getName() { return name; }
public int getAge() { return age; }
// Setter with validation — enforces invariant:
public void setName(String name) {
if (name == null || name.isBlank())
throw new IllegalArgumentException("Name cannot be blank");
this.name = name.trim();
}
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 "Person{name='" + name + "', age=" + age + "}";
}
}
// ── Invariants are enforced — invalid state is impossible: ────────────
Person p = new Person("Alice", 30);
// p.age = -5; // COMPILE ERROR — private field
p.setAge(-5); // throws IllegalArgumentException
p.setName(""); // throws IllegalArgumentException
p.setName(" Bob "); // accepted, trimmed to "Bob"
// ── Encapsulation enables implementation change: ──────────────────────
// Version 1 — stores full name as one string:
public class User {
private String fullName;
public String getName() { return fullName; }
}
// Version 2 — stores as two fields (internal change, same API):
public class User {
private String firstName;
private String lastName;
public String getName() { return firstName + " " + lastName; }
// All existing callers of getName() still work — nothing breaks.
}
// ── Read-only property — getter without setter: ───────────────────────
public class ImmutablePoint {
private final double x; // final = set once, never changed
private final double y;
public ImmutablePoint(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
// No setters — x and y can never change after construction.
public double distanceTo(ImmutablePoint other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}Inheritance
Inheritance is a mechanism by which a new class (the subclass or child class) acquires all the non-private fields and methods of an existing class (the superclass or parent class). The subclass then extends that foundation by adding new fields and methods or overriding existing methods to change their behaviour.
The relationship established by inheritance is the "is-a" relationship. A Dog is an Animal. A Manager is an Employee. A Circle is a Shape. This relationship must be genuinely true for inheritance to be appropriate — if you cannot truthfully say "X is a Y", you should not use inheritance. Using inheritance where a "has-a" relationship is more appropriate (a Car has-a Engine, not a Car is-an Engine) produces brittle, confusing code and is one of the most common OOP design mistakes.
Java uses the extends keyword for inheritance and supports single inheritance of classes — a class can only extend one superclass. This avoids the "diamond problem" of multiple inheritance. However, a class can implement multiple interfaces, which provides most of the flexibility of multiple inheritance without its complications.
The real power of inheritance comes from the Liskov Substitution Principle (LSP): anywhere the superclass is expected, the subclass should work correctly without the caller needing to know the difference. If a method accepts an Animal, it should work correctly when passed a Dog, a Cat, or any other Animal subclass. Violating LSP — creating a subclass that behaves in unexpected ways or throws exceptions where the superclass does not — undermines the entire OOP design and causes runtime failures that are hard to diagnose.
Java
// ── Base class (superclass): ─────────────────────────────────────────
public class Employee {
private String name;
private double baseSalary;
public Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
public String getName() { return name; }
public double getBaseSalary() { return baseSalary; }
// Method that subclasses will override:
public double calculatePay() {
return baseSalary; // base: just the salary
}
public void printPaySlip() {
System.out.printf("%-15s Base: %8.2f Total: %8.2f%n",
name, baseSalary, calculatePay());
}
}
// ── Subclass — inherits and extends: ─────────────────────────────────
public class Manager extends Employee {
private double bonus;
public Manager(String name, double baseSalary, double bonus) {
super(name, baseSalary); // must call superclass constructor first
this.bonus = bonus;
}
@Override // override superclass method
public double calculatePay() {
return getBaseSalary() + bonus; // adds bonus on top
}
}
// ── Another subclass: ─────────────────────────────────────────────────
public class Contractor extends Employee {
private int hoursWorked;
private double hourlyRate;
public Contractor(String name, int hoursWorked, double hourlyRate) {
super(name, 0); // contractors have no base salary
this.hoursWorked = hoursWorked;
this.hourlyRate = hourlyRate;
}
@Override
public double calculatePay() {
return hoursWorked * hourlyRate; // different pay calculation
}
}
// ── Usage — polymorphism in action: ──────────────────────────────────
List<Employee> payroll = List.of(
new Employee("Alice", 50_000),
new Manager("Bob", 60_000, 15_000),
new Contractor("Carol", 160, 75.0)
);
for (Employee emp : payroll) {
emp.printPaySlip(); // printPaySlip is inherited by all
}
// Output:
// Alice Base: 50000.00 Total: 50000.00
// Bob Base: 60000.00 Total: 75000.00
// Carol Base: 0.00 Total: 12000.00
// ── Inheritance chain: ────────────────────────────────────────────────
// Object ← Employee ← Manager
// ← Employee ← Contractor
// All classes ultimately extend java.lang.Object.
// ── super keyword — access superclass members: ────────────────────────
public class SeniorManager extends Manager {
private double stockOptions;
public SeniorManager(String name, double salary,
double bonus, double stocks) {
super(name, salary, bonus); // call Manager's constructor
this.stockOptions = stocks;
}
@Override
public double calculatePay() {
return super.calculatePay() + stockOptions; // add to Manager's result
}
}Polymorphism
Polymorphism means "many forms" — the same operation behaves differently depending on the type of the object it operates on. It is the principle that allows code to be written in terms of a general type (Shape, Animal, Employee) and work correctly with any specific subtype (Circle, Dog, Manager) without being rewritten.
There are two forms of polymorphism in Java. Compile-time polymorphism (static polymorphism) is achieved through method overloading — multiple methods in the same class with the same name but different parameter lists. The compiler chooses which overload to call based on the argument types at compile time. Runtime polymorphism (dynamic polymorphism) is achieved through method overriding — a subclass provides its own implementation of a method declared in the superclass. The JVM decides which implementation to call at runtime based on the actual type of the object, not the declared type of the variable.
Runtime polymorphism is the more powerful and important form. It is enabled by the virtual dispatch mechanism built into the JVM. When you write shape.area() and shape holds a Circle reference, the JVM looks at the actual type of the object (Circle), finds its area() method, and calls it — even though the variable is declared as Shape. This happens automatically, at runtime, without any if-instanceof checks.
The Open/Closed Principle — one of the SOLID principles of OOP design — states that software should be open for extension but closed for modification. Polymorphism is the mechanism that makes this possible. To add a new Shape, you write a new class — you do not modify any existing code. The loop that calls shape.area() works for the new shape automatically.
Java
// ── Runtime polymorphism — virtual dispatch: ─────────────────────────
public abstract class Animal {
private String name;
public Animal(String name) { this.name = name; }
public String getName() { return name; }
public abstract String speak(); // must be overridden
public void introduce() {
// Calls speak() — which implementation runs depends on actual type:
System.out.println("I am " + name + " and I say: " + speak());
}
}
public class Dog extends Animal {
public Dog(String name) { super(name); }
@Override
public String speak() { return "Woof!"; }
}
public class Cat extends Animal {
public Cat(String name) { super(name); }
@Override
public String speak() { return "Meow!"; }
}
public class Duck extends Animal {
public Duck(String name) { super(name); }
@Override
public String speak() { return "Quack!"; }
}
// ── One variable type, many behaviours: ───────────────────────────────
Animal a1 = new Dog("Rex");
Animal a2 = new Cat("Whiskers");
Animal a3 = new Duck("Donald");
// Same method call, different results based on actual type:
System.out.println(a1.speak()); // Woof! — Dog's speak()
System.out.println(a2.speak()); // Meow! — Cat's speak()
System.out.println(a3.speak()); // Quack! — Duck's speak()
// ── Polymorphism enables extensible loops: ────────────────────────────
List<Animal> animals = List.of(a1, a2, a3, new Dog("Buddy"));
for (Animal animal : animals) {
animal.introduce(); // correct speak() called automatically
}
// Output:
// I am Rex and I say: Woof!
// I am Whiskers and I say: Meow!
// I am Donald and I say: Quack!
// I am Buddy and I say: Woof!
// ── Compile-time polymorphism — method overloading: ───────────────────
public class Printer {
// Same method name, different parameter types:
public void print(int value) {
System.out.println("Integer: " + value);
}
public void print(double value) {
System.out.printf("Double: %.2f%n", value);
}
public void print(String value) {
System.out.println("String: " + value);
}
public void print(int value, int times) {
for (int i = 0; i < times; i++) {
System.out.print(value + " ");
}
System.out.println();
}
}
Printer printer = new Printer();
printer.print(42); // Integer: 42
printer.print(3.14); // Double: 3.14
printer.print("Hello"); // String: Hello
printer.print(7, 3); // 7 7 7
// Compiler picks the correct overload based on argument types.
// ── instanceof and casting: ───────────────────────────────────────────
for (Animal animal : animals) {
if (animal instanceof Dog dog) { // Java 16+ pattern matching
System.out.println(dog.getName() + " is a dog — can fetch!");
}
}Abstraction
Abstraction is the process of simplifying complex systems by exposing only the essential details and hiding the irrelevant ones. In OOP, abstraction means defining what an object does without specifying how it does it. The "what" is captured in abstract classes and interfaces; the "how" is left to concrete implementing classes.
Think of a television remote control. The user interface is abstract: power, volume up, volume down, channel change. The user does not need to know — and should not need to know — about the infrared signal encoding, the carrier frequency, the demodulation circuitry in the television, or the firmware that processes the signal. The abstraction of "press volume up button → volume increases" hides all of that complexity behind a simple interface.
In Java, abstraction is implemented primarily through interfaces and abstract classes. An interface is a pure contract — it defines method signatures without any implementation. Any class that implements the interface must provide implementations for all its methods. An abstract class is a partial implementation — it can have both abstract methods (no body, must be overridden) and concrete methods (with a body, inherited). Abstract classes cannot be instantiated directly.
The Dependency Inversion Principle states that high-level code should depend on abstractions, not on concrete implementations. A service that sends notifications should depend on a Notifier interface, not on a specific EmailNotifier class. This allows the implementation to be swapped — from email to SMS to push notification — without changing the service. Abstraction is the foundation of testability, extensibility, and loose coupling in professional Java code.
Java
// ── Interface — pure abstraction (what, not how): ────────────────────
public interface PaymentProcessor {
boolean processPayment(double amount, String currency);
void refund(String transactionId);
String getProviderName();
}
// ── Multiple concrete implementations: ───────────────────────────────
public class StripeProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount, String currency) {
// Stripe-specific HTTP calls, authentication, error handling:
System.out.printf("Stripe: charging %.2f %s%n", amount, currency);
return true; // simplified
}
@Override
public void refund(String transactionId) {
System.out.println("Stripe: refunding " + transactionId);
}
@Override
public String getProviderName() { return "Stripe"; }
}
public class PayPalProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount, String currency) {
System.out.printf("PayPal: charging %.2f %s%n", amount, currency);
return true;
}
@Override
public void refund(String transactionId) {
System.out.println("PayPal: refunding " + transactionId);
}
@Override
public String getProviderName() { return "PayPal"; }
}
// ── High-level code depends on the abstraction — not the concrete class: ─
public class OrderService {
private final PaymentProcessor processor; // interface type — not Stripe!
public OrderService(PaymentProcessor processor) {
this.processor = processor; // injected — can be anything
}
public void checkout(double amount) {
boolean success = processor.processPayment(amount, "GBP");
if (success) {
System.out.println("Order confirmed via " +
processor.getProviderName());
} else {
System.out.println("Payment failed.");
}
}
}
// ── Swap implementation without changing OrderService: ────────────────
OrderService stripeService = new OrderService(new StripeProcessor());
OrderService paypalService = new OrderService(new PayPalProcessor());
stripeService.checkout(99.99); // Stripe: charging 99.99 GBP → Order confirmed via Stripe
paypalService.checkout(49.99); // PayPal: charging 49.99 GBP → Order confirmed via PayPal
// ── Abstract class — partial abstraction with shared code: ────────────
public abstract class Report {
private String title;
public Report(String title) { this.title = title; }
// Template method — defines the skeleton, uses abstract steps:
public final void generate() {
System.out.println("=== " + title + " ===");
fetchData(); // abstract — each subclass provides data
processData(); // abstract — each subclass processes differently
formatOutput(); // abstract — each subclass formats differently
System.out.println("=== End of Report ===");
}
protected abstract void fetchData();
protected abstract void processData();
protected abstract void formatOutput();
}
public class SalesReport extends Report {
public SalesReport() { super("Sales Report"); }
@Override protected void fetchData() { System.out.println("Fetching sales data..."); }
@Override protected void processData() { System.out.println("Calculating totals..."); }
@Override protected void formatOutput() { System.out.println("Formatting as table..."); }
}Objects, Classes, and Instances
A class is a blueprint or template — it defines what data an object will hold and what operations it can perform. An object is a specific instance created from that blueprint. The distinction is the same as the difference between an architectural drawing and the actual house built from it. You can build many houses from one drawing; each house is independent and has its own state.
In Java, objects are created with the new keyword, which allocates memory on the heap for the object's fields and calls the constructor to initialise them. A reference variable holds the memory address of the object, not the object itself. Two reference variables can point to the same object — changes through one variable are visible through the other because they reference the same underlying object.
The state of an object is the combination of all its field values at a given moment. The behaviour of an object is defined by its methods. The identity of an object is its unique memory address — two objects can have identical state but different identities. Java's == operator compares identity (same memory address) while the equals() method compares state (same content). Understanding this distinction prevents one of the most common Java bugs: comparing strings with == instead of equals().
Java
// ── Class is the blueprint, object is the instance: ──────────────────
public class Car {
// Fields — define the state each object will have:
private String make;
private String model;
private int year;
private int mileage;
// Constructor — initialises a new instance:
public Car(String make, String model, int year) {
this.make = make;
this.model = model;
this.year = year;
this.mileage = 0; // default — new car has no mileage
}
// Methods — define the behaviour:
public void drive(int miles) {
if (miles > 0) mileage += miles;
}
public String getInfo() {
return year + " " + make + " " + model +
" (" + mileage + " miles)";
}
}
// ── Three independent objects from one blueprint: ─────────────────────
Car car1 = new Car("Toyota", "Camry", 2022);
Car car2 = new Car("Honda", "Civic", 2023);
Car car3 = new Car("Ford", "Mustang", 2021);
car1.drive(15000);
car2.drive(8000);
// car3 stays at 0 miles — completely independent
System.out.println(car1.getInfo()); // 2022 Toyota Camry (15000 miles)
System.out.println(car2.getInfo()); // 2023 Honda Civic (8000 miles)
System.out.println(car3.getInfo()); // 2021 Ford Mustang (0 miles)
// ── Reference variables — two variables can point to one object: ───────
Car myCar = new Car("BMW", "X5", 2023);
Car yourCar = myCar; // both variables point to SAME object
myCar.drive(5000);
System.out.println(yourCar.getInfo()); // BMW X5 (5000 miles)
// yourCar shows the change because it references the same object as myCar.
// ── Identity vs equality: ─────────────────────────────────────────────
String s1 = new String("Hello");
String s2 = new String("Hello");
System.out.println(s1 == s2); // false — different objects in memory
System.out.println(s1.equals(s2)); // true — same content
// Same rule applies to all objects:
Car a = new Car("Ford", "Focus", 2020);
Car b = new Car("Ford", "Focus", 2020);
System.out.println(a == b); // false — different objects
// equals() would also return false unless we override it in Car
// to compare field values rather than identity.OOP Design Principles
Beyond the four pillars, professional OOP design is guided by a set of principles that have emerged from decades of industry experience. The most influential are the SOLID principles, each addressing a specific way that OOP systems grow brittle as they scale. These principles are not rules enforced by the compiler — they are guidelines for human judgment, and applying them well is a skill developed through practice and code review.
Single Responsibility Principle: a class should have one, and only one, reason to change. If a class handles both database persistence and email notification, changes to the email system force recompilation of the persistence code. Separating these concerns produces smaller, more focused classes that change for fewer reasons.
Open/Closed Principle: classes should be open for extension but closed for modification. Adding a new payment method should require writing a new class, not modifying an existing one. Polymorphism is the mechanism that makes this possible.
Liskov Substitution Principle: subclasses must be substitutable for their superclass without breaking the program. A method that accepts a Shape must work correctly when passed any Shape subclass without needing to know the specific type.
Interface Segregation Principle: clients should not be forced to depend on methods they do not use. A fat interface that requires implementing twenty methods when only three are needed should be split into smaller, more focused interfaces.
Dependency Inversion Principle: high-level modules should not depend on low-level modules. Both should depend on abstractions. This is why constructor injection with interfaces produces more testable and flexible code than directly instantiating concrete classes.
Java
// ── Single Responsibility Principle: ─────────────────────────────────
// BAD — one class does too much:
public class UserManager {
public void createUser(String name, String email) { /* ... */ }
public void sendWelcomeEmail(String email) { /* ... */ }
public void saveToDatabase(User user) { /* ... */ }
public void logActivity(String action) { /* ... */ }
}
// UserManager changes when: user creation rules change, email changes,
// database schema changes, OR logging format changes. Too many reasons.
// GOOD — each class has one responsibility:
public class UserService { public void createUser(String name, String email) { /* ... */ } }
public class EmailService { public void sendWelcomeEmail(String email) { /* ... */ } }
public class UserRepository { public void save(User user) { /* ... */ } }
public class ActivityLogger { public void log(String action) { /* ... */ } }
// ── Open/Closed Principle: ────────────────────────────────────────────
// BAD — must modify existing class to add new discount type:
public class PriceCalculator {
public double applyDiscount(Order order, String type) {
if (type.equals("STUDENT")) return order.getTotal() * 0.90;
else if (type.equals("SENIOR")) return order.getTotal() * 0.85;
// Adding MILITARY discount requires modifying this class:
else if (type.equals("MILITARY")) return order.getTotal() * 0.80;
else return order.getTotal();
}
}
// GOOD — extend by adding a new class, not modifying existing ones:
public interface DiscountStrategy {
double apply(double total);
}
public class StudentDiscount implements DiscountStrategy {
public double apply(double total) { return total * 0.90; } }
public class SeniorDiscount implements DiscountStrategy {
public double apply(double total) { return total * 0.85; } }
public class MilitaryDiscount implements DiscountStrategy {
public double apply(double total) { return total * 0.80; } }
// Adding a new discount = new class, existing classes untouched.
// ── Dependency Inversion Principle: ──────────────────────────────────
// BAD — tightly coupled to concrete implementation:
public class NotificationService {
private EmailSender emailSender = new EmailSender(); // concrete!
public void notify(String msg) { emailSender.send(msg); }
}
// GOOD — depends on abstraction, concrete type injected from outside:
public class NotificationService {
private final MessageSender sender; // interface — not concrete class
public NotificationService(MessageSender sender) {
this.sender = sender; // injected — easy to swap or mock
}
public void notify(String msg) { sender.send(msg); }
}
// In tests: inject MockMessageSender
// In production: inject EmailSender, SmsSender, or PushNotificationSenderRelated Topics in Object-Oriented Programming
Classes
A class is the fundamental building block of Java. It is a blueprint that defines the structure and behaviour of objects — what data each object holds (fields) and what operations it can perform (methods). Every Java program is composed of classes. Understanding how to design a class well — choosing the right access modifiers, separating state from behaviour, and writing cohesive single-responsibility classes — is the foundation of object-oriented programming in Java. This entry covers class anatomy, fields, methods, access modifiers, static vs instance members, the this keyword, and class design principles.
Objects
An object is a runtime instance of a class. Where a class is a blueprint that exists in source code, an object is a living entity that exists in memory during program execution — it has its own identity, its own state stored in its fields, and the ability to respond to method calls. Every object has three fundamental properties: identity (a unique memory address), state (the current values of its fields), and behaviour (the methods it responds to). This entry covers object identity vs equality, the Object class hierarchy, object state and mutation, method calls, toString, equals and hashCode, and the object lifecycle.
Object Creation
Object creation in Java is the process of allocating memory, initialising fields, and running constructor logic to bring an object into existence. The new keyword is the primary mechanism, but Java also provides factory methods, builder patterns, copy constructors, and object cloning. Constructors are special methods that set up the initial state — their design determines how easy or difficult the class is to use correctly. This entry covers constructors in depth, constructor overloading and chaining, copy constructors, factory methods, the builder pattern, and the difference between shallow and deep copy.
Default Constructor
A default constructor is a constructor that takes no parameters. If you write a class without any constructor, the Java compiler automatically inserts a no-argument constructor that calls the superclass no-argument constructor and does nothing else. The moment you define any constructor yourself — with or without parameters — the compiler no longer inserts the default constructor. Understanding when the default constructor exists, when it disappears, and what it initialises is fundamental to understanding how Java objects come into existence.