Java Table of Content




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

required
required


Leave a Reply

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

Java Sealed Class

1. What Are Sealed Classes?

Imagine you own a VIP club. You do not let just anyone walk in — you have a guest list. Only people whose names appear on that list are allowed through the door. If someone’s name is not on the list, they cannot enter, no exceptions. A sealed class in Java works exactly like that guest list: it explicitly declares which classes are permitted to extend it, and no one else can.

Before sealed classes, Java gave you two extremes for controlling inheritance:

  • Open (public class) — Any class, anywhere, can extend it. No control at all. It is like having no door at your club.
  • Closed (final class) — Nobody can extend it. Period. The door is locked permanently.

Sealed classes fill the gap between these two extremes. They give you controlled inheritance — you decide exactly which classes are allowed to extend, and the compiler enforces it.

Modifier Who Can Extend? Analogy
public class Anyone, from anywhere Open park — everyone is welcome
sealed class Only classes listed in the permits clause VIP club — guest list only
final class Nobody Vault — sealed shut permanently

Why Were Sealed Classes Introduced?

Sealed classes were introduced as a preview feature in Java 15 (JEP 360), refined in Java 16 (JEP 397), and became a permanent feature in Java 17 (JEP 409). They were introduced for several important reasons:

  • Controlled inheritance hierarchies — The class author can declare the complete set of subclasses, making the type hierarchy a known, fixed set rather than an open-ended one.
  • Exhaustive pattern matching — When the compiler knows all possible subtypes, it can verify that a switch expression handles every case without needing a default branch.
  • Better domain modeling — Real-world domains often have a fixed set of variants. A payment can be Credit Card, Debit Card, or Bank Transfer — not “anything you want.” Sealed classes let you model this precisely.
  • Compiler-enforced completeness — If you add a new permitted subclass later, the compiler flags every switch that does not handle the new type. No more forgotten cases hiding as runtime bugs.
// Before sealed classes: no way to control who extends Shape
public class Shape { }

// Anyone can do this -- you have no control
class Hexagon extends Shape { }
class RandomShape extends Shape { }

// With sealed classes: you control exactly who can extend Shape
public sealed class Shape permits Circle, Rectangle, Triangle { }

// Only these three classes can extend Shape
final class Circle extends Shape { }
final class Rectangle extends Shape { }
final class Triangle extends Shape { }

// This will NOT compile:
// class Hexagon extends Shape { }
// Error: class is not allowed to extend sealed class 'Shape'

2. Sealed Class Syntax

The syntax for sealed classes introduces two new keywords: sealed and permits. The sealed modifier goes on the parent class, and the permits clause lists every class that is allowed to extend it.

Basic Declaration

The general syntax is:

// Syntax: sealed class ClassName permits SubClass1, SubClass2, ... { }

public sealed class Vehicle permits Car, Truck, Motorcycle {

    private String make;
    private String model;
    private int year;

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

    public String getMake() { return make; }
    public String getModel() { return model; }
    public int getYear() { return year; }

    @Override
    public String toString() {
        return year + " " + make + " " + model;
    }
}

Rules for Sealed Classes

There are several important rules that the compiler enforces:

# Rule What Happens If Violated
1 The permits clause must list all direct subclasses Compile error — unlisted class cannot extend
2 Every permitted subclass must directly extend the sealed class Compile error — indirect extension not counted
3 Every permitted subclass must declare one of: final, sealed, or non-sealed Compile error — subclass must declare its own extension policy
4 Permitted subclasses must be in the same module (or same package if no module) Compile error — cannot permit classes from other modules/packages
5 Permitted subclasses cannot be anonymous or local classes Compile error
6 If all permitted subclasses are in the same file, the permits clause can be omitted Compiler infers it from the file contents

Implicit permits Clause

When all subclasses are declared in the same source file as the sealed class, you can omit the permits clause entirely. The compiler infers the list of permitted subclasses from the file.

// File: Shape.java
// All subclasses are in the same file, so 'permits' is optional

public sealed class Shape {
    // permits is inferred: Circle, Rectangle, Triangle
}

final class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double area() { return Math.PI * radius * radius; }
}

final class Rectangle extends Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double area() { return width * height; }
}

final class Triangle extends Shape {
    private final double base, height;
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
    public double area() { return 0.5 * base * height; }
}

3. final, sealed, and non-sealed Subclasses

Every class that extends a sealed class must declare its own extension policy. It must be one of three things: final, sealed, or non-sealed. This is a requirement, not a suggestion — the compiler enforces it.

Think of it as a chain of responsibility: the sealed parent class says “only these kids can inherit from me,” and then each kid must decide how it handles its own children.

Modifier Meaning When to Use
final No further extension allowed. The hierarchy stops here. When the subclass is a concrete leaf node with no need for further specialization.
sealed Further extension is allowed, but only to a specific set of sub-subclasses. When you need a multi-level hierarchy but still want full control at every level.
non-sealed Opens the hierarchy back up. Anyone can extend this class freely. When a subclass represents a category that others should be able to extend (e.g., a plugin system).

3.1 final Subclasses — Stop the Hierarchy

A final subclass is a dead end. No other class can extend it. This is the most common choice for sealed hierarchies because most leaf types do not need further specialization.

public sealed class Notification permits EmailNotification, SmsNotification, PushNotification {

    private final String message;
    private final String recipient;

    public Notification(String message, String recipient) {
        this.message = message;
        this.recipient = recipient;
    }

    public String getMessage() { return message; }
    public String getRecipient() { return recipient; }
}

// final -- nobody can extend EmailNotification
final class EmailNotification extends Notification {
    private final String subject;

    public EmailNotification(String message, String recipient, String subject) {
        super(message, recipient);
        this.subject = subject;
    }

    public String getSubject() { return subject; }

    @Override
    public String toString() {
        return "Email to " + getRecipient() + ": [" + subject + "] " + getMessage();
    }
}

// final -- nobody can extend SmsNotification
final class SmsNotification extends Notification {
    private final String phoneNumber;

    public SmsNotification(String message, String recipient, String phoneNumber) {
        super(message, recipient);
        this.phoneNumber = phoneNumber;
    }

    public String getPhoneNumber() { return phoneNumber; }

    @Override
    public String toString() {
        return "SMS to " + phoneNumber + ": " + getMessage();
    }
}

// final -- nobody can extend PushNotification
final class PushNotification extends Notification {
    private final String deviceToken;

    public PushNotification(String message, String recipient, String deviceToken) {
        super(message, recipient);
        this.deviceToken = deviceToken;
    }

    @Override
    public String toString() {
        return "Push to device " + deviceToken + ": " + getMessage();
    }
}

3.2 sealed Subclasses — Controlled Multi-Level Hierarchy

A sealed subclass extends the sealed parent and is itself sealed, declaring its own set of permitted subclasses. This creates a multi-level hierarchy where every level is fully controlled.

// Level 1: sealed parent
public sealed class Account permits PersonalAccount, BusinessAccount {
    private final String id;
    private double balance;

    public Account(String id, double balance) {
        this.id = id;
        this.balance = balance;
    }

    public String getId() { return id; }
    public double getBalance() { return balance; }
    protected void setBalance(double balance) { this.balance = balance; }
}

// Level 2: sealed subclass -- further controlled extension
sealed class PersonalAccount extends Account permits CheckingAccount, SavingsAccount {
    private final String ownerName;

    public PersonalAccount(String id, double balance, String ownerName) {
        super(id, balance);
        this.ownerName = ownerName;
    }

    public String getOwnerName() { return ownerName; }
}

// Level 3: final leaf nodes
final class CheckingAccount extends PersonalAccount {
    private final double overdraftLimit;

    public CheckingAccount(String id, double balance, String ownerName, double overdraftLimit) {
        super(id, balance, ownerName);
        this.overdraftLimit = overdraftLimit;
    }

    public double getOverdraftLimit() { return overdraftLimit; }
}

final class SavingsAccount extends PersonalAccount {
    private final double interestRate;

    public SavingsAccount(String id, double balance, String ownerName, double interestRate) {
        super(id, balance, ownerName);
        this.interestRate = interestRate;
    }

    public double getInterestRate() { return interestRate; }
}

// Level 2: final leaf -- no further specialization needed
final class BusinessAccount extends Account {
    private final String companyName;
    private final String taxId;

    public BusinessAccount(String id, double balance, String companyName, String taxId) {
        super(id, balance);
        this.companyName = companyName;
        this.taxId = taxId;
    }

    public String getCompanyName() { return companyName; }
    public String getTaxId() { return taxId; }
}

3.3 non-sealed Subclasses — Open the Door Back Up

A non-sealed subclass breaks out of the sealed hierarchy and allows unrestricted extension. Use this when one branch of your hierarchy needs to be open for extension — for example, when you want to allow plugins or user-defined types.

Warning: Once you use non-sealed, you lose the exhaustiveness guarantee for that branch. The compiler cannot know all subtypes of a non-sealed class, so switch expressions over the sealed parent will require a default branch if they need to handle unknown subtypes.

public sealed class Transport permits Train, Bus, CustomTransport {
    private final String name;
    private final int capacity;

    public Transport(String name, int capacity) {
        this.name = name;
        this.capacity = capacity;
    }

    public String getName() { return name; }
    public int getCapacity() { return capacity; }
}

// final -- no further extension
final class Train extends Transport {
    private final int carriages;
    public Train(String name, int capacity, int carriages) {
        super(name, capacity);
        this.carriages = carriages;
    }
    public int getCarriages() { return carriages; }
}

// final -- no further extension
final class Bus extends Transport {
    private final String route;
    public Bus(String name, int capacity, String route) {
        super(name, capacity);
        this.route = route;
    }
    public String getRoute() { return route; }
}

// non-sealed -- open for extension by anyone
non-sealed class CustomTransport extends Transport {
    public CustomTransport(String name, int capacity) {
        super(name, capacity);
    }
}

// Now anyone can extend CustomTransport freely
class ElectricScooter extends CustomTransport {
    private final int range;
    public ElectricScooter(String name, int capacity, int range) {
        super(name, capacity);
        this.range = range;
    }
    public int getRange() { return range; }
}

class Rickshaw extends CustomTransport {
    public Rickshaw(String name) {
        super(name, 2);
    }
}

4. Sealed Interfaces

Sealed classes are not limited to classes alone — interfaces can be sealed too. A sealed interface declares which classes or interfaces are permitted to implement or extend it. The syntax and rules are virtually identical to sealed classes.

Sealed interfaces are especially powerful because interfaces represent contracts. When you seal an interface, you are saying: “Here is the contract, and here is the exhaustive list of types that will ever fulfill it.”

Why Seal an Interface?

  • APIs with fixed implementations — You know exactly which types implement the interface and want to prevent external implementations.
  • Algebraic data types — Model a value that can be one of a fixed set of variants, each potentially carrying different data.
  • Exhaustive switch expressions — The compiler can verify that all implementations are handled.
// Sealed interface -- only these three implementations are allowed
public sealed interface Payment permits CreditCardPayment, DebitCardPayment, BankTransferPayment {

    double getAmount();
    String getCurrency();
    String describe();
}

// Each implementation is final
final class CreditCardPayment implements Payment {
    private final double amount;
    private final String currency;
    private final String cardNumber;    // last 4 digits only
    private final String cardHolder;

    public CreditCardPayment(double amount, String currency, String cardNumber, String cardHolder) {
        this.amount = amount;
        this.currency = currency;
        this.cardNumber = cardNumber;
        this.cardHolder = cardHolder;
    }

    @Override public double getAmount() { return amount; }
    @Override public String getCurrency() { return currency; }

    @Override
    public String describe() {
        return "Credit Card payment of " + currency + " " + amount
               + " from card ending in " + cardNumber + " (" + cardHolder + ")";
    }
}

final class DebitCardPayment implements Payment {
    private final double amount;
    private final String currency;
    private final String bankName;

    public DebitCardPayment(double amount, String currency, String bankName) {
        this.amount = amount;
        this.currency = currency;
        this.bankName = bankName;
    }

    @Override public double getAmount() { return amount; }
    @Override public String getCurrency() { return currency; }

    @Override
    public String describe() {
        return "Debit Card payment of " + currency + " " + amount + " via " + bankName;
    }
}

final class BankTransferPayment implements Payment {
    private final double amount;
    private final String currency;
    private final String bankAccountNumber;
    private final String routingNumber;

    public BankTransferPayment(double amount, String currency, String bankAccountNumber, String routingNumber) {
        this.amount = amount;
        this.currency = currency;
        this.bankAccountNumber = bankAccountNumber;
        this.routingNumber = routingNumber;
    }

    @Override public double getAmount() { return amount; }
    @Override public String getCurrency() { return currency; }

    @Override
    public String describe() {
        return "Bank Transfer of " + currency + " " + amount
               + " to account " + bankAccountNumber + " (routing: " + routingNumber + ")";
    }
}

Sealed Interface Extending Another Sealed Interface

A sealed interface can be permitted to extend another sealed interface, creating a multi-level hierarchy of interfaces.

// Top-level sealed interface
public sealed interface Shape permits Shape2D, Shape3D {
    String name();
}

// Sealed sub-interface for 2D shapes
sealed interface Shape2D extends Shape permits Circle, Rectangle {
    double area();
    double perimeter();
}

// Sealed sub-interface for 3D shapes
sealed interface Shape3D extends Shape permits Sphere, Cube {
    double volume();
    double surfaceArea();
}

// 2D implementations
final class Circle implements Shape2D {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }

    @Override public String name() { return "Circle"; }
    @Override public double area() { return Math.PI * radius * radius; }
    @Override public double perimeter() { return 2 * Math.PI * radius; }
}

final class Rectangle implements Shape2D {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override public String name() { return "Rectangle"; }
    @Override public double area() { return width * height; }
    @Override public double perimeter() { return 2 * (width + height); }
}

// 3D implementations
final class Sphere implements Shape3D {
    private final double radius;
    public Sphere(double radius) { this.radius = radius; }

    @Override public String name() { return "Sphere"; }
    @Override public double volume() { return (4.0 / 3.0) * Math.PI * Math.pow(radius, 3); }
    @Override public double surfaceArea() { return 4 * Math.PI * radius * radius; }
}

final class Cube implements Shape3D {
    private final double side;
    public Cube(double side) { this.side = side; }

    @Override public String name() { return "Cube"; }
    @Override public double volume() { return Math.pow(side, 3); }
    @Override public double surfaceArea() { return 6 * side * side; }
}

5. Sealed Classes with Records

Records (introduced in Java 16) and sealed classes (Java 17) are a perfect combination. Records are implicitly final, which means they automatically satisfy the requirement that a permitted subclass must be final, sealed, or non-sealed. This pairing gives you immutable, data-carrying leaf nodes in your sealed hierarchy — exactly what you want for algebraic data types.

The combination of sealed interfaces with record implementations gives you:

  • A closed set of variants — the sealed interface defines what types are possible.
  • Immutable data carriers — each record holds its own data without boilerplate.
  • Automatic equals/hashCode/toString — records generate these for you.
  • Pattern matching support — records can be deconstructed in switch expressions (Java 21+).
// Sealed interface + records = algebraic data type
public sealed interface Shape
        permits Circle, Rectangle, Triangle {

    double area();
}

// Records are implicitly final -- perfect for sealed hierarchies
record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }
}

record Triangle(double base, double height) implements Shape {
    @Override
    public double area() {
        return 0.5 * base * height;
    }
}

// Usage
public class ShapeDemo {
    public static void main(String[] args) {
        Shape circle = new Circle(5.0);
        Shape rectangle = new Rectangle(4.0, 6.0);
        Shape triangle = new Triangle(3.0, 8.0);

        System.out.println(circle);      // Circle[radius=5.0]
        System.out.println(rectangle);   // Rectangle[width=4.0, height=6.0]
        System.out.println(triangle);    // Triangle[base=3.0, height=8.0]

        System.out.println("Circle area: " + circle.area());       // Circle area: 78.53981633974483
        System.out.println("Rectangle area: " + rectangle.area()); // Rectangle area: 24.0
        System.out.println("Triangle area: " + triangle.area());   // Triangle area: 12.0
    }
}

A Richer Example: Result Type

One of the most practical uses of sealed interfaces with records is building a Result type — a value that represents either success or failure. This is a common pattern in functional programming languages like Rust (Result<T, E>) and Kotlin (Result<T>).

// A generic Result type using sealed interface + records
public sealed interface Result permits Result.Success, Result.Failure {

    record Success(T value) implements Result { }
    record Failure(String errorMessage, Exception cause) implements Result {
        // Convenience constructor when there is no cause
        Failure(String errorMessage) {
            this(errorMessage, null);
        }
    }
}

// A service that returns Result instead of throwing exceptions
class UserService {

    public static Result findUserEmail(int userId) {
        if (userId <= 0) {
            return new Result.Failure<>("Invalid user ID: " + userId);
        }
        if (userId == 42) {
            return new Result.Success<>("john.doe@example.com");
        }
        return new Result.Failure<>("User not found with ID: " + userId);
    }

    public static void main(String[] args) {
        // Process the result using pattern matching (Java 21+)
        Result result = findUserEmail(42);

        switch (result) {
            case Result.Success s ->
                System.out.println("Found email: " + s.value());
            case Result.Failure f ->
                System.out.println("Error: " + f.errorMessage());
        }
        // Output: Found email: john.doe@example.com

        Result error = findUserEmail(999);

        switch (error) {
            case Result.Success s ->
                System.out.println("Found email: " + s.value());
            case Result.Failure f ->
                System.out.println("Error: " + f.errorMessage());
        }
        // Output: Error: User not found with ID: 999
    }
}

6. Pattern Matching with Sealed Classes

This is where sealed classes truly shine. Because the compiler knows every possible subtype of a sealed class or interface, it can verify that a switch expression is exhaustive — meaning it handles every case. If you forget one, the code will not compile. If you add a new permitted subclass later, the compiler will flag every switch that does not handle the new type. No more “I forgot to handle that case” bugs discovered at 2 AM in production.

6.1 Exhaustive switch Expressions

When you use a sealed type in a switch expression, you do not need a default branch — the compiler knows all possibilities. This is a massive improvement over traditional polymorphism, where you would need either a default or a Visitor pattern to ensure completeness.

public sealed interface Shape permits Circle, Rectangle, Triangle { }

record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

public class PatternMatchingDemo {

    // Exhaustive switch -- no default needed!
    // The compiler knows Shape can only be Circle, Rectangle, or Triangle.
    public static double calculateArea(Shape shape) {
        return switch (shape) {
            case Circle c    -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t  -> 0.5 * t.base() * t.height();
            // No default needed -- all cases are covered
        };
    }

    public static String describe(Shape shape) {
        return switch (shape) {
            case Circle c    -> "A circle with radius " + c.radius();
            case Rectangle r -> "A " + r.width() + " x " + r.height() + " rectangle";
            case Triangle t  -> "A triangle with base " + t.base() + " and height " + t.height();
        };
    }

    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5.0),
            new Rectangle(4.0, 6.0),
            new Triangle(3.0, 8.0)
        };

        for (Shape shape : shapes) {
            System.out.println(describe(shape) + " -> area = " + calculateArea(shape));
        }
        // Output:
        // A circle with radius 5.0 -> area = 78.53981633974483
        // A 4.0 x 6.0 rectangle -> area = 24.0
        // A triangle with base 3.0 and height 8.0 -> area = 12.0
    }
}

6.2 Compiler Safety When Adding New Subtypes

Here is the real power: when you add a new permitted subclass to the sealed hierarchy, the compiler will produce errors at every switch that does not handle the new case. This is compile-time safety that was previously impossible without the Visitor pattern.

// Step 1: Original hierarchy
public sealed interface Shape permits Circle, Rectangle, Triangle { }

record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

// Step 2: You add a new shape
// public sealed interface Shape permits Circle, Rectangle, Triangle, Pentagon { }
// record Pentagon(double side) implements Shape { }

// Step 3: The compiler immediately flags this method:
//
// public static double calculateArea(Shape shape) {
//     return switch (shape) {
//         case Circle c    -> Math.PI * c.radius() * c.radius();
//         case Rectangle r -> r.width() * r.height();
//         case Triangle t  -> 0.5 * t.base() * t.height();
//         // COMPILE ERROR: "the switch expression does not cover all possible input values"
//         // You MUST add: case Pentagon p -> ...
//     };
// }
//
// This forces you to handle the new case everywhere. No forgotten cases!

6.3 Guarded Patterns (when clause)

Java 21 introduced guarded patterns using the when keyword. You can add conditions to pattern matching cases to create more specific branches within a type match.

public sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

public class GuardedPatterns {

    public static String classify(Shape shape) {
        return switch (shape) {
            case Circle c when c.radius() > 100    -> "Large circle";
            case Circle c when c.radius() > 10     -> "Medium circle";
            case Circle c                          -> "Small circle";

            case Rectangle r when r.width() == r.height() -> "Square (" + r.width() + " x " + r.height() + ")";
            case Rectangle r                              -> "Rectangle (" + r.width() + " x " + r.height() + ")";

            case Triangle t when t.base() == t.height()   -> "Isoceles-ish triangle";
            case Triangle t                               -> "Triangle";
        };
    }

    public static void main(String[] args) {
        System.out.println(classify(new Circle(5)));        // Small circle
        System.out.println(classify(new Circle(50)));       // Medium circle
        System.out.println(classify(new Circle(200)));      // Large circle
        System.out.println(classify(new Rectangle(5, 5)));  // Square (5.0 x 5.0)
        System.out.println(classify(new Rectangle(4, 7)));  // Rectangle (4.0 x 7.0)
        System.out.println(classify(new Triangle(6, 6)));   // Isoceles-ish triangle
    }
}

6.4 instanceof Pattern Matching

Sealed classes also work with instanceof pattern matching (Java 16+). While switch is usually more concise, instanceof is useful when you need to check a single type in an if statement.

public sealed interface Event permits ClickEvent, KeyEvent, ScrollEvent { }
record ClickEvent(int x, int y, String button) implements Event { }
record KeyEvent(char key, boolean ctrlPressed) implements Event { }
record ScrollEvent(int delta) implements Event { }

public class InstanceofDemo {

    public static void handleEvent(Event event) {
        // instanceof with pattern matching
        if (event instanceof ClickEvent click) {
            System.out.println("Mouse clicked at (" + click.x() + ", " + click.y()
                             + ") with " + click.button() + " button");
        } else if (event instanceof KeyEvent key && key.ctrlPressed()) {
            System.out.println("Ctrl+" + key.key() + " pressed -- shortcut detected!");
        } else if (event instanceof KeyEvent key) {
            System.out.println("Key pressed: " + key.key());
        } else if (event instanceof ScrollEvent scroll) {
            String direction = scroll.delta() > 0 ? "up" : "down";
            System.out.println("Scrolled " + direction + " by " + Math.abs(scroll.delta()) + " units");
        }
    }

    public static void main(String[] args) {
        handleEvent(new ClickEvent(100, 200, "left"));
        // Output: Mouse clicked at (100, 200) with left button

        handleEvent(new KeyEvent('S', true));
        // Output: Ctrl+S pressed -- shortcut detected!

        handleEvent(new KeyEvent('A', false));
        // Output: Key pressed: A

        handleEvent(new ScrollEvent(-3));
        // Output: Scrolled down by 3 units
    }
}

7. Sealed Classes vs Enums

At first glance, sealed classes and enums seem to solve the same problem: representing a fixed set of types. But they serve different purposes and have different strengths. Understanding when to use each is important.

Feature Enum Sealed Class/Interface
Number of instances Fixed set of singleton constants Unlimited instances of each subtype
Data per instance Same fields for all constants (set at declaration) Different fields per subtype (set at construction)
Inheritance Cannot extend other classes (implicitly extends Enum) Can extend classes and implement interfaces
Behavior per variant Can override methods per constant (anonymous class body) Each subclass has its own methods and fields
Pattern matching Match on constant values Match on type + deconstruct data from fields
Generics Cannot be parameterized Can use generics freely
Serialization Built-in (name-based) Standard Java serialization rules apply
When to use Simple, fixed set of related constants Fixed set of types, each carrying different data

The Key Difference: Data

The fundamental question is: does each variant carry different data?

  • If all variants have the same structure (or no data at all), use an enum. Example: days of the week, compass directions, log levels.
  • If each variant has different fields, use a sealed class or interface. Example: a Shape where Circle has radius, Rectangle has width and height, and Triangle has base and height.
// USE AN ENUM: all variants have the same structure (or no data)
public enum Direction {
    NORTH, SOUTH, EAST, WEST;

    public Direction opposite() {
        return switch (this) {
            case NORTH -> SOUTH;
            case SOUTH -> NORTH;
            case EAST  -> WEST;
            case WEST  -> EAST;
        };
    }
}

// USE AN ENUM: simple status with same fields
public enum OrderStatus {
    PENDING("Order placed"),
    PROCESSING("Being prepared"),
    SHIPPED("On the way"),
    DELIVERED("Arrived");

    private final String description;
    OrderStatus(String description) { this.description = description; }
    public String getDescription() { return description; }
}

// USE A SEALED TYPE: each variant carries DIFFERENT data
public sealed interface PaymentMethod permits CreditCard, PayPal, Crypto { }

record CreditCard(String cardNumber, String expiry, String cvv) implements PaymentMethod { }
record PayPal(String email) implements PaymentMethod { }
record Crypto(String walletAddress, String coin) implements PaymentMethod { }

// Each variant has completely different fields.
// An enum cannot model this cleanly.

8. Sealed Classes and the Visitor Pattern

The Visitor pattern has long been the go-to solution in Java for adding operations to a type hierarchy without modifying the types themselves. However, the Visitor pattern is notoriously verbose: you need a Visitor interface, an accept() method on every type, and a concrete visitor class for every operation. Sealed classes combined with pattern matching give you the same compile-time exhaustiveness guarantee with far less ceremony.

Traditional Visitor Pattern (Before Sealed Classes)

// Step 1: Define the Visitor interface with a method per type
interface ShapeVisitor {
    R visitCircle(Circle circle);
    R visitRectangle(Rectangle rectangle);
    R visitTriangle(Triangle triangle);
}

// Step 2: Define the hierarchy with accept() on each type
abstract class Shape {
    abstract  R accept(ShapeVisitor visitor);
}

class Circle extends Shape {
    final double radius;
    Circle(double radius) { this.radius = radius; }

    @Override
     R accept(ShapeVisitor visitor) { return visitor.visitCircle(this); }
}

class Rectangle extends Shape {
    final double width, height;
    Rectangle(double width, double height) { this.width = width; this.height = height; }

    @Override
     R accept(ShapeVisitor visitor) { return visitor.visitRectangle(this); }
}

class Triangle extends Shape {
    final double base, height;
    Triangle(double base, double height) { this.base = base; this.height = height; }

    @Override
     R accept(ShapeVisitor visitor) { return visitor.visitTriangle(this); }
}

// Step 3: Implement a visitor for each operation
class AreaCalculator implements ShapeVisitor {
    @Override public Double visitCircle(Circle c) { return Math.PI * c.radius * c.radius; }
    @Override public Double visitRectangle(Rectangle r) { return r.width * r.height; }
    @Override public Double visitTriangle(Triangle t) { return 0.5 * t.base * t.height; }
}

// Usage:
Shape shape = new Circle(5);
double area = shape.accept(new AreaCalculator());  // 78.539...

Modern Approach with Sealed Classes + Pattern Matching

With sealed types and switch expressions, you achieve the same compile-time safety with dramatically less code. No visitor interface, no accept() methods, no boilerplate.

// The entire hierarchy in a few lines
public sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

// Operations are just methods with switch expressions -- no Visitor needed
public class ShapeOperations {

    public static double area(Shape shape) {
        return switch (shape) {
            case Circle c    -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t  -> 0.5 * t.base() * t.height();
        };
    }

    public static double perimeter(Shape shape) {
        return switch (shape) {
            case Circle c    -> 2 * Math.PI * c.radius();
            case Rectangle r -> 2 * (r.width() + r.height());
            case Triangle t  -> t.base() + t.height()
                              + Math.sqrt(t.base() * t.base() + t.height() * t.height());
        };
    }

    public static String svg(Shape shape) {
        return switch (shape) {
            case Circle c    -> "";
            case Rectangle r -> "";
            case Triangle t  -> "";
        };
    }

    public static void main(String[] args) {
        Shape shape = new Circle(10);
        System.out.println("Area: " + area(shape));           // Area: 314.1592653589793
        System.out.println("Perimeter: " + perimeter(shape)); // Perimeter: 62.83185307179586
        System.out.println("SVG: " + svg(shape));             // SVG: 
    }
}

// Compare:
// Visitor pattern: ~50 lines of infrastructure + operation code
// Sealed + switch:  ~30 lines total, including all three operations
// Same compile-time safety. Far less code.

9. Real-World Use Cases

Sealed classes are not just an academic exercise. They solve real problems in production code. Here are several practical use cases where sealed types improve code quality, safety, and readability.

9.1 State Machines (Order Processing)

An order in an e-commerce system moves through a fixed set of states. Each state carries different data. Sealed types model this perfectly.

import java.time.LocalDateTime;

public sealed interface OrderState
        permits OrderState.Pending, OrderState.Confirmed, OrderState.Shipped,
                OrderState.Delivered, OrderState.Cancelled {

    record Pending(LocalDateTime createdAt) implements OrderState { }

    record Confirmed(LocalDateTime confirmedAt, String paymentId) implements OrderState { }

    record Shipped(LocalDateTime shippedAt, String trackingNumber, String carrier)
            implements OrderState { }

    record Delivered(LocalDateTime deliveredAt, String signedBy) implements OrderState { }

    record Cancelled(LocalDateTime cancelledAt, String reason) implements OrderState { }
}

class OrderProcessor {

    public static String getStatusMessage(OrderState state) {
        return switch (state) {
            case OrderState.Pending p ->
                "Order placed on " + p.createdAt() + ". Awaiting confirmation.";
            case OrderState.Confirmed c ->
                "Order confirmed on " + c.confirmedAt() + ". Payment ID: " + c.paymentId();
            case OrderState.Shipped s ->
                "Shipped via " + s.carrier() + " on " + s.shippedAt()
                + ". Tracking: " + s.trackingNumber();
            case OrderState.Delivered d ->
                "Delivered on " + d.deliveredAt() + ". Signed by: " + d.signedBy();
            case OrderState.Cancelled c ->
                "Cancelled on " + c.cancelledAt() + ". Reason: " + c.reason();
        };
    }

    public static boolean canCancel(OrderState state) {
        return switch (state) {
            case OrderState.Pending p    -> true;
            case OrderState.Confirmed c  -> true;   // can still cancel before shipping
            case OrderState.Shipped s    -> false;
            case OrderState.Delivered d  -> false;
            case OrderState.Cancelled c  -> false;
        };
    }
}

9.2 Expression Evaluation (AST Nodes)

When building compilers, interpreters, or expression evaluators, you need to represent an Abstract Syntax Tree (AST). Each node type carries different data: a number literal has a value, a binary operation has an operator and two operands, a variable reference has a name. Sealed types make this elegant.

// AST for a simple arithmetic expression evaluator
public sealed interface Expr
        permits Expr.Num, Expr.Add, Expr.Mul, Expr.Neg, Expr.Var {

    record Num(double value) implements Expr { }
    record Add(Expr left, Expr right) implements Expr { }
    record Mul(Expr left, Expr right) implements Expr { }
    record Neg(Expr operand) implements Expr { }
    record Var(String name) implements Expr { }
}

class ExprEvaluator {
    private final java.util.Map variables;

    public ExprEvaluator(java.util.Map variables) {
        this.variables = variables;
    }

    public double evaluate(Expr expr) {
        return switch (expr) {
            case Expr.Num n   -> n.value();
            case Expr.Add a   -> evaluate(a.left()) + evaluate(a.right());
            case Expr.Mul m   -> evaluate(m.left()) * evaluate(m.right());
            case Expr.Neg neg -> -evaluate(neg.operand());
            case Expr.Var v   -> variables.getOrDefault(v.name(), 0.0);
        };
    }

    public String prettyPrint(Expr expr) {
        return switch (expr) {
            case Expr.Num n   -> String.valueOf(n.value());
            case Expr.Add a   -> "(" + prettyPrint(a.left()) + " + " + prettyPrint(a.right()) + ")";
            case Expr.Mul m   -> "(" + prettyPrint(m.left()) + " * " + prettyPrint(m.right()) + ")";
            case Expr.Neg neg -> "(-" + prettyPrint(neg.operand()) + ")";
            case Expr.Var v   -> v.name();
        };
    }

    public static void main(String[] args) {
        // Expression: (x + 2) * (y + 3)
        Expr expression = new Expr.Mul(
            new Expr.Add(new Expr.Var("x"), new Expr.Num(2)),
            new Expr.Add(new Expr.Var("y"), new Expr.Num(3))
        );

        var variables = java.util.Map.of("x", 5.0, "y", 7.0);
        var evaluator = new ExprEvaluator(variables);

        System.out.println("Expression: " + evaluator.prettyPrint(expression));
        // Output: Expression: ((x + 2) * (y + 3))

        System.out.println("Result: " + evaluator.evaluate(expression));
        // Output: Result: 70.0
    }
}

9.3 Event Systems

In event-driven architectures, events represent things that happen in the system. Each event type carries different data, but the set of events is typically fixed and well-known within a bounded context.

import java.time.Instant;

public sealed interface DomainEvent
        permits UserRegistered, OrderPlaced, PaymentReceived, ItemShipped {

    Instant occurredAt();
}

record UserRegistered(String userId, String email, Instant occurredAt) implements DomainEvent { }
record OrderPlaced(String orderId, String userId, double total, Instant occurredAt) implements DomainEvent { }
record PaymentReceived(String paymentId, String orderId, double amount, Instant occurredAt) implements DomainEvent { }
record ItemShipped(String orderId, String trackingNumber, Instant occurredAt) implements DomainEvent { }

