Skip to main content
  1. Languages/
  2. Java Guides/

Mastering Java OOP in 2025: Inheritance, Polymorphism, and Modern Design Patterns

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.
Table of Contents

Object-Oriented Programming (OOP) has been the bedrock of enterprise Java development for nearly three decades. However, if you are still writing Java code the way you did in 2015—heavy with getters, setters, rigid inheritance hierarchies, and verbose anonymous inner classes—you are missing out on a revolution.

As we move through 2025, the Java landscape has shifted significantly. With the adoption of Java 21 LTS and subsequent releases, OOP in Java has evolved to embrace Data-Oriented Programming principles. Features like Sealed Classes, Records, and Pattern Matching have fundamentally changed how we model domains, enforce constraints, and implement polymorphism.

In this deep-dive guide, we will move beyond the syntax basics. We will explore how to construct robust object models, avoid the “fragile base class” problem, and modernize classic GoF (Gang of Four) design patterns using functional paradigms and new language features.

Prerequisites and Environment
#

To follow the code examples in this article effectively, ensure your development environment meets the following criteria:

  • JDK: Java 21 LTS or higher (Java 23 recommended for latest preview features).
  • IDE: IntelliJ IDEA 2024.x+, Eclipse 2024+, or VS Code with the latest Java extension pack.
  • Build Tool: Maven 3.9+ or Gradle 8.5+.

Maven Configuration
#

Ensure your pom.xml is configured to use the latest language level:

<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

1. The Evolution of Inheritance: Sealed Classes
#

Inheritance is one of the most powerful yet misused pillars of OOP. Historically, Java developers had only two choices for class extension:

  1. Open: Anyone can extend the class (default).
  2. Closed: No one can extend the class (final).

This binary choice often led to the “Fragile Base Class” problem, where unintended extensions broke the internal contract of a superclass.

The Problem with Unrestricted Inheritance
#

In a complex domain, you might define a Transaction class. If this class is public and non-final, a junior developer or an external library could extend it as HackedTransaction, overriding critical validation logic.

The Solution: Sealed Classes
#

Introduced in Java 17 and refined in Java 21, Sealed Classes provide fine-grained control over the inheritance hierarchy. They allow you to define exactly which classes are permitted to extend a superclass. This bridges the gap between open and final, enabling algebraic data types in Java.

Implementation: A Secure Payment System
#

Let’s model a payment processing system where we strictly control the payment types.

package com.javadevpro.oop.sealed;

import java.math.BigDecimal;

/**
 * The root of our sealed hierarchy.
 * Only CreditCard, PayPal, and Crypto are allowed to extend this.
 */
public sealed interface PaymentMethod 
    permits CreditCard, PayPal, CryptoPayment {
    
    void process(BigDecimal amount);
}

/**
 * A final implementation. No one can extend CreditCard further.
 * This represents a leaf in our hierarchy.
 */
final class CreditCard implements PaymentMethod {
    private final String cardNumber;

    public CreditCard(String cardNumber) {
        this.cardNumber = cardNumber;
    }

    @Override
    public void process(BigDecimal amount) {
        System.out.println("Charging " + amount + " to Card " + cardNumber);
    }
}

/**
 * A sealed sub-implementation.
 * It allows extension, but only by specific classes (BusinessPayPal, PersonalPayPal).
 */
sealed class PayPal implements PaymentMethod 
    permits BusinessPayPal, PersonalPayPal {
    
    protected final String email;

    public PayPal(String email) {
        this.email = email;
    }

    @Override
    public void process(BigDecimal amount) {
        System.out.println("Processing generic PayPal for " + email);
    }
}

final class BusinessPayPal extends PayPal {
    public BusinessPayPal(String email) { super(email); }
}

final class PersonalPayPal extends PayPal {
    public PersonalPayPal(String email) { super(email); }
}

/**
 * A non-sealed implementation.
 * This re-opens the hierarchy for extension by anyone.
 * Use this sparingly!
 */
non-sealed class CryptoPayment implements PaymentMethod {
    @Override
    public void process(BigDecimal amount) {
        System.out.println("Processing crypto payment: " + amount);
    }
}

// Anyone can now extend CryptoPayment
class BitcoinPayment extends CryptoPayment {}

Visualizing the Hierarchy
#

The following diagram illustrates the strict boundaries enforced by the compiler. Notice how CreditCard is a dead-end (final), while PayPal allows controlled extension.

