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.
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());
}
}
}Rectangle[width=3.0, height=4.0] area 12.0
sides must be positive, got -1.0 x 2.0The 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.
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());
}
}
}circle with area 3.14
square with area 4.00List<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
enumis a fixed set of constants. Each constant can have fields and methods, but every constant has the same shape. - A
sealed interfacelists its permitted implementations. Each one can be arecordwith its own components. Aswitchexpression over a sealed type must handle every permitted type, with nodefaultbranch, and the compiler reports aswitchthat 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.
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));
}
}total area 19.142The 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.
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());
}
}4-door car with 150 hp engineShared 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.
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
}
}true
seat 42 booked for $12.00Money 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>(orOptionalInt,OptionalLong). The caller must decide what to do with the empty case.Optionalis 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.
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));
}
}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,hashCodeandtoString. A class with identity and changing state stays a class. - A sealed interface closes a hierarchy, and a
switchexpression over it needs nodefaultand 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, notdouble. - 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
Optionalfor 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.