class EventLogger {

    public static String formatEvent(DomainEvent event) {
        return switch (event) {
            case UserRegistered ur ->
                "[USER] " + ur.occurredAt() + " - New user " + ur.email() + " (ID: " + ur.userId() + ")";
            case OrderPlaced op ->
                "[ORDER] " + op.occurredAt() + " - Order " + op.orderId()
                + " placed by " + op.userId() + " ($" + op.total() + ")";
            case PaymentReceived pr ->
                "[PAYMENT] " + pr.occurredAt() + " - Payment " + pr.paymentId()
                + " of $" + pr.amount() + " for order " + pr.orderId();
            case ItemShipped is ->
                "[SHIPPING] " + is.occurredAt() + " - Order " + is.orderId()
                + " shipped (tracking: " + is.trackingNumber() + ")";
        };
    }

    public static void main(String[] args) {
        var now = Instant.now();
        DomainEvent[] events = {
            new UserRegistered("U001", "jane@example.com", now),
            new OrderPlaced("ORD-100", "U001", 149.99, now),
            new PaymentReceived("PAY-50", "ORD-100", 149.99, now),
            new ItemShipped("ORD-100", "1Z999AA10123456784", now)
        };

        for (DomainEvent event : events) {
            System.out.println(formatEvent(event));
        }
        // Output:
        // [USER] 2026-02-28T... - New user jane@example.com (ID: U001)
        // [ORDER] 2026-02-28T... - Order ORD-100 placed by U001 ($149.99)
        // [PAYMENT] 2026-02-28T... - Payment PAY-50 of $149.99 for order ORD-100
        // [SHIPPING] 2026-02-28T... - Order ORD-100 shipped (tracking: 1Z999AA10123456784)
    }
}

10. Restrictions and Rules

Sealed classes come with a specific set of restrictions enforced by the compiler. Understanding these rules prevents surprises.

# Rule Explanation
1 All permitted subclasses must be listed The permits clause must name every direct subclass. You cannot have a “hidden” subclass.
2 Same module or same package If you use modules, all permitted subclasses must be in the same module. If no module system, they must be in the same package.
3 Direct extension only Permitted classes must directly extend/implement the sealed type. You cannot list a grandchild class in the permits clause.
4 Must be final, sealed, or non-sealed Every permitted subclass must declare one of these three modifiers. You cannot leave a subclass “plain.”
5 No anonymous classes You cannot create an anonymous subclass of a sealed class. new Shape() { } is illegal if Shape is sealed.
6 No local classes You cannot declare a subclass of a sealed class inside a method body.
7 Abstract sealed classes are allowed A sealed class can be abstract. The permitted subclasses provide the concrete implementations.
8 Sealed + final is redundant A class that is both sealed and final would permit subclasses but prevent them from existing. The compiler will warn about this.
9 permits clause cannot be empty A sealed class must have at least one permitted subclass. Use final instead if you want zero subclasses.
10 Every listed class must actually extend If you list Dog in permits, the Dog class must actually extend the sealed class. Otherwise, it is a compile error.
// RULE 1: All subclasses must be listed
public sealed class Animal permits Dog, Cat { }
final class Dog extends Animal { }
final class Cat extends Animal { }
// final class Parrot extends Animal { }  // COMPILE ERROR: not in permits list

// RULE 2: Same package (no modules)
// package com.example.animals;
// public sealed class Animal permits Dog, Cat { }
//
// package com.example.other;
// final class Dog extends Animal { }  // COMPILE ERROR: different package

// RULE 4: Must declare final, sealed, or non-sealed
// class Dog extends Animal { }  // COMPILE ERROR: Dog must be final, sealed, or non-sealed

// RULE 5: No anonymous classes
// Animal a = new Animal() { };  // COMPILE ERROR: cannot create anonymous subclass

// RULE 6: No local classes
// void someMethod() {
//     class LocalAnimal extends Animal { }  // COMPILE ERROR
// }

// RULE 10: Listed class must actually extend
// sealed class Vehicle permits Car { }
// final class Car { }  // COMPILE ERROR: Car does not extend Vehicle

11. Best Practices

Here are guidelines from real-world experience to help you use sealed classes effectively.

1. Combine Sealed Interfaces with Records for Data Modeling

This is the most common and idiomatic pattern. Sealed interfaces define the closed set of types, and records provide immutable, boilerplate-free data carriers. This combination gives you algebraic data types in Java.

2. Prefer Sealed Interfaces Over Sealed Classes

Sealed interfaces are more flexible than sealed classes because Java allows only single inheritance for classes but multiple implementation for interfaces. Unless you need shared state (fields) in the parent, prefer sealing an interface.

3. Use Pattern Matching — Do Not Fight It

Sealed types were designed to work hand-in-hand with pattern matching. If you find yourself using instanceof chains or the Visitor pattern with sealed types, consider whether a switch expression would be simpler and safer.

4. Keep Hierarchies Shallow

Deep hierarchies (sealed class that permits a sealed class that permits another sealed class…) become hard to reason about. Two levels is comfortable. Three is the practical maximum. Beyond that, consider redesigning.

5. Do Not Use non-sealed Unless You Have a Specific Reason

The non-sealed modifier breaks the exhaustiveness guarantee. Only use it when one branch of your hierarchy genuinely needs to be open for extension, such as a plugin system or third-party extension point.

6. Think About Future Maintainability

Adding a new permitted subclass is safe because the compiler flags all incomplete switches. However, removing a permitted subclass requires changing all code that references it. Design your hierarchy to be stable.

7. Do Not Mix Sealed Types with Complex Inheritance

Sealed classes work best for clean, simple hierarchies. Avoid combining them with deep abstract class chains, multiple interfaces, and complex generics. Simplicity is the point.

Practice Do Do Not
Data modeling sealed interface + record Sealed class with mutable fields
Extensibility Prefer sealed interfaces Sealed classes unless you need shared state
Operations on types switch expression with pattern matching Visitor pattern or instanceof chains
Hierarchy depth 1-2 levels 3+ levels of sealed types
Opening up Use non-sealed only when needed Defaulting to non-sealed out of convenience
Naming Name the sealed type after the concept (e.g., Shape, Payment) Generic names like BaseType or AbstractData
Location Keep all subtypes in the same file (if small) Scattering subtypes across many files

12. Complete Practical Example: Payment Processing System

Let us put everything together in a realistic, production-style example. We will build a payment processing system that demonstrates sealed interfaces, records, pattern matching, guarded patterns, exhaustive switches, and the full power of these features working in concert.

This example models a payment gateway that accepts multiple payment methods, validates them, calculates fees, processes them, and generates receipts — all with compile-time safety guaranteeing that every payment type is handled everywhere.

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;

// ============================================================
// 1. SEALED INTERFACE: The payment method hierarchy
// ============================================================
public sealed interface PaymentMethod
        permits PaymentMethod.CreditCard, PaymentMethod.DebitCard,
                PaymentMethod.BankTransfer, PaymentMethod.DigitalWallet {

    // Records are implicitly final -- perfect for sealed hierarchies
    record CreditCard(String cardNumber, String cardHolder, String expiry, String cvv)
            implements PaymentMethod { }

    record DebitCard(String cardNumber, String cardHolder, String bankName)
            implements PaymentMethod { }

    record BankTransfer(String accountNumber, String routingNumber, String accountHolder)
            implements PaymentMethod { }

    record DigitalWallet(String provider, String email)
            implements PaymentMethod { }
}

// ============================================================
// 2. SEALED INTERFACE: The processing result
// ============================================================
sealed interface ProcessingResult
        permits ProcessingResult.Approved, ProcessingResult.Declined,
                ProcessingResult.PendingReview {

    record Approved(String transactionId, LocalDateTime processedAt) implements ProcessingResult { }
    record Declined(String reason, String errorCode) implements ProcessingResult { }
    record PendingReview(String reviewId, String reason) implements ProcessingResult { }
}

// ============================================================
// 3. PAYMENT VALIDATOR: Uses pattern matching with guards
// ============================================================
class PaymentValidator {

    public static String validate(PaymentMethod method, double amount) {
        if (amount <= 0) {
            return "Amount must be positive";
        }

        return switch (method) {
            case PaymentMethod.CreditCard cc when cc.cardNumber().length() != 16 ->
                "Credit card number must be 16 digits";
            case PaymentMethod.CreditCard cc when cc.cvv().length() != 3 ->
                "CVV must be 3 digits";
            case PaymentMethod.CreditCard cc ->
                null;  // valid

            case PaymentMethod.DebitCard dc when dc.cardNumber().length() != 16 ->
                "Debit card number must be 16 digits";
            case PaymentMethod.DebitCard dc ->
                null;  // valid

            case PaymentMethod.BankTransfer bt when bt.routingNumber().length() != 9 ->
                "Routing number must be 9 digits";
            case PaymentMethod.BankTransfer bt ->
                null;  // valid

            case PaymentMethod.DigitalWallet dw when !dw.email().contains("@") ->
                "Invalid email for digital wallet";
            case PaymentMethod.DigitalWallet dw ->
                null;  // valid
        };
    }
}

// ============================================================
// 4. FEE CALCULATOR: Different fees per payment type
// ============================================================
class FeeCalculator {

    public static double calculateFee(PaymentMethod method, double amount) {
        return switch (method) {
            case PaymentMethod.CreditCard cc  -> amount * 0.029 + 0.30;  // 2.9% + $0.30
            case PaymentMethod.DebitCard dc   -> amount * 0.015 + 0.25;  // 1.5% + $0.25
            case PaymentMethod.BankTransfer bt -> 1.50;                   // flat $1.50
            case PaymentMethod.DigitalWallet dw -> amount * 0.025;       // 2.5%
        };
    }

    public static String getFeeDescription(PaymentMethod method) {
        return switch (method) {
            case PaymentMethod.CreditCard cc    -> "2.9% + $0.30";
            case PaymentMethod.DebitCard dc     -> "1.5% + $0.25";
            case PaymentMethod.BankTransfer bt  -> "Flat $1.50";
            case PaymentMethod.DigitalWallet dw -> "2.5%";
        };
    }
}

// ============================================================
// 5. PAYMENT PROCESSOR: Processes payments, returns results
// ============================================================
class PaymentProcessor {

    public static ProcessingResult process(PaymentMethod method, double amount) {
        String validationError = PaymentValidator.validate(method, amount);
        if (validationError != null) {
            return new ProcessingResult.Declined(validationError, "VALIDATION_ERROR");
        }

        double fee = FeeCalculator.calculateFee(method, amount);
        double total = amount + fee;

        // Simulate processing logic
        return switch (method) {
            case PaymentMethod.CreditCard cc when total > 10000 ->
                new ProcessingResult.PendingReview(
                    UUID.randomUUID().toString().substring(0, 8),
                    "High-value credit card transaction requires review"
                );

            case PaymentMethod.BankTransfer bt when total > 50000 ->
                new ProcessingResult.PendingReview(
                    UUID.randomUUID().toString().substring(0, 8),
                    "Large bank transfer requires compliance review"
                );

            case PaymentMethod.CreditCard cc ->
                new ProcessingResult.Approved(
                    "CC-" + UUID.randomUUID().toString().substring(0, 8),
                    LocalDateTime.now()
                );

            case PaymentMethod.DebitCard dc ->
                new ProcessingResult.Approved(
                    "DC-" + UUID.randomUUID().toString().substring(0, 8),
                    LocalDateTime.now()
                );

            case PaymentMethod.BankTransfer bt ->
                new ProcessingResult.Approved(
                    "BT-" + UUID.randomUUID().toString().substring(0, 8),
                    LocalDateTime.now()
                );

            case PaymentMethod.DigitalWallet dw ->
                new ProcessingResult.Approved(
                    "DW-" + UUID.randomUUID().toString().substring(0, 8),
                    LocalDateTime.now()
                );
        };
    }
}

// ============================================================
// 6. RECEIPT GENERATOR: Exhaustive handling of all types
// ============================================================
class ReceiptGenerator {

    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static String generateReceipt(PaymentMethod method, double amount, ProcessingResult result) {
        StringBuilder sb = new StringBuilder();
        sb.append("========================================\n");
        sb.append("           PAYMENT RECEIPT\n");
        sb.append("========================================\n");

        // Payment method details (exhaustive switch)
        sb.append("Payment Method: ").append(switch (method) {
            case PaymentMethod.CreditCard cc ->
                "Credit Card ending in " + cc.cardNumber().substring(12) + "\n"
                + "  Card Holder: " + cc.cardHolder();
            case PaymentMethod.DebitCard dc ->
                "Debit Card ending in " + dc.cardNumber().substring(12) + "\n"
                + "  Bank: " + dc.bankName();
            case PaymentMethod.BankTransfer bt ->
                "Bank Transfer\n"
                + "  Account: ****" + bt.accountNumber().substring(bt.accountNumber().length() - 4) + "\n"
                + "  Account Holder: " + bt.accountHolder();
            case PaymentMethod.DigitalWallet dw ->
                dw.provider() + " (" + dw.email() + ")";
        }).append("\n");

        double fee = FeeCalculator.calculateFee(method, amount);
        sb.append(String.format("Amount:         $%,.2f%n", amount));
        sb.append(String.format("Fee (%s): $%,.2f%n", FeeCalculator.getFeeDescription(method), fee));
        sb.append(String.format("Total:          $%,.2f%n", amount + fee));
        sb.append("----------------------------------------\n");

        // Result details (exhaustive switch)
        sb.append("Status: ").append(switch (result) {
            case ProcessingResult.Approved a ->
                "APPROVED\n"
                + "  Transaction ID: " + a.transactionId() + "\n"
                + "  Processed at:   " + a.processedAt().format(FMT);
            case ProcessingResult.Declined d ->
                "DECLINED\n"
                + "  Reason: " + d.reason() + "\n"
                + "  Error Code: " + d.errorCode();
            case ProcessingResult.PendingReview pr ->
                "PENDING REVIEW\n"
                + "  Review ID: " + pr.reviewId() + "\n"
                + "  Reason: " + pr.reason();
        }).append("\n");

        sb.append("========================================\n");
        return sb.toString();
    }
}

// ============================================================
// 7. MAIN APPLICATION: Putting it all together
// ============================================================
class PaymentApp {

    public static void main(String[] args) {

        // Test various payment methods
        List payments = List.of(
            new PaymentMethod.CreditCard("4111111111111234", "Jane Smith", "12/27", "123"),
            new PaymentMethod.DebitCard("5500000000001234", "Bob Johnson", "Chase Bank"),
            new PaymentMethod.BankTransfer("123456789012", "021000021", "Alice Williams"),
            new PaymentMethod.DigitalWallet("PayPal", "alice@example.com")
        );

        double[] amounts = { 99.99, 250.00, 1500.00, 45.50 };

        for (int i = 0; i < payments.size(); i++) {
            PaymentMethod method = payments.get(i);
            double amount = amounts[i];

            ProcessingResult result = PaymentProcessor.process(method, amount);
            String receipt = ReceiptGenerator.generateReceipt(method, amount, result);
            System.out.println(receipt);
        }

        // Test validation failure
        System.out.println("--- Testing invalid payment ---");
        PaymentMethod invalid = new PaymentMethod.CreditCard("123", "Bad Card", "01/25", "12");
        ProcessingResult failed = PaymentProcessor.process(invalid, 50.0);
        System.out.println(ReceiptGenerator.generateReceipt(invalid, 50.0, failed));

        // Test high-value transaction (triggers review)
        System.out.println("--- Testing high-value transaction ---");
        PaymentMethod bigCharge = new PaymentMethod.CreditCard("4111111111115678", "CEO", "06/28", "999");
        ProcessingResult review = PaymentProcessor.process(bigCharge, 15000.00);
        System.out.println(ReceiptGenerator.generateReceipt(bigCharge, 15000.00, review));
    }
}

// Sample output:
// ========================================
//            PAYMENT RECEIPT
// ========================================
// Payment Method: Credit Card ending in 1234
//   Card Holder: Jane Smith
// Amount:         $99.99
// Fee (2.9% + $0.30): $3.20
// Total:          $103.19
// ----------------------------------------
// Status: APPROVED
//   Transaction ID: CC-a1b2c3d4
//   Processed at:   2026-02-28 14:30:15
// ========================================

What This Example Demonstrates

# Feature Where It Appears
1 Sealed interface PaymentMethod and ProcessingResult
2 Records as permitted types All subtypes (CreditCard, Approved, etc.)
3 Exhaustive switch expressions FeeCalculator, ReceiptGenerator, PaymentProcessor
4 Guarded patterns (when) PaymentValidator and high-value transaction check in PaymentProcessor
5 No default branch needed Every switch expression covers all cases without default
6 Type safety Adding a new PaymentMethod variant forces updates to every switch
7 Different data per subtype Each payment method and result carries different fields
8 Nested records in sealed interface Records declared inside the sealed interface for clean organization
9 Pattern deconstruction Accessing record fields directly in switch arms (e.g., cc.cardNumber())
10 Separation of concerns Validator, FeeCalculator, Processor, ReceiptGenerator are separate classes

If you were to add a fifth payment method (say, Cryptocurrency), you would add it to the permits clause, and the compiler would immediately flag every switch expression that needs to handle the new type. Zero chance of forgetting a case.

Quick Reference

Concept Syntax / Key Point
Declare a sealed class public sealed class Shape permits Circle, Rectangle { }
Declare a sealed interface public sealed interface Payment permits CreditCard, DebitCard { }
final subclass final class Circle extends Shape { } -- no further extension
sealed subclass sealed class PersonalAccount extends Account permits Checking, Savings { }
non-sealed subclass non-sealed class CustomTransport extends Transport { } -- opens hierarchy
Implicit permits Omit permits when all subclasses are in the same source file
Records + sealed record Circle(double r) implements Shape { } -- records are implicitly final
Exhaustive switch switch (shape) { case Circle c -> ...; case Rectangle r -> ...; } -- no default needed
Guarded patterns case Circle c when c.radius() > 100 -> "large"
Java version Preview in Java 15-16, permanent in Java 17 (JEP 409)
Location rule All permitted subclasses must be in the same module (or same package if no modules)
vs Enum Use enums for fixed constants with same structure; sealed types for subtypes with different data
July 27, 2023

Java Record

1. What is a Record?

Java developers have written the same boilerplate code millions of times: a class with private fields, a constructor, getters, equals(), hashCode(), and toString(). For a simple class that carries two fields, you end up writing 40+ lines of code where only two lines — the field declarations — carry actual meaning. The rest is ceremony.

Records, introduced as a preview feature in Java 14 and finalized in Java 16, solve this problem. A record is a special kind of class designed to be a transparent carrier of immutable data. You declare the data, and the compiler generates everything else.

Think of a record as a named tuple with built-in structure. You tell Java “I need a type that holds these values,” and Java handles the plumbing — constructor, accessors, equality, hashing, and a human-readable string representation.

The problem records solve:

Before records, a simple class to hold a 2D point looked like this:

// Before records: 40+ lines for a simple data carrier
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

With records, the entire class collapses to a single line:

// After records: 1 line, same behavior
public record Point(int x, int y) { }

That single line gives you everything the 40-line class had: a constructor, accessor methods x() and y(), properly implemented equals(), hashCode(), and toString(). The fields are private and final. The class is implicitly final. No boilerplate, no bugs from forgetting to update equals() when you add a field.

What the compiler generates for you:

  • A canonical constructor that takes all components as parameters and assigns them to fields
  • Accessor methods for each component (named after the component, not with a get prefix)
  • equals() that compares all components for equality
  • hashCode() computed from all components
  • toString() that prints the record name and all component values
public class RecordBasics {
    // A record with two components
    record Point(int x, int y) { }

    public static void main(String[] args) {
        Point p1 = new Point(3, 7);
        Point p2 = new Point(3, 7);
        Point p3 = new Point(1, 2);

        // Accessor methods -- note: x() not getX()
        System.out.println("x = " + p1.x());       // Output: x = 3
        System.out.println("y = " + p1.y());       // Output: y = 7

        // toString() includes component names and values
        System.out.println(p1);                     // Output: Point[x=3, y=7]

        // equals() compares all components
        System.out.println(p1.equals(p2));          // Output: true
        System.out.println(p1.equals(p3));          // Output: false

        // hashCode() is consistent with equals()
        System.out.println(p1.hashCode() == p2.hashCode());  // Output: true
    }
}

2. Record Syntax

A record declaration consists of a name and a component list in parentheses. The components define both the fields and the constructor parameters.

// Basic syntax: record Name(Type1 component1, Type2 component2, ...) { }

// A record with no components (legal but uncommon)
record Empty() { }

// A record with one component
record Wrapper(String value) { }

// A record with multiple components
record Person(String name, int age, String email) { }

// A record can have a body with additional members
record Temperature(double celsius) {
    // Static fields are allowed
    static final double ABSOLUTE_ZERO = -273.15;

    // Instance methods are allowed
    double fahrenheit() {
        return celsius * 9.0 / 5.0 + 32;
    }

    // Static methods are allowed
    static Temperature fromFahrenheit(double f) {
        return new Temperature((f - 32) * 5.0 / 9.0);
    }
}

2.1 Component Accessors

Unlike traditional JavaBean getters that use the get prefix (getName(), getAge()), record accessor methods are named directly after the component. A record Person(String name, int age) has accessors name() and age(), not getName() and getAge().

This is a deliberate design choice. Records are not JavaBeans. They are transparent data carriers, and their accessor names reflect this transparency — the accessor name matches the component name exactly.

record Employee(String name, String department, double salary) { }

public class AccessorDemo {
    public static void main(String[] args) {
        Employee emp = new Employee("Alice", "Engineering", 95000.0);

        // Correct: accessor name matches the component name
        String name = emp.name();             // "Alice"
        String dept = emp.department();       // "Engineering"
        double salary = emp.salary();         // 95000.0

        // Wrong: there is no getName(), getDepartment(), getSalary()
        // emp.getName();    // COMPILE ERROR
        // emp.getSalary();  // COMPILE ERROR

        System.out.println(name + " in " + dept + " earns $" + salary);
        // Output: Alice in Engineering earns $95000.0
    }
}

3. What You Get for Free

When you declare a record, the compiler generates several members automatically. Understanding exactly what gets generated helps you reason about record behavior and decide when to override the defaults.

For the record declaration record RGB(int red, int green, int blue) { }, here is the equivalent class the compiler conceptually generates:

// What the compiler generates for: record RGB(int red, int green, int blue) { }
// This is the conceptual equivalent -- you never write this yourself.

public final class RGB extends java.lang.Record {

    private final int red;
    private final int green;
    private final int blue;

    // Canonical constructor
    public RGB(int red, int green, int blue) {
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    // Accessor methods
    public int red()   { return this.red; }
    public int green() { return this.green; }
    public int blue()  { return this.blue; }

    // equals() -- two RGB values are equal if all components match
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof RGB)) return false;
        RGB rgb = (RGB) o;
        return red == rgb.red && green == rgb.green && blue == rgb.blue;
    }

    // hashCode() -- computed from all components
    @Override
    public int hashCode() {
        return Objects.hash(red, green, blue);
    }

    // toString() -- includes record name and component values
    @Override
    public String toString() {
        return "RGB[red=" + red + ", green=" + green + ", blue=" + blue + "]";
    }
}

Let us verify this behavior with a concrete example:

import java.util.HashSet;
import java.util.Set;

public class FreeFeatures {
    record RGB(int red, int green, int blue) { }

    public static void main(String[] args) {
        RGB crimson  = new RGB(220, 20, 60);
        RGB crimson2 = new RGB(220, 20, 60);
        RGB navy     = new RGB(0, 0, 128);

        // 1. toString() -- human-readable by default
        System.out.println(crimson);
        // Output: RGB[red=220, green=20, blue=60]

        // 2. equals() -- structural equality, not reference equality
        System.out.println(crimson.equals(crimson2));  // Output: true
        System.out.println(crimson.equals(navy));      // Output: false
        System.out.println(crimson == crimson2);       // Output: false (different objects)

        // 3. hashCode() -- consistent with equals, works in hash-based collections
        Set colors = new HashSet<>();
        colors.add(crimson);
        colors.add(crimson2);  // duplicate -- not added
        colors.add(navy);
        System.out.println("Set size: " + colors.size());  // Output: Set size: 2

        // 4. Accessor methods
        System.out.printf("R=%d, G=%d, B=%d%n", crimson.red(), crimson.green(), crimson.blue());
        // Output: R=220, G=20, B=60
    }
}

4. Compact Constructor

A compact constructor is a special constructor syntax available only in records. It lets you add validation, normalization, or defensive copying logic without writing the boilerplate assignment statements. The compiler inserts the this.field = field; assignments automatically at the end of the compact constructor.

The compact constructor has no parameter list — it implicitly receives the same parameters as the canonical constructor. You work directly with the parameter names, and the compiler assigns them to the corresponding fields after your code runs.

public class CompactConstructorDemo {

    // Validation: reject invalid values
    record Age(int value) {
        Age {  // compact constructor -- no parentheses
            if (value < 0 || value > 150) {
                throw new IllegalArgumentException("Age must be between 0 and 150, got: " + value);
            }
            // No need to write: this.value = value;
            // The compiler adds it automatically after this block
        }
    }

    // Normalization: convert data to a canonical form
    record Email(String address) {
        Email {
            // Trim whitespace and convert to lowercase
            address = address.strip().toLowerCase();
            // The compiler assigns: this.address = address;
            // So the normalized value gets stored
        }
    }

    // Multiple validations and normalization combined
    record Username(String value) {
        Username {
            if (value == null || value.isBlank()) {
                throw new IllegalArgumentException("Username cannot be null or blank");
            }
            value = value.strip().toLowerCase();
            if (value.length() < 3 || value.length() > 30) {
                throw new IllegalArgumentException(
                    "Username must be 3-30 characters, got: " + value.length());
            }
            if (!value.matches("^[a-z0-9_]+$")) {
                throw new IllegalArgumentException(
                    "Username can only contain lowercase letters, digits, and underscores");
            }
        }
    }

    public static void main(String[] args) {
        // Validation in action
        Age validAge = new Age(25);
        System.out.println(validAge);          // Output: Age[value=25]

        try {
            Age invalid = new Age(-5);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage()); // Output: Age must be between 0 and 150, got: -5
        }

        // Normalization in action
        Email email = new Email("  Alice@Example.COM  ");
        System.out.println(email.address());   // Output: alice@example.com

        // Combined validation and normalization
        Username user = new Username("  John_Doe42  ");
        System.out.println(user.value());      // Output: john_doe42
    }
}

4.1 Defensive Copying in Compact Constructors

Records guarantee that their fields are final, but final does not mean deeply immutable. If a record component is a mutable object (like a List or Date), the caller could modify the original reference after construction, breaking the record’s data integrity. Defensive copying solves this by storing a copy of the mutable data.

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

public class DefensiveCopyDemo {

    // BAD: mutable list can be modified from outside
    record TagsBad(String name, List tags) { }

    // GOOD: defensive copy in compact constructor
    record TagsGood(String name, List tags) {
        TagsGood {
            // Create an unmodifiable copy so the caller cannot mutate our internal state
            tags = List.copyOf(tags);
        }
    }

    public static void main(String[] args) {
        // Demonstrating the problem with mutable components
        var mutableList = new java.util.ArrayList<>(List.of("java", "record"));
        TagsBad bad = new TagsBad("post", mutableList);

        System.out.println(bad.tags());       // Output: [java, record]
        mutableList.add("hacked");            // Modifying the original list
        System.out.println(bad.tags());       // Output: [java, record, hacked] -- broken!

        // Demonstrating the fix with defensive copy
        var anotherList = new java.util.ArrayList<>(List.of("java", "record"));
        TagsGood good = new TagsGood("post", anotherList);

        System.out.println(good.tags());      // Output: [java, record]
        anotherList.add("hacked");            // Modifying the original list
        System.out.println(good.tags());      // Output: [java, record] -- safe!

        try {
            good.tags().add("oops");          // Trying to modify the copy
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify -- list is unmodifiable");
            // Output: Cannot modify -- list is unmodifiable
        }
    }
}

5. Custom Constructors

Records can have additional constructors beyond the canonical one. Every custom constructor must eventually delegate to the canonical constructor using this(...). This ensures that all validation and normalization logic in the canonical (or compact) constructor is always executed.

public class CustomConstructorDemo {

    record Range(int low, int high) {
        // Compact constructor for validation
        Range {
            if (low > high) {
                throw new IllegalArgumentException(
                    "low (" + low + ") must be <= high (" + high + ")");
            }
        }

        // Convenience constructor: single value range
        Range(int value) {
            this(value, value);  // Delegates to canonical constructor
        }

        // Convenience constructor: from zero to a value
        static Range upTo(int high) {
            return new Range(0, high);
        }

        int span() {
            return high - low;
        }

        boolean contains(int value) {
            return value >= low && value <= high;
        }
    }

    // Copy-with-modification pattern
    record UserProfile(String name, String email, String role) {
        // "Wither" methods -- return a new record with one field changed
        UserProfile withName(String newName) {
            return new UserProfile(newName, this.email, this.role);
        }

        UserProfile withEmail(String newEmail) {
            return new UserProfile(this.name, newEmail, this.role);
        }

        UserProfile withRole(String newRole) {
            return new UserProfile(this.name, this.email, newRole);
        }
    }

    public static void main(String[] args) {
        // Using the canonical constructor
        Range full = new Range(1, 100);
        System.out.println(full);              // Output: Range[low=1, high=100]

        // Using the convenience constructor
        Range single = new Range(42);
        System.out.println(single);            // Output: Range[low=42, high=42]

        // Using the static factory method
        Range zeroTo = Range.upTo(10);
        System.out.println(zeroTo);            // Output: Range[low=0, high=10]

        // Instance methods
        System.out.println(full.span());       // Output: 99
        System.out.println(full.contains(50)); // Output: true

        // Copy-with-modification
        UserProfile alice = new UserProfile("Alice", "alice@example.com", "user");
        UserProfile admin = alice.withRole("admin");
        System.out.println(alice);
        // Output: UserProfile[name=Alice, email=alice@example.com, role=user]
        System.out.println(admin);
        // Output: UserProfile[name=Alice, email=alice@example.com, role=admin]
    }
}

6. Adding Methods and Implementing Interfaces

Records can contain instance methods, static methods, static fields, and can implement interfaces. What they cannot do is extend another class (they implicitly extend java.lang.Record) or declare instance fields beyond the components.

This section demonstrates how to add behavior to records and how to make them participate in the Java type system through interfaces.

import java.util.Objects;

public class RecordMethodsDemo {

    // Record implementing Comparable
    record Money(double amount, String currency) implements Comparable {
        // Compact constructor for validation
        Money {
            if (amount < 0) {
                throw new IllegalArgumentException("Amount cannot be negative: " + amount);
            }
            Objects.requireNonNull(currency, "Currency cannot be null");
            currency = currency.toUpperCase();
        }

        // Instance methods for business logic
        Money add(Money other) {
            requireSameCurrency(other);
            return new Money(this.amount + other.amount, this.currency);
        }

        Money subtract(Money other) {
            requireSameCurrency(other);
            return new Money(this.amount - other.amount, this.currency);
        }

        Money multiply(double factor) {
            return new Money(this.amount * factor, this.currency);
        }

        // Private helper method
        private void requireSameCurrency(Money other) {
            if (!this.currency.equals(other.currency)) {
                throw new IllegalArgumentException(
                    "Cannot operate on different currencies: " + currency + " vs " + other.currency);
            }
        }

        // Implementing Comparable
        @Override
        public int compareTo(Money other) {
            requireSameCurrency(other);
            return Double.compare(this.amount, other.amount);
        }

        // Static factory method
        static Money usd(double amount) {
            return new Money(amount, "USD");
        }

        static Money eur(double amount) {
            return new Money(amount, "EUR");
        }
    }

    public static void main(String[] args) {
        Money price = Money.usd(29.99);
        Money tax   = Money.usd(2.40);
        Money total  = price.add(tax);

        System.out.println("Price: " + price);    // Output: Money[amount=29.99, currency=USD]
        System.out.println("Tax:   " + tax);      // Output: Money[amount=2.4, currency=USD]
        System.out.println("Total: " + total);     // Output: Money[amount=32.39, currency=USD]

        Money discounted = total.multiply(0.9);
        System.out.println("After 10% off: " + discounted);
        // Output: After 10% off: Money[amount=29.151, currency=USD]

        // Comparable works -- useful for sorting
        System.out.println(price.compareTo(tax) > 0);  // Output: true

        // Currency mismatch throws exception
        try {
            Money euros = Money.eur(25.00);
            price.add(euros);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
            // Output: Cannot operate on different currencies: USD vs EUR
        }
    }
}

6.1 Records Implementing Multiple Interfaces

A record can implement as many interfaces as needed. This is particularly useful when you want records to participate in functional programming patterns or framework contracts.

import java.io.Serializable;
import java.util.function.Supplier;

public class MultiInterfaceDemo {

    // A record implementing multiple interfaces
    interface Printable {
        String toPrettyString();
    }

    interface Exportable {
        String toCsv();
    }

    record Product(String sku, String name, double price)
            implements Printable, Exportable, Serializable, Comparable {

        @Override
        public String toPrettyString() {
            return String.format("%-10s | %-20s | $%8.2f", sku, name, price);
        }

        @Override
        public String toCsv() {
            return String.join(",", sku, name, String.valueOf(price));
        }

        @Override
        public int compareTo(Product other) {
            return Double.compare(this.price, other.price);
        }
    }

