Guide contents

Lesson 3 of 31

Modeling Domains in Java: From Nouns to Types

Every low-level design starts by turning a description into types. Java 21 offers six tools for this: classes, records, interfaces, enums, sealed interfaces and generics. Choosing the right one for each noun is the first design decision. This article gives the mapping and a decision rule, using one running domain: shapes in a drawing program.

Examples that check behavior use plain assert statements. The example checker runs with assertions enabled.

Classes and records

A class holds data and the operations on it. A record is a class for immutable data: the compiler writes the constructor, the accessors, equals, hashCode and toString from the component list. A compact constructor is a constructor without a parameter list that runs before the fields are assigned, so it is the place for validation.

Java
public class Main {
    record Rectangle(double width, double height) {
        Rectangle {
            if (width <= 0 || height <= 0) {
                throw new IllegalArgumentException("sides must be positive, got " + width + " x " + height);
            }
        }

        double area() {
            return width * height;
        }
    }

    public static void main(String[] args) {
        var r = new Rectangle(3.0, 4.0);
        System.out.println(r + " area " + r.area());
        try {
            new Rectangle(-1.0, 2.0);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
Output
Rectangle[width=3.0, height=4.0] area 12.0
sides must be positive, got -1.0 x 2.0

The fields of a record are private and final. The only way to create a rectangle is through the constructor, so a rectangle with a negative side cannot exist. That is encapsulation. See encapsulation and invariants.

Use a record when the object is a value: two rectangles with the same sides are interchangeable. Use a class when the object has an identity and changes over time (an account, an elevator, a ticket machine).

Interfaces for open sets of behavior

An interface declares behavior that several types share. It is the tool for an open set: any class, including ones written later by someone else, can implement it. A default method gives implementations a shared behavior built from the abstract methods.

Java
import java.util.List;
import java.util.Locale;

public class Main {
    interface Shape {
        double area();

        String name();

        default String describe() {
            return String.format(Locale.ROOT, "%s with area %.2f", name(), area());
        }
    }

    record Circle(double radius) implements Shape {
        public double area() { return Math.PI * radius * radius; }
        public String name() { return "circle"; }
    }

    record Square(double side) implements Shape {
        public double area() { return side * side; }
        public String name() { return "square"; }
    }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Circle(1.0), new Square(2.0));
        for (Shape shape : shapes) {
            System.out.println(shape.describe());
        }
    }
}
Output
circle with area 3.14
square with area 4.00

List<Shape> stores different classes behind one interface. Each call to describe is dispatched at run time to the implementation of the object it is called on. Interface methods are public, so the implementing methods are declared public as well.

Enums and sealed interfaces for closed sets

If the set of variants is fixed and known to the module, closing it is simpler than leaving it open. There are two tools.

  • An enum is a fixed set of constants. Each constant can have fields and methods, but every constant has the same shape.
  • A sealed interface lists its permitted implementations. Each one can be a record with its own components. A switch expression over a sealed type must handle every permitted type, with no default branch, and the compiler reports a switch that misses one.

When the variants are nested in the sealed interface, the permits clause can be left out: the compiler infers the permitted types from the same file.

Java
import java.util.List;
import java.util.Locale;

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

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

    public static void main(String[] args) {
        List<Shape> shapes = List.of(
                new Shape.Circle(1.0),
                new Shape.Rectangle(2.0, 3.0),
                new Shape.Triangle(4.0, 5.0));
        double total = 0;
        for (Shape shape : shapes) {
            total += area(shape);
        }
        System.out.println(String.format(Locale.ROOT, "total area %.3f", total));
    }
}
Output
total area 19.142

The Rectangle and Triangle cases use a record pattern, which takes the record apart and binds its components to variables. The Circle case binds the whole record instead. Both forms are allowed in one switch.

This is the visitor pattern without the visitor classes. To add an operation (perimeter), write one new method with one switch. To add a variant, the compiler points to every switch that needs a new case. The cost is on the other side: a new variant needs edits in every switch, so a sealed type fits a set that changes rarely.

Choosing between a sealed interface, an interface and a generic

The same "many kinds behind one type" need can be met three ways. Use this table.

Question Sealed interface or enum Open interface Generic <T extends Shape>
Who defines the variants? this file or module anyone, including other code bases the caller picks one type per call
Set of kinds closed open open
Mixed kinds in one collection yes yes no, one type per collection
Adding a new operation easy (one switch) hard (change the interface and all implementations) hard
Adding a new kind edit the permitted list and every switch add a class add a class
Exhaustiveness checked by compiler yes no not applicable

A working rule for design problems: start with a sealed interface or an enum when the domain names the variants (vehicle sizes, coin values, card types, message kinds). Move to an open interface when variants must be added without editing the module (payment providers, pricing strategies, storage backends). Use a generic method or class when one function must work on several types and keep the exact type of its argument, for example static <T extends Shape> T largest(List<T> items).

Composition instead of inheritance of data

A class hierarchy shares fields and methods through a parent. Composition shares them by holding another object and calling it. This avoids the fragile base class problem: a change in a parent class can silently alter the behavior of every subclass, while a change in a contained object only affects the methods that call it.

Java
public class Main {
    record Engine(int horsepower) {
        String describe() {
            return horsepower + " hp engine";
        }
    }

    record Car(Engine engine, int doors) {
        String describe() {
            return doors + "-door car with " + engine.describe();
        }
    }

