Index

Enums, Records, and Sealed Classes

Java Object-Oriented Design

17.1 Using Enums in OOD

What Are Enums?

In Java, an enum (short for enumeration) is a special type used to define a fixed set of constants. Introduced in Java 5, enums provide a type-safe and object-oriented way to model a limited number of possible values, such as days of the week, directions, states, or operation types.

Instead of using arbitrary strings or integers, enums improve readability, enforce compile-time checking, and offer a structured way to encapsulate related behavior alongside the constants.

Defining and Using Enums

A basic enum looks like this:

public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}

You can use it in code as:

Day today = Day.MONDAY;

if (today == Day.MONDAY) {
    System.out.println("Start of the week!");
}

This approach ensures type safety—you can’t assign a value outside the predefined set—and avoids typos or invalid constants often seen with string or integer constants.

Click to view full runnable Code

public class EnumDemo {
    // Define the enum
    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
    }

    public static void main(String[] args) {
        Day today = Day.MONDAY;

        if (today == Day.MONDAY) {
            System.out.println("Start of the week!");
        } else {
            System.out.println("Not Monday");
        }
    }
}

Enums with Fields, Constructors, and Methods

Java enums are more powerful than simple constants. You can associate fields and behavior with each enum constant.

public enum Operation {
    ADD("+") {
        public double apply(double x, double y) {
            return x + y;
        }
    },
    SUBTRACT("-") {
        public double apply(double x, double y) {
            return x - y;
        }
    };

    private final String symbol;

    Operation(String symbol) {
        this.symbol = symbol;
    }

    public String getSymbol() {
        return symbol;
    }

    public abstract double apply(double x, double y);
}

Usage:

double result = Operation.ADD.apply(3, 5);
System.out.println("Result: " + result); // Output: Result: 8.0

This pattern allows enums to behave like classes, encapsulating logic related to each constant.

Click to view full runnable Code

public class EnumOperationDemo {
    public enum Operation {
        ADD("+") {
            public double apply(double x, double y) {
                return x + y;
            }
        },
        SUBTRACT("-") {
            public double apply(double x, double y) {
                return x - y;
            }
        };

        private final String symbol;

        Operation(String symbol) {
            this.symbol = symbol;
        }

        public String getSymbol() {
            return symbol;
        }

        public abstract double apply(double x, double y);
    }

    public static void main(String[] args) {
        double result = Operation.ADD.apply(3, 5);
        System.out.println("Result: " + result); // Output: Result: 8.0
    }
}

Enums in Object-Oriented Design

Enums are often used in object-oriented design to model fixed categories of behavior or states. They are especially helpful in the following design contexts:

State Machines

You can represent finite states and transitions between them using enums.

enum TrafficLight {
    RED, GREEN, YELLOW;
}

Pairing this with switch statements or state-specific behavior methods can create clean, understandable models for systems with well-defined states.

Command Pattern

Enums can serve as lightweight implementations of the Command pattern, particularly when each constant represents a distinct action:

enum Command {
    START {
        public void execute() { System.out.println("Starting..."); }
    },
    STOP {
        public void execute() { System.out.println("Stopping..."); }
    };

    public abstract void execute();
}

This provides a concise and readable way to encapsulate commands without requiring separate classes for each.

Click to view full runnable Code

public class CommandEnumDemo {
    enum Command {
        START {
            public void execute() { System.out.println("Starting..."); }
        },
        STOP {
            public void execute() { System.out.println("Stopping..."); }
        };

        public abstract void execute();
    }

    public static void main(String[] args) {
        Command.START.execute(); // Output: Starting...
        Command.STOP.execute();  // Output: Stopping...
    }
}

Advantages of Enums

Conclusion

Enums are a powerful tool in Java’s object-oriented toolkit. They elevate constants into rich, expressive types that can carry behavior, state, and data. Whether modeling a fixed set of values, implementing state machines, or organizing command logic, enums improve both safety and clarity. When used thoughtfully, they reduce bugs, enhance maintainability, and align perfectly with the principles of clean, robust design.

Index

17.2 Java Records for Immutable Data Models

Introduction to Java Records

Introduced in Java 14 as a preview and standardized in Java 16, records are a special kind of class in Java designed to model immutable data. They drastically reduce boilerplate by automatically generating constructors, accessors, equals(), hashCode(), and toString() methods for you.

Records are particularly useful when you need simple data carriers—also called value objects—that are defined more by the data they contain than the behavior they perform.

Declaring a Record

Here’s how you declare a record:

public record Person(String name, int age) {}

Behind the scenes, Java automatically generates:

Usage:

Person p = new Person("Alice", 30);
System.out.println(p.name()); // prints "Alice"
System.out.println(p); // prints "Person[name=Alice, age=30]"

This eliminates the need for verbose code often found in traditional POJOs.

Records vs Traditional POJOs

Consider a typical POJO:

public 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 boolean equals(Object o) { /* standard implementation */ }
    @Override
    public int hashCode() { /* standard implementation */ }
    @Override
    public String toString() { /* standard implementation */ }
}

Now compare this to:

public record Person(String name, int age) {}

Both provide the same core functionality, but the record version is cleaner, more concise, and easier to maintain.

Immutability and Finality

All fields in a record are implicitly final, and records themselves are final classes. This means:

This immutability makes records ideal for use in functional programming, multi-threaded code, or data transfer objects (DTOs) where consistency and thread safety are important.

Custom Behavior in Records