    public static void main(String[] args) {
        var products = java.util.List.of(
            new Product("SKU-001", "Mechanical Keyboard", 149.99),
            new Product("SKU-002", "USB-C Hub", 39.99),
            new Product("SKU-003", "Monitor Stand", 79.99)
        );

        // Using Printable interface
        System.out.println("Product Listing:");
        products.forEach(p -> System.out.println(p.toPrettyString()));
        // Output:
        // Product Listing:
        // SKU-001    | Mechanical Keyboard  |  $ 149.99
        // SKU-002    | USB-C Hub            |  $  39.99
        // SKU-003    | Monitor Stand        |  $  79.99

        // Using Exportable interface
        System.out.println("\nCSV Export:");
        products.forEach(p -> System.out.println(p.toCsv()));
        // Output:
        // CSV Export:
        // SKU-001,Mechanical Keyboard,149.99
        // SKU-002,USB-C Hub,39.99
        // SKU-003,Monitor Stand,79.99

        // Using Comparable -- sorting by price
        products.stream()
                .sorted()
                .forEach(p -> System.out.println(p.name() + ": $" + p.price()));
        // Output:
        // USB-C Hub: $39.99
        // Monitor Stand: $79.99
        // Mechanical Keyboard: $149.99
    }
}

7. Records with Generics

Records fully support generics, making them excellent for creating reusable data containers. Generic records are particularly useful for wrapper types, pairs, result types, and API response structures.

import java.util.Optional;
import java.util.List;

public class GenericRecordsDemo {

    // A generic pair -- holds two values of potentially different types
    record Pair(A first, B second) {
        // Factory method for type inference
        static  Pair of(A first, B second) {
            return new Pair<>(first, second);
        }

        // Transform one side
         Pair mapFirst(java.util.function.Function fn) {
            return new Pair<>(fn.apply(first), second);
        }

         Pair mapSecond(java.util.function.Function fn) {
            return new Pair<>(first, fn.apply(second));
        }
    }

    // A Result type -- represents success or failure (like Rust's Result)
    sealed interface Result {
        record Success(T value) implements Result { }
        record Failure(String error, Exception cause) implements Result {
            Failure(String error) {
                this(error, null);
            }
        }
    }

    // A generic triple
    record Triple(A first, B second, C third) { }

    // Bounded type parameter
    record MinMax>(T min, T max) {
        MinMax {
            if (min.compareTo(max) > 0) {
                throw new IllegalArgumentException(
                    "min (" + min + ") must be <= max (" + max + ")");
            }
        }

        boolean contains(T value) {
            return value.compareTo(min) >= 0 && value.compareTo(max) <= 0;
        }
    }

    public static void main(String[] args) {
        // Pair usage
        Pair entry = Pair.of("Alice", 30);
        System.out.println(entry);
        // Output: Pair[first=Alice, second=30]

        Pair uppered = entry.mapFirst(String::toUpperCase);
        System.out.println(uppered);
        // Output: Pair[first=ALICE, second=30]

        // Result usage
        Result success = new Result.Success<>(42);
        Result failure = new Result.Failure<>("Division by zero");

        // Pattern matching with Result (Java 21+)
        printResult(success);   // Output: Got value: 42
        printResult(failure);   // Output: Error: Division by zero

        // MinMax with bounded type
        MinMax range = new MinMax<>(1, 100);
        System.out.println(range.contains(50));    // Output: true
        System.out.println(range.contains(200));   // Output: false

        MinMax wordRange = new MinMax<>("apple", "mango");
        System.out.println(wordRange.contains("cherry")); // Output: true
        System.out.println(wordRange.contains("zebra"));  // Output: false
    }

    static  void printResult(Result result) {
        switch (result) {
            case Result.Success s -> System.out.println("Got value: " + s.value());
            case Result.Failure f -> System.out.println("Error: " + f.error());
        }
    }
}

8. Record Patterns (Java 21+)

Java 21 finalized record patterns, which let you destructure a record directly in instanceof checks and switch expressions. Instead of first checking the type and then calling accessor methods, you extract the components right in the pattern -- similar to destructuring in languages like Kotlin, Scala, or JavaScript.

Record patterns work because records are transparent: their components are known at compile time. The compiler can safely decompose a record into its parts.

public class RecordPatternsDemo {

    record Point(int x, int y) { }
    record Circle(Point center, double radius) { }
    record Rectangle(Point topLeft, Point bottomRight) { }

    sealed interface Shape permits CircleShape, RectangleShape, Line { }
    record CircleShape(Point center, double radius) implements Shape { }
    record RectangleShape(Point topLeft, int width, int height) implements Shape { }
    record Line(Point start, Point end) implements Shape { }

    public static void main(String[] args) {
        // 1. Record pattern in instanceof
        Object obj = new Point(3, 7);

        // Old way: check type, cast, then access
        if (obj instanceof Point p) {
            System.out.println("x=" + p.x() + ", y=" + p.y());
        }

        // New way: destructure in the pattern itself
        if (obj instanceof Point(int x, int y)) {
            System.out.println("x=" + x + ", y=" + y);
            // Output: x=3, y=7
        }

        // 2. Nested record patterns -- destructure a record inside a record
        Circle circle = new Circle(new Point(10, 20), 5.0);
        Object shapeObj = circle;

        if (shapeObj instanceof Circle(Point(int cx, int cy), double r)) {
            System.out.printf("Circle at (%d, %d) with radius %.1f%n", cx, cy, r);
            // Output: Circle at (10, 20) with radius 5.0
        }

        // 3. Record patterns in switch
        describeShape(new CircleShape(new Point(0, 0), 10));
        describeShape(new RectangleShape(new Point(5, 5), 100, 50));
        describeShape(new Line(new Point(0, 0), new Point(10, 10)));
    }

    static void describeShape(Shape shape) {
        String description = switch (shape) {
            // Destructure CircleShape and its nested Point in one pattern
            case CircleShape(Point(int x, int y), double r)
                    -> String.format("Circle at (%d,%d) r=%.0f", x, y, r);

            // Destructure RectangleShape
            case RectangleShape(Point(int x, int y), int w, int h)
                    -> String.format("Rectangle at (%d,%d) %dx%d", x, y, w, h);

            // Destructure Line with its two endpoints
            case Line(Point(int x1, int y1), Point(int x2, int y2))
                    -> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
        };
        System.out.println(description);
        // Output:
        // Circle at (0,0) r=10
        // Rectangle at (5,5) 100x50
        // Line from (0,0) to (10,10)
    }
}

8.1 Guarded Record Patterns

You can combine record patterns with guards (when clause) to add conditions beyond just the type match. This gives you powerful, readable conditional logic.

public class GuardedPatternsDemo {

    record Temperature(double value, String unit) {
        Temperature {
            unit = unit.toUpperCase();
        }
    }

    record Coordinate(double lat, double lon) { }

    static String describeTemperature(Temperature temp) {
        return switch (temp) {
            case Temperature(double v, String u) when v < 0 && u.equals("C")
                    -> "Below freezing (Celsius)";
            case Temperature(double v, String u) when v < 32 && u.equals("F")
                    -> "Below freezing (Fahrenheit)";
            case Temperature(double v, String u) when v > 100 && u.equals("C")
                    -> "Above boiling point (Celsius)";
            case Temperature(double v, String u) when v > 212 && u.equals("F")
                    -> "Above boiling point (Fahrenheit)";
            case Temperature(double v, String u)
                    -> String.format("%.1f°%s -- normal range", v, u);
        };
    }

    static String describeLocation(Coordinate coord) {
        return switch (coord) {
            case Coordinate(double lat, double lon) when lat == 0 && lon == 0
                    -> "Null Island (0,0)";
            case Coordinate(double lat, var lon) when lat > 66.5
                    -> "Arctic region";
            case Coordinate(double lat, var lon) when lat < -66.5
                    -> "Antarctic region";
            case Coordinate(double lat, double lon)
                    -> String.format("Location (%.2f, %.2f)", lat, lon);
        };
    }

    public static void main(String[] args) {
        System.out.println(describeTemperature(new Temperature(-10, "C")));
        // Output: Below freezing (Celsius)

        System.out.println(describeTemperature(new Temperature(72, "F")));
        // Output: 72.0°F -- normal range

        System.out.println(describeLocation(new Coordinate(0, 0)));
        // Output: Null Island (0,0)

        System.out.println(describeLocation(new Coordinate(78.2, 15.6)));
        // Output: Arctic region

        System.out.println(describeLocation(new Coordinate(40.7, -74.0)));
        // Output: Location (40.70, -74.00)
    }
}

9. Local Records

Records can be declared inside a method, making them local records. This is powerful when you need a temporary data structure for intermediate computation -- particularly in stream pipelines or complex method logic. The record's scope is limited to the method, keeping it out of the class's public API.

Local records are implicitly static (they do not capture the enclosing method's local variables or the enclosing instance), which makes them safe and predictable.

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class LocalRecordsDemo {

    static void analyzeScores(List names, List scores) {
        // Local record -- only visible inside this method
        record StudentScore(String name, int score) { }

        // Combine two parallel lists into a structured stream
        var results = new java.util.ArrayList();
        for (int i = 0; i < names.size(); i++) {
            results.add(new StudentScore(names.get(i), scores.get(i)));
        }

        // Now use the local record for clear, type-safe stream processing
        var topStudents = results.stream()
                .filter(s -> s.score() >= 80)
                .sorted(Comparator.comparingInt(StudentScore::score).reversed())
                .toList();

        System.out.println("Top students:");
        topStudents.forEach(s ->
            System.out.printf("  %s: %d%n", s.name(), s.score()));

        double average = results.stream()
                .mapToInt(StudentScore::score)
                .average()
                .orElse(0);
        System.out.printf("Class average: %.1f%n", average);
    }

    static Map computeWeightedGrades(
            List students, List> allGrades, List weights) {

        // Local record to hold intermediate computation results
        record GradeEntry(String student, double weightedAverage) { }

        return java.util.stream.IntStream.range(0, students.size())
                .mapToObj(i -> {
                    double weightedSum = 0;
                    List grades = allGrades.get(i);
                    for (int j = 0; j < grades.size(); j++) {
                        weightedSum += grades.get(j) * weights.get(j);
                    }
                    return new GradeEntry(students.get(i), weightedSum);
                })
                .collect(Collectors.toMap(GradeEntry::student, GradeEntry::weightedAverage));
    }

    public static void main(String[] args) {
        var names  = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");
        var scores = List.of(92, 78, 85, 96, 73);

        analyzeScores(names, scores);
        // Output:
        // Top students:
        //   Diana: 96
        //   Alice: 92
        //   Charlie: 85
        // Class average: 84.8

        var students = List.of("Alice", "Bob");
        var allGrades = List.of(
            List.of(90, 85, 92),  // Alice's grades
            List.of(78, 82, 88)   // Bob's grades
        );
        var weights = List.of(0.3, 0.3, 0.4);

        Map weighted = computeWeightedGrades(students, allGrades, weights);
        weighted.forEach((name, avg) ->
            System.out.printf("%s: weighted avg = %.1f%n", name, avg));
        // Output:
        // Alice: weighted avg = 89.3
        // Bob: weighted avg = 83.2
    }
}

10. Records and Serialization

Records have a special relationship with Java serialization. When a record implements Serializable, it is serialized and deserialized differently from ordinary classes -- and this difference makes records safer.

Key difference: Traditional class deserialization bypasses constructors entirely, using internal JVM mechanisms to reconstruct objects. This can lead to objects in invalid states because your validation logic never runs. Record deserialization, by contrast, always invokes the canonical constructor. This means your validation and normalization logic in the compact constructor executes during deserialization, guaranteeing that deserialized records are always valid.

import java.io.*;

public class RecordSerializationDemo {

    record Config(String host, int port, boolean ssl) implements Serializable {
        Config {
            if (host == null || host.isBlank()) {
                throw new IllegalArgumentException("Host cannot be blank");
            }
            if (port < 1 || port > 65535) {
                throw new IllegalArgumentException("Port must be 1-65535, got: " + port);
            }
            host = host.strip().toLowerCase();
        }
    }

    public static void main(String[] args) throws Exception {
        Config original = new Config("  API.Example.COM  ", 8443, true);
        System.out.println("Original: " + original);
        // Output: Original: Config[host=api.example.com, port=8443, ssl=true]

        // Serialize to bytes
        byte[] bytes;
        try (var baos = new ByteArrayOutputStream();
             var oos = new ObjectOutputStream(baos)) {
            oos.writeObject(original);
            bytes = baos.toByteArray();
        }
        System.out.println("Serialized to " + bytes.length + " bytes");

        // Deserialize -- the canonical constructor runs, so validation is enforced
        Config restored;
        try (var bais = new ByteArrayInputStream(bytes);
             var ois = new ObjectInputStream(bais)) {
            restored = (Config) ois.readObject();
        }

        System.out.println("Restored: " + restored);
        // Output: Restored: Config[host=api.example.com, port=8443, ssl=true]

        // The deserialized record is equal to the original
        System.out.println("Equal: " + original.equals(restored));
        // Output: Equal: true

        // Deserialization safety: if someone tampers with the byte stream
        // to set port to -1, the canonical constructor would throw
        // IllegalArgumentException -- the invalid object never gets created.
    }
}

11. Record vs Traditional Class

Records are not a replacement for all classes. They serve a specific purpose: carrying immutable data transparently. Here is a detailed comparison to help you decide which to use.

Feature Record Traditional Class
Purpose Transparent, immutable data carrier General-purpose modeling with behavior and state
Fields Implicitly private final, defined by components only Any access modifier, mutable or immutable
Additional instance fields Not allowed Allowed
Inheritance Cannot extend classes (implicitly extends Record), is final Can extend one class, can be subclassed
Interfaces Can implement any number of interfaces Can implement any number of interfaces
Constructor Canonical constructor auto-generated, compact constructor available Must write constructors manually
equals/hashCode Auto-generated based on all components Must write manually (or use IDE/Lombok)
toString Auto-generated with component names and values Must write manually
Mutability Immutable by design (fields are final, no setters) Can be mutable or immutable
Boilerplate Minimal -- one line for basic records Significant -- constructor, getters, equals, hashCode, toString
Serialization Safe -- uses canonical constructor for deserialization Fragile -- bypasses constructors, can create invalid objects
Pattern matching Supports record patterns and destructuring (Java 21+) Supports only type patterns
Best for DTOs, value objects, API responses, map keys, config Entities with identity, mutable state, complex behavior

When to Use What

Scenario Use Why
Data transfer between layers (DTO) Record Pure data, no behavior, immutable
Database entity with JPA Class JPA requires a no-arg constructor and mutable setters
Value object (Money, Email, DateRange) Record Equality based on value, immutable
Mutable builder or accumulator Class State changes over time
Fixed set of constants Enum Finite, known instances
API response payload Record Structured data, easy to destructure
Complex domain model with behavior Class Encapsulation, inheritance, mutable state
Composite map key Record Auto-generated equals/hashCode

12. Common Use Cases

This section shows real-world scenarios where records shine. Each example is something you will encounter in production Java code.

12.1 DTOs (Data Transfer Objects)

DTOs carry data between layers of an application -- from a REST controller to a service, from a service to a repository, or from an API to the client. Records are a perfect fit because DTOs are pure data with no behavior and no identity.

import java.time.LocalDate;
import java.util.List;

public class DtoExample {

    // Request DTO -- data coming in from the client
    record CreateUserRequest(String username, String email, String password) {
        CreateUserRequest {
            if (username == null || username.isBlank())
                throw new IllegalArgumentException("Username is required");
            if (email == null || !email.contains("@"))
                throw new IllegalArgumentException("Valid email is required");
            if (password == null || password.length() < 8)
                throw new IllegalArgumentException("Password must be at least 8 characters");

            username = username.strip().toLowerCase();
            email = email.strip().toLowerCase();
        }
    }

    // Response DTO -- data going out to the client (no password!)
    record UserResponse(long id, String username, String email, LocalDate createdAt) { }

    // Paginated response
    record Page(List items, int pageNumber, int pageSize, long totalElements) {
        int totalPages() {
            return (int) Math.ceil((double) totalElements / pageSize);
        }

        boolean hasNext() {
            return pageNumber < totalPages() - 1;
        }

        boolean hasPrevious() {
            return pageNumber > 0;
        }
    }

    public static void main(String[] args) {
        // Creating a request DTO with validation
        var request = new CreateUserRequest("  JohnDoe  ", "john@example.com", "securePass123");
        System.out.println(request);
        // Output: CreateUserRequest[username=johndoe, email=john@example.com, password=securePass123]

        // Creating a response DTO
        var response = new UserResponse(1L, "johndoe", "john@example.com", LocalDate.now());
        System.out.println(response);
        // Output: UserResponse[id=1, username=johndoe, email=john@example.com, createdAt=2026-02-28]

        // Paginated response
        var page = new Page<>(
            List.of(response),
            0, 10, 42
        );
        System.out.println("Total pages: " + page.totalPages());  // Output: Total pages: 5
        System.out.println("Has next: " + page.hasNext());         // Output: Has next: true
    }
}

12.2 Composite Map Keys

One of the most practical uses of records is as composite map keys. Because records auto-generate equals() and hashCode() based on all components, they work correctly in HashMap and HashSet without any extra effort. With traditional classes, forgetting to implement these methods is a common and subtle bug.

import java.util.HashMap;
import java.util.Map;

public class CompositeKeyDemo {

    // Composite key for a cache
    record CacheKey(String userId, String resource, String locale) { }

    // Composite key for a grid or matrix
    record GridCell(int row, int col) { }

    public static void main(String[] args) {
        // Using record as HashMap key
        Map cache = new HashMap<>();
        cache.put(new CacheKey("user-42", "/api/profile", "en-US"), "{\"name\":\"Alice\"}");
        cache.put(new CacheKey("user-42", "/api/profile", "fr-FR"), "{\"nom\":\"Alice\"}");
        cache.put(new CacheKey("user-99", "/api/profile", "en-US"), "{\"name\":\"Bob\"}");

        // Lookup works correctly because equals/hashCode are based on all components
        CacheKey lookup = new CacheKey("user-42", "/api/profile", "en-US");
        System.out.println(cache.get(lookup));
        // Output: {"name":"Alice"}

        // Grid example
        Map board = new HashMap<>();
        board.put(new GridCell(0, 0), "X");
        board.put(new GridCell(0, 1), "O");
        board.put(new GridCell(1, 1), "X");

        System.out.println("Center: " + board.get(new GridCell(1, 1)));
        // Output: Center: X
    }
}

12.3 Stream Intermediate Results

When processing data with streams, you often need to carry multiple values through the pipeline. Records (especially local records) provide a clean, type-safe way to do this instead of using arrays, maps, or Object[].

import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class StreamRecordDemo {

    record Sale(String product, String region, double amount) { }

    public static void main(String[] args) {
        var sales = List.of(
            new Sale("Laptop",  "North", 1200.00),
            new Sale("Laptop",  "South", 1100.00),
            new Sale("Laptop",  "North", 1300.00),
            new Sale("Phone",   "North",  800.00),
            new Sale("Phone",   "South",  750.00),
            new Sale("Tablet",  "North",  500.00),
            new Sale("Tablet",  "South",  450.00),
            new Sale("Tablet",  "South",  520.00)
        );

        // Local record for aggregated results
        record ProductSummary(String product, long count, double total, double average) { }

        // Group by product and compute summary statistics
        var summaries = sales.stream()
                .collect(Collectors.groupingBy(Sale::product))
                .entrySet()
                .stream()
                .map(entry -> {
                    String product = entry.getKey();
                    List productSales = entry.getValue();
                    long count = productSales.size();
                    double total = productSales.stream().mapToDouble(Sale::amount).sum();
                    double avg = total / count;
                    return new ProductSummary(product, count, total, avg);
                })
                .sorted(Comparator.comparingDouble(ProductSummary::total).reversed())
                .toList();

        System.out.println("Sales Summary (sorted by total revenue):");
        summaries.forEach(s -> System.out.printf(
            "  %-8s | %d sales | $%,.2f total | $%,.2f avg%n",
            s.product(), s.count(), s.total(), s.average()));
        // Output:
        // Sales Summary (sorted by total revenue):
        //   Laptop   | 3 sales | $3,600.00 total | $1,200.00 avg
        //   Phone    | 2 sales | $1,550.00 total | $775.00 avg
        //   Tablet   | 3 sales | $1,470.00 total | $490.00 avg
    }
}

13. Limitations and Restrictions

Records are deliberately restricted to keep them focused on their purpose as transparent data carriers. Understanding these restrictions helps you avoid surprises and choose the right tool for the job.

Restriction Explanation
Cannot extend classes Records implicitly extend java.lang.Record. Since Java does not support multiple inheritance of classes, you cannot extend any other class.
Cannot be abstract Records are implicitly final. They cannot be declared abstract and cannot be subclassed.
All fields are final The component fields are implicitly private final. There are no setter methods and no way to change field values after construction.
Cannot declare instance fields The only fields a record can have are those declared in the component list. You can declare static fields but not additional instance fields.
No native declarations Record methods cannot be declared native.
Component types are restricted in annotations Annotations on record components apply to multiple targets (field, accessor method, constructor parameter). Annotation authors must use @Target to control where they apply.
public class RecordLimitations {

    record Point(int x, int y) { }

    // COMPILE ERROR: cannot extend a class
    // record Point3D(int x, int y, int z) extends Point { }

    // COMPILE ERROR: cannot be abstract
    // abstract record Shape(String type) { }

    // COMPILE ERROR: cannot add instance fields
    // record Person(String name) {
    //     int age;  // Not allowed -- only static fields are permitted
    // }

    // COMPILE ERROR: cannot have a no-arg constructor that does not delegate
    // record Config(String key, String value) {
    //     Config() { }  // Must delegate to canonical: this(key, value)
    // }

    // This IS allowed: static fields and methods
    record Constants(String name, double value) {
        static final Constants PI = new Constants("pi", Math.PI);
        static final Constants E  = new Constants("e", Math.E);

        // Static field -- OK
        static int instanceCount = 0;
    }

    // This IS allowed: implementing interfaces
    record Timestamp(long epochMillis) implements Comparable {
        @Override
        public int compareTo(Timestamp other) {
            return Long.compare(this.epochMillis, other.epochMillis);
        }
    }

    public static void main(String[] args) {
        // Records are final -- cannot be subclassed
        System.out.println(Point.class.getSuperclass());
        // Output: class java.lang.Record

        // Verify the class is final
        System.out.println(java.lang.reflect.Modifier.isFinal(Point.class.getModifiers()));
        // Output: true

        // isRecord() check (Java 16+)
        System.out.println(Point.class.isRecord());
        // Output: true

        // Get record components via reflection
        var components = Point.class.getRecordComponents();
        for (var comp : components) {
            System.out.println(comp.getName() + " : " + comp.getType().getSimpleName());
        }
        // Output:
        // x : int
        // y : int
    }
}

14. Best Practices

After working with records across projects of varying sizes, several patterns consistently lead to clean, maintainable code. Here are the practices that experienced Java developers follow.

14.1 Use Records for Data, Not Behavior

Records should primarily carry data. Adding a few convenience methods is fine, but if you find yourself adding complex business logic, you probably want a class instead.

14.2 Always Validate in the Compact Constructor

Since every constructor must delegate to the canonical constructor, validation in the compact constructor guarantees that no invalid instance can ever exist -- including deserialized instances.

14.3 Defensively Copy Mutable Components

If a record component is a mutable type (List, Date, arrays), always create an unmodifiable copy in the compact constructor. A record that appears immutable but holds mutable references is a bug waiting to happen.

14.4 Prefer Records Over Lombok @Data for New Code

If you are writing new Java 16+ code and need an immutable data carrier, use a record instead of Lombok's @Data or @Value. Records are a language feature -- they require no annotation processor, no IDE plugin, and they work with pattern matching.

14.5 Use Records with Sealed Interfaces

Records and sealed interfaces are designed to work together. A sealed interface with record implementations gives you exhaustive pattern matching -- the compiler guarantees that your switch handles every case.

import java.util.List;

public class BestPracticesDemo {

    // Best practice: sealed interface + records = exhaustive pattern matching
    sealed interface PaymentMethod permits CreditCard, BankTransfer, DigitalWallet { }

    record CreditCard(String number, String expiry, String cvv) implements PaymentMethod {
        CreditCard {
            // Validate and mask the number
            if (number == null || number.replaceAll("\\s", "").length() != 16) {
                throw new IllegalArgumentException("Credit card number must be 16 digits");
            }
            number = number.replaceAll("\\s", "");
            if (cvv == null || !cvv.matches("\\d{3,4}")) {
                throw new IllegalArgumentException("CVV must be 3 or 4 digits");
            }
        }

        // Convenience: masked number for display
        String maskedNumber() {
            return "****-****-****-" + number.substring(12);
        }
    }

    record BankTransfer(String iban, String bic) implements PaymentMethod {
        BankTransfer {
            if (iban == null || iban.isBlank()) {
                throw new IllegalArgumentException("IBAN is required");
            }
            iban = iban.replaceAll("\\s", "").toUpperCase();
        }
    }

    record DigitalWallet(String provider, String accountId) implements PaymentMethod {
        DigitalWallet {
            if (provider == null || provider.isBlank()) {
                throw new IllegalArgumentException("Provider is required");
            }
            provider = provider.strip();
        }
    }

    // Exhaustive switch -- compiler ensures all cases are handled
    static String processPayment(PaymentMethod method, double amount) {
        return switch (method) {
            case CreditCard cc ->
                String.format("Charging $%.2f to card %s", amount, cc.maskedNumber());
            case BankTransfer bt ->
                String.format("Transferring $%.2f to IBAN %s", amount, bt.iban());
            case DigitalWallet dw ->
                String.format("Sending $%.2f via %s (%s)", amount, dw.provider(), dw.accountId());
        };
    }

    public static void main(String[] args) {
        List payments = List.of(
            new CreditCard("4111 1111 1111 1234", "12/25", "123"),
            new BankTransfer("DE89 3704 0044 0532 0130 00", "COBADEFFXXX"),
            new DigitalWallet("PayPal", "alice@example.com")
        );

        payments.forEach(pm ->
            System.out.println(processPayment(pm, 99.99)));
        // Output:
        // Charging $99.99 to card ****-****-****-1234
        // Transferring $99.99 to IBAN DE89370400440532013000
        // Sending $99.99 via PayPal (alice@example.com)
    }
}

15. Complete Practical Example: API Response Handling System

This final example ties together everything we have covered -- generic records, sealed interfaces, record patterns, compact constructors, defensive copying, and stream processing -- into a realistic API response handling system. This is the kind of code you would write in a production Spring Boot or microservice application.

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

public class ApiResponseSystem {

    // ─── Domain Records ───────────────────────────────────────────────

    record Address(String street, String city, String state, String zipCode, String country) {
        Address {
            Objects.requireNonNull(street, "Street is required");
            Objects.requireNonNull(city, "City is required");
            Objects.requireNonNull(country, "Country is required");
            country = country.toUpperCase();
        }

        String formatted() {
            return String.format("%s, %s, %s %s, %s", street, city, state, zipCode, country);
        }
    }

    record User(long id, String name, String email, Address address, List roles) {
        User {
            Objects.requireNonNull(name, "Name is required");
            Objects.requireNonNull(email, "Email is required");
            email = email.strip().toLowerCase();
            roles = roles == null ? List.of() : List.copyOf(roles);
        }

        boolean hasRole(String role) {
            return roles.contains(role);
        }
    }

    record ErrorDetail(String field, String code, String message) {
        ErrorDetail {
            Objects.requireNonNull(code, "Error code is required");
            Objects.requireNonNull(message, "Error message is required");
        }
    }

    // ─── Generic API Response ─────────────────────────────────────────

    sealed interface ApiResponse {
        Instant timestamp();

        record Success(T data, Instant timestamp, Map metadata)
                implements ApiResponse {
            Success(T data) {
                this(data, Instant.now(), Map.of());
            }

            Success(T data, Map metadata) {
                this(data, Instant.now(), metadata);
            }

            Success {
                Objects.requireNonNull(data, "Success response must have data");
                metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
            }
        }

        record Failure(List errors, int statusCode, Instant timestamp)
                implements ApiResponse {
            Failure(List errors, int statusCode) {
                this(errors, statusCode, Instant.now());
            }

            Failure {
                if (errors == null || errors.isEmpty()) {
                    throw new IllegalArgumentException("Failure must have at least one error");
                }
                errors = List.copyOf(errors);
                if (statusCode < 400 || statusCode > 599) {
                    throw new IllegalArgumentException(
                        "Status code must be 4xx or 5xx, got: " + statusCode);
                }
            }
        }
    }

    // Paginated response built on records
    record PagedResponse(List items, int page, int size, long totalItems) {
        PagedResponse {
            items = items == null ? List.of() : List.copyOf(items);
            if (page < 0) throw new IllegalArgumentException("Page cannot be negative");
            if (size < 1) throw new IllegalArgumentException("Size must be at least 1");
        }

        long totalPages() {
            return (totalItems + size - 1) / size;
        }

        boolean hasNext() {
            return page < totalPages() - 1;
        }
    }

    // ─── Service Layer ────────────────────────────────────────────────

    static ApiResponse fetchUser(long id) {
        // Simulating an API call
        if (id <= 0) {
            return new ApiResponse.Failure<>(
                List.of(new ErrorDetail("id", "INVALID_ID", "User ID must be positive")),
                400
            );
        }
        if (id == 999) {
            return new ApiResponse.Failure<>(
                List.of(new ErrorDetail(null, "NOT_FOUND", "User not found: " + id)),
                404
            );
        }

        User user = new User(
            id,
            "Alice Johnson",
            "alice@example.com",
            new Address("123 Main St", "Springfield", "IL", "62701", "us"),
            List.of("USER", "ADMIN")
        );
        return new ApiResponse.Success<>(user, Map.of("source", "database", "cached", "false"));
    }

    static ApiResponse> fetchUsers(int page, int size) {
        List users = List.of(
            new User(1, "Alice", "alice@example.com",
                new Address("123 Main St", "Springfield", "IL", "62701", "US"),
                List.of("ADMIN")),
            new User(2, "Bob", "bob@example.com",
                new Address("456 Oak Ave", "Portland", "OR", "97201", "US"),
                List.of("USER")),
            new User(3, "Charlie", "charlie@example.com",
                new Address("789 Pine Rd", "Austin", "TX", "73301", "US"),
                List.of("USER", "EDITOR"))
        );

        var paged = new PagedResponse<>(users, page, size, 42);
        return new ApiResponse.Success<>(paged);
    }

    // ─── Response Processing with Pattern Matching ────────────────────

    static void handleUserResponse(ApiResponse response) {
        switch (response) {
            case ApiResponse.Success(User user, var timestamp, var metadata) -> {
                System.out.println("=== User Found ===");
                System.out.println("  Name:    " + user.name());
                System.out.println("  Email:   " + user.email());
                System.out.println("  Address: " + user.address().formatted());
                System.out.println("  Roles:   " + String.join(", ", user.roles()));
                System.out.println("  Is admin: " + user.hasRole("ADMIN"));
                System.out.println("  Metadata: " + metadata);
            }

            case ApiResponse.Failure(var errors, int status, var timestamp) -> {
                System.out.println("=== Request Failed (HTTP " + status + ") ===");
                errors.forEach(e -> System.out.printf(
                    "  [%s] %s: %s%n",
                    e.code(), e.field() != null ? e.field() : "general", e.message()));
            }
        }
    }

    // ─── Main ─────────────────────────────────────────────────────────

    public static void main(String[] args) {
        // Scenario 1: Successful user fetch
        System.out.println("--- Fetch user 1 ---");
        handleUserResponse(fetchUser(1));
        // Output:
        // --- Fetch user 1 ---
        // === User Found ===
        //   Name:    Alice Johnson
        //   Email:   alice@example.com
        //   Address: 123 Main St, Springfield, IL 62701, US
        //   Roles:   USER, ADMIN
        //   Is admin: true
        //   Metadata: {source=database, cached=false}

        // Scenario 2: User not found
        System.out.println("\n--- Fetch user 999 ---");
        handleUserResponse(fetchUser(999));
        // Output:
        // --- Fetch user 999 ---
        // === Request Failed (HTTP 404) ===
        //   [NOT_FOUND] general: User not found: 999

        // Scenario 3: Invalid request
        System.out.println("\n--- Fetch user -1 ---");
        handleUserResponse(fetchUser(-1));
        // Output:
        // --- Fetch user -1 ---
        // === Request Failed (HTTP 400) ===
        //   [INVALID_ID] id: User ID must be positive

        // Scenario 4: Paginated listing with stream processing
        System.out.println("\n--- Fetch users page 0 ---");
        var usersResponse = fetchUsers(0, 10);

        if (usersResponse instanceof ApiResponse.Success>(
                PagedResponse paged, var ts, var meta)) {

            System.out.println("Page " + (paged.page() + 1) + " of " + paged.totalPages());
            System.out.println("Has next page: " + paged.hasNext());

            // Stream processing: find admins
            var admins = paged.items().stream()
                    .filter(u -> u.hasRole("ADMIN"))
                    .map(User::name)
                    .toList();
            System.out.println("Admins on this page: " + admins);

            // Stream processing: group by state
            var byState = paged.items().stream()
                    .collect(Collectors.groupingBy(
                        u -> u.address().state(),
                        Collectors.mapping(User::name, Collectors.toList())));
            System.out.println("Users by state: " + byState);
        }
        // Output:
        // --- Fetch users page 0 ---
        // Page 1 of 5
        // Has next page: true
        // Admins on this page: [Alice]
        // Users by state: {TX=[Charlie], OR=[Bob], IL=[Alice]}
    }
}

Summary

Java Records are one of the most impactful additions to the language since lambdas. They eliminate boilerplate, enforce immutability, and enable powerful pattern matching. Here is what to remember:

  • Records are transparent, immutable data carriers -- they replace verbose POJOs with a single-line declaration.
  • The compiler generates the constructor, accessors (without get prefix), equals(), hashCode(), and toString().
  • Use compact constructors for validation, normalization, and defensive copying.
  • Records can implement interfaces and contain instance methods, static methods, and static fields -- but cannot extend classes or declare instance fields.
  • Generic records enable reusable container types like Pair, Result, and Page.
  • Record patterns (Java 21+) allow destructuring in instanceof and switch, making records even more powerful when combined with sealed interfaces.
  • Records are serialization-safe -- deserialization uses the canonical constructor, so validation always runs.
  • Use records for DTOs, value objects, API responses, composite map keys, and stream intermediate results.
  • Do not use records for JPA entities, mutable objects, or classes with complex behavior and inheritance hierarchies.

Start using records today in any Java 16+ project. Your codebase will be shorter, safer, and easier to maintain.

July 27, 2023

Java – Method Reference

1. What is a Method Reference?

Imagine you are writing directions for someone. You could say: “Go to the kitchen, open the drawer, pick up the knife, and cut the bread.” Or you could simply say: “Cut the bread” — because the person already knows how to do it. A method reference works the same way. Instead of spelling out the steps inside a lambda expression, you point directly to an existing method that already does the work.

A method reference is a shorthand notation for a lambda expression that calls a single existing method. Introduced in Java 8 alongside lambdas and the Stream API, method references use the :: operator (double colon) to refer to a method without invoking it.

Here is the core idea: if your lambda expression does nothing more than call an existing method, you can replace the lambda with a method reference.

import java.util.Arrays;
import java.util.List;

public class MethodReferenceIntro {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        // Lambda expression -- spells out the call
        names.forEach(name -> System.out.println(name));

        // Method reference -- points directly to the method
        names.forEach(System.out::println);

        // Both produce the same output:
        // Alice
        // Bob
        // Charlie
    }
}