classDiagram class PaymentMethod { <<interface>> <<sealed>> +process(amount) } class CreditCard { <<final>> -cardNumber } class PayPal { <<sealed>> -email } class CryptoPayment { <<non-sealed>> } class BusinessPayPal { <<final>> } class PersonalPayPal { <<final>> } class BitcoinPayment PaymentMethod <|-- CreditCard PaymentMethod <|-- PayPal PaymentMethod <|-- CryptoPayment PayPal <|-- BusinessPayPal PayPal <|-- PersonalPayPal CryptoPayment <|-- BitcoinPayment

Why This Matters in 2025
#

  1. Security: You prevent unauthorized implementations of sensitive interfaces.
  2. Maintainability: You know exactly every subclass that exists. When refactoring the parent, you only need to test the permitted subclasses.
  3. Compiler Support: This enables exhaustive pattern matching (discussed next).

2. Polymorphism 2.0: Pattern Matching
#

Polymorphism allows objects to be treated as instances of their parent class. However, the reverse operation—determining the specific type of an object—used to be verbose and error-prone in Java.

The Old Way (Pre-Java 16)
#

We’ve all written code like this. It is cluttered with casting and repetitive checks.

// LEGACY CODE - DO NOT USE
public void handlePaymentOld(PaymentMethod pm) {
    if (pm instanceof CreditCard) {
        CreditCard cc = (CreditCard) pm;
        // logic...
    } else if (pm instanceof PayPal) {
        PayPal pp = (PayPal) pm;
        // logic...
    }
}

The Modern Way: Pattern Matching for Switch
#

With Java 21, polymorphism meets functional style. Because PaymentMethod is sealed, the compiler knows all possible subtypes. This allows us to use a switch expression that covers all cases without needing a default clause. If you add a new permit to the interface later, the compiler will force you to update this switch statement.

package com.javadevpro.oop.polymorphism;

import com.javadevpro.oop.sealed.*;
import java.math.BigDecimal;

public class PaymentProcessor {

    public String authorize(PaymentMethod pm) {
        // Pattern Matching for Switch
        return switch (pm) {
            case CreditCard cc -> "Authorized Credit Card ending in " + cc.toString(); // assuming toString logic
            
            // We can even use "Guarded Patterns" with the 'when' keyword
            case PayPal pp when pp.toString().contains("enterprise") -> 
                "Authorized Enterprise PayPal: " + pp.toString();
                
            case PayPal pp -> "Authorized Standard PayPal";
            
            case CryptoPayment cp -> "Authorized Crypto Transaction";
            
            // No 'default' needed because the interface is Sealed!
        };
    }
}

Record Patterns
#

Polymorphism becomes even more powerful when combined with Records. Records are immutable data carriers that work seamlessly with destructuring patterns.

// Defining a record
public record Transaction(String id, BigDecimal amount, PaymentMethod method) {}

// Destructuring in a switch
public void logTransaction(Object obj) {
    if (obj instanceof Transaction(String id, BigDecimal amount, PaymentMethod pm)) {
        System.out.printf("Transaction %s for amount %s using %s%n", id, amount, pm.getClass().getSimpleName());
    }
}

3. Composition vs. Inheritance: The Eternal Debate
#

In 2025, the preference for Composition over Inheritance is stronger than ever. Inheritance creates a tight coupling between the parent and child. Changes in the parent ripple down, often causing regressions.

The Liskov Substitution Principle (LSP)
#

The LSP states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

Common Violation: Creating a Square class that inherits from Rectangle. If you change the width of a rectangle, the height remains. If you change the width of a square, the height must change. Thus, Square is not behaviorally a Rectangle.

Strategic Refactoring: Using Interfaces and Delegation
#

Instead of inheriting implementation, define capabilities via interfaces and inject behavior.

Before (Bad Inheritance)
#

public class Report {
    public void generate() { /* logic for PDF */ }
}

public class HtmlReport extends Report {
    @Override
    public void generate() { /* logic for HTML */ }
}

After (Composition & Strategy)
#

We separate the data (ReportData) from the behavior (ExportStrategy).

package com.javadevpro.oop.composition;

import java.util.List;

// 1. The Data
public record ReportData(String title, List<String> rows) {}

// 2. The Capability Interface
public interface ExportStrategy {
    String export(ReportData data);
}

// 3. The Context
public class ReportService {
    private final ExportStrategy exportStrategy;

    // Dependency Injection via Constructor
    public ReportService(ExportStrategy exportStrategy) {
        this.exportStrategy = exportStrategy;
    }

    public void createReport(ReportData data) {
        String result = exportStrategy.export(data);
        System.out.println("Report created: " + result);
    }
}

This adheres to the Open/Closed Principle. To add a CSV export, we create a new class implementing ExportStrategy without modifying ReportService.


4. Modernizing Design Patterns
#

Classic Design Patterns (GoF) are still relevant, but their implementation in Java has become much more concise thanks to Lambdas, Functional Interfaces, and Records.