You can still add methods, static fields, and custom constructors:

public record Rectangle(int width, int height) {
    public int area() {
        return width * height;
    }
}

You can also customize the constructor to enforce invariants:

public record Rectangle(int width, int height) {
    public Rectangle {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Dimensions must be positive");
        }
    }
}

This flexibility lets records participate in robust design without losing their simplicity.

Click to view full runnable Code

public record Rectangle(int width, int height) {
    // Custom constructor to enforce positive dimensions
    public Rectangle {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Dimensions must be positive");
        }
    }

    // Custom method to calculate area
    public int area() {
        return width * height;
    }

    public static void main(String[] args) {
        Rectangle rect = new Rectangle(5, 10);
        System.out.println("Area: " + rect.area()); // Output: Area: 50

        // Uncommenting the following line will throw IllegalArgumentException
        // Rectangle invalidRect = new Rectangle(-3, 4);
    }
}

Use Cases: Modeling Value Objects

Records are best suited for:

Example:

record Address(String city, String country) {}

record Customer(String name, Address address) {}

Here, both Customer and Address act as pure data containers, well-suited for serialization, database interaction, or API contracts.

Conclusion

Java records streamline the creation of simple, immutable data models, bringing clarity and conciseness to object-oriented design. By auto-generating common boilerplate and enforcing immutability, they encourage clean code and reduce opportunities for error. When designing classes whose identity is purely based on their data, records are a modern, elegant alternative to traditional POJOs.

Index

17.3 Sealed Classes and Controlled Inheritance

What Are Sealed Classes?

In traditional Java inheritance, any class marked as public or protected can be extended by any other class—unless it’s marked as final. This flexible model is powerful but sometimes too open. In cases where you want explicit control over subclassing, Java 15 (as a preview) and Java 17 (as a standard feature) introduced sealed classes.

A sealed class lets you define a closed set of permitted subclasses. It allows superclass authors to declare, “Only these specific classes can extend me,” providing a safer and more predictable class hierarchy.

Syntax: sealed, permits, and non-sealed

Sealed class declarations involve three main keywords:

  1. sealed – Marks the class whose inheritance is restricted.
  2. permits – Explicitly lists the permitted subclasses.
  3. non-sealed – Marks a subclass that removes sealing, allowing unrestricted subclassing.
  4. final – A permitted subclass can still be marked final to prohibit further extension.

Here’s a basic example:

public sealed class Vehicle permits Car, Truck {}

public final class Car extends Vehicle {}

public non-sealed class Truck extends Vehicle {}

Practical Example: Modeling Payment Types

Let’s model a controlled payment system:

public sealed class Payment permits CreditCard, PayPal, Crypto {}

public final class CreditCard extends Payment {
    // implementation
}

public final class PayPal extends Payment {
    // implementation
}

public non-sealed class Crypto extends Payment {
    // allows further extensions like Bitcoin or Ethereum
}

This ensures only CreditCard, PayPal, and Crypto are valid payment types. It avoids unauthorized extensions and keeps the class hierarchy tightly scoped.

Why Use Sealed Classes?

Enhanced Security and Predictability

By explicitly declaring permitted subclasses, sealed classes help ensure that your hierarchy cannot be arbitrarily extended. This is especially important in:

Better Maintainability

Knowing exactly which classes belong to a sealed hierarchy simplifies reasoning about the code. There’s no need to anticipate unforeseen subclasses when applying logic across types.

For instance, consider a switch expression:

static String describe(Payment payment) {
    return switch (payment) {
        case CreditCard c -> "Paid by Credit Card";
        case PayPal p -> "Paid with PayPal";
        case Crypto crypto -> "Paid with Crypto";
    };
}

Because Payment is sealed and the compiler knows all its subtypes, the switch is exhaustive. If a new subtype is added, the compiler will require the switch to be updated, preventing silent omissions.

Click to view full runnable Code

// Sealed class example demonstrating sealed, permits, non-sealed, and final

sealed class Vehicle permits Car, Truck {}

final class Car extends Vehicle {
    // Car is final - no further subclassing allowed
}

non-sealed class Truck extends Vehicle {
    // Truck allows unrestricted subclassing
}

class PickupTruck extends Truck {
    // This is allowed because Truck is non-sealed
}

public class Demo {
    static String describe(Vehicle vehicle) {
        return switch (vehicle) {
            case Car c -> "This is a Car";
            case Truck t -> "This is a Truck";
            default -> throw new IllegalArgumentException("Unexpected value: " + vehicle);
        };
    }

    public static void main(String[] args) {
        Vehicle car = new Car();
        Vehicle truck = new Truck();
        Vehicle pickup = new PickupTruck();

        System.out.println(describe(car));     // Output: This is a Car
        System.out.println(describe(truck));   // Output: This is a Truck
        System.out.println(describe(pickup));  // Output: This is a Truck
    }
}

Improved Pattern Matching Support

Java’s recent enhancements in pattern matching work elegantly with sealed types. You can use sealed hierarchies to write concise, type-safe matching logic that the compiler can verify for completeness.

Design Considerations

Limitations and Rules

Conclusion

Sealed classes represent a powerful enhancement to Java’s type system, allowing developers to create more robust and predictable class hierarchies. By limiting which classes can extend a given type, sealed classes provide greater control, improve pattern matching safety, and reduce the chance of misuse in large codebases. They’re a natural choice when modeling domain-specific hierarchies where only certain subclasses make logical sense.

Index