In the example above, the lambda name -> System.out.println(name) receives a parameter and immediately passes it to println. Since the lambda is just a pass-through — it adds no extra logic — the method reference System.out::println is a cleaner, more readable alternative.

Key points about method references:

  • Syntactic sugar — A method reference is not a new feature in the JVM. It compiles to the same bytecode as the equivalent lambda expression. It is purely a readability improvement.
  • The :: operator — Separates the class or instance from the method name. It tells the compiler “look up this method and use it as the implementation of the functional interface.”
  • No parentheses — You write String::toUpperCase, not String::toUpperCase(). Adding parentheses would invoke the method immediately rather than referencing it.
  • Must match a functional interface — The referenced method’s signature (parameter types and return type) must be compatible with the single abstract method of the target functional interface.
  • Four types — Java defines exactly four kinds of method references, each covering a different scenario.

2. The Four Types of Method References

Every method reference falls into one of four categories. The table below summarizes each type, its syntax, and what the equivalent lambda expression looks like.

Type Syntax Lambda Equivalent Example
Static method ClassName::staticMethod (args) -> ClassName.staticMethod(args) Integer::parseInt
Instance method of a particular object instance::method (args) -> instance.method(args) System.out::println
Instance method of an arbitrary object ClassName::instanceMethod (obj, args) -> obj.instanceMethod(args) String::compareToIgnoreCase
Constructor ClassName::new (args) -> new ClassName(args) ArrayList::new

The first two are straightforward. The third — instance method of an arbitrary object — is the one that confuses most developers. We will dedicate extra attention to it in section 5. Let us now explore each type in detail.

3. Static Method Reference (ClassName::staticMethodName)

A static method reference points to a static method on a class. The compiler maps the functional interface’s parameters directly to the static method’s parameters.

Pattern:

Lambda Method Reference
(args) -> ClassName.staticMethod(args) ClassName::staticMethod

The arguments from the functional interface are passed directly to the static method in the same order.

3.1 Built-in Static Methods

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StaticMethodReference {
    public static void main(String[] args) {

        // --- Integer::parseInt ---
        // Lambda:   s -> Integer.parseInt(s)
        // Reference: Integer::parseInt
        List numberStrings = Arrays.asList("1", "2", "3", "4", "5");

        List numbers = numberStrings.stream()
                .map(Integer::parseInt)       // static method reference
                .collect(Collectors.toList());

        System.out.println(numbers);
        // Output: [1, 2, 3, 4, 5]


        // --- Math::max ---
        // Lambda:   (a, b) -> Math.max(a, b)
        // Reference: Math::max
        int result = numbers.stream()
                .reduce(Math::max)            // static method reference
                .orElse(0);

        System.out.println("Max: " + result);
        // Output: Max: 5


        // --- String::valueOf ---
        // Lambda:   n -> String.valueOf(n)
        // Reference: String::valueOf
        List backToStrings = numbers.stream()
                .map(String::valueOf)         // static method reference
                .collect(Collectors.toList());

        System.out.println(backToStrings);
        // Output: [1, 2, 3, 4, 5]


        // --- Collections::sort with a list ---
        // Math::abs as a function
        List values = Arrays.asList(-5, 3, -1, 4, -2);
        values.sort((a, b) -> Integer.compare(Math.abs(a), Math.abs(b)));
        System.out.println("Sorted by absolute value: " + values);
        // Output: Sorted by absolute value: [-1, -2, 3, 4, -5]
    }
}

3.2 Custom Static Methods

Method references are not limited to the standard library. You can reference any static method you write, as long as its signature matches the functional interface.

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class CustomStaticMethodReference {

    // Custom static method: checks if a number is even
    public static boolean isEven(int number) {
        return number % 2 == 0;
    }

    // Custom static method: formats a name
    public static String formatName(String name) {
        return name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
    }

    // Custom static method: calculates the square
    public static int square(int n) {
        return n * n;
    }

    public static void main(String[] args) {

        // Using custom static method reference as a Predicate
        List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

        List evens = numbers.stream()
                .filter(CustomStaticMethodReference::isEven)   // custom static ref
                .collect(Collectors.toList());

        System.out.println("Even numbers: " + evens);
        // Output: Even numbers: [2, 4, 6, 8]


        // Using custom static method reference as a Function
        List rawNames = Arrays.asList("aLICE", "bOB", "cHARLIE");

        List formatted = rawNames.stream()
                .map(CustomStaticMethodReference::formatName)  // custom static ref
                .collect(Collectors.toList());

        System.out.println("Formatted: " + formatted);
        // Output: Formatted: [Alice, Bob, Charlie]


        // Using custom static method reference as a UnaryOperator
        List squares = numbers.stream()
                .map(CustomStaticMethodReference::square)      // custom static ref
                .collect(Collectors.toList());

        System.out.println("Squares: " + squares);
        // Output: Squares: [1, 4, 9, 16, 25, 36, 49, 64]


        // Storing a method reference in a variable
        Predicate evenCheck = CustomStaticMethodReference::isEven;
        Function nameFormatter = CustomStaticMethodReference::formatName;

        System.out.println("Is 7 even? " + evenCheck.test(7));
        // Output: Is 7 even? false

        System.out.println("Format 'dAVID': " + nameFormatter.apply("dAVID"));
        // Output: Format 'dAVID': David
    }
}

4. Instance Method Reference of a Particular Object (instance::methodName)

This type references an instance method on a specific, already-existing object. The object is captured at the time the method reference is created, and the functional interface’s parameters are passed as arguments to that method.

Pattern:

Lambda Method Reference
(args) -> existingObject.method(args) existingObject::method

The key distinction from the arbitrary-object type (section 5) is that here the object is known and fixed before the method reference is used. The most iconic example is System.out::printlnSystem.out is a specific PrintStream instance.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class ParticularObjectMethodReference {
    public static void main(String[] args) {

        // --- System.out::println ---
        // System.out is a specific PrintStream object
        // Lambda:   item -> System.out.println(item)
        // Reference: System.out::println
        List languages = Arrays.asList("Java", "Python", "Go", "Rust");
        languages.forEach(System.out::println);
        // Output:
        // Java
        // Python
        // Go
        // Rust


        // --- Custom object reference ---
        StringBuilder sb = new StringBuilder();

        // Lambda:   s -> sb.append(s)
        // Reference: sb::append
        languages.forEach(sb::append);
        System.out.println("Appended: " + sb);
        // Output: Appended: JavaPythonGoRust


        // --- Using a specific String instance ---
        String prefix = "Hello, ";

        // Lambda:   name -> prefix.concat(name)
        // Reference: prefix::concat
        List greetings = languages.stream()
                .map(prefix::concat)
                .collect(Collectors.toList());

        System.out.println(greetings);
        // Output: [Hello, Java, Hello, Python, Hello, Go, Hello, Rust]
    }
}

4.1 Using with Custom Objects

Method references on particular objects are especially useful when you have a service or helper object with instance methods you want to use in a stream pipeline.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CustomObjectMethodReference {

    // A validator object with instance methods
    static class EmailValidator {
        private final String requiredDomain;

        public EmailValidator(String requiredDomain) {
            this.requiredDomain = requiredDomain;
        }

        public boolean isValid(String email) {
            return email != null
                    && email.contains("@")
                    && email.endsWith(requiredDomain);
        }
    }

    // A formatter object
    static class NameFormatter {
        private final String title;

        public NameFormatter(String title) {
            this.title = title;
        }

        public String format(String name) {
            return title + " " + name;
        }
    }

    public static void main(String[] args) {
        // Create specific objects
        EmailValidator companyValidator = new EmailValidator("@company.com");
        NameFormatter doctorFormatter = new NameFormatter("Dr.");

        List emails = Arrays.asList(
                "alice@company.com", "bob@gmail.com",
                "charlie@company.com", "dave@yahoo.com"
        );

        // Use instance method reference on a particular object
        // Lambda:   email -> companyValidator.isValid(email)
        // Reference: companyValidator::isValid
        List validEmails = emails.stream()
                .filter(companyValidator::isValid)
                .collect(Collectors.toList());

        System.out.println("Valid company emails: " + validEmails);
        // Output: Valid company emails: [alice@company.com, charlie@company.com]


        List names = Arrays.asList("Smith", "Johnson", "Williams");

        // Lambda:   name -> doctorFormatter.format(name)
        // Reference: doctorFormatter::format
        List formattedNames = names.stream()
                .map(doctorFormatter::format)
                .collect(Collectors.toList());

        System.out.println("Formatted: " + formattedNames);
        // Output: Formatted: [Dr. Smith, Dr. Johnson, Dr. Williams]
    }
}

4.2 The “this” and “super” References

Inside an instance method, you can use this::methodName or super::methodName as method references. this refers to the current object, and super refers to the parent class implementation.

import java.util.Arrays;
import java.util.List;

public class ThisSuperReference {

    static class Animal {
        public void speak(String sound) {
            System.out.println("Animal says: " + sound);
        }
    }

    static class Dog extends Animal {
        @Override
        public void speak(String sound) {
            System.out.println("Dog barks: " + sound);
        }

        public void makeNoise(String sound) {
            System.out.println("Dog noise: " + sound.toUpperCase());
        }

        public void demonstrate() {
            List sounds = Arrays.asList("woof", "bark", "growl");

            System.out.println("--- Using this::makeNoise ---");
            sounds.forEach(this::makeNoise);
            // Dog noise: WOOF
            // Dog noise: BARK
            // Dog noise: GROWL

            System.out.println("--- Using this::speak (Dog's override) ---");
            sounds.forEach(this::speak);
            // Dog barks: woof
            // Dog barks: bark
            // Dog barks: growl

            System.out.println("--- Using super::speak (Animal's version) ---");
            sounds.forEach(super::speak);
            // Animal says: woof
            // Animal says: bark
            // Animal says: growl
        }
    }

    public static void main(String[] args) {
        new Dog().demonstrate();
    }
}

5. Instance Method Reference of an Arbitrary Object (ClassName::instanceMethodName)

This is the type that trips up most developers. The syntax looks identical to a static method reference — you write ClassName::methodName — but the method is not static. It is an instance method. The difference is in which object the method is called on.

Pattern:

Lambda Method Reference
(obj) -> obj.instanceMethod() ClassName::instanceMethod
(obj, arg) -> obj.instanceMethod(arg) ClassName::instanceMethod

How it works: The first parameter of the functional interface becomes the object on which the method is invoked. Any remaining parameters become the method’s arguments. The “arbitrary” object is whichever object happens to flow through the stream or be supplied by the functional interface at runtime.

Let us break this down step by step. Consider a list of strings and the task of converting each to uppercase:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class ArbitraryObjectBasic {
    public static void main(String[] args) {
        List words = Arrays.asList("hello", "world", "java");

        // Lambda version: each element 's' calls its own toUpperCase()
        List upper1 = words.stream()
                .map(s -> s.toUpperCase())
                .collect(Collectors.toList());

        // Method reference version: String::toUpperCase
        // The stream element (a String) becomes the object on which toUpperCase() is called
        List upper2 = words.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(upper1);  // [HELLO, WORLD, JAVA]
        System.out.println(upper2);  // [HELLO, WORLD, JAVA]
    }
}

In String::toUpperCase, we are telling Java: “For each element in the stream, call its toUpperCase() method.” The word “arbitrary” means we do not know in advance which specific String object will be used — it is whichever object the stream provides at that moment.

5.1 Understanding the Transformation

This is the critical mental model: the first parameter of the functional interface becomes the target object. The table below shows how this transformation works for common examples.

Method Reference Functional Interface Lambda Equivalent Who calls the method?
String::toUpperCase Function<String, String> s -> s.toUpperCase() The stream element (s)
String::length Function<String, Integer> s -> s.length() The stream element (s)
String::isEmpty Predicate<String> s -> s.isEmpty() The stream element (s)
String::trim Function<String, String> s -> s.trim() The stream element (s)
String::compareTo Comparator<String> (a, b) -> a.compareTo(b) The first parameter (a)
String::compareToIgnoreCase Comparator<String> (a, b) -> a.compareToIgnoreCase(b) The first parameter (a)

Notice the last two rows: when the functional interface has two parameters (like Comparator which takes two arguments), the first becomes the object, and the second becomes the method argument.

5.2 With One Parameter (No-arg instance method)

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class ArbitraryObjectNoArg {
    public static void main(String[] args) {

        // --- String::length ---
        // Function: takes a String, returns its length
        List words = Arrays.asList("Java", "is", "powerful");

        List lengths = words.stream()
                .map(String::length)
                .collect(Collectors.toList());

        System.out.println("Lengths: " + lengths);
        // Output: Lengths: [4, 2, 8]


        // --- String::trim ---
        List messy = Arrays.asList("  hello  ", " world ", "  java  ");

        List trimmed = messy.stream()
                .map(String::trim)
                .collect(Collectors.toList());

        System.out.println("Trimmed: " + trimmed);
        // Output: Trimmed: [hello, world, java]


        // --- String::isEmpty used as a Predicate ---
        List mixed = Arrays.asList("Java", "", "Python", "", "Go");

        List nonEmpty = mixed.stream()
                .filter(s -> !s.isEmpty())     // cannot directly negate a method ref
                .collect(Collectors.toList());

        System.out.println("Non-empty: " + nonEmpty);
        // Output: Non-empty: [Java, Python, Go]
    }
}

5.3 With Two Parameters (Instance method takes an argument)

When the functional interface accepts two parameters, the first parameter becomes the object and the second becomes the method argument. This is common with Comparator and BiFunction.

import java.util.Arrays;
import java.util.List;

public class ArbitraryObjectWithArg {
    public static void main(String[] args) {

        // --- String::compareTo used as Comparator ---
        // Comparator has: int compare(String a, String b)
        // String::compareTo maps to: a.compareTo(b)
        List names = Arrays.asList("Charlie", "Alice", "Bob");

        names.sort(String::compareTo);
        System.out.println("Sorted: " + names);
        // Output: Sorted: [Alice, Bob, Charlie]


        // --- String::compareToIgnoreCase ---
        List mixed = Arrays.asList("banana", "Apple", "cherry");

        mixed.sort(String::compareToIgnoreCase);
        System.out.println("Case-insensitive sort: " + mixed);
        // Output: Case-insensitive sort: [Apple, banana, cherry]


        // --- String::concat used as BiFunction ---
        // BiFunction: (a, b) -> a.concat(b)
        java.util.function.BiFunction joiner = String::concat;

        String result = joiner.apply("Hello, ", "World!");
        System.out.println(result);
        // Output: Hello, World!
    }
}

5.4 With Custom Classes

The arbitrary-object pattern works with your own classes too. If a stream contains objects of type Person, you can write Person::getName to call getName() on each Person object flowing through the stream.

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class ArbitraryObjectCustomClass {

    static class Person {
        private final String name;
        private final int age;

        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() { return name; }
        public int getAge()     { return age; }

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

    public static void main(String[] args) {
        List people = Arrays.asList(
                new Person("Charlie", 30),
                new Person("Alice", 25),
                new Person("Bob", 35)
        );

        // --- Person::getName used as Function ---
        // Lambda: person -> person.getName()
        List names = people.stream()
                .map(Person::getName)
                .collect(Collectors.toList());

        System.out.println("Names: " + names);
        // Output: Names: [Charlie, Alice, Bob]


        // --- Person::getAge used as Function ---
        List ages = people.stream()
                .map(Person::getAge)
                .collect(Collectors.toList());

        System.out.println("Ages: " + ages);
        // Output: Ages: [30, 25, 35]


        // --- Sorting with Comparator.comparing and method reference ---
        people.sort(Comparator.comparing(Person::getName));
        System.out.println("Sorted by name: " + people);
        // Output: Sorted by name: [Alice (25), Bob (35), Charlie (30)]

        people.sort(Comparator.comparing(Person::getAge));
        System.out.println("Sorted by age: " + people);
        // Output: Sorted by age: [Alice (25), Charlie (30), Bob (35)]

        people.sort(Comparator.comparing(Person::getAge).reversed());
        System.out.println("Sorted by age descending: " + people);
        // Output: Sorted by age descending: [Bob (35), Charlie (30), Alice (25)]
    }
}

5.5 How Does the Compiler Know Which Type?

You might wonder: when you write String::toUpperCase, how does the compiler know this is an arbitrary-object instance method reference and not a static method reference? The answer is simple: the compiler checks whether toUpperCase is a static method or an instance method on the String class. Since String.toUpperCase() is an instance method, the compiler resolves it as the arbitrary-object type.

If String had a static toUpperCase(String s) method, there would be an ambiguity, and the compiler would raise an error. Fortunately, this situation is rare in practice.

6. Constructor Reference (ClassName::new)

A constructor reference points to a constructor of a class. The compiler determines which constructor to call based on the functional interface’s parameter types.

Pattern:

Lambda Method Reference
() -> new ClassName() ClassName::new
(arg) -> new ClassName(arg) ClassName::new
(arg1, arg2) -> new ClassName(arg1, arg2) ClassName::new

The same ClassName::new syntax works for all constructors. The compiler infers which constructor to use from the context (the functional interface it needs to match).

6.1 No-Argument Constructor (Supplier)

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;

public class ConstructorReferenceNoArg {
    public static void main(String[] args) {

        // --- ArrayList::new as Supplier> ---
        // Lambda:   () -> new ArrayList<>()
        // Reference: ArrayList::new
        Supplier> listFactory = ArrayList::new;

        List list1 = listFactory.get();
        list1.add("Java");
        list1.add("Python");
        System.out.println("List 1: " + list1);
        // Output: List 1: [Java, Python]

        // Each call to get() creates a new instance
        List list2 = listFactory.get();
        System.out.println("List 2 (empty): " + list2);
        // Output: List 2 (empty): []


        // --- HashMap::new ---
        Supplier> mapFactory = HashMap::new;
        HashMap scores = mapFactory.get();
        scores.put("Alice", 95);
        System.out.println("Scores: " + scores);
        // Output: Scores: {Alice=95}


        // --- StringBuilder::new ---
        Supplier sbFactory = StringBuilder::new;
        StringBuilder sb = sbFactory.get();
        sb.append("Hello").append(" World");
        System.out.println(sb);
        // Output: Hello World
    }
}

6.2 One-Argument Constructor (Function)

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

public class ConstructorReferenceOneArg {

    static class User {
        private final String name;

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

        @Override
        public String toString() {
            return "User{name='" + name + "'}";
        }
    }

    public static void main(String[] args) {

        // --- User::new as Function ---
        // Lambda:   name -> new User(name)
        // Reference: User::new
        Function userFactory = User::new;

        User alice = userFactory.apply("Alice");
        System.out.println(alice);
        // Output: User{name='Alice'}


        // --- Using constructor reference in a stream ---
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        List users = names.stream()
                .map(User::new)               // creates a new User for each name
                .collect(Collectors.toList());

        System.out.println(users);
        // Output: [User{name='Alice'}, User{name='Bob'}, User{name='Charlie'}]


        // --- Integer::new (deprecated, prefer Integer::valueOf) ---
        // Lambda:   s -> new Integer(s)   // deprecated since Java 9
        // Better:   Integer::valueOf
        List numberStrings = Arrays.asList("10", "20", "30");

        List numbers = numberStrings.stream()
                .map(Integer::valueOf)
                .collect(Collectors.toList());

        System.out.println("Numbers: " + numbers);
        // Output: Numbers: [10, 20, 30]
    }
}

6.3 Two-Argument Constructor (BiFunction)

import java.util.function.BiFunction;

public class ConstructorReferenceTwoArg {

    static class Product {
        private final String name;
        private final double price;

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

        @Override
        public String toString() {
            return name + " ($" + String.format("%.2f", price) + ")";
        }
    }

    public static void main(String[] args) {

        // --- Product::new as BiFunction ---
        // Lambda:   (name, price) -> new Product(name, price)
        // Reference: Product::new
        BiFunction productFactory = Product::new;

        Product laptop = productFactory.apply("Laptop", 999.99);
        Product phone = productFactory.apply("Phone", 699.99);

        System.out.println(laptop);
        // Output: Laptop ($999.99)

        System.out.println(phone);
        // Output: Phone ($699.99)
    }
}

6.4 Array Constructor Reference (Type[]::new)

A special form of constructor reference creates arrays. This is particularly useful with the toArray() method on streams, which needs an IntFunction<T[]> that takes a size and returns an array of that size.

import java.util.Arrays;
import java.util.List;
import java.util.function.IntFunction;
import java.util.stream.Stream;

public class ArrayConstructorReference {
    public static void main(String[] args) {

        // --- String[]::new with Stream.toArray() ---
        List list = Arrays.asList("Java", "Python", "Go");

        // Without array constructor reference: must specify how to create the array
        // Lambda:   size -> new String[size]
        String[] array1 = list.stream().toArray(size -> new String[size]);

        // With array constructor reference
        String[] array2 = list.stream().toArray(String[]::new);

        System.out.println(Arrays.toString(array1));
        // Output: [Java, Python, Go]
        System.out.println(Arrays.toString(array2));
        // Output: [Java, Python, Go]


        // --- int[]::new ---
        IntFunction intArrayFactory = int[]::new;
        int[] intArray = intArrayFactory.apply(5);  // creates int[5]
        System.out.println("int array length: " + intArray.length);
        // Output: int array length: 5


        // --- Common stream-to-array pattern ---
        String[] upperNames = Stream.of("alice", "bob", "charlie")
                .map(String::toUpperCase)
                .toArray(String[]::new);

        System.out.println(Arrays.toString(upperNames));
        // Output: [ALICE, BOB, CHARLIE]


        // --- Integer[]::new ---
        Integer[] numbers = Stream.of(1, 2, 3, 4, 5)
                .filter(n -> n % 2 != 0)
                .toArray(Integer[]::new);

        System.out.println(Arrays.toString(numbers));
        // Output: [1, 3, 5]
    }
}

7. Method Reference vs Lambda Expression

Method references and lambdas are interchangeable when the lambda simply delegates to a single method call. But they are not always interchangeable, and one is not always better than the other. Here is a practical guide to deciding which to use.

7.1 When to Use a Method Reference

  • The lambda does nothing except call a single existing method
  • The method name clearly describes the operation (e.g., String::toUpperCase is self-documenting)
  • You want to reduce visual noise in a stream pipeline

7.2 When to Use a Lambda

  • You need to add extra logic (conditions, transformations, multiple statements)
  • You need to call a method with additional arguments that are not provided by the functional interface
  • The method reference would be ambiguous or confusing
  • You need to negate or combine the result

7.3 Conversion Examples

Lambda Method Reference Recommendation
s -> s.toUpperCase() String::toUpperCase Use method reference — cleaner
s -> System.out.println(s) System.out::println Use method reference — iconic pattern
() -> new ArrayList<>() ArrayList::new Use method reference — concise
s -> Integer.parseInt(s) Integer::parseInt Use method reference — clear intent
s -> s.length() > 3 No direct reference Use lambda — contains extra logic (comparison)
s -> "prefix_" + s No direct reference Use lambda — contains concatenation logic
(a, b) -> a + b Integer::sum Use method reference — Integer.sum() exists for this
x -> !x.isEmpty() Predicate.not(String::isEmpty) Use Predicate.not() (Java 11+) or lambda

7.4 Readability Comparison

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class MethodRefVsLambda {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", null, "Bob", "", "Charlie", null, "Dave");

        // ===== METHOD REFERENCES: cleaner for simple delegation =====

        // Filter nulls: Objects::nonNull is instantly readable
        List nonNull = names.stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        System.out.println("Non-null: " + nonNull);
        // Output: Non-null: [Alice, Bob, , Charlie, Dave]


        // Filter non-null AND non-empty: lambda required (two conditions)
        List nonEmpty = names.stream()
                .filter(Objects::nonNull)
                .filter(s -> !s.isEmpty())    // lambda needed for negation
                .collect(Collectors.toList());
        System.out.println("Non-empty: " + nonEmpty);
        // Output: Non-empty: [Alice, Bob, Charlie, Dave]


        // Java 11+: Predicate.not() lets you use method reference for negation
        List nonEmptyJava11 = names.stream()
                .filter(Objects::nonNull)
                .filter(Predicate.not(String::isEmpty))  // method reference with not()
                .collect(Collectors.toList());
        System.out.println("Non-empty (Java 11): " + nonEmptyJava11);
        // Output: Non-empty (Java 11): [Alice, Bob, Charlie, Dave]


        // ===== LAMBDA: necessary for complex logic =====

        // Transformation with extra logic -- lambda is the right choice
        List processed = names.stream()
                .filter(Objects::nonNull)
                .filter(Predicate.not(String::isEmpty))
                .map(name -> name.length() > 4 ? name.substring(0, 4) + "..." : name)
                .collect(Collectors.toList());
        System.out.println("Processed: " + processed);
        // Output: Processed: [Alic..., Bob, Char..., Dave]
    }
}

8. Method References with Streams

Method references truly shine in stream pipelines, where they make each step in the pipeline read like a sentence. This section shows practical patterns you will use daily.

8.1 map() — Transforming Elements

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamMapExamples {

    static class Employee {
        private final String name;
        private final String department;
        private final double salary;

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

        public String getName()       { return name; }
        public String getDepartment() { return department; }
        public double getSalary()     { return salary; }

        @Override
        public String toString() {
            return name + " [" + department + "] $" + String.format("%.0f", salary);
        }
    }

    public static void main(String[] args) {

        // --- String transformations ---
        List words = Arrays.asList("  hello  ", "  world  ", "  java  ");

        List cleaned = words.stream()
                .map(String::trim)             // arbitrary object: s -> s.trim()
                .map(String::toUpperCase)      // arbitrary object: s -> s.toUpperCase()
                .collect(Collectors.toList());

        System.out.println(cleaned);
        // Output: [HELLO, WORLD, JAVA]


        // --- Extracting fields from objects ---
        List employees = Arrays.asList(
                new Employee("Alice", "Engineering", 95000),
                new Employee("Bob", "Marketing", 75000),
                new Employee("Charlie", "Engineering", 105000),
                new Employee("Diana", "Marketing", 80000)
        );

        List names = employees.stream()
                .map(Employee::getName)        // arbitrary object: e -> e.getName()
                .collect(Collectors.toList());

        System.out.println("Names: " + names);
        // Output: Names: [Alice, Bob, Charlie, Diana]


        // --- Converting types ---
        List numberStrings = Arrays.asList("100", "200", "300");

        List numbers = numberStrings.stream()
                .map(Integer::parseInt)        // static: s -> Integer.parseInt(s)
                .collect(Collectors.toList());

        System.out.println("Numbers: " + numbers);
        // Output: Numbers: [100, 200, 300]
    }
}

8.2 filter() — Selecting Elements

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class StreamFilterExamples {

    static class Task {
        private final String title;
        private final boolean completed;

        public Task(String title, boolean completed) {
            this.title = title;
            this.completed = completed;
        }

        public String getTitle()    { return title; }
        public boolean isCompleted() { return completed; }

        @Override
        public String toString() { return title + (completed ? " [DONE]" : " [TODO]"); }
    }

    public static void main(String[] args) {

        // --- Objects::nonNull for null filtering ---
        List items = Arrays.asList("Java", null, "Python", null, "Go");

        List safe = items.stream()
                .filter(Objects::nonNull)           // static: obj -> Objects.nonNull(obj)
                .collect(Collectors.toList());

        System.out.println("Non-null: " + safe);
        // Output: Non-null: [Java, Python, Go]


        // --- Using a method reference on a boolean getter ---
        List tasks = Arrays.asList(
                new Task("Write code", true),
                new Task("Review PR", false),
                new Task("Deploy", true),
                new Task("Write tests", false)
        );

        // Task::isCompleted as Predicate
        List done = tasks.stream()
                .filter(Task::isCompleted)          // arbitrary object: t -> t.isCompleted()
                .collect(Collectors.toList());

        System.out.println("Done: " + done);
        // Output: Done: [Write code [DONE], Deploy [DONE]]

        // Negate with Predicate.not() (Java 11+)
        List pending = tasks.stream()
                .filter(Predicate.not(Task::isCompleted))
                .collect(Collectors.toList());

        System.out.println("Pending: " + pending);
        // Output: Pending: [Review PR [TODO], Write tests [TODO]]
    }
}

8.3 sorted() — Ordering Elements

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class StreamSortedExamples {

    static class Student {
        private final String name;
        private final double gpa;
        private final int age;

        public Student(String name, double gpa, int age) {
            this.name = name;
            this.gpa = gpa;
            this.age = age;
        }

        public String getName() { return name; }
        public double getGpa()  { return gpa; }
        public int getAge()     { return age; }

        @Override
        public String toString() {
            return name + " (GPA: " + gpa + ", age: " + age + ")";
        }
    }

    public static void main(String[] args) {

        List students = Arrays.asList(
                new Student("Charlie", 3.5, 22),
                new Student("Alice", 3.9, 20),
                new Student("Bob", 3.5, 21),
                new Student("Diana", 3.8, 23)
        );

        // --- Sort by single field ---
        List byName = students.stream()
                .sorted(Comparator.comparing(Student::getName))
                .collect(Collectors.toList());

        System.out.println("By name: " + byName);
        // Output: By name: [Alice (GPA: 3.9, age: 20), Bob (GPA: 3.5, age: 21),
        //                    Charlie (GPA: 3.5, age: 22), Diana (GPA: 3.8, age: 23)]


        // --- Sort by field descending ---
        List byGpaDesc = students.stream()
                .sorted(Comparator.comparing(Student::getGpa).reversed())
                .collect(Collectors.toList());

        System.out.println("By GPA (desc): " + byGpaDesc);
        // Output: By GPA (desc): [Alice (GPA: 3.9, age: 20), Diana (GPA: 3.8, age: 23),
        //                         Charlie (GPA: 3.5, age: 22), Bob (GPA: 3.5, age: 21)]


        // --- Multi-field sort: GPA descending, then name ascending ---
        List multiSort = students.stream()
                .sorted(Comparator.comparing(Student::getGpa).reversed()
                        .thenComparing(Student::getName))
                .collect(Collectors.toList());

        System.out.println("By GPA desc, then name: " + multiSort);
        // Output: By GPA desc, then name: [Alice (GPA: 3.9, age: 20), Diana (GPA: 3.8, age: 23),
        //                                  Bob (GPA: 3.5, age: 21), Charlie (GPA: 3.5, age: 22)]


        // --- Natural order for strings ---
        List words = Arrays.asList("banana", "apple", "cherry");
        words.sort(String::compareToIgnoreCase);
        System.out.println("Sorted words: " + words);
        // Output: Sorted words: [apple, banana, cherry]
    }
}

8.4 forEach() — Consuming Elements

import java.util.Arrays;
import java.util.List;
import java.util.Map;

public class StreamForEachExamples {
    public static void main(String[] args) {

        // --- System.out::println -- the classic method reference ---
        List languages = Arrays.asList("Java", "Python", "Go", "Rust");
        languages.forEach(System.out::println);
        // Output:
        // Java
        // Python
        // Go
        // Rust


        // --- With a Map ---
        Map scores = Map.of("Alice", 95, "Bob", 87, "Charlie", 92);

        // Print each entry -- lambda is needed here for formatting
        scores.forEach((name, score) ->
                System.out.println(name + ": " + score));
        // Output (order may vary):
        // Alice: 95
        // Bob: 87
        // Charlie: 92


        // --- Collecting to a list then printing ---
        List numbers = Arrays.asList(1, 2, 3, 4, 5);

        System.out.print("Doubled: ");
        numbers.stream()
                .map(n -> n * 2)              // lambda needed (extra logic)
                .forEach(System.out::print);  // method reference for output
        System.out.println();
        // Output: Doubled: 246810
    }
}