The Strategy Pattern (Functional Approach)
#

We don’t always need a separate class file for every strategy. Java’s java.util.function package allows us to define strategies inline or as constants.

package com.javadevpro.oop.patterns;

import java.math.BigDecimal;
import java.util.function.UnaryOperator;

public class ModernDiscounts {

    // Strategies defined as constants using Functional Interfaces
    public static final UnaryOperator<BigDecimal> CHRISTMAS_DISCOUNT = 
        amount -> amount.multiply(new BigDecimal("0.90")); // 10% off

    public static final UnaryOperator<BigDecimal> VIP_DISCOUNT = 
        amount -> amount.multiply(new BigDecimal("0.50")); // 50% off

    public static void main(String[] args) {
        BigDecimal total = new BigDecimal("100.00");

        // Apply strategy dynamically
        BigDecimal finalPrice = applyDiscount(total, CHRISTMAS_DISCOUNT);
        System.out.println("Final Price: " + finalPrice);
    }

    public static BigDecimal applyDiscount(BigDecimal amount, UnaryOperator<BigDecimal> discountStrategy) {
        return discountStrategy.apply(amount);
    }
}

The Builder Pattern (Lombok vs. Records)
#

For years, the Builder pattern was boiler-plate heavy. Lombok helped, but in 2025, Records with compact constructors or static factory methods often negate the need for complex builders for data objects.

Comparison Table: Data Object Approaches

Feature POJO (Classic) Lombok @Data Java Record
Mutability Mutable Mutable (usually) Immutable
Boilerplate High Low (requires plugin) Zero
Serialization Standard Standard optimized
Pattern Matching No No Native Support
Builder Needed? Yes @Builder Optional (Compact Ctor)

If you need a Builder for a Record (due to many optional parameters), implement a static inner builder manually or use a library like RecordBuilder. However, for objects with < 5 fields, just use the canonical constructor.


5. Performance and Best Practices
#

While OOP provides structure, it incurs costs. Understanding these is vital for high-frequency systems.

The Cost of Abstraction (Virtual Calls)
#

Every time you call a method on an interface or an abstract class, the JVM performs a vtable (virtual method table) lookup to decide which implementation to execute.

  • Monomorphic Call: The JVM knows there is only one implementation. It can inline the code (Zero overhead).
  • Megamorphic Call: There are many implementations (e.g., a List interface with ArrayList, LinkedList, Vector used randomly). The JVM cannot optimize this easily (Performance hit).

Tip: Sealed classes help the JIT (Just-In-Time) compiler. By limiting subclasses, you help the JVM speculate on optimizations, effectively turning megamorphic calls into bimorphic or polimorphic ones that are easier to optimize.

Avoiding “Deep” Inheritance
#

Deep inheritance trees (e.g., Object -> Entity -> BaseUser -> AuthenticatedUser -> AdminUser -> SuperAdmin) are a performance and maintenance nightmare.

  1. Memory Layout: The object header grows, and initialization becomes complex.
  2. Mental Model: Developers have to traverse 5 files to understand what fields an object has.

Recommendation: Keep hierarchy depth to a maximum of 3 levels. Prefer composition.

Defensive Copying with Records
#

While Records are shallowly immutable, they are not deeply immutable if they contain mutable objects (like ArrayList).

// Dangerous Record
public record UserGroup(String name, List<String> users) {
    // The list 'users' can still be modified by the caller!
}

// Safe Record (Java 21 style)
import java.util.List;
import java.util.Collections;

public record SecureUserGroup(String name, List<String> users) {
    // Compact Constructor
    public SecureUserGroup {
        // Defensive copy and unmodifiable view
        users = List.copyOf(users); 
    }
}

6. Conclusion
#

Mastering Java OOP in 2025 is not about memorizing syntax; it is about architecture and constraints.

By adopting Sealed Classes, you define clear boundaries for your domain models. By utilizing Pattern Matching, you write declarative, readable logic that replaces brittle casting. By integrating Functional Programming with OOP, you reduce boilerplate and focus on business logic.

Key Takeaways:

  1. Seal your hierarchies: Default to final classes or sealed interfaces. Open inheritance should be an opt-in decision.
  2. Embrace Records: Use Records for all data-carrying classes to gain immutability and pattern matching benefits.
  3. Refactor Legacy Patterns: Replace verbose Strategy or Factory classes with functional interfaces and static methods.
  4. Prioritize Composition: Keep inheritance flat and use dependency injection for behavior.

Java is no longer just the “verbose enterprise language.” It is a modern, expressive toolset that, when used correctly, offers the perfect balance between structure and flexibility.


Further Reading
#