    public static void main(String[] args) {
        var car = new Car(new Engine(150), 4);
        System.out.println(car.describe());
    }
}
Output
4-door car with 150 hp engine

Shared behavior goes in an interface with default methods. Shared data goes in a contained object. Inheriting from a class is reserved for the cases where the parent is designed for it.

Records give domain meaning to primitives

An int can be an ID, a count, a price or a floor number, and the compiler treats them all as interchangeable. A record with one component makes each its own type, so passing a seat ID where a price is expected does not compile.

Java
public class Main {
    record FloorNumber(int value) implements Comparable<FloorNumber> {
        public int compareTo(FloorNumber other) {
            return Integer.compare(value, other.value);
        }
    }

    record SeatId(int value) {}

    record Cents(long amount) {
        Cents {
            if (amount < 0) {
                throw new IllegalArgumentException("amount must not be negative: " + amount);
            }
        }

        static Cents fromDollars(long dollars) {
            return new Cents(Math.multiplyExact(dollars, 100L));
        }

        @Override
        public String toString() {
            return "$%d.%02d".formatted(amount / 100, amount % 100);
        }
    }

    static String bookSeat(SeatId seat, Cents price) {
        return "seat " + seat.value() + " booked for " + price;
    }

    public static void main(String[] args) {
        var ground = new FloorNumber(0);
        var top = new FloorNumber(12);
        System.out.println(top.compareTo(ground) > 0);
        System.out.println(bookSeat(new SeatId(42), Cents.fromDollars(12)));
        // bookSeat(new Cents(100), new SeatId(42));  // does not compile: arguments swapped
    }
}
Output
true
seat 42 booked for $12.00

Money is the classic case: store cents in a long, never a double, so 0.1 + 0.2 cannot produce a wrong total. Math.multiplyExact throws ArithmeticException on overflow instead of wrapping silently. Records also give a place to put validation and formatting (toString above). java.math.BigDecimal is the alternative when amounts need fractions of a cent.

Optional and exceptions instead of null

  • A method that may have no result returns Optional<T> (or OptionalInt, OptionalLong). The caller must decide what to do with the empty case. Optional is meant for return values: it is not used for fields or parameters.
  • An operation that may fail throws an exception, or returns a result type that lists the failure reasons. See errors in domain design.

Applied to modeling: a field that exists only in some situations should not be a nullable field on a large class. Move it into the type for the situation where it is meaningful.

Java
import java.util.OptionalInt;

public class Main {
    // Weak: exitMinute is null until the vehicle leaves; nothing stops code from reading it too early.
    static final class WeakTicket {
        int entryMinute;
        Integer exitMinute;
    }

    // Strong: a closed ticket is a different type with the field present.
    sealed interface Ticket {
        record Open(int entryMinute) implements Ticket {}
        record Closed(int entryMinute, int exitMinute) implements Ticket {}
    }

    static Ticket close(Ticket ticket, int exitMinute) {
        return switch (ticket) {
            case Ticket.Open(int entry) -> new Ticket.Closed(entry, exitMinute);
            case Ticket.Closed closed -> closed;
        };
    }

    static OptionalInt duration(Ticket ticket) {
        return switch (ticket) {
            case Ticket.Open open -> OptionalInt.empty();
            case Ticket.Closed(int entry, int exit) -> OptionalInt.of(exit - entry);
        };
    }

    public static void main(String[] args) {
        Ticket ticket = new Ticket.Open(10);
        System.out.println(duration(ticket));
        ticket = close(ticket, 70);
        System.out.println(ticket + " " + duration(ticket));
    }
}
Output
OptionalInt.empty
Closed[entryMinute=10, exitMinute=70] OptionalInt[60]

Mapping table

Object-oriented concept Java 21 form
class with identity and changing state final class with private fields
immutable value record
constructor with validation compact constructor, or a static factory method
interface / abstract class interface (with default methods)
subclass hierarchy (closed) sealed interface with records, or an enum
subclass hierarchy (open) interface with several implementing classes
generics with bounds <T extends Shape>
private / public members private by default, package-private or public only where needed
inherited fields a contained object
inherited methods interface default methods
null Optional<T> as a return type, or a separate type per state
exceptions unchecked exceptions or a sealed result type
singleton an object created in main and passed to constructors
getter and setter pairs a method only where callers need one, named for the operation
static factory static method on the type

Interview points

  • A record is an immutable value type with generated equals, hashCode and toString. A class with identity and changing state stays a class.
  • A sealed interface closes a hierarchy, and a switch expression over it needs no default and fails to compile when a case is missing.
  • An open interface suits behavior that other code will extend. A sealed interface suits a domain that names its variants.
  • Composition holds another object and delegates. It avoids the fragile base class problem of inheriting from a class.
  • Records for IDs and money stop swapped arguments at compile time. Store money in integer cents or BigDecimal, not double.
  • A field that exists in only one situation belongs in the type for that situation, not in a nullable field.

Summary

  • A class holds state with identity. A record holds an immutable value and validates in a compact constructor.
  • Use a sealed interface or an enum for a closed set of variants and an interface for an open set. Use a generic when one function must work on several types.
  • Share data by containing an object and share behavior through interface default methods.
  • Wrap primitives in single-component records for IDs, money and units. It stops swapped arguments.
  • Return Optional for results that may be absent. Do not use it for fields or parameters.
  • Put data that exists only in one situation inside the type for that situation.