8.5 collect() and reduce() Patterns

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class StreamCollectReduceExamples {

    static class Order {
        private final String product;
        private final String category;
        private final double amount;

        public Order(String product, String category, double amount) {
            this.product = product;
            this.category = category;
            this.amount = amount;
        }

        public String getProduct()  { return product; }
        public String getCategory() { return category; }
        public double getAmount()   { return amount; }

        @Override
        public String toString() { return product + " ($" + amount + ")"; }
    }

    public static void main(String[] args) {

        List orders = Arrays.asList(
                new Order("Laptop", "Electronics", 999.99),
                new Order("Phone", "Electronics", 699.99),
                new Order("Shirt", "Clothing", 29.99),
                new Order("Pants", "Clothing", 49.99),
                new Order("Tablet", "Electronics", 399.99)
        );

        // --- Grouping by field using method reference ---
        Map> byCategory = orders.stream()
                .collect(Collectors.groupingBy(Order::getCategory));

        byCategory.forEach((category, categoryOrders) ->
                System.out.println(category + ": " + categoryOrders));
        // Output:
        // Electronics: [Laptop ($999.99), Phone ($699.99), Tablet ($399.99)]
        // Clothing: [Shirt ($29.99), Pants ($49.99)]


        // --- Mapping to field, then joining ---
        String productList = orders.stream()
                .map(Order::getProduct)
                .collect(Collectors.joining(", "));

        System.out.println("Products: " + productList);
        // Output: Products: Laptop, Phone, Shirt, Pants, Tablet


        // --- reduce() with method reference ---
        List numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                .reduce(0, Integer::sum);   // static method reference

        System.out.println("Sum: " + sum);
        // Output: Sum: 15

        int max = numbers.stream()
                .reduce(Integer::max)       // static method reference
                .orElse(0);

        System.out.println("Max: " + max);
        // Output: Max: 5
    }
}

9. Common Patterns and Techniques

9.1 Chaining Method References in Comparators

The Comparator class was designed with method references in mind. Its static and default methods (comparing(), thenComparing(), reversed(), nullsFirst(), nullsLast()) all accept method references as key extractors.

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class ComparatorChaining {

    static class Employee {
        private final String name;
        private final String department;
        private final double salary;

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

        public String getName()       { return name; }
        public String getDepartment() { return department; }
        public double getSalary()     { return salary; }

        @Override
        public String toString() {
            return String.format("%-10s %-12s $%,.0f", name, department, salary);
        }
    }

    public static void main(String[] args) {

        List employees = Arrays.asList(
                new Employee("Alice", "Engineering", 105000),
                new Employee("Bob", "Marketing", 75000),
                new Employee("Charlie", "Engineering", 95000),
                new Employee("Diana", "Marketing", 80000),
                new Employee("Eve", "Engineering", 105000)
        );

        // Sort by department, then by salary descending, then by name
        List sorted = employees.stream()
                .sorted(Comparator.comparing(Employee::getDepartment)
                        .thenComparing(Comparator.comparing(Employee::getSalary).reversed())
                        .thenComparing(Employee::getName))
                .collect(Collectors.toList());

        System.out.println("Department | Salary | Name");
        System.out.println("--------------------------------------");
        sorted.forEach(System.out::println);
        // Output:
        // Alice      Engineering  $105,000
        // Eve        Engineering  $105,000
        // Charlie    Engineering  $95,000
        // Diana      Marketing    $80,000
        // Bob        Marketing    $75,000
    }
}

9.2 Using with Optional

Method references work beautifully with Optional to handle values that may or may not be present.

import java.util.Optional;

public class OptionalMethodReferences {

    static class Config {
        private final String databaseUrl;

        public Config(String databaseUrl) {
            this.databaseUrl = databaseUrl;
        }

        public String getDatabaseUrl() { return databaseUrl; }
    }

    public static void main(String[] args) {

        // --- map() with method reference ---
        Optional name = Optional.of("  Alice  ");

        String trimmed = name
                .map(String::trim)              // arbitrary object ref
                .map(String::toUpperCase)       // arbitrary object ref
                .orElse("UNKNOWN");

        System.out.println("Name: " + trimmed);
        // Output: Name: ALICE


        // --- orElseGet() with constructor reference ---
        Optional maybeConfig = Optional.empty();

        // Instead of: orElseGet(() -> new Config("default"))
        // We cannot use a constructor reference here because we need an argument.
        // Constructor references work only when the supplier interface matches.
        Config config = maybeConfig.orElseGet(() -> new Config("jdbc:h2:mem:default"));
        System.out.println("DB URL: " + config.getDatabaseUrl());
        // Output: DB URL: jdbc:h2:mem:default


        // --- ifPresent() with method reference ---
        Optional greeting = Optional.of("Hello, World!");
        greeting.ifPresent(System.out::println);
        // Output: Hello, World!


        // --- filter() with method reference ---
        Optional word = Optional.of("Java");

        Optional longWord = word.filter(s -> s.length() > 3);
        longWord.ifPresent(System.out::println);
        // Output: Java

        Optional shortWord = word.filter(String::isEmpty);
        System.out.println("Short word present? " + shortWord.isPresent());
        // Output: Short word present? false
    }
}

9.3 Factory Methods and the Strategy Pattern

Method references can replace verbose strategy pattern implementations. Instead of creating multiple anonymous classes, you can pass method references that match the required functional interface.

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;

public class StrategyWithMethodReferences {

    // Different text processing strategies -- each is a simple static method
    static class TextProcessor {
        public static String toUpperCase(String s) { return s.toUpperCase(); }
        public static String toLowerCase(String s) { return s.toLowerCase(); }
        public static String trim(String s)        { return s.trim(); }
        public static String reverse(String s)     { return new StringBuilder(s).reverse().toString(); }
    }

    // A pipeline that accepts a list of strategies
    static List process(List input, List> strategies) {
        List result = input;
        for (UnaryOperator strategy : strategies) {
            result = result.stream()
                    .map(strategy)
                    .collect(Collectors.toList());
        }
        return result;
    }

    public static void main(String[] args) {
        List data = Arrays.asList("  Hello  ", "  World  ", "  Java  ");

        // Build a pipeline of strategies using method references
        List> pipeline = Arrays.asList(
                TextProcessor::trim,          // step 1: trim whitespace
                TextProcessor::toUpperCase,   // step 2: convert to uppercase
                TextProcessor::reverse        // step 3: reverse
        );

        List result = process(data, pipeline);
        System.out.println(result);
        // Output: [OLLEH, DLROW, AVAJ]
    }
}

10. Common Mistakes

Method references are concise, but they introduce subtle pitfalls. Here are the mistakes developers encounter most often.

10.1 Trying to Add Logic to a Method Reference

A method reference is a direct pointer to a method. You cannot embed extra logic, conditions, or transformations inside it. If you need any additional behavior, use a lambda.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MistakeExtraLogic {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        // WRONG -- cannot add logic to a method reference
        // names.stream().map(String::toUpperCase + "!").collect(...)  // compile error

        // CORRECT -- use a lambda when extra logic is needed
        List shouting = names.stream()
                .map(name -> name.toUpperCase() + "!")
                .collect(Collectors.toList());

        System.out.println(shouting);
        // Output: [ALICE!, BOB!, CHARLIE!]


        // WRONG -- cannot negate inside a method reference
        // names.stream().filter(!String::isEmpty)  // compile error

        // CORRECT -- use a lambda for negation (or Predicate.not() in Java 11+)
        List nonEmpty = names.stream()
                .filter(s -> !s.isEmpty())
                .collect(Collectors.toList());

        System.out.println(nonEmpty);
        // Output: [Alice, Bob, Charlie]
    }
}

10.2 Ambiguous Method References with Overloaded Methods

When a class has overloaded methods (same name, different parameters), the compiler may not be able to determine which one you mean. This results in a compile-time error.

import java.util.function.Function;
import java.util.function.BiFunction;

public class MistakeAmbiguousOverload {

    // Two overloaded static methods
    public static String format(int number) {
        return "Number: " + number;
    }

    public static String format(String text) {
        return "Text: " + text;
    }

    public static void main(String[] args) {

        // This works -- compiler can infer which 'format' based on Function
        Function intFormatter = MistakeAmbiguousOverload::format;
        System.out.println(intFormatter.apply(42));
        // Output: Number: 42

        // This also works -- different functional interface type resolves the overload
        Function strFormatter = MistakeAmbiguousOverload::format;
        System.out.println(strFormatter.apply("hello"));
        // Output: Text: hello

        // The compiler resolves overloads based on the target functional interface.
        // Problems arise when the target type is ambiguous itself.

        // Example of a potentially confusing situation:
        // If a method accepts both Function and Function,
        // you would need to cast:
        // processData((Function) MistakeAmbiguousOverload::format);
    }
}

10.3 Forgetting That Method References Must Match the Functional Interface

The referenced method must have a compatible signature with the functional interface’s abstract method. Parameter types, count, and return type must all be compatible.

import java.util.function.Function;
import java.util.function.BiFunction;
import java.util.function.Supplier;

public class MistakeSignatureMismatch {

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

    public static String greet(String name) {
        return "Hello, " + name;
    }

    public static void main(String[] args) {

        // CORRECT: add(int, int) -> int matches BiFunction
        BiFunction adder = MistakeSignatureMismatch::add;
        System.out.println(adder.apply(3, 4));
        // Output: 7

        // WRONG: add(int, int) does NOT match Function
        // Function wrongAdder = MistakeSignatureMismatch::add;
        // Compile error: method add in class MistakeSignatureMismatch cannot be applied to given types

        // CORRECT: greet(String) -> String matches Function
        Function greeter = MistakeSignatureMismatch::greet;
        System.out.println(greeter.apply("Alice"));
        // Output: Hello, Alice

        // WRONG: greet(String) does NOT match Supplier (Supplier takes no args)
        // Supplier wrongGreeter = MistakeSignatureMismatch::greet;
        // Compile error: method greet expects 1 argument but Supplier provides 0
    }
}

10.4 Confusing Static and Arbitrary-Object References

Because both static method references and arbitrary-object references use the ClassName::methodName syntax, developers sometimes confuse them. Remember: the compiler distinguishes them based on whether the method is static or not.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MistakeStaticVsArbitrary {
    public static void main(String[] args) {
        List words = Arrays.asList("hello", "world");

        // String::toUpperCase -- INSTANCE method on String
        // This is an ARBITRARY OBJECT reference: s -> s.toUpperCase()
        // The stream element becomes the object
        List upper = words.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());
        System.out.println(upper);
        // Output: [HELLO, WORLD]


        // Integer::parseInt -- STATIC method on Integer
        // This is a STATIC method reference: s -> Integer.parseInt(s)
        // The stream element is passed as an argument
        List nums = Arrays.asList("1", "2", "3");
        List parsed = nums.stream()
                .map(Integer::parseInt)
                .collect(Collectors.toList());
        System.out.println(parsed);
        // Output: [1, 2, 3]


        // Both use ClassName::methodName syntax, but:
        // - String::toUpperCase => arbitrary object (toUpperCase is an instance method)
        // - Integer::parseInt   => static reference (parseInt is a static method)
        // The compiler knows the difference. You should too.
    }
}

10.5 Using Method References on a Null Object

If you create a method reference on a particular object and that object is null, you will get a NullPointerException at the point where the method reference is created (not when it is called).

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class MistakeNullObject {
    public static void main(String[] args) {

        String validString = "Hello";
        Consumer validRef = System.out::println;   // fine
        validRef.accept(validString);
        // Output: Hello


        // Dangerous: if the object is null, creating the method reference throws NPE
        String nullString = null;

        try {
            // This throws NullPointerException when the reference is CREATED
            // because you are trying to reference a method on a null object
            Consumer nullRef = nullString::charAt;
            // NullPointerException is thrown on the line above, not on the line below
            nullRef.accept(0);
        } catch (NullPointerException e) {
            System.out.println("Caught NPE: " + e.getMessage());
            // Output: Caught NPE: null
        }


        // Safe approach: validate before creating the reference
        List items = Arrays.asList("Java", null, "Python");

        items.stream()
                .filter(s -> s != null)   // filter nulls first
                .map(String::toUpperCase) // now safe to use method reference
                .forEach(System.out::println);
        // Output:
        // JAVA
        // PYTHON
    }
}

11. Best Practices

Follow these guidelines to use method references effectively in production code.

# Practice Do Avoid
1 Prefer method references when they improve readability map(String::trim) map(s -> s.trim()) when no extra logic
2 Use lambdas for multi-step operations map(s -> s.trim().toUpperCase()) Trying to chain method references inside map()
3 Let the method name tell the story filter(Objects::nonNull) filter(o -> o != null)
4 Extract complex lambdas into named methods filter(this::isEligible) A 5-line lambda inline
5 Use Comparator.comparing() with method references sorted(comparing(Person::getAge)) sorted((a, b) -> a.getAge() - b.getAge())
6 Use Predicate.not() in Java 11+ filter(Predicate.not(String::isEmpty)) filter(s -> !s.isEmpty())
7 Use toArray(Type[]::new) for stream-to-array toArray(String[]::new) toArray(size -> new String[size])
8 Be consistent within a pipeline All method refs or all lambdas for similar steps Mixing for no reason

11.1 Extract Complex Lambdas Into Named Methods

If a lambda is too complex to express as a single method reference but too verbose to inline, the best approach is to extract the logic into a well-named private method and then reference it.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class ExtractToNamedMethod {

    // Before: inline lambda with complex logic
    static List processNamesInline(List names) {
        return names.stream()
                .filter(name -> name != null && !name.isBlank()
                        && name.length() >= 2 && name.length() <= 50
                        && Character.isLetter(name.charAt(0)))
                .map(name -> name.substring(0, 1).toUpperCase()
                        + name.substring(1).toLowerCase().trim())
                .collect(Collectors.toList());
    }


    // After: extract into named methods, then use method references
    private static boolean isValidName(String name) {
        return name != null && !name.isBlank()
                && name.length() >= 2 && name.length() <= 50
                && Character.isLetter(name.charAt(0));
    }

    private static String capitalizeName(String name) {
        String trimmed = name.trim();
        return trimmed.substring(0, 1).toUpperCase()
                + trimmed.substring(1).toLowerCase();
    }

    static List processNamesClean(List names) {
        return names.stream()
                .filter(ExtractToNamedMethod::isValidName)      // reads like English
                .map(ExtractToNamedMethod::capitalizeName)      // clear intent
                .collect(Collectors.toList());
    }


    public static void main(String[] args) {
        List names = Arrays.asList(
                "aLICE", "  ", null, "bOB", "x", "cHARLIE", "123bad"
        );

        System.out.println("Inline:  " + processNamesInline(names));
        // Output: Inline:  [Alice, Bob, Charlie]

        System.out.println("Clean:   " + processNamesClean(names));
        // Output: Clean:   [Alice, Bob, Charlie]
    }
}

12. Complete Practical Example

Let us tie everything together with a realistic data processing pipeline that uses all four types of method references. We will build an employee report system that reads raw CSV-style input, parses it into objects, transforms and sorts the data, and produces a summary report.

This example demonstrates:

  • Static method reference — parsing strings into objects (Employee::fromCsv)
  • Instance method of a particular object — using a formatter object (formatter::format)
  • Instance method of an arbitrary object — extracting fields (Employee::getSalary, Employee::getName)
  • Constructor reference — creating department summary objects (DepartmentSummary::new) and output arrays (String[]::new)
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public class EmployeeReportSystem {

    // ========== Domain Classes ==========

    static class Employee {
        private final String name;
        private final String department;
        private final double salary;

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

        // STATIC METHOD -- used as a static method reference
        public static Employee fromCsv(String csv) {
            String[] parts = csv.split(",");
            return new Employee(
                    parts[0].trim(),
                    parts[1].trim(),
                    Double.parseDouble(parts[2].trim())
            );
        }

        // INSTANCE METHODS -- used as arbitrary-object method references
        public String getName()       { return name; }
        public String getDepartment() { return department; }
        public double getSalary()     { return salary; }

        public boolean isHighEarner() {
            return salary >= 90000;
        }

        @Override
        public String toString() {
            return String.format("%-12s %-15s $%,.2f", name, department, salary);
        }
    }


    static class DepartmentSummary {
        private final String department;
        private final long count;
        private final double totalSalary;

        // CONSTRUCTOR -- used as a constructor reference
        public DepartmentSummary(String department, List employees) {
            this.department = department;
            this.count = employees.size();
            this.totalSalary = employees.stream()
                    .mapToDouble(Employee::getSalary)
                    .sum();
        }

        public String getDepartment()  { return department; }
        public long getCount()         { return count; }
        public double getTotalSalary() { return totalSalary; }
        public double getAvgSalary()   { return totalSalary / count; }

        @Override
        public String toString() {
            return String.format("%-15s %d employees, avg $%,.2f",
                    department, count, getAvgSalary());
        }
    }


    // A formatter instance -- used as a particular-object method reference
    static class ReportFormatter {
        private final String companyName;

        public ReportFormatter(String companyName) {
            this.companyName = companyName;
        }

        public String format(Employee emp) {
            return String.format("[%s] %s - %s ($%,.2f)",
                    companyName, emp.getName(), emp.getDepartment(), emp.getSalary());
        }
    }


    // ========== Main Pipeline ==========

    public static void main(String[] args) {

        // Raw input data (simulating CSV lines)
        List rawData = Arrays.asList(
                "Alice, Engineering, 105000",
                "Bob, Marketing, 72000",
                "Charlie, Engineering, 95000",
                "Diana, Marketing, 88000",
                "Eve, Engineering, 115000",
                "Frank, Sales, 67000",
                "Grace, Sales, 71000",
                "Henry, Engineering, 98000"
        );

        // ---- STEP 1: Parse using STATIC method reference ----
        // Employee::fromCsv is a static method: (String) -> Employee
        List employees = rawData.stream()
                .map(Employee::fromCsv)              // static method reference
                .collect(Collectors.toList());

        System.out.println("=== ALL EMPLOYEES ===");
        employees.forEach(System.out::println);      // particular object (System.out)
        System.out.println();


        // ---- STEP 2: Filter using ARBITRARY OBJECT method reference ----
        // Employee::isHighEarner: the stream element calls its own isHighEarner()
        List highEarners = employees.stream()
                .filter(Employee::isHighEarner)      // arbitrary object method reference
                .collect(Collectors.toList());

        System.out.println("=== HIGH EARNERS (>= $90,000) ===");
        highEarners.forEach(System.out::println);
        System.out.println();


        // ---- STEP 3: Extract names using ARBITRARY OBJECT method reference ----
        String[] nameArray = employees.stream()
                .map(Employee::getName)              // arbitrary object method reference
                .sorted(String::compareTo)           // arbitrary object method reference
                .toArray(String[]::new);             // array constructor reference

        System.out.println("=== SORTED NAMES (as array) ===");
        System.out.println(Arrays.toString(nameArray));
        System.out.println();


        // ---- STEP 4: Sort using Comparator with method references ----
        List sortedBySalary = employees.stream()
                .sorted(Comparator.comparing(Employee::getSalary).reversed()
                        .thenComparing(Employee::getName))
                .collect(Collectors.toList());

        System.out.println("=== SORTED BY SALARY (DESC) ===");
        sortedBySalary.forEach(System.out::println);
        System.out.println();


        // ---- STEP 5: Format using PARTICULAR OBJECT method reference ----
        ReportFormatter formatter = new ReportFormatter("Acme Corp");

        List formattedReport = employees.stream()
                .filter(Employee::isHighEarner)
                .sorted(Comparator.comparing(Employee::getSalary).reversed())
                .map(formatter::format)              // particular object method reference
                .collect(Collectors.toList());

        System.out.println("=== FORMATTED HIGH-EARNER REPORT ===");
        formattedReport.forEach(System.out::println);
        System.out.println();


        // ---- STEP 6: Group and summarize using CONSTRUCTOR reference ----
        Map> byDepartment = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment));

        List summaries = byDepartment.entrySet().stream()
                .map(entry -> new DepartmentSummary(entry.getKey(), entry.getValue()))
                .sorted(Comparator.comparing(DepartmentSummary::getAvgSalary).reversed())
                .collect(Collectors.toList());

        System.out.println("=== DEPARTMENT SUMMARIES ===");
        summaries.forEach(System.out::println);
        System.out.println();


        // ---- STEP 7: Statistics using method references with reduce ----
        double totalPayroll = employees.stream()
                .map(Employee::getSalary)
                .reduce(0.0, Double::sum);           // static method reference

        Optional highestPaid = employees.stream()
                .max(Comparator.comparing(Employee::getSalary));

        Optional lowestPaid = employees.stream()
                .min(Comparator.comparing(Employee::getSalary));

        System.out.println("=== PAYROLL STATISTICS ===");
        System.out.printf("Total payroll: $%,.2f%n", totalPayroll);
        System.out.printf("Average salary: $%,.2f%n", totalPayroll / employees.size());
        highestPaid.ifPresent(e ->
                System.out.printf("Highest paid: %s ($%,.2f)%n", e.getName(), e.getSalary()));
        lowestPaid.ifPresent(e ->
                System.out.printf("Lowest paid: %s ($%,.2f)%n", e.getName(), e.getSalary()));
    }
}

// ========== OUTPUT ==========
// === ALL EMPLOYEES ===
// Alice        Engineering     $105,000.00
// Bob          Marketing       $72,000.00
// Charlie      Engineering     $95,000.00
// Diana        Marketing       $88,000.00
// Eve          Engineering     $115,000.00
// Frank        Sales           $67,000.00
// Grace        Sales           $71,000.00
// Henry        Engineering     $98,000.00
//
// === HIGH EARNERS (>= $90,000) ===
// Alice        Engineering     $105,000.00
// Charlie      Engineering     $95,000.00
// Eve          Engineering     $115,000.00
// Henry        Engineering     $98,000.00
//
// === SORTED NAMES (as array) ===
// [Alice, Bob, Charlie, Diana, Eve, Frank, Grace, Henry]
//
// === SORTED BY SALARY (DESC) ===
// Eve          Engineering     $115,000.00
// Alice        Engineering     $105,000.00
// Henry        Engineering     $98,000.00
// Charlie      Engineering     $95,000.00
// Diana        Marketing       $88,000.00
// Bob          Marketing       $72,000.00
// Grace        Sales           $71,000.00
// Frank        Sales           $67,000.00
//
// === FORMATTED HIGH-EARNER REPORT ===
// [Acme Corp] Eve - Engineering ($115,000.00)
// [Acme Corp] Alice - Engineering ($105,000.00)
// [Acme Corp] Henry - Engineering ($98,000.00)
// [Acme Corp] Charlie - Engineering ($95,000.00)
//
// === DEPARTMENT SUMMARIES ===
// Engineering     4 employees, avg $103,250.00
// Marketing       2 employees, avg $80,000.00
// Sales           2 employees, avg $69,000.00
//
// === PAYROLL STATISTICS ===
// Total payroll: $711,000.00
// Average salary: $88,875.00
// Highest paid: Eve ($115,000.00)
// Lowest paid: Frank ($67,000.00)

Method Reference Types Used in This Example

Type Usage in Example Line
Static method Employee::fromCsv — parses CSV string into Employee map(Employee::fromCsv)
Static method Double::sum — adds two doubles in reduce reduce(0.0, Double::sum)
Particular object System.out::println — prints to console forEach(System.out::println)
Particular object formatter::format — formats using company name map(formatter::format)
Arbitrary object Employee::getName — extracts name from each employee map(Employee::getName)
Arbitrary object Employee::getSalary — extracts salary for sorting Comparator.comparing(Employee::getSalary)
Arbitrary object Employee::isHighEarner — filters high earners filter(Employee::isHighEarner)
Arbitrary object Employee::getDepartment — groups by department Collectors.groupingBy(Employee::getDepartment)
Arbitrary object String::compareTo — sorts strings naturally sorted(String::compareTo)
Array constructor String[]::new — converts stream to String array toArray(String[]::new)

Quick Reference

Concept Summary
What it is Shorthand for a lambda that calls a single existing method
Operator :: (double colon)
Static reference ClassName::staticMethod — args passed to static method
Particular object instance::method — args passed to method on that specific instance
Arbitrary object ClassName::instanceMethod — first arg becomes the object, rest are method args
Constructor ClassName::new — args passed to matching constructor
Array constructor Type[]::new — takes size, returns new array
No parentheses Write String::length, NOT String::length()
Must match signature Referenced method must be compatible with the functional interface
When to prefer When the lambda does nothing except call a single method
When to use lambda When you need extra logic, multiple statements, or additional arguments
July 27, 2023

Java – Optional

1. What is Optional?

Imagine you ask a hotel receptionist for a room key. There are two possible outcomes: either they hand you a key, or they tell you no room is available. In old Java code, the answer to “give me the user with this ID” was either a User object or null — and if you forgot to check for null, your application would crash with a NullPointerException (NPE).

Optional is a container object introduced in Java 8 that may or may not hold a non-null value. Instead of returning null to indicate “no result,” a method returns an Optional that explicitly communicates: “this might be empty, and you need to handle that case.”

The Billion Dollar Mistake

Sir Tony Hoare, the inventor of the null reference in 1965, famously called it his “billion dollar mistake.” The problem is not that null exists — it is that nothing in the type system forces you to handle it. A method signature like User findById(int id) gives no indication that it might return null. You have to read the documentation (if it exists), or discover the hard way at 2 AM when production crashes.

Optional as a return type makes the possibility of absence visible in the API itself. The caller is forced to think about the empty case before they can get the value.

What Optional Replaces

Before Optional With Optional
Return null to mean “not found” Return Optional.empty()
Check if (result != null) everywhere Use ifPresent(), orElse(), map()
Javadoc says “@return the user, or null if not found” Return type Optional says it all
NullPointerException at runtime Compiler-visible intent, handled at call site

Optional lives in java.util and is a final class — you cannot subclass it. It is not serializable by design, which means it should not be used as a field in entities or DTOs. We will cover where Optional should and should not be used later in this tutorial.

import java.util.Optional;

public class OptionalIntro {
    public static void main(String[] args) {
        // Before Optional: null means "not found"
        String resultOld = findNicknameOld("Alice");
        if (resultOld != null) {
            System.out.println("Nickname: " + resultOld);
        } else {
            System.out.println("No nickname found");
        }

        // With Optional: the return type tells you it might be empty
        Optional resultNew = findNickname("Alice");
        resultNew.ifPresentOrElse(
            nickname -> System.out.println("Nickname: " + nickname),
            () -> System.out.println("No nickname found")
        );
    }

    // Old way: caller has no idea this might return null
    static String findNicknameOld(String name) {
        if ("Bob".equals(name)) return "Bobby";
        return null; // surprise!
    }

    // New way: return type makes absence explicit
    static Optional findNickname(String name) {
        if ("Bob".equals(name)) return Optional.of("Bobby");
        return Optional.empty();
    }
}
// Output:
// No nickname found
// No nickname found

2. Creating Optional

There are exactly three ways to create an Optional. Choosing the right one depends on whether you know the value is guaranteed to be non-null, might be null, or is definitely absent.

2.1 Optional.of(value)

Use Optional.of() when you are certain the value is not null. If you pass null, it throws a NullPointerException immediately — this is intentional. It acts as a fail-fast assertion that “this value must exist.”

import java.util.Optional;

public class OptionalOf {
    public static void main(String[] args) {
        // Safe: we know "Java" is not null
        Optional language = Optional.of("Java");
        System.out.println(language);
        // Output: Optional[Java]

        // Dangerous: passing null throws NullPointerException
        try {
            Optional bad = Optional.of(null);
        } catch (NullPointerException e) {
            System.out.println("Optional.of(null) throws NPE: " + e.getMessage());
        }
        // Output: Optional.of(null) throws NPE: null
    }
}

2.2 Optional.ofNullable(value)

Use Optional.ofNullable() when the value might be null. If the value is non-null, it behaves like Optional.of(). If the value is null, it returns Optional.empty(). This is the most commonly used factory method in practice because most values you wrap in Optional come from sources you do not control — database queries, API calls, map lookups, etc.

import java.util.Map;
import java.util.Optional;

public class OptionalOfNullable {
    public static void main(String[] args) {
        Map config = Map.of("host", "localhost", "port", "8080");

        // Key exists: wraps the value
        Optional host = Optional.ofNullable(config.get("host"));
        System.out.println(host);
        // Output: Optional[localhost]

        // Key does not exist: map.get() returns null, so we get empty
        Optional timeout = Optional.ofNullable(config.get("timeout"));
        System.out.println(timeout);
        // Output: Optional.empty

        // Common pattern: wrap any potentially-null value
        String envVar = System.getenv("MY_APP_SECRET");
        Optional secret = Optional.ofNullable(envVar);
        System.out.println("Secret present? " + secret.isPresent());
        // Output: Secret present? false  (assuming env var is not set)
    }
}

2.3 Optional.empty()

Use Optional.empty() to explicitly return “no value.” This is the Optional equivalent of returning null, but it is type-safe and intentional. All empty Optionals share a single singleton instance, so there is zero overhead.

import java.util.Optional;

public class OptionalEmpty {
    public static void main(String[] args) {
        Optional empty = Optional.empty();
        System.out.println(empty);
        // Output: Optional.empty

        System.out.println(empty.isPresent());
        // Output: false

        // Practical use: return empty when a condition is not met
        Optional result = findDiscountCode("regular");
        System.out.println("Discount: " + result);
        // Output: Discount: Optional.empty
    }

    static Optional findDiscountCode(String memberType) {
        if ("premium".equals(memberType)) {
            return Optional.of("SAVE20");
        }
        return Optional.empty();
    }
}

When to Use Each

Factory Method Value Can Be Null? When to Use
Optional.of(value) No — throws NPE if null You are wrapping a value you know is non-null (e.g., a literal, a validated input)
Optional.ofNullable(value) Yes — returns empty if null You are wrapping a value from an external source that might be null (e.g., map lookup, database result)
Optional.empty() N/A — always empty You want to explicitly return “no value” from a method

3. Checking and Getting Values

Once you have an Optional, you need to work with its value. The most basic operations are checking whether a value is present and retrieving it.

3.1 isPresent() and isEmpty()

isPresent() returns true if the Optional contains a value, false otherwise. isEmpty() (added in Java 11) is the opposite — it returns true if the Optional is empty. These methods are straightforward, but using them alongside get() is often a code smell, as we will see next.

import java.util.Optional;

public class IsPresentExample {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");
        Optional empty = Optional.empty();

        // isPresent()
        System.out.println(name.isPresent());   // true
        System.out.println(empty.isPresent());  // false

        // isEmpty() - Java 11+
        System.out.println(name.isEmpty());     // false
        System.out.println(empty.isEmpty());    // true
    }
}

3.2 get() — And Why You Should Avoid It

get() returns the value if present, or throws NoSuchElementException if the Optional is empty. This makes get() essentially as dangerous as working with null directly — if you call it without checking first, you trade a NullPointerException for a NoSuchElementException. You have gained nothing.

In fact, get() is considered such an anti-pattern that its Javadoc in Java 10+ was updated to recommend using orElseThrow() instead. The Java API designers have even discussed deprecating it.

import java.util.NoSuchElementException;
import java.util.Optional;

public class GetAntiPattern {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");
        Optional empty = Optional.empty();

        // This works, but it's a bad habit
        System.out.println(name.get());
        // Output: Alice

        // This crashes -- just like a NullPointerException would
        try {
            String value = empty.get();
        } catch (NoSuchElementException e) {
            System.out.println("get() on empty: " + e.getMessage());
        }
        // Output: get() on empty: No value present

        // BAD: isPresent + get is just null-checking with extra steps
        if (name.isPresent()) {
            System.out.println(name.get()); // Why use Optional at all?
        }

        // GOOD: use orElse, ifPresent, map, or orElseThrow instead
        System.out.println(name.orElse("Unknown"));
        // Output: Alice

        System.out.println(empty.orElse("Unknown"));
        // Output: Unknown
    }
}

The pattern if (optional.isPresent()) { optional.get() } is a code smell that indicates you are using Optional like a null check instead of leveraging its functional API. Every use of get() can be replaced with a safer alternative:

Instead of… Use…
if (opt.isPresent()) { doSomething(opt.get()); } opt.ifPresent(val -> doSomething(val));
return opt.isPresent() ? opt.get() : defaultVal; return opt.orElse(defaultVal);
if (!opt.isPresent()) throw new Ex(); return opt.get(); return opt.orElseThrow(() -> new Ex());
if (opt.isPresent()) { return transform(opt.get()); } return opt.map(val -> transform(val));

4. Providing Default Values

One of the most practical uses of Optional is providing a fallback when a value is absent. Java gives you several methods for this, and the differences between them matter for performance and correctness.

4.1 orElse(defaultValue)

orElse() returns the value if present, otherwise returns the provided default value. The default value is always evaluated, even when the Optional contains a value. This is important to understand.

import java.util.Optional;

public class OrElseExample {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");
        Optional empty = Optional.empty();

        // Value is present: returns "Alice"
        String result1 = name.orElse("Default Name");
        System.out.println(result1);
        // Output: Alice

        // Value is absent: returns "Default Name"
        String result2 = empty.orElse("Default Name");
        System.out.println(result2);
        // Output: Default Name

        // Common use: providing sensible defaults
        String port = Optional.ofNullable(System.getenv("APP_PORT"))
                              .orElse("8080");
        System.out.println("Using port: " + port);
        // Output: Using port: 8080
    }
}

4.2 orElseGet(supplier)

orElseGet() takes a Supplier — a function that produces the default value. The supplier is only called when the Optional is empty. This makes orElseGet() the better choice when the default value is expensive to compute (e.g., a database query, file read, or complex object creation).

import java.util.Optional;

public class OrElseGetExample {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");
        Optional empty = Optional.empty();

        // Supplier is NOT called because value is present
        String result1 = name.orElseGet(() -> computeDefault());
        System.out.println(result1);
        // Output: Alice

        // Supplier IS called because value is absent
        String result2 = empty.orElseGet(() -> computeDefault());
        System.out.println(result2);
        // Output: Computing default...
        //         Generated Default
    }

    static String computeDefault() {
        System.out.println("Computing default...");
        return "Generated Default";
    }
}

4.3 orElse vs orElseGet: The Critical Difference

This is a common interview question and a real-world source of bugs. The difference is when the default expression is evaluated. Let’s prove it with a side-effect demonstration:

import java.util.Optional;

