Phase 2: Object-Oriented Programming in Java

Welcome to Phase 2
Welcome to the Object-Oriented Programming phase! If you've completed Phase 1, you already understand Java's basic syntax. Now we'll dive deep into OOP principles and see how Java implements them with precision and power.
Coming from languages like Python or JavaScript, you'll find Java's OOP approach more explicit and structured. This isn't overhead—it's clarity that scales to large codebases.
Time commitment: 2 weeks, 1-2 hours daily
Prerequisite: Completed Phase 1 or equivalent Java fundamentals
What You'll Learn
By the end of Phase 2, you'll be able to:
✅ Design classes with proper encapsulation
✅ Use inheritance effectively with method overriding
✅ Implement polymorphism for flexible code
✅ Choose between abstract classes and interfaces
✅ Work with inner and anonymous classes
✅ Override Object class methods correctly
✅ Apply OOP best practices and design principles
Classes and Objects: Deep Dive
Anatomy of a Class
A well-designed Java class encapsulates data and behavior:
public class BankAccount {
// Fields (instance variables)
private String accountNumber;
private double balance;
private String ownerName;
// Constructor
public BankAccount(String accountNumber, String ownerName) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.balance = 0.0;
}
// Methods
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
public boolean withdraw(double amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
return true;
}
return false;
}
// Getter (accessor)
public double getBalance() {
return balance;
}
}Key principles:
- Fields are private (data hiding)
- Constructor initializes state
- Methods provide controlled access
- Business logic is encapsulated
Multiple Constructors (Overloading)
Java allows multiple constructors with different parameters:
public class Employee {
private String name;
private int id;
private double salary;
// Default constructor
public Employee() {
this("Unknown", 0, 0.0);
}
// Constructor with name and id
public Employee(String name, int id) {
this(name, id, 50000.0); // Default salary
}
// Full constructor
public Employee(String name, int id, double salary) {
this.name = name;
this.id = id;
this.salary = salary;
}
}
// Usage
var emp1 = new Employee();
var emp2 = new Employee("Alice", 101);
var emp3 = new Employee("Bob", 102, 75000.0);Constructor chaining with this():
- Must be the first statement in constructor
- Reduces code duplication
- Establishes a "primary" constructor
Initialization Blocks
Java provides initialization blocks for complex setup:
public class Configuration {
private Map<String, String> settings;
// Instance initialization block (runs before constructor)
{
settings = new HashMap<>();
settings.put("timeout", "30");
settings.put("retries", "3");
System.out.println("Instance block executed");
}
// Static initialization block (runs once when class loads)
static {
System.out.println("Configuration class loaded");
// Initialize static resources
}
public Configuration() {
System.out.println("Constructor executed");
}
}Execution order:
- Static initialization blocks (once, when class loads)
- Instance initialization blocks (before each constructor)
- Constructor body
Encapsulation and Access Modifiers
Encapsulation is about controlled access to data. Java has four access levels:
Access Modifier Matrix
public class AccessDemo {
public String publicField; // Accessible everywhere
protected String protectedField; // Accessible in package + subclasses
String packageField; // Package-private (default)
private String privateField; // Only within this class
public void publicMethod() { }
protected void protectedMethod() { }
void packageMethod() { }
private void privateMethod() { }
}| Modifier | Same Class | Same Package | Subclass | Everywhere |
|---|---|---|---|---|
public | ✅ | ✅ | ✅ | ✅ |
protected | ✅ | ✅ | ✅ | ❌ |
| default | ✅ | ✅ | ❌ | ❌ |
private | ✅ | ❌ | ❌ | ❌ |
Best Practice: Encapsulation Pattern
public class User {
// Private fields
private String username;
private String email;
private LocalDateTime createdAt;
// Constructor
public User(String username, String email) {
this.username = username;
this.email = email;
this.createdAt = LocalDateTime.now();
}
// Public getters
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
// No setter for createdAt (immutable after creation)
public LocalDateTime getCreatedAt() {
return createdAt;
}
// Controlled setter with validation
public void setEmail(String email) {
if (email != null && email.contains("@")) {
this.email = email;
} else {
throw new IllegalArgumentException("Invalid email");
}
}
}Why encapsulation matters:
- Control how data is accessed and modified
- Add validation logic
- Change internal implementation without breaking external code
- Hide complexity
Inheritance
Inheritance allows classes to inherit fields and methods from parent classes.
Basic Inheritance
// Parent class
public class Vehicle {
protected String brand;
protected int year;
public Vehicle(String brand, int year) {
this.brand = brand;
this.year = year;
}
public void startEngine() {
System.out.println(brand + " engine started");
}
public void displayInfo() {
System.out.println(year + " " + brand);
}
}
// Child class
public class Car extends Vehicle {
private int numberOfDoors;
public Car(String brand, int year, int numberOfDoors) {
super(brand, year); // Call parent constructor
this.numberOfDoors = numberOfDoors;
}
public void honk() {
System.out.println("Beep beep!");
}
}
// Usage
Car myCar = new Car("Toyota", 2024, 4);
myCar.startEngine(); // Inherited method
myCar.honk(); // Own methodMethod Overriding
Child classes can override parent methods:
public class ElectricCar extends Vehicle {
private int batteryCapacity;
public ElectricCar(String brand, int year, int batteryCapacity) {
super(brand, year);
this.batteryCapacity = batteryCapacity;
}
// Override parent method
@Override
public void startEngine() {
System.out.println(brand + " electric motor activated silently");
}
// Override and extend
@Override
public void displayInfo() {
super.displayInfo(); // Call parent version
System.out.println("Battery: " + batteryCapacity + " kWh");
}
}@Override annotation:
- Compile-time safety (error if method doesn't actually override)
- Documents intent
- Helps refactoring tools
- Always use it!
The super Keyword
public class SportsCar extends Car {
private int topSpeed;
public SportsCar(String brand, int year, int doors, int topSpeed) {
super(brand, year, doors); // Call parent constructor
this.topSpeed = topSpeed;
}
@Override
public void displayInfo() {
super.displayInfo(); // Call parent method
System.out.println("Top speed: " + topSpeed + " mph");
}
}Rules for super():
- Must be the first statement in constructor
- Used to call parent constructor
- If not explicitly called, Java calls
super()automatically
Polymorphism
Polymorphism means "many forms"—the ability to treat objects of different types uniformly.
Compile-Time Polymorphism (Method Overloading)
Same method name, different parameters:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
public int add(int a, int b, int c) {
return a + b + c;
}
public String add(String a, String b) {
return a + b;
}
}
// Usage
Calculator calc = new Calculator();
calc.add(5, 10); // Calls int version
calc.add(5.5, 10.5); // Calls double version
calc.add(1, 2, 3); // Calls three-parameter version
calc.add("Hello", "World"); // Calls String versionRuntime Polymorphism (Method Overriding)
Parent reference can hold child objects:
public class Animal {
public void makeSound() {
System.out.println("Some generic animal sound");
}
}
public class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof! Woof!");
}
}
public class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow!");
}
}
// Polymorphism in action
Animal myDog = new Dog(); // Parent reference, child object
Animal myCat = new Cat();
myDog.makeSound(); // "Woof! Woof!" (Dog's version)
myCat.makeSound(); // "Meow!" (Cat's version)
// Array of different animals
Animal[] animals = {new Dog(), new Cat(), new Dog()};
for (Animal animal : animals) {
animal.makeSound(); // Calls appropriate method at runtime
}Key insight: The method called depends on the actual object type, not the reference type.
Practical Polymorphism Example
public interface PaymentProcessor {
boolean processPayment(double amount);
}
public class CreditCardProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
System.out.println("Processing credit card payment: $" + amount);
// Credit card logic
return true;
}
}
public class PayPalProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
System.out.println("Processing PayPal payment: $" + amount);
// PayPal logic
return true;
}
}
public class CheckoutService {
public void checkout(PaymentProcessor processor, double amount) {
if (processor.processPayment(amount)) {
System.out.println("Payment successful!");
}
}
}
// Usage
CheckoutService checkout = new CheckoutService();
checkout.checkout(new CreditCardProcessor(), 100.0);
checkout.checkout(new PayPalProcessor(), 50.0);This design allows adding new payment methods without changing CheckoutService.
Abstract Classes
Abstract classes provide partial implementation and serve as base classes:
public abstract class Shape {
protected String color;
public Shape(String color) {
this.color = color;
}
// Abstract method (no implementation)
public abstract double calculateArea();
// Concrete method (with implementation)
public void displayColor() {
System.out.println("Color: " + color);
}
// Template method pattern
public final void display() {
displayColor();
System.out.println("Area: " + calculateArea());
}
}
public class Circle extends Shape {
private double radius;
public Circle(String color, double radius) {
super(color);
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
// Usage
Shape circle = new Circle("Red", 5.0);
Shape rectangle = new Rectangle("Blue", 4.0, 6.0);
circle.display(); // "Color: Red\nArea: 78.54"
rectangle.display(); // "Color: Blue\nArea: 24.0"Abstract class rules:
- Cannot be instantiated directly
- Can have both abstract and concrete methods
- Can have constructors, fields, and static methods
- Subclasses must implement all abstract methods
Interfaces
Interfaces define contracts that classes must fulfill:
public interface Drawable {
void draw(); // Implicitly public and abstract
default void show() { // Default method (Java 8+)
System.out.println("Showing drawable");
draw();
}
}
public interface Resizable {
void resize(double factor);
}
// Class implementing multiple interfaces
public class Image implements Drawable, Resizable {
private String filename;
private int width;
private int height;
public Image(String filename, int width, int height) {
this.filename = filename;
this.width = width;
this.height = height;
}
@Override
public void draw() {
System.out.println("Drawing " + filename + " at " + width + "x" + height);
}
@Override
public void resize(double factor) {
width = (int) (width * factor);
height = (int) (height * factor);
}
}Interface Features (Modern Java)
Default methods (Java 8+):
public interface Logger {
void log(String message);
default void logError(String message) {
log("ERROR: " + message);
}
default void logWarning(String message) {
log("WARNING: " + message);
}
}Static methods (Java 8+):
public interface MathUtils {
static int add(int a, int b) {
return a + b;
}
static double calculateCircleArea(double radius) {
return Math.PI * radius * radius;
}
}
// Usage
int sum = MathUtils.add(5, 10);Private methods (Java 9+):
public interface DataProcessor {
default void processData(String data) {
validateData(data); // Call private method
// Process...
}
private void validateData(String data) {
if (data == null || data.isEmpty()) {
throw new IllegalArgumentException("Invalid data");
}
}
}Abstract Classes vs Interfaces
When to use abstract classes:
- Share code among closely related classes
- Need non-static or non-final fields
- Need non-public members
- Want to declare constructors
When to use interfaces:
- Define contracts for unrelated classes
- Support multiple inheritance of behavior
- Expect API to remain stable (can add default methods later)
- Want to achieve loose coupling
Comparison example:
// Abstract class: "is-a" relationship
public abstract class Employee {
protected String name;
protected double salary;
public abstract double calculateBonus();
public void giveRaise(double percentage) {
salary *= (1 + percentage / 100);
}
}
// Interface: "can-do" capability
public interface Trainable {
void attendTraining(String course);
boolean hasCompleted(String course);
}
// Implementation
public class Developer extends Employee implements Trainable {
private List<String> completedCourses = new ArrayList<>();
@Override
public double calculateBonus() {
return salary * 0.15;
}
@Override
public void attendTraining(String course) {
completedCourses.add(course);
}
@Override
public boolean hasCompleted(String course) {
return completedCourses.contains(course);
}
}Pro tip: A class can extend one abstract class but implement multiple interfaces.
The this Keyword
this refers to the current object instance:
public class User {
private String name;
private int age;
// 1. Distinguish field from parameter
public User(String name, int age) {
this.name = name; // this.name is field, name is parameter
this.age = age;
}
// 2. Call another constructor
public User(String name) {
this(name, 0); // Calls User(String, int)
}
// 3. Return current instance (fluent API)
public User setName(String name) {
this.name = name;
return this; // Return current object
}
public User setAge(int age) {
this.age = age;
return this;
}
// 4. Pass current instance to method
public void registerUser(UserService service) {
service.register(this); // Pass current User object
}
}
// Fluent API usage
User user = new User("Alice")
.setName("Alice Johnson")
.setAge(30);Inner Classes and Nested Classes
Member Inner Class
public class OuterClass {
private String outerField = "Outer";
public class InnerClass {
public void display() {
// Can access outer class members
System.out.println(outerField);
}
}
}
// Usage
OuterClass outer = new OuterClass();
OuterClass.InnerClass inner = outer.new InnerClass();
inner.display();Static Nested Class
public class OuterClass {
private static String staticField = "Static";
private String instanceField = "Instance";
public static class StaticNestedClass {
public void display() {
System.out.println(staticField); // OK
// System.out.println(instanceField); // ERROR: can't access instance member
}
}
}
// Usage (no outer instance needed)
OuterClass.StaticNestedClass nested = new OuterClass.StaticNestedClass();
nested.display();Use static nested class when the nested class doesn't need access to outer instance members.
Local Inner Class
public class OuterClass {
public void method() {
final String localVar = "Local";
// Local class inside method
class LocalInnerClass {
public void display() {
System.out.println(localVar); // Can access final local variables
}
}
LocalInnerClass local = new LocalInnerClass();
local.display();
}
}Anonymous Inner Class
Useful for one-time implementations:
public interface Greeting {
void greet(String name);
}
public class Demo {
public void demonstrate() {
// Anonymous class implementing interface
Greeting greeting = new Greeting() {
@Override
public void greet(String name) {
System.out.println("Hello, " + name + "!");
}
};
greeting.greet("Alice");
}
}Modern alternative: Lambda expressions (covered in Phase 4 with Functional Programming):
Greeting greeting = name -> System.out.println("Hello, " + name + "!");Enums
Enums define a fixed set of constants:
Basic Enum
public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
// Usage
Day today = Day.MONDAY;
if (today == Day.SATURDAY || today == Day.SUNDAY) {
System.out.println("Weekend!");
}
// Switch with enums
switch (today) {
case MONDAY -> System.out.println("Start of work week");
case FRIDAY -> System.out.println("TGIF!");
case SATURDAY, SUNDAY -> System.out.println("Weekend!");
default -> System.out.println("Midweek");
}Enum with Fields and Methods
public enum OrderStatus {
PENDING("Order received", 1),
PROCESSING("Being processed", 2),
SHIPPED("Out for delivery", 3),
DELIVERED("Delivered successfully", 4),
CANCELLED("Order cancelled", -1);
private final String description;
private final int priority;
// Constructor (always private)
OrderStatus(String description, int priority) {
this.description = description;
this.priority = priority;
}
public String getDescription() {
return description;
}
public int getPriority() {
return priority;
}
public boolean isActive() {
return priority > 0;
}
}
// Usage
OrderStatus status = OrderStatus.SHIPPED;
System.out.println(status.getDescription()); // "Out for delivery"
System.out.println(status.isActive()); // trueEnum Utilities
// Get all enum values
for (Day day : Day.values()) {
System.out.println(day);
}
// Convert string to enum
Day day = Day.valueOf("MONDAY");
// Get enum name
String name = Day.MONDAY.name(); // "MONDAY"
// Get enum position
int ordinal = Day.MONDAY.ordinal(); // 0Object Class Methods
Every class in Java inherits from Object. Three methods are crucial to override:
equals()
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object obj) {
// 1. Check if same object
if (this == obj) return true;
// 2. Check if null or different class
if (obj == null || getClass() != obj.getClass()) return false;
// 3. Cast and compare fields
Point other = (Point) obj;
return x == other.x && y == other.y;
}
}For Java 17+, use the Objects utility:
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Point other = (Point) obj;
return Objects.equals(x, other.x) && Objects.equals(y, other.y);
}hashCode()
Rule: If two objects are equal (via equals()), they must have the same hash code.
public class Point {
private final int x;
private final int y;
@Override
public boolean equals(Object obj) {
// ... (as above)
}
@Override
public int hashCode() {
return Objects.hash(x, y); // Java 7+ utility
}
}Why it matters: Collections like HashMap and HashSet use hash codes for performance.
toString()
public class Point {
private final int x;
private final int y;
@Override
public String toString() {
return "Point{x=" + x + ", y=" + y + "}";
}
}
// Usage
Point p = new Point(3, 4);
System.out.println(p); // "Point{x=3, y=4}" (instead of "Point@1a2b3c")Pro tip: Most IDEs can generate equals(), hashCode(), and toString() for you.
Record Classes (Java 16+)
For simple data carriers, use records:
public record Point(int x, int y) {}
// Automatically provides:
// - Constructor: new Point(3, 4)
// - Getters: point.x(), point.y()
// - equals(), hashCode(), toString()
// - Immutable fieldsRecords are perfect for DTOs, value objects, and data transfer. See our deep dive on Java Generics for more on records with generics.
Best Practices
1. Favor Composition Over Inheritance
// ❌ Inheritance: tight coupling
public class Car extends Engine {
// Car "is-an" Engine? Doesn't make sense
}
// ✅ Composition: flexible
public class Car {
private Engine engine; // Car "has-an" Engine
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
}
}Why: Composition is more flexible and easier to test.
2. Program to Interfaces, Not Implementations
// ❌ Depends on concrete class
public class OrderProcessor {
private ArrayList<Order> orders; // Tied to ArrayList
}
// ✅ Depends on interface
public class OrderProcessor {
private List<Order> orders; // Can be ArrayList, LinkedList, etc.
public OrderProcessor(List<Order> orders) {
this.orders = orders;
}
}3. Keep Classes Focused (Single Responsibility)
// ❌ Too many responsibilities
public class User {
public void saveToDatabase() { }
public void sendEmail() { }
public void generateReport() { }
public void validateInput() { }
}
// ✅ Single responsibility per class
public class User { /* Just user data */ }
public class UserRepository { /* Database operations */ }
public class EmailService { /* Email operations */ }
public class ReportGenerator { /* Report operations */ }
public class UserValidator { /* Validation logic */ }4. Use Enums Instead of Constants
// ❌ Magic numbers and strings
public static final int STATUS_PENDING = 1;
public static final int STATUS_APPROVED = 2;
public static final int STATUS_REJECTED = 3;
// ✅ Type-safe enums
public enum Status {
PENDING, APPROVED, REJECTED
}5. Override equals() and hashCode() Together
// ❌ Only override equals()
public class User {
@Override
public boolean equals(Object obj) { /* ... */ }
// Missing hashCode()! HashMap/HashSet will break
}
// ✅ Override both
public class User {
@Override
public boolean equals(Object obj) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
}6. Make Classes Immutable When Possible
// ✅ Immutable class
public final class ImmutableUser {
private final String name;
private final String email;
public ImmutableUser(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
// No setters!
}Benefits: Thread-safe, easier to reason about, cacheable.
Common Pitfalls
❌ Breaking Encapsulation with Public Fields
// Bad
public class User {
public String name; // Direct access
public int age;
}
user.age = -5; // No validation!
// Good
public class User {
private String name;
private int age;
public void setAge(int age) {
if (age >= 0) {
this.age = age;
}
}
}❌ Not Using @Override
public class Child extends Parent {
// Typo in method name - won't override!
public void disply() { // Should be display()
// ...
}
}
// ✅ Use @Override - compiler will catch this
@Override
public void display() {
// ...
}❌ Comparing Objects with ==
String s1 = new String("hello");
String s2 = new String("hello");
s1 == s2; // false (different objects)
s1.equals(s2); // true (same content)
// Always use .equals() for objects!❌ Returning Mutable Collections
public class Team {
private List<Player> players = new ArrayList<>();
// ❌ Exposes internal state
public List<Player> getPlayers() {
return players; // Caller can modify!
}
// ✅ Return unmodifiable view
public List<Player> getPlayers() {
return Collections.unmodifiableList(players);
}
// ✅ Or return a copy
public List<Player> getPlayers() {
return new ArrayList<>(players);
}
}❌ Using Inheritance for Code Reuse Only
// ❌ Bad: Car is not a Vehicle that is also a GPS
public class Car extends Vehicle, GPS { // Can't do this anyway!
// ...
}
// ✅ Good: Use composition
public class Car extends Vehicle {
private GPS gps; // Has-a relationship
public Car(GPS gps) {
this.gps = gps;
}
}Practice Exercises
Reinforce your OOP skills with these exercises:
- Bank Account System: Create
Account,SavingsAccount,CheckingAccountclasses with proper inheritance and encapsulation - Shape Calculator: Build abstract
Shapeclass withCircle,Rectangle,Triangleimplementations - Employee Management: Design
Employeeabstract class, implementFullTimeEmployeeandContractorwith polymorphic salary calculation - Card Game: Create
CardandDeckclasses, use enums for suits and ranks - Library System: Design
Book,Member, andLibraryclasses with proper relationships and encapsulation
Summary
You've now mastered Object-Oriented Programming in Java:
✅ Design classes with proper encapsulation
✅ Use inheritance and method overriding effectively
✅ Implement polymorphism for flexible code
✅ Choose between abstract classes and interfaces
✅ Work with inner, nested, and anonymous classes
✅ Use enums for type-safe constants
✅ Override Object methods correctly
✅ Apply OOP best practices and design principles
Next Steps
You're ready to move to Phase 3: Exception Handling & I/O, where you'll learn:
- Exception handling mechanisms
- Try-catch-finally blocks
- Custom exceptions
- File I/O operations
- Serialization
- Try-with-resources
🚀 Continue to Phase 3: Core Java APIs →
Previous: Phase 1: Java Fundamentals ← Next: Phase 3: Core Java APIs → Related: Java Generics Explained →
Happy coding! ☕
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.