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:
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 |
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:
switch expression handles every case without needing a default branch.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'
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.
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;
}
}
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 |
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; }
}
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). |
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();
}
}
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; }
}
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);
}
}
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.”
// 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 + ")";
}
}
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; }
}
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:
// 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
}
}
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 Resultpermits 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 } }
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.
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
}
}
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!
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
}
}
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
}
}
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 fundamental question is: does each variant carry different data?
// 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.
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.
// 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...
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.
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.
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;
};
}
}
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
}
}
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)
}
}
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
Here are guidelines from real-world experience to help you use sealed classes effectively.
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.
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.
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.
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.
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.
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.
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 |
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
// ========================================
| # | 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.
| 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 |
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:
get prefix)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
}
}
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);
}
}
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
}
}
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
}
}
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
}
}
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
}
}
}
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]
}
}
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
}
}
}
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
}
}
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());
}
}
}
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)
}
}
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)
}
}
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
}
}
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.
}
}
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 |
| 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 |
This section shows real-world scenarios where records shine. Each example is something you will encounter in production Java code.
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
}
}
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
}
}
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
}
}
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
}
}
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.
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.
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.
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.
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.
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)
}
}
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]}
}
}
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:
get prefix), equals(), hashCode(), and toString().Pair, Result, and Page.instanceof and switch, making records even more powerful when combined with sealed interfaces.Start using records today in any Java 16+ project. Your codebase will be shorter, safer, and easier to maintain.
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:
:: 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.”String::toUpperCase, not String::toUpperCase(). Adding parentheses would invoke the method immediately rather than referencing it.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.
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.
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]
}
}
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
}
}
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::println — System.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]
}
}
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]
}
}
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();
}
}
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.
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.
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]
}
}
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!
}
}
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)]
}
}
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.
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).
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
}
}
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]
}
}
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)
}
}
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]
}
}
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.
String::toUpperCase is self-documenting)| 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 |
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]
}
}
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.
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]
}
}
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]]
}
}
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]
}
}
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
}
}
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
}
}
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
}
}
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
}
}
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]
}
}
Method references are concise, but they introduce subtle pitfalls. Here are the mistakes developers encounter most often.
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]
}
}
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);
}
}
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
}
}
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.
}
}
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
}
}
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 |
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]
}
}
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:
Employee::fromCsv)formatter::format)Employee::getSalary, Employee::getName)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)
| 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) |
| 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 |
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.”
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.
| 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
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.
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
}
}
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)
}
}
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();
}
}
| 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 |
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.
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
}
}
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)); |
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.
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
}
}
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";
}
}
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) |
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);
}
}
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!)
}
}
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.
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";
}
}
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;
}
}
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
}
}
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
}
}
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.
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
));
}
}
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
}
}
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
Optional and the Stream API work together naturally. Java 9 added Optional.stream() to make this integration even smoother.
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]
}
}
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
}
}
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));
}
}
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.
Return Optional from methods where the caller legitimately might not get a result and needs to handle that case:
findById(), findByEmail(), getConfigValue()max(), first(), parse()| 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 |
@Nullable annotation instead.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
}
}
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());
}
}
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.
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();
}
}
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");
}
}
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)
}
}
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]
}
}
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"));
}
}
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-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 |
Follow these guidelines to use Optional effectively. They represent the consensus of the Java community and the original intent of the API designers.
Every time you write isPresent() + get(), stop and find the functional alternative. The whole point of Optional is to avoid imperative null-check patterns.
Build pipelines with map(), flatMap(), filter(), and terminal methods. This produces code that reads like a sentence describing the transformation.
Not for fields, not for parameters, not for collection elements. The designers of Optional -- Brian Goetz and Stuart Marks -- have been explicit about this.
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.
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.
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.
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();
}
}
| # | 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 |
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.
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 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();
}
}
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();
}
}
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());
}
}
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]
| 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 |
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. |
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.
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.
CompletableFuture solves all of these problems:
get() and block.Future which wraps everything in ExecutionException.CompletableFuture and complete it yourself from any thread, which is why it’s called “completable.”| 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();
}
}
There are four main ways to create a CompletableFuture, each suited for different situations.
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'}
}
}
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.
}
}
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; }
}
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();
}
}
| 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) |
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.
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.
}
}
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
}
}
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.
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"; }
}
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; }
}
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.
}
}
| 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.
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.
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);
}
}
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); }
}
}
| 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.
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.
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); }
}
}
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); }
}
}
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
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.
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
}
}
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
}
}
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]
}
}
| 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 |
Every callback method in CompletableFuture has an async version: thenApplyAsync(), thenAcceptAsync(), thenRunAsync(), thenComposeAsync(), handleAsync(), etc.
| 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();
}
}
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.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).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.Now that you understand the individual methods, let us look at patterns you will actually use in production code.
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); }
}
}
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); }
}
}
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;
}
}
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
}
}
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:
CompletableFuture for complex pipelines.These are the bugs and anti-patterns I see most often in production code using CompletableFuture. Learn them so you can avoid them.
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!
}
}
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;
}
}
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); }
}
}
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!";
}
}
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
}
}
Follow these guidelines to write reliable, maintainable, and performant CompletableFuture code.
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.
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; }
}
When one async operation depends on another, use thenCompose() instead of nesting CompletableFutures inside thenApply(). This keeps the pipeline flat and avoids blocking.
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.
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.
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.
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.
| # | 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 |
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
| # | 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 |
| 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 |