public class OrElseVsOrElseGet {
    public static void main(String[] args) {
        Optional present = Optional.of("Existing Value");

        System.out.println("=== orElse (always evaluates) ===");
        String r1 = present.orElse(expensiveOperation());
        System.out.println("Result: " + r1);
        // Output:
        // === orElse (always evaluates) ===
        // Expensive operation executed!    <-- called even though value exists!
        // Result: Existing Value

        System.out.println();

        System.out.println("=== orElseGet (lazy evaluation) ===");
        String r2 = present.orElseGet(() -> expensiveOperation());
        System.out.println("Result: " + r2);
        // Output:
        // === orElseGet (lazy evaluation) ===
        // Result: Existing Value           <-- supplier NOT called!
    }

    static String expensiveOperation() {
        System.out.println("Expensive operation executed!");
        // Imagine this is a database query or REST API call
        return "Fallback Value";
    }
}

Notice that with orElse(), the expensiveOperation() runs even though the Optional contains a value. With orElseGet(), the supplier lambda is never invoked because it is not needed. Use this rule of thumb:

Method Evaluation When to Use
orElse(value) Always evaluated (eager) Default is a simple constant or already-computed value (e.g., orElse("N/A"), orElse(0))
orElseGet(supplier) Only when empty (lazy) Default requires computation, I/O, or object creation (e.g., database fallback, new object)

4.4 orElseThrow(exceptionSupplier)

orElseThrow() returns the value if present, or throws an exception produced by the supplied function. This is the correct way to say "a value must be here -- if it's not, that's an error." Use this when absence indicates a problem, not just an alternative path.

import java.util.Optional;

public class OrElseThrowExample {
    public static void main(String[] args) {
        Optional userId = Optional.of("U-1234");
        Optional empty = Optional.empty();

        // Value present: returns normally
        String id = userId.orElseThrow(() -> new IllegalArgumentException("User ID is required"));
        System.out.println("User ID: " + id);
        // Output: User ID: U-1234

        // Value absent: throws the exception you specify
        try {
            String missing = empty.orElseThrow(
                () -> new IllegalStateException("Expected a value but none was found")
            );
        } catch (IllegalStateException e) {
            System.out.println("Caught: " + e.getMessage());
        }
        // Output: Caught: Expected a value but none was found

        // Custom exception class
        try {
            String user = findUser(999)
                .orElseThrow(() -> new UserNotFoundException(999));
        } catch (UserNotFoundException e) {
            System.out.println("Caught: " + e.getMessage());
        }
        // Output: Caught: User not found with ID: 999
    }

    static Optional findUser(int id) {
        if (id == 1) return Optional.of("Alice");
        return Optional.empty();
    }
}

class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(int id) {
        super("User not found with ID: " + id);
    }
}

4.5 orElseThrow() -- No-Arg Version (Java 10+)

Java 10 added a no-argument version of orElseThrow() that throws NoSuchElementException if empty. This is the recommended replacement for get(). Both do the same thing, but orElseThrow() makes the intent clear -- you are choosing to throw if the value is missing.

import java.util.Optional;

public class OrElseThrowNoArg {
    public static void main(String[] args) {
        Optional name = Optional.of("Alice");

        // Java 10+: orElseThrow() with no argument
        // Same behavior as get(), but the name makes intent clear
        String value = name.orElseThrow();
        System.out.println(value);
        // Output: Alice

        // Preferred over get() because the method name signals danger
        // get()          -- "give me the value" (sounds safe)
        // orElseThrow()  -- "give me the value or throw" (sounds dangerous -- good!)
    }
}

5. Transforming Values

The real power of Optional comes from its transformation methods. These let you build pipelines that process the value if it exists, without writing any if statements or null checks. Think of them as assembly line operations that only run when there is material on the belt.

5.1 map(function)

map() applies a function to the value inside the Optional (if present) and wraps the result in a new Optional. If the Optional is empty, map() returns Optional.empty() without calling the function. This is how you transform values without unwrapping them.

import java.util.Optional;

public class MapExample {
    public static void main(String[] args) {
        Optional name = Optional.of("alice");
        Optional empty = Optional.empty();

        // Transform the value: convert to uppercase
        Optional upper = name.map(String::toUpperCase);
        System.out.println(upper);
        // Output: Optional[ALICE]

        // Map on empty: function is never called
        Optional result = empty.map(String::toUpperCase);
        System.out.println(result);
        // Output: Optional.empty

        // Extract a property: get the length of the string
        Optional length = name.map(String::length);
        System.out.println(length);
        // Output: Optional[5]

        // Chain map with orElse for a complete pipeline
        String displayName = Optional.ofNullable(getUserEmail())
                                     .map(email -> email.split("@")[0])
                                     .map(String::toUpperCase)
                                     .orElse("ANONYMOUS");
        System.out.println("Display: " + displayName);
        // Output: Display: ALICE
    }

    static String getUserEmail() {
        return "alice@example.com";
    }
}

5.2 flatMap(function)

flatMap() is like map(), but the function you provide must itself return an Optional. This prevents double-wrapping. If you used map() with a function that returns Optional, you would get Optional> -- which is useless. flatMap() flattens that into a single Optional.

Use flatMap() when you are chaining methods that already return Optional -- like navigating a chain of objects where each level might be absent.

import java.util.Optional;

public class FlatMapExample {
    public static void main(String[] args) {
        Optional user = Optional.of(new User("Alice", "alice@example.com"));
        Optional noUser = Optional.empty();

        // map vs flatMap demonstration
        // user.getEmail() returns Optional

        // BAD: map wraps the result in another Optional
        Optional> doubleWrapped = user.map(User::getEmail);
        System.out.println(doubleWrapped);
        // Output: Optional[Optional[alice@example.com]]  <-- Nested! Useless!

        // GOOD: flatMap flattens it to a single Optional
        Optional email = user.flatMap(User::getEmail);
        System.out.println(email);
        // Output: Optional[alice@example.com]

        // Chaining flatMap for nested navigation
        String domain = user.flatMap(User::getEmail)
                            .map(e -> e.split("@")[1])
                            .orElse("unknown");
        System.out.println("Domain: " + domain);
        // Output: Domain: example.com

        // Empty case: entire chain short-circuits
        String noDomain = noUser.flatMap(User::getEmail)
                                .map(e -> e.split("@")[1])
                                .orElse("unknown");
        System.out.println("Domain: " + noDomain);
        // Output: Domain: unknown
    }
}

class User {
    private String name;
    private String email;

    User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Returns Optional because email might not be set
    Optional getEmail() {
        return Optional.ofNullable(email);
    }

    String getName() {
        return name;
    }
}

5.3 filter(predicate)

filter() tests the value against a condition. If the Optional contains a value and the predicate returns true, the Optional is returned unchanged. If the predicate returns false or the Optional is empty, it returns Optional.empty().

Think of filter() as a quality gate: only values that meet the criteria pass through.

import java.util.Optional;

public class FilterExample {
    public static void main(String[] args) {
        Optional age = Optional.of(25);
        Optional childAge = Optional.of(10);
        Optional empty = Optional.empty();

        // Filter: keep only if 18 or older
        Optional adult = age.filter(a -> a >= 18);
        System.out.println("Adult: " + adult);
        // Output: Adult: Optional[25]

        Optional minor = childAge.filter(a -> a >= 18);
        System.out.println("Minor: " + minor);
        // Output: Minor: Optional.empty

        Optional nothing = empty.filter(a -> a >= 18);
        System.out.println("Empty: " + nothing);
        // Output: Empty: Optional.empty

        // Practical: validate input
        String password = "Str0ngP@ss!";
        String result = Optional.of(password)
                                .filter(p -> p.length() >= 8)
                                .filter(p -> p.matches(".*[A-Z].*"))
                                .filter(p -> p.matches(".*[0-9].*"))
                                .map(p -> "Password accepted")
                                .orElse("Password does not meet requirements");
        System.out.println(result);
        // Output: Password accepted
    }
}

5.4 Chaining map, filter, and orElse

The real elegance of Optional shows when you chain these operations together. Each step in the pipeline processes the value only if all previous steps produced a value. This eliminates deeply nested if blocks.

import java.util.Map;
import java.util.Optional;

public class ChainingExample {
    public static void main(String[] args) {
        Map userPreferences = Map.of(
            "alice", "dark",
            "bob", "light"
        );

        // Without Optional: deeply nested null/validity checks
        String userId = "alice";
        String theme = null;
        if (userId != null) {
            String pref = userPreferences.get(userId);
            if (pref != null) {
                if (pref.equals("dark") || pref.equals("light")) {
                    theme = pref.toUpperCase();
                }
            }
        }
        if (theme == null) {
            theme = "SYSTEM";
        }
        System.out.println("Old way: " + theme);
        // Output: Old way: DARK

        // With Optional: clean, readable pipeline
        String themeNew = Optional.ofNullable(userId)
                                  .map(userPreferences::get)
                                  .filter(p -> p.equals("dark") || p.equals("light"))
                                  .map(String::toUpperCase)
                                  .orElse("SYSTEM");
        System.out.println("New way: " + themeNew);
        // Output: New way: DARK

        // With unknown user: chain short-circuits
        String unknown = Optional.ofNullable("charlie")
                                 .map(userPreferences::get)
                                 .filter(p -> p.equals("dark") || p.equals("light"))
                                 .map(String::toUpperCase)
                                 .orElse("SYSTEM");
        System.out.println("Unknown user: " + unknown);
        // Output: Unknown user: SYSTEM
    }
}

6. Consuming Values

Sometimes you do not need to transform or extract a value -- you just want to do something with it if it exists. Optional provides methods for consuming values with side effects.

6.1 ifPresent(consumer)

ifPresent() executes the given action only if a value is present. If the Optional is empty, nothing happens. This is the functional replacement for the if (value != null) { doSomething(value); } pattern.

import java.util.Optional;

public class IfPresentExample {
    public static void main(String[] args) {
        Optional email = Optional.of("alice@example.com");
        Optional noEmail = Optional.empty();

        // Action runs only if value exists
        email.ifPresent(e -> System.out.println("Sending welcome email to: " + e));
        // Output: Sending welcome email to: alice@example.com

        // Nothing happens -- no exception, no output
        noEmail.ifPresent(e -> System.out.println("This will never print"));

        // Practical: log a warning only if a deprecated config is set
        Optional.ofNullable(System.getenv("OLD_CONFIG"))
                .ifPresent(val -> System.out.println(
                    "WARNING: OLD_CONFIG is deprecated. Found value: " + val
                ));
    }
}

6.2 ifPresentOrElse(action, emptyAction) -- Java 9+

ifPresentOrElse() was added in Java 9 to handle both cases: do one thing if the value exists, and do another thing if it does not. Before Java 9, you had to use isPresent() with an if/else block. This method replaces that pattern cleanly.

import java.util.Optional;

public class IfPresentOrElseExample {
    public static void main(String[] args) {
        Optional user = Optional.of("Alice");
        Optional noUser = Optional.empty();

        // Before Java 9: clunky
        if (user.isPresent()) {
            System.out.println("Welcome, " + user.get());
        } else {
            System.out.println("Welcome, Guest");
        }
        // Output: Welcome, Alice

        // Java 9+: clean
        user.ifPresentOrElse(
            name -> System.out.println("Welcome, " + name),
            () -> System.out.println("Welcome, Guest")
        );
        // Output: Welcome, Alice

        noUser.ifPresentOrElse(
            name -> System.out.println("Welcome, " + name),
            () -> System.out.println("Welcome, Guest")
        );
        // Output: Welcome, Guest

        // Practical: load config or set defaults
        Optional.ofNullable(System.getenv("LOG_LEVEL"))
                .ifPresentOrElse(
                    level -> System.out.println("Log level set to: " + level),
                    () -> System.out.println("Log level defaulting to: INFO")
                );
        // Output: Log level defaulting to: INFO
    }
}

7. or() Method -- Java 9+

The or() method was introduced in Java 9 to provide an alternative Optional when the current one is empty. Unlike orElse() which returns the unwrapped value, or() returns another Optional. This is useful when you have a chain of fallback sources -- for example, check the cache first, then the database, then a default.

import java.util.Optional;

public class OrMethodExample {
    public static void main(String[] args) {
        // Simulate a chain of fallback lookups
        Optional config = findInArgs()
                .or(() -> findInEnv())
                .or(() -> findInFile())
                .or(() -> Optional.of("default-value"));

        System.out.println("Config: " + config.orElseThrow());
        // Output: Config: from-config-file

        // Key difference: or() returns Optional, orElse() returns the value
        // or()     -> Optional  (keeps the pipeline in Optional land)
        // orElse() -> String            (unwraps and ends the Optional)
    }

    static Optional findInArgs() {
        System.out.println("Checking command line args...");
        return Optional.empty(); // not provided
    }

    static Optional findInEnv() {
        System.out.println("Checking environment variables...");
        return Optional.empty(); // not set
    }

    static Optional findInFile() {
        System.out.println("Checking config file...");
        return Optional.of("from-config-file"); // found!
    }
}
// Output:
// Checking command line args...
// Checking environment variables...
// Checking config file...
// Config: from-config-file

8. Optional with Streams

Optional and the Stream API work together naturally. Java 9 added Optional.stream() to make this integration even smoother.

8.1 Optional.stream() -- Java 9+

stream() converts an Optional into a Stream: a Stream with one element if the Optional has a value, or an empty Stream if it does not. This is particularly useful when you have a list of Optionals and want to extract only the present values.

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class OptionalStreamExample {
    public static void main(String[] args) {
        // Optional.stream() creates a 0-or-1 element stream
        Optional present = Optional.of("Hello");
        Optional empty = Optional.empty();

        present.stream().forEach(System.out::println);
        // Output: Hello

        empty.stream().forEach(System.out::println);
        // Output: (nothing)

        // Powerful use: extract values from a list of Optionals
        List> optionals = List.of(
            Optional.of("Alice"),
            Optional.empty(),
            Optional.of("Bob"),
            Optional.empty(),
            Optional.of("Charlie")
        );

        // Before Java 9: filter + map
        List namesBefore = optionals.stream()
                .filter(Optional::isPresent)
                .map(Optional::get)
                .collect(Collectors.toList());
        System.out.println("Before: " + namesBefore);
        // Output: Before: [Alice, Bob, Charlie]

        // Java 9+: flatMap with Optional.stream()
        List namesAfter = optionals.stream()
                .flatMap(Optional::stream)
                .collect(Collectors.toList());
        System.out.println("After: " + namesAfter);
        // Output: After: [Alice, Bob, Charlie]
    }
}

8.2 Streams Returning Optional

Several Stream terminal operations return Optional because the result might not exist: findFirst(), findAny(), min(), max(), and reduce(). Working with these naturally produces code that chains Optional methods.

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

public class StreamsReturningOptional {
    public static void main(String[] args) {
        List names = List.of("Charlie", "Alice", "Bob", "David");
        List emptyList = List.of();

        // findFirst: returns Optional
        Optional first = names.stream()
                .filter(n -> n.startsWith("B"))
                .findFirst();
        System.out.println("First B-name: " + first.orElse("none"));
        // Output: First B-name: Bob

        // findFirst on empty result
        Optional missing = names.stream()
                .filter(n -> n.startsWith("Z"))
                .findFirst();
        System.out.println("First Z-name: " + missing.orElse("none"));
        // Output: First Z-name: none

        // min/max: returns Optional
        Optional shortest = names.stream()
                .min(Comparator.comparingInt(String::length));
        shortest.ifPresent(s -> System.out.println("Shortest: " + s));
        // Output: Shortest: Bob

        // reduce: returns Optional when no identity is provided
        Optional sum = List.of(1, 2, 3, 4, 5).stream()
                .reduce(Integer::sum);
        System.out.println("Sum: " + sum.orElse(0));
        // Output: Sum: 15

        // reduce on empty list: Optional.empty
        Optional emptySum = emptyList.stream()
                .map(s -> 1)
                .reduce(Integer::sum);
        System.out.println("Empty sum: " + emptySum.orElse(0));
        // Output: Empty sum: 0
    }
}

8.3 Filtering a Stream of Optionals

A common real-world scenario: you call a method that returns Optional for each element in a collection, and you want to collect only the successful results.

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

public class FilterOptionalStream {
    static Map database = Map.of(
        1, "Alice",
        2, "Bob",
        5, "Eve"
    );

    public static void main(String[] args) {
        List requestedIds = List.of(1, 2, 3, 4, 5);

        // Each lookup returns Optional -- we want only the found users
        List foundUsers = requestedIds.stream()
                .map(id -> findUserById(id))    // Stream>
                .flatMap(Optional::stream)       // Stream (only present values)
                .collect(Collectors.toList());

        System.out.println("Found: " + foundUsers);
        // Output: Found: [Alice, Bob, Eve]

        // Count how many were missing
        long missingCount = requestedIds.stream()
                .map(id -> findUserById(id))
                .filter(Optional::isEmpty)
                .count();
        System.out.println("Missing: " + missingCount);
        // Output: Missing: 2
    }

    static Optional findUserById(int id) {
        return Optional.ofNullable(database.get(id));
    }
}

9. Optional in Method Return Types

Optional was designed with one primary use case in mind: as a method return type to indicate that a result might be absent. Understanding where to use it -- and where not to -- is critical to writing clean, idiomatic Java.

When to Return Optional

Return Optional from methods where the caller legitimately might not get a result and needs to handle that case:

  • Lookup/query methods -- findById(), findByEmail(), getConfigValue()
  • Computation that may have no answer -- max(), first(), parse()
  • Conditional returns -- methods where a result depends on some condition

When NOT to Return Optional

Scenario Why Not Optional What to Do Instead
Collection/List/Set/Map results An empty collection already means "no results" Return an empty collection: Collections.emptyList()
Array results Same as above -- return an empty array Return new String[0]
Primitive return types Optional wraps objects, not primitives (use OptionalInt, OptionalLong, OptionalDouble for primitives) Use primitive Optional types or return a sentinel value
Performance-critical hot paths Optional creates an object allocation per call Return null with clear documentation
Never-null results If the method always returns a value, Optional is misleading Return the value directly

Never Use Optional As...

  • Method parameters -- It makes the API awkward. Use overloaded methods or null with @Nullable annotation instead.
  • Class fields -- Optional is not Serializable. Use null internally and return Optional from getter methods if needed.
  • Constructor parameters -- Same reason as method parameters.
  • Map keys or collection elements -- This defeats the purpose and adds confusion.
import java.util.*;

public class OptionalReturnTypes {

    // GOOD: lookup method that might not find a result
    static Optional findUserById(int id) {
        Map users = Map.of(1, "Alice", 2, "Bob");
        return Optional.ofNullable(users.get(id));
    }

    // GOOD: computation that might have no answer
    static Optional findLongestWord(List words) {
        if (words == null || words.isEmpty()) return Optional.empty();
        return words.stream().max(Comparator.comparingInt(String::length));
    }

    // BAD: returning Optional of a collection
    // static Optional> findUsers() { ... }

    // GOOD: return empty collection instead
    static List findUsers(String role) {
        Map> roleUsers = Map.of(
            "admin", List.of("Alice", "Bob")
        );
        return roleUsers.getOrDefault(role, Collections.emptyList());
    }

    // BAD: Optional as a parameter
    // static void sendEmail(String to, Optional cc) { ... }

    // GOOD: use overloaded methods instead
    static void sendEmail(String to) {
        sendEmail(to, null);
    }

    static void sendEmail(String to, String cc) {
        System.out.println("Sending to: " + to + (cc != null ? ", CC: " + cc : ""));
    }

    public static void main(String[] args) {
        findUserById(1).ifPresent(u -> System.out.println("Found: " + u));
        // Output: Found: Alice

        findUserById(99).ifPresent(u -> System.out.println("Found: " + u));
        // Output: (nothing)

        List admins = findUsers("admin");
        System.out.println("Admins: " + admins);
        // Output: Admins: [Alice, Bob]

        List guests = findUsers("guest");
        System.out.println("Guests: " + guests);
        // Output: Guests: []

        sendEmail("alice@test.com");
        // Output: Sending to: alice@test.com

        sendEmail("alice@test.com", "bob@test.com");
        // Output: Sending to: alice@test.com, CC: bob@test.com
    }
}

10. Optional with Collections

A frequent design question is: should I return Optional> or just List? The answer is almost always to return an empty collection. However, Optional and collections do interact in useful ways.

import java.util.*;
import java.util.stream.Collectors;

public class OptionalWithCollections {
    static Map> departmentEmployees = Map.of(
        "engineering", List.of("Alice", "Bob", "Charlie"),
        "marketing", List.of("Diana", "Eve")
    );

    public static void main(String[] args) {
        // BAD: Optional> -- empty list already means "no results"
        // Optional> employees = getEmployeesBad("sales");

        // GOOD: just return an empty list
        List engineers = getEmployees("engineering");
        System.out.println("Engineers: " + engineers);
        // Output: Engineers: [Alice, Bob, Charlie]

        List sales = getEmployees("sales");
        System.out.println("Sales: " + sales);
        // Output: Sales: []

        // Useful: Optional.map() to safely transform a nullable collection
        String firstEngineer = Optional.ofNullable(departmentEmployees.get("engineering"))
                .map(list -> list.get(0))
                .orElse("No one");
        System.out.println("First engineer: " + firstEngineer);
        // Output: First engineer: Alice

        String firstSales = Optional.ofNullable(departmentEmployees.get("sales"))
                .map(list -> list.get(0))
                .orElse("No one");
        System.out.println("First sales: " + firstSales);
        // Output: First sales: No one

        // Useful: Optional.map to safely get collection size
        int engineerCount = Optional.ofNullable(departmentEmployees.get("engineering"))
                .map(List::size)
                .orElse(0);
        System.out.println("Engineer count: " + engineerCount);
        // Output: Engineer count: 3

        // Useful: Optional.filter + map to get only large departments
        Optional.ofNullable(departmentEmployees.get("engineering"))
                .filter(list -> list.size() > 2)
                .ifPresent(list -> System.out.println("Large department: " + list));
        // Output: Large department: [Alice, Bob, Charlie]
    }

    // Return empty collection, NOT Optional>
    static List getEmployees(String department) {
        return departmentEmployees.getOrDefault(department, Collections.emptyList());
    }
}

11. Anti-Patterns

Optional can be misused just as easily as null. Here are the most common anti-patterns, why they are wrong, and how to fix them. Learning to recognize these will make you a better Java developer.

Anti-Pattern 1: Using get() Without Checking

This is the most common and most dangerous anti-pattern. You replaced null with Optional, but then immediately called get() -- gaining nothing.

import java.util.Optional;

public class AntiPattern1 {
    public static void main(String[] args) {
        Optional name = findName("unknown");

        // BAD: calling get() without checking -- same as dereferencing null
        // String value = name.get();  // throws NoSuchElementException

        // BAD: isPresent + get -- just null-checking with extra steps
        if (name.isPresent()) {
            System.out.println(name.get());
        }

        // GOOD: use orElse, ifPresent, or map
        System.out.println(name.orElse("Anonymous"));
        // Output: Anonymous
    }

    static Optional findName(String key) {
        if ("admin".equals(key)) return Optional.of("Alice");
        return Optional.empty();
    }
}

Anti-Pattern 2: Optional as Method Parameter

Never accept Optional as a parameter. It forces the caller to wrap values in Optional, makes the API awkward, and every call site has to decide between Optional.of(), Optional.ofNullable(), or Optional.empty().

import java.util.Optional;

public class AntiPattern2 {

    // BAD: Optional as parameter -- callers hate this
    static void sendNotificationBad(String message, Optional recipient) {
        String to = recipient.orElse("admin@company.com");
        System.out.println("Sending '" + message + "' to " + to);
    }

    // GOOD option 1: overloaded methods
    static void sendNotification(String message) {
        sendNotification(message, "admin@company.com");
    }

    static void sendNotification(String message, String recipient) {
        System.out.println("Sending '" + message + "' to " + recipient);
    }

    // GOOD option 2: nullable parameter with @Nullable annotation
    // static void sendNotification(String message, @Nullable String recipient) {
    //     String to = (recipient != null) ? recipient : "admin@company.com";
    //     System.out.println("Sending '" + message + "' to " + to);
    // }

    public static void main(String[] args) {
        // BAD: caller is forced to wrap
        sendNotificationBad("Server down", Optional.of("ops@company.com"));
        sendNotificationBad("Server down", Optional.empty());

        // GOOD: caller just calls naturally
        sendNotification("Server down", "ops@company.com");
        sendNotification("Server down");
    }
}

Anti-Pattern 3: Optional as a Field

Optional is not Serializable. Using it as a field breaks serialization frameworks (Jackson, JPA/Hibernate, Java Serialization). Instead, store the field as a nullable type and return Optional from the getter.

import java.util.Optional;

public class AntiPattern3 {

    // BAD: Optional as field
    static class UserBad {
        private String name;
        private Optional nickname; // Not serializable! Breaks frameworks!

        UserBad(String name, Optional nickname) {
            this.name = name;
            this.nickname = nickname;
        }
    }

    // GOOD: nullable field, Optional in getter
    static class UserGood {
        private String name;
        private String nickname; // nullable -- that's fine for fields

        UserGood(String name, String nickname) {
            this.name = name;
            this.nickname = nickname;
        }

        public String getName() {
            return name;
        }

        // Return Optional from getter to signal it might be absent
        public Optional getNickname() {
            return Optional.ofNullable(nickname);
        }
    }

    public static void main(String[] args) {
        UserGood user = new UserGood("Alice", null);

        String display = user.getNickname()
                             .map(nick -> nick + " (" + user.getName() + ")")
                             .orElse(user.getName());
        System.out.println(display);
        // Output: Alice

        UserGood user2 = new UserGood("Bob", "Bobby");
        String display2 = user2.getNickname()
                               .map(nick -> nick + " (" + user2.getName() + ")")
                               .orElse(user2.getName());
        System.out.println(display2);
        // Output: Bobby (Bob)
    }
}

Anti-Pattern 4: Optional of Collection

Never return Optional>, Optional>, or Optional>. An empty collection already communicates "no results." Wrapping it in Optional adds a useless layer of indirection.

import java.util.*;

public class AntiPattern4 {

    // BAD: Optional of collection -- caller now has two layers of "empty" to handle
    static Optional> getTagsBad(String articleId) {
        Map> articles = Map.of(
            "1", List.of("java", "tutorial")
        );
        return Optional.ofNullable(articles.get(articleId));
    }

    // GOOD: return empty collection
    static List getTags(String articleId) {
        Map> articles = Map.of(
            "1", List.of("java", "tutorial")
        );
        return articles.getOrDefault(articleId, Collections.emptyList());
    }

    public static void main(String[] args) {
        // BAD: caller has to unwrap Optional AND then check if list is empty
        Optional> tagsBad = getTagsBad("2");
        // tagsBad.ifPresent(list -> list.forEach(...)); // What if list is empty?

        // GOOD: caller just uses the list -- empty is handled naturally
        List tags = getTags("2");
        if (tags.isEmpty()) {
            System.out.println("No tags found");
        }
        // Output: No tags found

        List tags2 = getTags("1");
        System.out.println("Tags: " + tags2);
        // Output: Tags: [java, tutorial]
    }
}

Anti-Pattern 5: Using isPresent + get Instead of Functional Methods

If you find yourself writing if (optional.isPresent()) { ... optional.get() ... }, you are not using Optional correctly. Every such pattern has a cleaner functional equivalent.

import java.util.Optional;

public class AntiPattern5 {
    public static void main(String[] args) {
        Optional email = Optional.of("alice@example.com");

        // BAD: isPresent + get -- imperative style, misses the point of Optional
        String domain1;
        if (email.isPresent()) {
            domain1 = email.get().split("@")[1];
        } else {
            domain1 = "unknown";
        }
        System.out.println(domain1);

        // GOOD: map + orElse -- functional style, clean and readable
        String domain2 = email.map(e -> e.split("@")[1])
                              .orElse("unknown");
        System.out.println(domain2);
        // Both output: example.com

        // BAD: isPresent + get for conditional action
        if (email.isPresent()) {
            System.out.println("Email: " + email.get());
        }

        // GOOD: ifPresent
        email.ifPresent(e -> System.out.println("Email: " + e));

        // BAD: isPresent for throwing
        Optional required = Optional.empty();
        // if (!required.isPresent()) throw new IllegalStateException("missing");
        // String val = required.get();

        // GOOD: orElseThrow
        // String val = required.orElseThrow(() -> new IllegalStateException("missing"));
    }
}

Anti-Pattern 6: Wrapping Everything in Optional

Optional is not a universal replacement for null. It is specifically for method return types where absence is a valid, expected outcome. Do not wrap values that you know will always be non-null, and do not use Optional in places where null is never a concern.

import java.util.Optional;

public class AntiPattern6 {

    // BAD: toString() never returns null, Optional is pointless here
    static Optional toStringBad(int value) {
        return Optional.of(String.valueOf(value));
    }

    // GOOD: just return the String
    static String toStringGood(int value) {
        return String.valueOf(value);
    }

    // BAD: wrapping non-null constants
    static Optional getAppName() {
        return Optional.of("MyApp"); // This is NEVER empty
    }

    // GOOD: return directly
    static String getAppNameGood() {
        return "MyApp";
    }

    public static void main(String[] args) {
        // Rule of thumb: if a method ALWAYS returns a value, don't use Optional
        System.out.println(toStringGood(42));
        // Output: 42

        System.out.println(getAppNameGood());
        // Output: MyApp
    }
}

Anti-Patterns Summary

Anti-Pattern Problem Fix
Calling get() without isPresent() Throws NoSuchElementException Use orElse(), orElseThrow(), map()
isPresent() + get() Null-checking with extra steps Use ifPresent(), map(), orElse()
Optional as method parameter Awkward API, forces callers to wrap Use overloaded methods or @Nullable
Optional as field Not Serializable, breaks frameworks Nullable field + Optional getter
Optional> Two layers of emptiness Return empty collection
Wrapping non-null values Misleading API, unnecessary overhead Return the value directly

12. Best Practices

Follow these guidelines to use Optional effectively. They represent the consensus of the Java community and the original intent of the API designers.

Practice 1: Prefer orElse / orElseGet / map Over isPresent + get

Every time you write isPresent() + get(), stop and find the functional alternative. The whole point of Optional is to avoid imperative null-check patterns.

Practice 2: Chain Operations Functionally

Build pipelines with map(), flatMap(), filter(), and terminal methods. This produces code that reads like a sentence describing the transformation.

Practice 3: Use Optional Only for Return Types

Not for fields, not for parameters, not for collection elements. The designers of Optional -- Brian Goetz and Stuart Marks -- have been explicit about this.

Practice 4: Never Call get()

If you need the value and it must be present, use orElseThrow(). If you want a default, use orElse(). If you want to transform, use map(). There is no situation where get() is the best choice.

Practice 5: Use orElseThrow() for Required Values

When the absence of a value is a programming error or a business rule violation, orElseThrow() with a meaningful exception is the right approach. It makes the failure explicit and the error message useful.

Practice 6: Use Primitive Optional Types for Primitives

Java provides OptionalInt, OptionalLong, and OptionalDouble to avoid the overhead of boxing primitives into wrapper objects. Use them when your value is a primitive type.

Practice 7: Do Not Return null From a Method That Returns Optional

This completely defeats the purpose. If you have no value, return Optional.empty(). A method returning Optional should never, ever return null.

import java.util.Optional;
import java.util.OptionalInt;

public class BestPractices {
    public static void main(String[] args) {
        // Practice 1: functional over imperative
        Optional name = Optional.of("alice");

        // Imperative (avoid)
        String upper1;
        if (name.isPresent()) {
            upper1 = name.get().toUpperCase();
        } else {
            upper1 = "UNKNOWN";
        }

        // Functional (prefer)
        String upper2 = name.map(String::toUpperCase).orElse("UNKNOWN");
        System.out.println(upper2);
        // Output: ALICE

        // Practice 2: chain operations
        String greeting = Optional.of("  alice  ")
                .map(String::trim)
                .filter(n -> !n.isEmpty())
                .map(n -> n.substring(0, 1).toUpperCase() + n.substring(1))
                .map(n -> "Hello, " + n + "!")
                .orElse("Hello, stranger!");
        System.out.println(greeting);
        // Output: Hello, Alice!

        // Practice 5: orElseThrow for required values
        String requiredConfig = Optional.ofNullable(System.getenv("APP_HOME"))
                .orElseThrow(() -> new IllegalStateException(
                    "APP_HOME environment variable must be set"
                ));

        // Practice 6: primitive Optionals
        OptionalInt maxAge = findMaxAge();
        maxAge.ifPresent(age -> System.out.println("Max age: " + age));

        int ageOrDefault = maxAge.orElse(-1);
        System.out.println("Age: " + ageOrDefault);
        // Output: Max age: 65
        //         Age: 65

        // Practice 7: NEVER return null from Optional-returning method
        // Optional result = findSomething();
        // result could be null!! Defeats the entire purpose!
    }

    static OptionalInt findMaxAge() {
        int[] ages = {25, 30, 65, 40};
        return java.util.Arrays.stream(ages).max();
    }
}

Best Practices Quick Reference

# Practice Do Don't
1 Access values orElse(), map(), ifPresent() isPresent() + get()
2 Chain operations map().filter().orElse() Nested if/else blocks
3 Use case Method return types only Fields, parameters, collection elements
4 Retrieve value orElseThrow(), orElse() get()
5 Required values orElseThrow(() -> new CustomEx()) Silent fallback when value is mandatory
6 Primitive values OptionalInt, OptionalLong, OptionalDouble Optional (autoboxing overhead)
7 Empty return Optional.empty() Return null from Optional-typed method

13. Complete Practical Example

Let's put everything together with a realistic example. We will build a user management system that demonstrates how Optional eliminates null checks and makes code safer and more expressive. First, we will see the old way with null checks, then refactor to use Optional.

The Domain Model

We have User objects that have an optional Address, which has an optional City. In a real application, any of these might be missing -- a user might not have filled in their profile, their address might be incomplete, etc.

import java.util.Optional;

// Domain classes
class City {
    private final String name;
    private final String zipCode;

    City(String name, String zipCode) {
        this.name = name;
        this.zipCode = zipCode;
    }

    public String getName() { return name; }
    public String getZipCode() { return zipCode; }

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

class Address {
    private final String street;
    private final City city;  // might be null internally

    Address(String street, City city) {
        this.street = street;
        this.city = city;
    }

    public String getStreet() { return street; }

    // Return Optional from getter -- city might not be set
    public Optional getCity() {
        return Optional.ofNullable(city);
    }

    @Override
    public String toString() {
        return street + ", " + (city != null ? city : "Unknown City");
    }
}

class User {
    private final int id;
    private final String name;
    private final String email;
    private final Address address;  // might be null internally

    User(int id, String name, String email, Address address) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.address = address;
    }

    public int getId() { return id; }
    public String getName() { return name; }

    // Return Optional -- email might not be provided
    public Optional getEmail() {
        return Optional.ofNullable(email);
    }

    // Return Optional -- address might not be set
    public Optional
getAddress() { return Optional.ofNullable(address); } @Override public String toString() { return "User{id=" + id + ", name='" + name + "'}"; } }

The Repository (Data Access Layer)

The UserRepository simulates a database. The findById() method returns Optional because the requested user might not exist.

import java.util.*;

class UserRepository {
    private final Map database = new HashMap<>();

    UserRepository() {
        // User with full profile
        database.put(1, new User(1, "Alice", "alice@example.com",
            new Address("123 Main St", new City("Springfield", "62704"))));

        // User with address but no city
        database.put(2, new User(2, "Bob", "bob@example.com",
            new Address("456 Oak Ave", null)));

        // User with no address
        database.put(3, new User(3, "Charlie", null, null));

        // User with address and city but no email
        database.put(4, new User(4, "Diana", null,
            new Address("789 Pine Rd", new City("Portland", "97201"))));
    }

    // Returns Optional because user might not exist
    public Optional findById(int id) {
        return Optional.ofNullable(database.get(id));
    }

    // Returns a list, not Optional -- empty list means no results
    public List findAll() {
        return new ArrayList<>(database.values());
    }

    public Optional findByEmail(String email) {
        return database.values().stream()
                .filter(u -> u.getEmail().isPresent())
                .filter(u -> u.getEmail().get().equals(email))
                .findFirst();
    }
}

Before: The Null-Check Nightmare

Here is what the code looks like without Optional. Notice the deeply nested null checks. Every level of the object graph requires another if (thing != null) check. Miss one, and you get a NullPointerException.

class UserServiceOld {

    // Get city name for a user -- the old way
    static String getCityNameOld(UserRepository repo, int userId) {
        User user = repo.findById(userId).orElse(null); // pretend this returned null
        if (user != null) {
            Address address = user.getAddress().orElse(null);
            if (address != null) {
                City city = address.getCity().orElse(null);
                if (city != null) {
                    return city.getName();
                }
            }
        }
        return "Unknown";
    }

    // Generate mailing label -- the old way
    static String getMailingLabelOld(UserRepository repo, int userId) {
        User user = repo.findById(userId).orElse(null);
        if (user == null) {
            return "User not found";
        }

        StringBuilder label = new StringBuilder();
        label.append(user.getName());

        String email = user.getEmail().orElse(null);
        if (email != null) {
            label.append(" <").append(email).append(">");
        }

        Address address = user.getAddress().orElse(null);
        if (address != null) {
            label.append("\n").append(address.getStreet());
            City city = address.getCity().orElse(null);
            if (city != null) {
                label.append("\n").append(city.getName()).append(", ").append(city.getZipCode());
            }
        }

        return label.toString();
    }
}

After: Clean Code with Optional

Now let's see the same operations using Optional's functional API. The nested null checks become flat chains of map() and flatMap().

import java.util.*;
import java.util.stream.Collectors;

class UserService {
    private final UserRepository repo;

    UserService(UserRepository repo) {
        this.repo = repo;
    }

    // Get city name: flatMap chains through User -> Address -> City
    String getCityName(int userId) {
        return repo.findById(userId)               // Optional
                   .flatMap(User::getAddress)       // Optional
.flatMap(Address::getCity) // Optional .map(City::getName) // Optional .orElse("Unknown"); // String } // Get zip code with validation: filter ensures it matches expected format String getZipCode(int userId) { return repo.findById(userId) .flatMap(User::getAddress) .flatMap(Address::getCity) .map(City::getZipCode) .filter(zip -> zip.matches("\\d{5}")) .orElse("N/A"); } // Get email domain: map + transform String getEmailDomain(int userId) { return repo.findById(userId) .flatMap(User::getEmail) .map(email -> email.split("@")[1]) .orElse("no-email"); } // Generate mailing label: combine multiple Optional results String getMailingLabel(int userId) { return repo.findById(userId) .map(user -> { StringBuilder label = new StringBuilder(user.getName()); user.getEmail() .ifPresent(email -> label.append(" <").append(email).append(">")); user.getAddress().ifPresent(address -> { label.append("\n").append(address.getStreet()); address.getCity().ifPresent(city -> label.append("\n").append(city.getName()) .append(", ").append(city.getZipCode()) ); }); return label.toString(); }) .orElse("User not found"); } // Get user or throw: for required operations User getRequiredUser(int userId) { return repo.findById(userId) .orElseThrow(() -> new IllegalArgumentException( "User with ID " + userId + " not found" )); } // Find all users in a specific city List findUsersInCity(String cityName) { return repo.findAll().stream() .filter(user -> user.getAddress() .flatMap(Address::getCity) .map(City::getName) .filter(name -> name.equalsIgnoreCase(cityName)) .isPresent()) .collect(Collectors.toList()); } // Collect all known email domains List getAllEmailDomains() { return repo.findAll().stream() .map(User::getEmail) // Stream> .flatMap(Optional::stream) // Stream -- only present emails .map(email -> email.split("@")[1]) .distinct() .sorted() .collect(Collectors.toList()); } }

Running the Example

Let's test all these methods to see how they handle the different user scenarios -- full profile, partial data, and missing data.

public class OptionalPracticalExample {
    public static void main(String[] args) {
        UserRepository repo = new UserRepository();
        UserService service = new UserService(repo);

        System.out.println("=== City Names ===");
        System.out.println("User 1: " + service.getCityName(1));  // Full profile
        System.out.println("User 2: " + service.getCityName(2));  // No city
        System.out.println("User 3: " + service.getCityName(3));  // No address
        System.out.println("User 99: " + service.getCityName(99)); // Not in DB

        System.out.println("\n=== Zip Codes ===");
        System.out.println("User 1: " + service.getZipCode(1));
        System.out.println("User 3: " + service.getZipCode(3));

        System.out.println("\n=== Email Domains ===");
        System.out.println("User 1: " + service.getEmailDomain(1));
        System.out.println("User 3: " + service.getEmailDomain(3));

        System.out.println("\n=== Mailing Labels ===");
        System.out.println("--- User 1 (full profile) ---");
        System.out.println(service.getMailingLabel(1));

        System.out.println("--- User 2 (no city) ---");
        System.out.println(service.getMailingLabel(2));

        System.out.println("--- User 3 (minimal) ---");
        System.out.println(service.getMailingLabel(3));

        System.out.println("--- User 4 (no email) ---");
        System.out.println(service.getMailingLabel(4));

        System.out.println("--- User 99 (not found) ---");
        System.out.println(service.getMailingLabel(99));

        System.out.println("\n=== Required User ===");
        try {
            User user = service.getRequiredUser(99);
        } catch (IllegalArgumentException e) {
            System.out.println("Caught: " + e.getMessage());
        }

        System.out.println("\n=== Users in Springfield ===");
        service.findUsersInCity("Springfield")
               .forEach(u -> System.out.println("  " + u));

        System.out.println("\n=== All Email Domains ===");
        System.out.println(service.getAllEmailDomains());
    }
}
// Output:
// === City Names ===
// User 1: Springfield
// User 2: Unknown
// User 3: Unknown
// User 99: Unknown
//
// === Zip Codes ===
// User 1: 62704
// User 3: N/A
//
// === Email Domains ===
// User 1: example.com
// User 3: no-email
//
// === Mailing Labels ===
// --- User 1 (full profile) ---
// Alice 
// 123 Main St
// Springfield, 62704
// --- User 2 (no city) ---
// Bob 
// 456 Oak Ave
// --- User 3 (minimal) ---
// Charlie
// --- User 4 (no email) ---
// Diana
// 789 Pine Rd
// Portland, 97201
// --- User 99 (not found) ---
// User not found
//
// === Required User ===
// Caught: User with ID 99 not found
//
// === Users in Springfield ===
//   User{id=1, name='Alice'}
//
// === All Email Domains ===
// [example.com]

What This Example Demonstrates

Concept Where Used
Optional.ofNullable() Getter methods in User, Address (wrapping nullable fields)
Optional.empty() findById() when user not in map
map() Transforming City to name, email to domain
flatMap() Chaining User -> Address -> City (each returns Optional)
filter() Validating zip code format, filtering users by city
orElse() Providing defaults like "Unknown", "N/A"
orElseThrow() getRequiredUser() -- throwing when absence is an error
ifPresent() Conditionally appending email and city to mailing label
Optional.stream() getAllEmailDomains() -- flattening Optional in stream pipeline
Empty collection (not Optional) findAll() returns List, findUsersInCity() returns List
Nullable field + Optional getter Address and email stored as nullable fields, exposed as Optional

14. Optional Method Quick Reference

Here is every method on Optional in one place, organized by purpose. Bookmark this for quick lookups.

Category Method Since Description
Creating Optional.of(value) Java 8 Wraps a non-null value. Throws NPE if null.
Optional.ofNullable(value) Java 8 Wraps a possibly-null value. Returns empty if null.
Optional.empty() Java 8 Returns an empty Optional.
Checking isPresent() Java 8 Returns true if value is present.
isEmpty() Java 11 Returns true if value is absent.
Getting get() Java 8 Returns value or throws NoSuchElementException. Avoid.
orElse(default) Java 8 Returns value or the provided default (eagerly evaluated).
orElseGet(supplier) Java 8 Returns value or invokes supplier (lazily evaluated).
orElseThrow(supplier) Java 8/10 Returns value or throws supplied exception. No-arg version (Java 10) throws NoSuchElementException.
Transforming map(function) Java 8 Applies function to value and wraps result in Optional.
flatMap(function) Java 8 Applies function that returns Optional; avoids double-wrapping.
filter(predicate) Java 8 Returns this Optional if predicate matches, empty otherwise.
Consuming ifPresent(consumer) Java 8 Executes action if value is present.
ifPresentOrElse(action, emptyAction) Java 9 Executes action if present, emptyAction if absent.
Alternative or(supplier) Java 9 Returns this Optional if present, or the Optional produced by supplier.
Stream stream() Java 9 Returns a Stream of 0 or 1 elements.
July 24, 2023

Java – CompletableFuture

1. What is CompletableFuture?

Imagine ordering food at a restaurant. You walk up to the counter, place your order, and the cashier hands you a buzzer. That buzzer is a promise: “Your food will be ready at some point. Go sit down, check your phone, chat with friends — when it’s done, the buzzer will vibrate and you can pick it up.” You are not standing at the counter blocking everyone behind you. You are free to do other things while your order is being prepared.

CompletableFuture is that buzzer. It represents a future result of an asynchronous computation — a value that will be available at some point. It was introduced in Java 8 (in the java.util.concurrent package) and is the most powerful tool Java offers for writing non-blocking, asynchronous code.

The Problem with Old-School Concurrency

Before CompletableFuture, Java had two main approaches to concurrent programming — both with significant limitations:

Raw Threads: You create a Thread, override run(), and call start(). But run() returns void — there is no built-in way to get a result back. You end up sharing mutable state, using wait()/notify(), and debugging race conditions at 2 AM.

ExecutorService + Future: Better. You submit a Callable to an ExecutorService and get a Future<T> back. But Future has a fatal flaw: the only way to get the result is to call get(), which blocks the calling thread. You cannot attach a callback. You cannot chain operations. You cannot combine multiple futures. You are back to blocking.

Why CompletableFuture?

CompletableFuture solves all of these problems:

  • Non-blocking — Attach callbacks that run when the result is available. No need to call get() and block.
  • Composable — Chain multiple async operations together: “When A finishes, do B. When B finishes, do C.” This is like piping Unix commands.
  • Combinable — Run multiple async operations in parallel and combine their results: “Fetch user data AND order history simultaneously, then merge them.”
  • Exception handling — Built-in methods to catch and recover from errors in async pipelines, unlike Future which wraps everything in ExecutionException.
  • Manually completable — You can create a CompletableFuture and complete it yourself from any thread, which is why it’s called “completable.”

Future vs CompletableFuture

Feature Future CompletableFuture
Get result get() — blocks get(), join(), or non-blocking callbacks
Attach callback Not supported thenApply(), thenAccept(), thenRun()
Chain operations Not supported thenCompose(), thenApply()
Combine futures Not supported thenCombine(), allOf(), anyOf()
Exception handling ExecutionException wrapper exceptionally(), handle(), whenComplete()
Manually complete Not supported complete(), completeExceptionally()
Cancel cancel() — limited cancel() — does not interrupt running tasks

Think of Future as a read-only receipt that says “your result will be here eventually, keep checking.” CompletableFuture is a full-featured event system: “When the result arrives, here’s what I want you to do with it.”

import java.util.concurrent.*;

public class FutureVsCompletableFuture {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // OLD WAY: Future -- blocks on get()
        Future future = executor.submit(() -> {
            Thread.sleep(1000);
            return "Result from Future";
        });
        // This blocks the current thread for ~1 second
        String result = future.get();
        System.out.println(result);
        // Output: Result from Future

        // NEW WAY: CompletableFuture -- non-blocking callbacks
        CompletableFuture cf = CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return "Result from CompletableFuture";
        });
        // Non-blocking! This runs on a different thread when the result is ready
        cf.thenAccept(r -> System.out.println(r));
        // Output (after ~1 second): Result from CompletableFuture

        // Keep the program alive long enough for async operations to complete
        Thread.sleep(2000);
        executor.shutdown();
    }
}

2. Creating CompletableFuture

There are four main ways to create a CompletableFuture, each suited for different situations.

2.1 supplyAsync() — Returns a Value

Use supplyAsync() when your asynchronous operation produces a result. It takes a Supplier<T> (a function that takes no arguments and returns a value) and runs it on a background thread.

import java.util.concurrent.CompletableFuture;

public class SupplyAsyncExample {
    public static void main(String[] args) {
        // supplyAsync runs on ForkJoinPool.commonPool() by default
        CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Running on: " + Thread.currentThread().getName());
            // Simulate database query
            try { Thread.sleep(500); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return "User{id=1, name='Alice'}";
        });

        // Do other work while the async operation runs
        System.out.println("Main thread is free to do other work...");
        System.out.println("Main thread: " + Thread.currentThread().getName());

        // Get the result (blocks only if not yet complete)
        String user = future.join();
        System.out.println("Result: " + user);

        // Output:
        // Main thread is free to do other work...
        // Main thread: main
        // Running on: ForkJoinPool.commonPool-worker-1
        // Result: User{id=1, name='Alice'}
    }
}

2.2 runAsync() — No Return Value

Use runAsync() when your asynchronous operation does not produce a result — it performs a side effect like logging, sending a notification, or writing to a file. It takes a Runnable and returns CompletableFuture<Void>.

import java.util.concurrent.CompletableFuture;

public class RunAsyncExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture.runAsync(() -> {
            System.out.println("Sending email on: " + Thread.currentThread().getName());
            try { Thread.sleep(300); } catch (InterruptedException e) { throw new RuntimeException(e); }
            System.out.println("Email sent successfully!");
        });

        System.out.println("Main thread continues...");

        // join() returns null for Void futures, but waits for completion
        future.join();
        System.out.println("Done.");

        // Output:
        // Main thread continues...
        // Sending email on: ForkJoinPool.commonPool-worker-1
        // Email sent successfully!
        // Done.
    }
}

2.3 completedFuture() — Already Completed

Use completedFuture() when you already have the result and want to wrap it in a CompletableFuture. This is useful for testing, caching, or when a method signature requires a CompletableFuture but you have the value immediately.

import java.util.concurrent.CompletableFuture;

public class CompletedFutureExample {
    public static void main(String[] args) {
        // Already completed -- no async work happens
        CompletableFuture cached = CompletableFuture.completedFuture("Cached Value");

        // join() returns immediately -- no waiting
        System.out.println(cached.join());
        System.out.println("Is done? " + cached.isDone());

        // Output:
        // Cached Value
        // Is done? true
    }

    // Common use case: method that returns CompletableFuture but sometimes has cached data
    static CompletableFuture fetchUser(String userId) {
        String cached = getFromCache(userId);
        if (cached != null) {
            return CompletableFuture.completedFuture(cached); // No async work needed
        }
        return CompletableFuture.supplyAsync(() -> fetchFromDatabase(userId)); // Async DB call
    }

    static String getFromCache(String userId) { return "1".equals(userId) ? "Alice" : null; }
    static String fetchFromDatabase(String userId) { return "User-" + userId; }
}

2.4 Custom Executor — Thread Pool Control

By default, supplyAsync() and runAsync() use the ForkJoinPool.commonPool(). This shared thread pool is designed for CPU-bound work. If your async operations involve I/O (database calls, HTTP requests, file operations), you should provide a custom executor to avoid starving the common pool.

This is one of the most important production considerations. The common pool has a limited number of threads (typically Runtime.getRuntime().availableProcessors() - 1). If you fill it with slow I/O operations, all CompletableFuture operations in your entire application slow down — including parallel streams.

import java.util.concurrent.*;

public class CustomExecutorExample {
    // Dedicated thread pool for I/O operations
    private static final ExecutorService IO_EXECUTOR = Executors.newFixedThreadPool(10, r -> {
        Thread t = new Thread(r);
        t.setDaemon(true); // Won't prevent JVM shutdown
        t.setName("io-worker-" + t.getId());
        return t;
    });

    public static void main(String[] args) {
        // Pass custom executor as second argument
        CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Running on: " + Thread.currentThread().getName());
            try { Thread.sleep(200); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return "Data from database";
        }, IO_EXECUTOR); // <-- Custom executor

        CompletableFuture logFuture = CompletableFuture.runAsync(() -> {
            System.out.println("Logging on: " + Thread.currentThread().getName());
        }, IO_EXECUTOR); // <-- Same custom executor

        future.thenAccept(data -> System.out.println("Received: " + data));
        logFuture.join();
        future.join();

        // Output:
        // Running on: io-worker-21
        // Logging on: io-worker-22
        // Received: Data from database

        IO_EXECUTOR.shutdown();
    }
}

Creation Methods Summary

Method Returns Input Use When
supplyAsync(supplier) CompletableFuture<T> Supplier<T> Async operation produces a result
supplyAsync(supplier, executor) CompletableFuture<T> Supplier<T> Same, with custom thread pool
runAsync(runnable) CompletableFuture<Void> Runnable Async operation with no result (side effects)
runAsync(runnable, executor) CompletableFuture<Void> Runnable Same, with custom thread pool
completedFuture(value) CompletableFuture<T> T Already have the value (caching, testing)

3. Getting Results

At some point, you need to extract the actual result from a CompletableFuture. Java provides several ways to do this, each with different trade-offs.

3.1 join() vs get()

Both join() and get() block the calling thread until the result is available. The key difference is how they handle exceptions:

Method Exception Type Requires try-catch? Preferred?
join() CompletionException (unchecked) No Yes — cleaner code, works in streams
get() ExecutionException + InterruptedException (checked) Yes Only when you need timeout
import java.util.concurrent.*;

public class JoinVsGetExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture.supplyAsync(() -> "Hello");

        // join() -- clean, no checked exceptions
        String result1 = future.join();
        System.out.println("join: " + result1);
        // Output: join: Hello

        // get() -- requires try-catch for checked exceptions
        try {
            String result2 = future.get();
            System.out.println("get: " + result2);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
        // Output: get: Hello

        // get() with timeout -- useful to prevent indefinite blocking
        CompletableFuture slowFuture = CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(5000); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return "Slow result";
        });

        try {
            String result3 = slowFuture.get(1, TimeUnit.SECONDS); // Wait max 1 second
        } catch (TimeoutException e) {
            System.out.println("Timed out! The operation took too long.");
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
        // Output: Timed out! The operation took too long.
    }
}

3.2 getNow() — Non-Blocking with Default

getNow(defaultValue) returns the result immediately if it is already complete, or returns the default value if it is not yet done. This never blocks.

import java.util.concurrent.CompletableFuture;

public class GetNowExample {
    public static void main(String[] args) throws InterruptedException {
        CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return "Computed Value";
        });

        // Not done yet -- returns default
        String immediate = future.getNow("Default Value");
        System.out.println("Immediate: " + immediate);
        // Output: Immediate: Default Value

        // Wait for completion
        Thread.sleep(1500);

        // Now done -- returns actual result
        String completed = future.getNow("Default Value");
        System.out.println("After wait: " + completed);
        // Output: After wait: Computed Value
    }
}

4. Transforming Results

The real power of CompletableFuture comes from its ability to chain transformations. When an async operation completes, you can automatically transform, consume, or follow up on the result without blocking. These three methods are the workhorses of CompletableFuture pipelines.

4.1 thenApply() — Transform the Result

thenApply() is like map() in the Stream API. It takes the result, transforms it, and returns a new CompletableFuture with the transformed value. Use it when you want to convert the result from one type to another.

import java.util.concurrent.CompletableFuture;

public class ThenApplyExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture
            .supplyAsync(() -> "  Hello, CompletableFuture!  ")
            .thenApply(s -> s.trim())            // Remove whitespace
            .thenApply(s -> s.toUpperCase())      // Convert to uppercase
            .thenApply(s -> s + " [processed]");  // Append suffix

        System.out.println(future.join());
        // Output: HELLO, COMPLETABLEFUTURE! [processed]

        // Practical: Fetch user ID -> Fetch user -> Extract name
        CompletableFuture userName = CompletableFuture
            .supplyAsync(() -> getUserId())         // Returns Integer
            .thenApply(id -> fetchUser(id))          // Integer -> User (String)
            .thenApply(user -> extractName(user));   // User -> Name (String)

        System.out.println("User: " + userName.join());
        // Output: User: Alice
    }

    static int getUserId() { return 42; }
    static String fetchUser(int id) { return "User{id=" + id + ", name=Alice}"; }
    static String extractName(String user) { return "Alice"; }
}

4.2 thenAccept() — Consume the Result

thenAccept() takes the result and does something with it but returns nothing (CompletableFuture<Void>). Use it as the final step in a pipeline when you want to perform a side effect like printing, logging, or saving to a database.

import java.util.concurrent.CompletableFuture;

public class ThenAcceptExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture
            .supplyAsync(() -> fetchOrderTotal())
            .thenApply(total -> total * 1.08)     // Add 8% tax
            .thenAccept(total ->                   // Consume: print invoice
                System.out.printf("Invoice Total: $%.2f%n", total)
            );

        future.join();
        // Output: Invoice Total: $108.00
    }

    static double fetchOrderTotal() { return 100.00; }
}

4.3 thenRun() — Run an Action (Ignore the Result)

thenRun() takes a Runnable — it does not receive the result at all. Use it when you want to run an action after the previous stage completes, but you do not need the result. Common for cleanup tasks, notifications, or logging that a process finished.

import java.util.concurrent.CompletableFuture;

public class ThenRunExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture
            .supplyAsync(() -> {
                System.out.println("Processing payment...");
                try { Thread.sleep(500); } catch (InterruptedException e) { throw new RuntimeException(e); }
                return "Payment Confirmed";
            })
            .thenAccept(result -> System.out.println("Result: " + result))
            .thenRun(() -> System.out.println("Audit log: payment processing completed."))
            .thenRun(() -> System.out.println("Cleanup: releasing resources."));

        future.join();
        // Output:
        // Processing payment...
        // Result: Payment Confirmed
        // Audit log: payment processing completed.
        // Cleanup: releasing resources.
    }
}

4.4 Comparison: thenApply vs thenAccept vs thenRun

Method Input Return Functional Interface Use When
thenApply(fn) Previous result New value Function<T, U> Transform the result (map)
thenAccept(consumer) Previous result Void Consumer<T> Consume the result (side effect)
thenRun(action) Nothing Void Runnable Run action after completion (ignore result)

A simple way to remember: thenApply = I need the result and return something new. thenAccept = I need the result but return nothing. thenRun = I don’t need the result at all.

5. Composing Futures

Sometimes one async operation depends on the result of another. For example: fetch a user ID, then use that ID to fetch the user’s orders. This is where thenCompose() and thenCombine() come in.

5.1 thenCompose() — Chain Dependent Async Operations (flatMap)

thenCompose() is like flatMap() in the Stream API. When the function you pass to thenApply() itself returns a CompletableFuture, you end up with a nested CompletableFuture<CompletableFuture<T>>. thenCompose() flattens this into a single CompletableFuture<T>.

Use it when: step B is itself an async operation that depends on the result of step A.

import java.util.concurrent.CompletableFuture;
import java.util.List;

public class ThenComposeExample {
    public static void main(String[] args) {
        // BAD: thenApply with async function creates nested CompletableFuture
        CompletableFuture>> nested =
            getUserIdAsync()
                .thenApply(userId -> getOrdersAsync(userId)); // Returns CF>!

        // GOOD: thenCompose flattens the nesting
        CompletableFuture> flat =
            getUserIdAsync()
                .thenCompose(userId -> getOrdersAsync(userId)); // Returns CF

        System.out.println("Orders: " + flat.join());
        // Output: Orders: [Order-1001, Order-1002, Order-1003]

        // Chain multiple dependent async operations
        CompletableFuture pipeline = getUserIdAsync()
            .thenCompose(userId -> getOrdersAsync(userId))
            .thenCompose(orders -> calculateTotalAsync(orders))
            .thenApply(total -> String.format("Total: $%.2f", total));

        System.out.println(pipeline.join());
        // Output: Total: $299.97
    }

    static CompletableFuture getUserIdAsync() {
        return CompletableFuture.supplyAsync(() -> 42);
    }

    static CompletableFuture> getOrdersAsync(int userId) {
        return CompletableFuture.supplyAsync(() ->
            List.of("Order-1001", "Order-1002", "Order-1003")
        );
    }

    static CompletableFuture calculateTotalAsync(List orders) {
        return CompletableFuture.supplyAsync(() -> orders.size() * 99.99);
    }
}

5.2 thenCombine() — Combine Two Independent Futures

thenCombine() takes two independent CompletableFutures that can run in parallel and combines their results when both are done. Think of it as: “Run A and B simultaneously. When both finish, merge their results.”

import java.util.concurrent.CompletableFuture;

public class ThenCombineExample {
    public static void main(String[] args) {
        // Two independent async operations -- run in parallel
        CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println("Fetching user on: " + Thread.currentThread().getName());
            sleep(1000);
            return "Alice";
        });

        CompletableFuture balanceFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println("Fetching balance on: " + Thread.currentThread().getName());
            sleep(800);
            return 1500.75;
        });

        // Combine results when BOTH are complete
        CompletableFuture combined = userFuture.thenCombine(
            balanceFuture,
            (user, balance) -> String.format("%s has a balance of $%.2f", user, balance)
        );

        System.out.println(combined.join());
        // Output: Alice has a balance of $1500.75
        // Total time: ~1000ms (parallel), not 1800ms (sequential)
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

5.3 thenCompose vs thenApply — When to Use Which

Scenario Method Why
Transform result synchronously (e.g., String -> Integer) thenApply() The function returns a plain value
Chain to another async operation (e.g., userId -> fetchOrders(userId)) thenCompose() The function returns a CompletableFuture
Combine two independent futures thenCombine() Both futures run in parallel, merge results

Rule of thumb: If your lambda returns a CompletableFuture, use thenCompose(). If it returns a plain value, use thenApply(). This is exactly the same distinction as map() vs flatMap() in streams.

6. Combining Multiple Futures

Real applications often need to fire off many async operations at once — fetching data from multiple microservices, querying multiple databases, or calling multiple APIs. CompletableFuture provides allOf() and anyOf() for this.

6.1 allOf() — Wait for All to Complete

CompletableFuture.allOf() takes an array of CompletableFutures and returns a new CompletableFuture<Void> that completes when all of them are done. Note: it returns Void, so you need to extract the individual results yourself.

import java.util.concurrent.CompletableFuture;
import java.util.List;
import java.util.stream.Collectors;

public class AllOfExample {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        // Fire off three independent async calls
        CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
            sleep(1000);
            return "Alice";
        });

        CompletableFuture> ordersFuture = CompletableFuture.supplyAsync(() -> {
            sleep(1200);
            return List.of("Laptop", "Mouse", "Keyboard");
        });

        CompletableFuture balanceFuture = CompletableFuture.supplyAsync(() -> {
            sleep(800);
            return 2500.00;
        });

        // Wait for ALL to complete
        CompletableFuture allDone = CompletableFuture.allOf(
            userFuture, ordersFuture, balanceFuture
        );

        // When all are done, extract individual results
        allDone.join();

        String user = userFuture.join();       // Already complete -- returns immediately
        List orders = ordersFuture.join();
        Double balance = balanceFuture.join();

        long elapsed = System.currentTimeMillis() - start;

        System.out.println("User: " + user);
        System.out.println("Orders: " + orders);
        System.out.printf("Balance: $%.2f%n", balance);
        System.out.println("Completed in " + elapsed + "ms");

        // Output:
        // User: Alice
        // Orders: [Laptop, Mouse, Keyboard]
        // Balance: $2500.00
        // Completed in ~1200ms (not 3000ms -- parallel!)
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

Collecting Results from allOf — Practical Pattern

A common pattern is to fire off a list of async operations and collect all the results into a list.

import java.util.concurrent.CompletableFuture;
import java.util.List;
import java.util.stream.Collectors;

public class AllOfCollectExample {
    public static void main(String[] args) {
        List userIds = List.of(1, 2, 3, 4, 5);

        // Fire async call for each user ID
        List> futures = userIds.stream()
            .map(id -> CompletableFuture.supplyAsync(() -> fetchUser(id)))
            .collect(Collectors.toList());

        // Wait for all and collect results into a list
        List users = futures.stream()
            .map(CompletableFuture::join)  // join() each future
            .collect(Collectors.toList());

        System.out.println("Users: " + users);
        // Output: Users: [User-1, User-2, User-3, User-4, User-5]
    }

    static String fetchUser(int id) {
        sleep(200); // Simulate API call
        return "User-" + id;
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

6.2 anyOf() — Wait for the First to Complete

CompletableFuture.anyOf() returns a CompletableFuture<Object> that completes as soon as any one of the given futures completes. This is useful for racing multiple data sources (e.g., primary DB vs cache vs backup) or implementing timeout patterns.

import java.util.concurrent.CompletableFuture;

public class AnyOfExample {
    public static void main(String[] args) {
        // Race multiple data sources -- take whichever responds first
        CompletableFuture primaryDb = CompletableFuture.supplyAsync(() -> {
            sleep(2000);
            return "Data from Primary DB";
        });

        CompletableFuture cache = CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "Data from Cache";
        });

        CompletableFuture backupDb = CompletableFuture.supplyAsync(() -> {
            sleep(3000);
            return "Data from Backup DB";
        });

        // Returns as soon as the fastest one completes
        CompletableFuture fastest = CompletableFuture.anyOf(primaryDb, cache, backupDb);

        String result = (String) fastest.join();
        System.out.println("Winner: " + result);
        // Output: Winner: Data from Cache (fastest at 100ms)
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

7. Exception Handling

Exception handling in async code is tricky. If an exception is thrown inside a supplyAsync() lambda, who catches it? There is no surrounding try-catch. The exception is captured by the CompletableFuture and propagated down the chain. CompletableFuture provides three methods for dealing with exceptions.

7.1 exceptionally() — Catch and Recover

exceptionally() is like a catch block for async pipelines. If the previous stage fails with an exception, exceptionally() catches it and provides a fallback value. If the previous stage succeeds, exceptionally() is skipped.

import java.util.concurrent.CompletableFuture;

public class ExceptionallyExample {
    public static void main(String[] args) {
        // Success case -- exceptionally() is skipped
        CompletableFuture success = CompletableFuture
            .supplyAsync(() -> "Data loaded")
            .exceptionally(ex -> "Fallback data");

        System.out.println(success.join());
        // Output: Data loaded

        // Failure case -- exceptionally() catches and recovers
        CompletableFuture failure = CompletableFuture
            .supplyAsync(() -> {
                if (true) throw new RuntimeException("Database is down!");
                return "Data loaded";
            })
            .exceptionally(ex -> {
                System.out.println("Caught: " + ex.getMessage());
                return "Fallback: cached data";
            });

        System.out.println(failure.join());
        // Output:
        // Caught: java.lang.RuntimeException: Database is down!
        // Fallback: cached data

        // Exception propagation through chains
        CompletableFuture chain = CompletableFuture
            .supplyAsync(() -> {
                throw new RuntimeException("Step 1 failed");
            })
            .thenApply(result -> {
                System.out.println("This never executes");
                return result + " -> Step 2";
            })
            .thenApply(result -> {
                System.out.println("This never executes either");
                return result + " -> Step 3";
            })
            .exceptionally(ex -> "Recovered from: " + ex.getCause().getMessage());

        System.out.println(chain.join());
        // Output: Recovered from: Step 1 failed
        // Note: thenApply steps were SKIPPED because an earlier stage failed
    }
}

7.2 handle() — Handle Result OR Exception

handle() is more general than exceptionally(). It receives both the result and the exception (one of them will be null). It always executes, regardless of success or failure. Use it when you need to transform the result on success AND provide a fallback on failure.

import java.util.concurrent.CompletableFuture;

public class HandleExample {
    public static void main(String[] args) {
        // handle() always runs -- both parameters are provided
        // On success: result = value, exception = null
        // On failure: result = null, exception = the exception

        CompletableFuture successHandled = CompletableFuture
            .supplyAsync(() -> "100")
            .handle((result, ex) -> {
                if (ex != null) {
                    return "Error: " + ex.getMessage();
                }
                return "Parsed: " + Integer.parseInt(result);
            });

        System.out.println(successHandled.join());
        // Output: Parsed: 100

        CompletableFuture failureHandled = CompletableFuture
            .supplyAsync(() -> {
                throw new RuntimeException("Network timeout");
            })
            .handle((result, ex) -> {
                if (ex != null) {
                    return "Error: " + ex.getCause().getMessage();
                }
                return "Success: " + result;
            });

        System.out.println(failureHandled.join());
        // Output: Error: Network timeout

        // Practical: Parse with fallback
        CompletableFuture parsed = CompletableFuture
            .supplyAsync(() -> "not_a_number")
            .handle((result, ex) -> {
                try {
                    return Integer.parseInt(result);
                } catch (NumberFormatException e) {
                    System.out.println("Parse failed, using default");
                    return 0;
                }
            });

        System.out.println("Value: " + parsed.join());
        // Output:
        // Parse failed, using default
        // Value: 0
    }
}

7.3 whenComplete() — Inspect Without Modifying

whenComplete() lets you observe the result or exception without modifying it. The original result (or exception) is passed through unchanged. This is ideal for logging or monitoring.

import java.util.concurrent.CompletableFuture;

public class WhenCompleteExample {
    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture
            .supplyAsync(() -> "Operation result")
            .whenComplete((result, ex) -> {
                // This is for side effects only (logging, metrics, etc.)
                if (ex != null) {
                    System.out.println("ALERT: Operation failed: " + ex.getMessage());
                } else {
                    System.out.println("LOG: Operation succeeded: " + result);
                }
            })
            .thenApply(result -> result + " [verified]"); // Original result flows through

        System.out.println(future.join());
        // Output:
        // LOG: Operation succeeded: Operation result
        // Operation result [verified]
    }
}

7.4 Exception Handling Comparison

Method Receives Returns New Value? Use When
exceptionally(ex) Exception only Yes — fallback value Recover from failure with a default
handle(result, ex) Result AND exception Yes — transformed value Transform result or recover from failure
whenComplete(result, ex) Result AND exception No — passes through original Side effects: logging, monitoring, cleanup

8. Async Variants

Every callback method in CompletableFuture has an async version: thenApplyAsync(), thenAcceptAsync(), thenRunAsync(), thenComposeAsync(), handleAsync(), etc.

The Difference: Which Thread Runs the Callback?

Method Callback Runs On Thread Behavior
thenApply(fn) Same thread that completed the previous stage, OR the calling thread No guarantee — may be the async thread or the thread calling thenApply
thenApplyAsync(fn) A thread from the default ForkJoinPool Always runs on a pool thread
thenApplyAsync(fn, executor) A thread from the specified executor You control exactly which pool
import java.util.concurrent.*;

public class AsyncVariantsExample {
    private static final ExecutorService IO_POOL = Executors.newFixedThreadPool(4);

    public static void main(String[] args) {
        CompletableFuture future = CompletableFuture
            .supplyAsync(() -> {
                System.out.println("Stage 1 on: " + Thread.currentThread().getName());
                return "data";
            })
            // Non-async: may run on same thread as previous stage
            .thenApply(data -> {
                System.out.println("Stage 2 (thenApply) on: " + Thread.currentThread().getName());
                return data.toUpperCase();
            })
            // Async: guaranteed to run on ForkJoinPool thread
            .thenApplyAsync(data -> {
                System.out.println("Stage 3 (thenApplyAsync) on: " + Thread.currentThread().getName());
                return data + "!";
            })
            // Async with custom executor: runs on our IO pool
            .thenApplyAsync(data -> {
                System.out.println("Stage 4 (thenApplyAsync+executor) on: " + Thread.currentThread().getName());
                return data + " [done]";
            }, IO_POOL);

        System.out.println(future.join());

        // Possible output:
        // Stage 1 on: ForkJoinPool.commonPool-worker-1
        // Stage 2 (thenApply) on: ForkJoinPool.commonPool-worker-1
        // Stage 3 (thenApplyAsync) on: ForkJoinPool.commonPool-worker-2
        // Stage 4 (thenApplyAsync+executor) on: pool-1-thread-1
        // DATA! [done]

        IO_POOL.shutdown();
    }
}

When to Use Async Variants

  • Use thenApply() (non-async) for quick, CPU-light transformations — parsing a string, extracting a field, simple formatting. There is no need to pay the overhead of switching threads.
  • Use thenApplyAsync() when the callback itself is slow or when you want to ensure it does not run on the calling thread (e.g., in a GUI application where the calling thread is the UI thread).
  • Use thenApplyAsync(fn, executor) when the callback involves I/O and you want to use a dedicated I/O thread pool instead of the shared ForkJoinPool.

9. Real-World Patterns

Now that you understand the individual methods, let us look at patterns you will actually use in production code.

9.1 Parallel API Calls

The most common use case: fetch data from multiple services simultaneously and combine the results. This is the bread and butter of microservices backends.

import java.util.concurrent.*;
import java.util.List;

public class ParallelApiCalls {
    private static final ExecutorService HTTP_POOL = Executors.newFixedThreadPool(10);

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        // Fire all three API calls simultaneously
        CompletableFuture userFuture = CompletableFuture.supplyAsync(
            () -> fetchFromApi("/users/42"), HTTP_POOL
        );

        CompletableFuture> ordersFuture = CompletableFuture.supplyAsync(
            () -> fetchOrders(42), HTTP_POOL
        );

        CompletableFuture> recommendationsFuture = CompletableFuture.supplyAsync(
            () -> fetchRecommendations(42), HTTP_POOL
        );

        // Wait for all and combine
        CompletableFuture dashboard = CompletableFuture
            .allOf(userFuture, ordersFuture, recommendationsFuture)
            .thenApply(v -> {
                String user = userFuture.join();
                List orders = ordersFuture.join();
                List recs = recommendationsFuture.join();
                return buildDashboard(user, orders, recs);
            });

        System.out.println(dashboard.join());
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("Total time: " + elapsed + "ms (parallel, not 3000ms sequential)");

        // Output:
        // === Dashboard for Alice ===
        // Recent Orders: [Laptop, Headphones]
        // Recommendations: [Keyboard, Monitor, Mouse Pad]
        // Total time: ~1200ms (parallel, not 3000ms sequential)

        HTTP_POOL.shutdown();
    }

    static String fetchFromApi(String endpoint) {
        sleep(1000); // Simulate HTTP call
        return "Alice";
    }

    static List fetchOrders(int userId) {
        sleep(1200); // Simulate HTTP call
        return List.of("Laptop", "Headphones");
    }

    static List fetchRecommendations(int userId) {
        sleep(800); // Simulate HTTP call
        return List.of("Keyboard", "Monitor", "Mouse Pad");
    }

    static String buildDashboard(String user, List orders, List recs) {
        return String.format("=== Dashboard for %s ===%nRecent Orders: %s%nRecommendations: %s",
            user, orders, recs);
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

9.2 Timeout Pattern (Java 9+)

Java 9 added two very useful methods to CompletableFuture: orTimeout() and completeOnTimeout(). Before Java 9, implementing timeouts required manual scheduling with ScheduledExecutorService.

import java.util.concurrent.*;

public class TimeoutPatterns {
    public static void main(String[] args) {
        // ===== Java 9+: orTimeout() =====
        // Completes exceptionally with TimeoutException if not done in time
        CompletableFuture withTimeout = CompletableFuture
            .supplyAsync(() -> {
                sleep(5000); // Simulates slow service
                return "Slow result";
            })
            .orTimeout(2, TimeUnit.SECONDS)
            .exceptionally(ex -> {
                System.out.println("Timed out: " + ex.getCause().getClass().getSimpleName());
                return "Default value (timed out)";
            });

        System.out.println(withTimeout.join());
        // Output:
        // Timed out: TimeoutException
        // Default value (timed out)

        // ===== Java 9+: completeOnTimeout() =====
        // Completes with a default value if not done in time (no exception)
        CompletableFuture withDefault = CompletableFuture
            .supplyAsync(() -> {
                sleep(5000); // Simulates slow service
                return "Slow result";
            })
            .completeOnTimeout("Fallback value", 1, TimeUnit.SECONDS);

        System.out.println(withDefault.join());
        // Output: Fallback value

        // ===== Pre-Java 9: Manual timeout pattern =====
        CompletableFuture manualTimeout = addTimeout(
            CompletableFuture.supplyAsync(() -> {
                sleep(5000);
                return "Slow result";
            }),
            2, TimeUnit.SECONDS,
            "Timeout fallback"
        );

        System.out.println(manualTimeout.join());
        // Output: Timeout fallback
    }

    // Pre-Java 9 timeout helper
    static  CompletableFuture addTimeout(
            CompletableFuture future, long timeout, TimeUnit unit, T fallback) {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.schedule(() -> future.complete(fallback), timeout, unit);
        return future.whenComplete((r, ex) -> scheduler.shutdown());
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

9.3 Retry Pattern

Network calls fail. APIs return 503. Databases have hiccups. A retry pattern lets you automatically reattempt a failed async operation a certain number of times before giving up.

import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;

public class RetryPattern {
    public static void main(String[] args) {
        // Retry up to 3 times
        CompletableFuture result = retryAsync(() -> callUnreliableApi(), 3);

        System.out.println(result.join());
        // Output (varies):
        // Attempt 1: calling API...
        // Attempt 1 failed: API error, retrying...
        // Attempt 2: calling API...
        // Attempt 2 succeeded!
        // API Response: {status: ok}
    }

    static int attempt = 0;

    static String callUnreliableApi() {
        attempt++;
        System.out.println("Attempt " + attempt + ": calling API...");
        if (attempt < 2) { // Fail first attempt, succeed on second
            throw new RuntimeException("API error");
        }
        System.out.println("Attempt " + attempt + " succeeded!");
        return "API Response: {status: ok}";
    }

    /**
     * Retries an async operation up to maxRetries times.
     * On each failure, it retries with a new CompletableFuture.
     */
    static  CompletableFuture retryAsync(Supplier supplier, int maxRetries) {
        CompletableFuture future = CompletableFuture.supplyAsync(supplier);

        for (int i = 0; i < maxRetries; i++) {
            future = future.handle((result, ex) -> {
                if (ex == null) {
                    return CompletableFuture.completedFuture(result);
                }
                System.out.println("Failed: " + ex.getCause().getMessage() + ", retrying...");
                return CompletableFuture.supplyAsync(supplier);
            }).thenCompose(f -> f);
        }

        return future;
    }
}

9.4 Circuit Breaker Concept

A circuit breaker prevents your application from repeatedly calling a service that is known to be down. After a certain number of failures, the circuit “opens” and subsequent calls fail immediately without attempting the call. After a cool-down period, the circuit “half-opens” to test if the service is back.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public class SimpleCircuitBreaker {
    enum State { CLOSED, OPEN, HALF_OPEN }

    private volatile State state = State.CLOSED;
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final AtomicLong lastFailureTime = new AtomicLong(0);

    private final int failureThreshold;    // failures before opening
    private final long cooldownMillis;     // wait before half-open

    public SimpleCircuitBreaker(int failureThreshold, long cooldownMillis) {
        this.failureThreshold = failureThreshold;
        this.cooldownMillis = cooldownMillis;
    }

    public  CompletableFuture execute(java.util.function.Supplier supplier) {
        if (state == State.OPEN) {
            long elapsed = System.currentTimeMillis() - lastFailureTime.get();
            if (elapsed > cooldownMillis) {
                state = State.HALF_OPEN; // Allow one test call
            } else {
                return CompletableFuture.failedFuture(
                    new RuntimeException("Circuit is OPEN -- failing fast")
                );
            }
        }

        return CompletableFuture.supplyAsync(supplier)
            .handle((result, ex) -> {
                if (ex != null) {
                    int failures = failureCount.incrementAndGet();
                    lastFailureTime.set(System.currentTimeMillis());
                    if (failures >= failureThreshold) {
                        state = State.OPEN;
                        System.out.println("Circuit OPENED after " + failures + " failures");
                    }
                    throw new RuntimeException("Service call failed", ex.getCause());
                }
                // Success -- reset
                failureCount.set(0);
                state = State.CLOSED;
                return result;
            });
    }

    public static void main(String[] args) throws InterruptedException {
        SimpleCircuitBreaker cb = new SimpleCircuitBreaker(3, 2000);

        // Simulate 5 calls to a failing service
        for (int i = 1; i <= 5; i++) {
            final int callNum = i;
            CompletableFuture result = cb.execute(() -> {
                throw new RuntimeException("Service unavailable");
            }).exceptionally(ex -> "Call " + callNum + " result: " + ex.getMessage());

            System.out.println(result.join());
        }

        // Output:
        // Call 1 result: Service call failed
        // Call 2 result: Service call failed
        // Circuit OPENED after 3 failures
        // Call 3 result: Service call failed
        // Call 4 result: Circuit is OPEN -- failing fast (no call attempted!)
        // Call 5 result: Circuit is OPEN -- failing fast
    }
}

10. CompletableFuture vs Other Approaches

Java has evolved its concurrency tools over the years. Here is how CompletableFuture compares to other approaches.

Feature Thread / Runnable ExecutorService + Future CompletableFuture Virtual Threads (Java 21+)
Java Version 1.0 1.5 1.8 21
Return value None (Runnable) Yes (Callable + Future) Yes (supplyAsync) Yes (Callable + Future)
Non-blocking result No No (get() blocks) Yes (callbacks) Blocking is cheap (virtual)
Chaining Manual thread coordination Manual (submit next task) Built-in (thenApply, thenCompose) Sequential code style
Combining CountDownLatch, join() invokeAll() allOf(), anyOf(), thenCombine() StructuredTaskScope (preview)
Exception handling Thread.UncaughtExceptionHandler ExecutionException wrapper exceptionally(), handle() Standard try-catch
Thread cost ~1MB stack per thread Pool managed, still OS threads Pool managed, OS threads ~KB per virtual thread
Best for Learning, simple background tasks Task submission, thread pool control Async pipelines, reactive patterns High-concurrency I/O (millions of tasks)
Code style Imperative, callback-based Submit-and-wait Functional, pipeline-based Synchronous-looking code

When to use what:

  • Thread — Learning exercises only. Never use raw threads in production code.
  • ExecutorService + Future — When you need simple task submission and can afford to block. Good for batch processing.
  • CompletableFuture — When you need non-blocking pipelines, combining multiple async operations, or reactive-style programming. The go-to choice for Java 8-17 applications.
  • Virtual Threads (Java 21+) — When you have high-concurrency I/O workloads (e.g., handling 100k+ concurrent HTTP requests). They let you write synchronous-looking code that scales like async code. If you are on Java 21+, consider virtual threads for simple use cases and CompletableFuture for complex pipelines.

11. Common Mistakes

These are the bugs and anti-patterns I see most often in production code using CompletableFuture. Learn them so you can avoid them.

Mistake 1: Not Handling Exceptions (Silent Failures)

If a CompletableFuture completes exceptionally and you never check or handle the exception, it fails silently. No stack trace, no error message, nothing. Your program continues with missing data and you spend hours debugging.

import java.util.concurrent.CompletableFuture;

public class SilentFailureMistake {
    public static void main(String[] args) throws InterruptedException {
        // BAD: Exception is swallowed silently
        CompletableFuture.supplyAsync(() -> {
            throw new RuntimeException("Database connection failed!");
        }).thenAccept(result -> {
            System.out.println("This never prints, and you will never know why");
        });

        Thread.sleep(1000);
        System.out.println("Program continues -- no error was visible!");
        // Output: Program continues -- no error was visible!
        // The RuntimeException vanished into thin air!

        // GOOD: Always handle exceptions
        CompletableFuture.supplyAsync(() -> {
            throw new RuntimeException("Database connection failed!");
        }).thenAccept(result -> {
            System.out.println("Processing: " + result);
        }).exceptionally(ex -> {
            System.err.println("ERROR: " + ex.getCause().getMessage());
            return null; // Required for Void futures
        });

        Thread.sleep(1000);
        // Output: ERROR: Database connection failed!
    }
}

Mistake 2: Blocking with get() in Async Code

Calling get() or join() inside a thenApply() or other callback defeats the purpose of async programming. You are blocking a thread pool thread, potentially causing a deadlock or starving the pool.

import java.util.concurrent.CompletableFuture;

public class BlockingInAsyncMistake {
    public static void main(String[] args) {
        // BAD: Blocking inside an async callback
        CompletableFuture bad = CompletableFuture
            .supplyAsync(() -> 42)
            .thenApply(userId -> {
                // This BLOCKS a ForkJoinPool thread!
                CompletableFuture orders = CompletableFuture.supplyAsync(
                    () -> fetchOrders(userId)
                );
                return orders.join(); // BLOCKING inside async pipeline -- BAD!
            });

        // GOOD: Use thenCompose() for dependent async operations
        CompletableFuture good = CompletableFuture
            .supplyAsync(() -> 42)
            .thenCompose(userId ->                        // Non-blocking chaining
                CompletableFuture.supplyAsync(() -> fetchOrders(userId))
            );

        System.out.println(good.join());
        // Output: Orders for user 42
    }

    static String fetchOrders(int userId) {
        return "Orders for user " + userId;
    }
}

Mistake 3: Using Common ForkJoinPool for I/O Operations

The common ForkJoinPool is shared across your entire JVM. If you fill it with slow I/O operations (database queries, HTTP calls), all async operations in your application slow down — including parallel streams and other CompletableFuture calls.

import java.util.concurrent.*;

public class CommonPoolMistake {
    public static void main(String[] args) {
        // BAD: Using common pool for slow I/O
        CompletableFuture bad = CompletableFuture.supplyAsync(() -> {
            // This slow DB call hogs a common pool thread
            sleep(5000);
            return "DB result";
        }); // Uses ForkJoinPool.commonPool() (default)

        // GOOD: Use a dedicated I/O pool
        ExecutorService ioPool = Executors.newFixedThreadPool(20);

        CompletableFuture good = CompletableFuture.supplyAsync(() -> {
            sleep(5000); // Slow DB call on dedicated pool
            return "DB result";
        }, ioPool); // Uses dedicated I/O pool

        System.out.println("Common pool size: " + ForkJoinPool.commonPool().getPoolSize());
        // Rule of thumb:
        //   CPU-bound tasks -> ForkJoinPool (default, cores-1 threads)
        //   I/O-bound tasks -> dedicated fixed/cached thread pool

        good.join();
        ioPool.shutdown();
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

Mistake 4: Not Completing Futures

If you create a CompletableFuture manually with new CompletableFuture<>() and forget to call complete() or completeExceptionally(), any code waiting on it with join() or get() will block forever.

import java.util.concurrent.*;

public class ForgotToCompleteMistake {
    public static void main(String[] args) {
        // BAD: This future is never completed -- join() blocks forever
        CompletableFuture neverCompleted = new CompletableFuture<>();
        // neverCompleted.join(); // This would hang indefinitely!

        // GOOD: Always complete manually-created futures
        CompletableFuture manual = new CompletableFuture<>();

        // Complete it from another thread
        CompletableFuture.runAsync(() -> {
            try {
                String result = doSomeWork();
                manual.complete(result);               // Success path
            } catch (Exception e) {
                manual.completeExceptionally(e);        // Failure path
            }
        });

        System.out.println(manual.join());
        // Output: Work done!

        // BEST: Use a timeout to protect against forgotten completions (Java 9+)
        CompletableFuture safe = new CompletableFuture<>();
        safe.orTimeout(5, TimeUnit.SECONDS); // Will throw TimeoutException after 5 seconds
    }

    static String doSomeWork() {
        return "Work done!";
    }
}

Mistake 5: Ignoring Return Values of Chained Methods

CompletableFuture is immutable in the sense that thenApply(), exceptionally(), etc., return a new CompletableFuture. If you do not capture the return value, the callback is still registered, but you lose the reference to the new stage.

import java.util.concurrent.CompletableFuture;

public class IgnoreReturnValueMistake {
    public static void main(String[] args) {
        CompletableFuture original = CompletableFuture.supplyAsync(() -> {
            throw new RuntimeException("Oops");
        });

        // BAD: exceptionally() returns a NEW future -- you're ignoring it!
        original.exceptionally(ex -> "Recovered"); // Return value discarded

        // If you join the ORIGINAL future, it still has the exception!
        try {
            original.join(); // Still throws!
        } catch (Exception e) {
            System.out.println("Original still failed: " + e.getCause().getMessage());
        }

        // GOOD: Capture the new future returned by exceptionally()
        CompletableFuture recovered = original.exceptionally(ex -> "Recovered");
        System.out.println(recovered.join());
        // Output: Recovered
    }
}

12. Best Practices

Follow these guidelines to write reliable, maintainable, and performant CompletableFuture code.

Practice 1: Always Handle Exceptions

Every CompletableFuture pipeline should end with an exception handler. Use exceptionally() for recovery, handle() for transformation, or whenComplete() for logging. Never let exceptions disappear silently.

Practice 2: Use Custom Executors for I/O Operations

Create dedicated thread pools for different types of work. A common pattern is to have separate pools for HTTP calls, database queries, and CPU-bound computation.

import java.util.concurrent.*;

public class ExecutorBestPractice {
    // Separate pools for different workload types
    private static final ExecutorService HTTP_POOL =
        Executors.newFixedThreadPool(20, namedThread("http-worker"));

    private static final ExecutorService DB_POOL =
        Executors.newFixedThreadPool(10, namedThread("db-worker"));

    // CPU-bound work uses the default ForkJoinPool (no custom executor needed)

    static ThreadFactory namedThread(String prefix) {
        return r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName(prefix + "-" + t.getId());
            return t;
        };
    }

    public static void main(String[] args) {
        CompletableFuture httpResult = CompletableFuture
            .supplyAsync(() -> callExternalApi(), HTTP_POOL)
            .thenApplyAsync(json -> parseResponse(json))         // CPU-bound: default pool
            .thenApplyAsync(data -> saveToDb(data), DB_POOL)     // I/O: DB pool
            .exceptionally(ex -> {
                System.err.println("Pipeline failed: " + ex.getCause().getMessage());
                return "Error";
            });

        System.out.println(httpResult.join());
        // Output: Saved: {parsed: api-response}

        HTTP_POOL.shutdown();
        DB_POOL.shutdown();
    }

    static String callExternalApi() { return "api-response"; }
    static String parseResponse(String json) { return "{parsed: " + json + "}"; }
    static String saveToDb(String data) { return "Saved: " + data; }
}

Practice 3: Prefer thenCompose Over Nested thenApply

When one async operation depends on another, use thenCompose() instead of nesting CompletableFutures inside thenApply(). This keeps the pipeline flat and avoids blocking.

Practice 4: Use allOf for Parallel Work

When you have multiple independent operations, fire them all at once with allOf() instead of running them sequentially. This reduces total latency from the sum of all operations to the duration of the slowest one.

Practice 5: Avoid Blocking in Async Code

Never call join(), get(), or Thread.sleep() inside a callback (thenApply, thenAccept, etc.). These block pool threads and can lead to thread starvation or deadlocks. Use thenCompose() for chaining and thenCombine() for combining.

Practice 6: Use Timeouts

Always set timeouts on operations that depend on external services. On Java 9+, use orTimeout() or completeOnTimeout(). On Java 8, use a ScheduledExecutorService to complete the future after a delay.

Practice 7: Name Your Thread Pools

Use custom ThreadFactory implementations that give descriptive names to threads. When you look at a thread dump or log output, http-worker-23 is far more useful than pool-1-thread-23.

Best Practices Summary

# Practice Do Don’t
1 Exception handling End every pipeline with exceptionally() or handle() Let exceptions vanish silently
2 Thread pools Use dedicated pools for I/O work Use common ForkJoinPool for database/HTTP calls
3 Chaining Use thenCompose() for dependent async ops Call join() inside thenApply()
4 Parallelism Use allOf() for independent operations Chain independent operations sequentially
5 Blocking Use callbacks and composition Call get() / join() inside callbacks
6 Timeouts Always set timeouts on external calls Trust that services will respond quickly
7 Thread naming Use custom ThreadFactory with descriptive names Use default pool-1-thread-N names

13. Complete Practical Example: E-Commerce Order Processing

Let us put everything together with a realistic example. An e-commerce system needs to process an order. This involves calling multiple services — inventory, payment, and notification — combining results, and handling failures gracefully. This example uses every major CompletableFuture feature covered in this tutorial.

import java.util.concurrent.*;
import java.util.List;
import java.util.Map;

/**
 * E-Commerce Order Processing System
 *
 * Demonstrates: supplyAsync, thenApply, thenCompose, thenCombine,
 * allOf, exceptionally, handle, whenComplete, custom executors,
 * timeout pattern, and combining parallel operations.
 */
public class OrderProcessingSystem {

    // Dedicated thread pools for different I/O operations
    private static final ExecutorService INVENTORY_POOL =
        Executors.newFixedThreadPool(5, namedThread("inventory"));
    private static final ExecutorService PAYMENT_POOL =
        Executors.newFixedThreadPool(5, namedThread("payment"));
    private static final ExecutorService NOTIFICATION_POOL =
        Executors.newFixedThreadPool(3, namedThread("notification"));

    // ===================== Service Simulations =====================

    /** Check if all items are in stock */
    static CompletableFuture checkInventory(String orderId, List items) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] Checking inventory for " + orderId);
            sleep(800); // Simulate DB call
            System.out.println("  Inventory check passed: all items in stock");
            return true; // All items available
        }, INVENTORY_POOL);
    }

    /** Reserve items in inventory */
    static CompletableFuture reserveItems(String orderId, List items) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] Reserving items for " + orderId);
            sleep(500);
            String reservationId = "RES-" + orderId.hashCode();
            System.out.println("  Items reserved: " + reservationId);
            return reservationId;
        }, INVENTORY_POOL);
    }

    /** Process payment */
    static CompletableFuture processPayment(String orderId, double amount) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] Processing payment: $" + amount);
            sleep(1200); // Simulate payment gateway call
            String transactionId = "TXN-" + System.currentTimeMillis();
            System.out.println("  Payment successful: " + transactionId);
            return transactionId;
        }, PAYMENT_POOL);
    }

    /** Send confirmation email (fire-and-forget, with retry) */
    static CompletableFuture sendConfirmationEmail(String orderId, String email) {
        return CompletableFuture.runAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] Sending email to " + email);
            sleep(600);
            System.out.println("  Email sent for order " + orderId);
        }, NOTIFICATION_POOL);
    }

    /** Send SMS notification */
    static CompletableFuture sendSmsNotification(String orderId, String phone) {
        return CompletableFuture.runAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] Sending SMS to " + phone);
            sleep(400);
            System.out.println("  SMS sent for order " + orderId);
        }, NOTIFICATION_POOL);
    }

    /** Calculate shipping estimate (async, depends on items) */
    static CompletableFuture calculateShipping(List items) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            return "3-5 business days";
        });
    }

    // ===================== Order Processing Pipeline =====================

    static CompletableFuture> processOrder(
            String orderId, List items, double total,
            String email, String phone) {

        System.out.println("========================================");
        System.out.println("Processing Order: " + orderId);
        System.out.println("Items: " + items);
        System.out.printf("Total: $%.2f%n", total);
        System.out.println("========================================\n");

        long startTime = System.currentTimeMillis();

        // Step 1: Check inventory (must complete before payment)
        CompletableFuture inventoryCheck = checkInventory(orderId, items)
            .orTimeout(3, TimeUnit.SECONDS)       // Timeout after 3 seconds
            .exceptionally(ex -> {
                System.err.println("Inventory check failed: " + ex.getMessage());
                return false;
            });

        // Step 2: If inventory OK -> reserve items AND process payment in PARALLEL
        CompletableFuture> orderResult = inventoryCheck
            .thenCompose(inStock -> {
                if (!inStock) {
                    return CompletableFuture.failedFuture(
                        new RuntimeException("Items out of stock")
                    );
                }

                // These two run in PARALLEL (independent of each other)
                CompletableFuture reservation = reserveItems(orderId, items);
                CompletableFuture payment = processPayment(orderId, total);
                CompletableFuture shipping = calculateShipping(items);

                // Combine all three results when done
                return reservation.thenCombine(payment, (resId, txnId) ->
                    Map.of("reservationId", resId, "transactionId", txnId)
                ).thenCombine(shipping, (result, ship) -> {
                    // Add shipping to the result map (Map.of is immutable, so create new one)
                    return Map.of(
                        "reservationId", result.get("reservationId"),
                        "transactionId", result.get("transactionId"),
                        "shipping", ship,
                        "status", "CONFIRMED"
                    );
                });
            })
            .whenComplete((result, ex) -> {
                // Log outcome (does not modify result)
                long elapsed = System.currentTimeMillis() - startTime;
                if (ex != null) {
                    System.err.println("\nOrder " + orderId + " FAILED in " + elapsed + "ms");
                } else {
                    System.out.println("\nOrder " + orderId + " completed in " + elapsed + "ms");
                }
            });

        // Step 3: After order confirmed -> send notifications in parallel (fire-and-forget)
        orderResult.thenAccept(result -> {
            System.out.println("\n--- Sending Notifications ---");
            // Fire both notifications in parallel -- don't wait for them
            CompletableFuture emailFuture = sendConfirmationEmail(orderId, email)
                .exceptionally(ex -> {
                    System.err.println("Email failed (will retry): " + ex.getMessage());
                    return null;
                });
            CompletableFuture smsFuture = sendSmsNotification(orderId, phone)
                .exceptionally(ex -> {
                    System.err.println("SMS failed: " + ex.getMessage());
                    return null;
                });

            // Wait for notifications to complete (optional)
            CompletableFuture.allOf(emailFuture, smsFuture).join();
        });

        return orderResult;
    }

    // ===================== Main =====================

    public static void main(String[] args) {
        // Process an order
        CompletableFuture> result = processOrder(
            "ORD-2024-001",
            List.of("Laptop", "Mouse", "USB-C Cable"),
            1299.97,
            "alice@example.com",
            "+1-555-0123"
        );

        // Wait for the full pipeline to complete
        try {
            Map orderConfirmation = result.join();
            System.out.println("\n========================================");
            System.out.println("ORDER CONFIRMATION");
            System.out.println("========================================");
            orderConfirmation.forEach((key, value) ->
                System.out.printf("  %-16s: %s%n", key, value)
            );
        } catch (CompletionException e) {
            System.err.println("Order failed: " + e.getCause().getMessage());
        }

        // Shutdown thread pools
        INVENTORY_POOL.shutdown();
        PAYMENT_POOL.shutdown();
        NOTIFICATION_POOL.shutdown();
    }

    // ===================== Utilities =====================

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }

    static ThreadFactory namedThread(String prefix) {
        return r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName(prefix + "-worker-" + t.getId());
            return t;
        };
    }
}

// Output:
// ========================================
// Processing Order: ORD-2024-001
// Items: [Laptop, Mouse, USB-C Cable]
// Total: $1299.97
// ========================================
//
// [inventory-worker-21] Checking inventory for ORD-2024-001
//   Inventory check passed: all items in stock
// [inventory-worker-22] Reserving items for ORD-2024-001
// [payment-worker-23] Processing payment: $1299.97
//   Items reserved: RES-1429070270
//   Payment successful: TXN-1709145600123
//
// Order ORD-2024-001 completed in 2018ms
//
// --- Sending Notifications ---
// [notification-worker-24] Sending email to alice@example.com
// [notification-worker-25] Sending SMS to +1-555-0123
//   SMS sent for order ORD-2024-001
//   Email sent for order ORD-2024-001
//
// ========================================
// ORDER CONFIRMATION
// ========================================
//   reservationId   : RES-1429070270
//   transactionId   : TXN-1709145600123
//   shipping        : 3-5 business days
//   status          : CONFIRMED

What This Example Demonstrates

# Concept Where Used
1 supplyAsync(supplier, executor) All service methods use dedicated thread pools
2 runAsync(runnable, executor) Email and SMS notifications (no return value)
3 thenCompose() Inventory check -> reserve + payment (dependent chain)
4 thenCombine() Merging reservation + payment + shipping results
5 allOf() Waiting for both notifications to finish
6 thenAccept() Triggering notifications after order confirmed
7 exceptionally() Inventory timeout fallback, notification error handling
8 whenComplete() Logging order outcome (success or failure) with timing
9 orTimeout() 3-second timeout on inventory check (Java 9+)
10 failedFuture() Short-circuit when items are out of stock
11 Custom executors Separate pools for inventory, payment, and notifications
12 Named threads namedThread() factory for debugging-friendly names
13 Parallel execution Reserve + payment + shipping run simultaneously
14 Fire-and-forget Notifications run after order is confirmed

14. Quick Reference

Category Method Description
Create supplyAsync(supplier) Run async task that returns a value
runAsync(runnable) Run async task with no return value
completedFuture(value) Create already-completed future
Get Result join() Get result (unchecked exception)
get() / get(timeout, unit) Get result (checked exception, with optional timeout)
getNow(default) Get result if done, else return default
Transform thenApply(fn) Transform result: T -> U
thenAccept(consumer) Consume result: T -> void
thenRun(action) Run action after completion (ignores result)
Compose thenCompose(fn) Chain dependent async op: T -> CF<U> (flatMap)
thenCombine(other, fn) Combine two independent futures
Multiple allOf(cf1, cf2, ...) Wait for all futures to complete
anyOf(cf1, cf2, ...) Wait for first future to complete
Exceptions exceptionally(fn) Catch exception, provide fallback value
handle(fn) Handle result or exception, return new value
whenComplete(action) Inspect result/exception (no modification)
Timeout (9+) orTimeout(timeout, unit) Complete exceptionally if not done in time
completeOnTimeout(value, timeout, unit) Complete with default if not done in time
July 27, 2021