You reach the end of the module with two outstanding debts carried over from module 3. The first: classifySeverity returns the strings "MINOR" and "SEVERE", which the whole project compares with equals. Nothing stops you writing "severe" in lower case, "CATASTROPHIC" or "MINRO", and the compiler will not say a word; the failure will show up in production, in the form of a misclassified fine. The second: you have written equals, hashCode and toString by hand in Material, in Loan.Incident and in every data class in the project — the same twenty lines, three times over, with the risk of getting any of them wrong.
Java has an exact answer for each debt. Enumerations (enum) turn a set of fixed values into a type of its own: Severity.SEVERE is not a string, it is a value from a closed set the compiler knows about, one where a typo is impossible and over which a switch can check that you have covered every case. Records (record, Java 16) automatically generate the constructor, the accessors, equals, hashCode and toString of an immutable data class, reducing fifty lines to one. By the end of this lesson, BiblioTech will be complete as an advanced OOP project, and you will see clearly what it still lacks to be a real system.
Contents
- The problem: constants as
Stringorint - What an
enumis - Implicit methods:
values,valueOf,name,ordinal - Comparing enums:
==versusequals enumwith fields, constructor and methodsenumwith a body per constantenumin aswitchand exhaustiveness- The singleton with an
enum - Records: what a
recordis - What a
recordgenerates automatically - Compact constructor and validation
- Additional constructors and methods of your own
- What a
recordCANNOT do and when not to use one - Records in BiblioTech: DTO and value objects
- Comparison table: class,
recordandenum - Closing the module: the state of BiblioTech
- Common Mistakes and Tips
- Exercises
- The problem: constants as
String or int
String or intThis is the method you have been dragging along since 03-07:
public final String classifySeverity(int elapsedDays) {
int daysLate = calculateDaysLate(elapsedDays);
if (daysLate == 0) { return "ON TIME"; }
if (daysLate <= MINOR_THRESHOLD) { return "MINOR"; }
return "SEVERE";
}And this is how it is used throughout the project:
Let us list the problems, because there are five and all of them are serious:
| Problem | Consequence |
|---|---|
| No type safety | equals("severe"), equals("SEVEER") and equals("URGENT") compile without complaint and always return false |
| No exhaustiveness | If you add a third severity level, nothing warns you about the ten ifs that need updating |
| No autocompletion | The IDE cannot suggest the valid values: they are arbitrary strings |
| No behaviour | The string "SEVERE" does not know its notice channel or its threshold |
| Comparison cost | Comparing strings walks characters; comparing references is one instruction |
The traditional alternative was worse: int constants.
public static final int SEVERITY_ON_TIME = 0;
public static final int SEVERITY_MINOR = 1;
public static final int SEVERITY_SEVERE = 2;
// ...and nothing stops this:
processSeverity(47); // compiles perfectly
processSeverity(daysLate); // a value from another domain slips inThis antipattern has a name in the literature: the int enum pattern. Java 5 solved it once and for all.
- What an
enum is
enum isAn enum is a type whose possible values are a fixed set known at compile time. In its simplest form:
package com.nexussoftware.bibliotech.domain;
/** Degree of severity of a loan's delay. */
public enum Severity {
ON_TIME,
MINOR,
SEVERE
}With that declaration, Severity is a type as real as String or Material:
Severity s = Severity.SEVERE;
// Severity s2 = "SEVERE"; // DOES NOT COMPILE: String is not Severity
// Severity s3 = Severity.SEVERE2;// DOES NOT COMPILE: no such constantUnder the hood, an enum is a class inheriting from java.lang.Enum whose constants are public static final instances of that class, created by the JVM when the type is loaded. Three consequences:
- You cannot instantiate an enum with
new. Its constructor is implicitlyprivate. - There is exactly one instance of each constant in the whole JVM. They are singletons by construction.
- It is a fully-fledged class: it can have fields, methods, constructors and implement interfaces (section 5).
And classifySeverity improves immediately:
public final Severity classifySeverity(int elapsedDays) {
int daysLate = calculateDaysLate(elapsedDays);
if (daysLate == 0) { return Severity.ON_TIME; }
if (daysLate <= MINOR_THRESHOLD) { return Severity.MINOR; }
return Severity.SEVERE;
}if (loan.classifySeverity() == Severity.SEVERE) { // == instead of equals
escalateIncident(loan);
}
// if (loan.classifySeverity() == Severity.URGENT) // DOES NOT COMPILEThe typo has gone from being a silent failure in production to a compilation error.
- Implicit methods:
values, valueOf, name, ordinal
values, valueOf, name, ordinalEvery enum gets a set of methods for free, some inherited from java.lang.Enum and others generated by the compiler.
| Method | Kind | What it returns |
|---|---|---|
values() |
static |
An array with all the constants, in declaration order |
valueOf(String) |
static |
The constant with that exact name (or IllegalArgumentException) |
name() |
instance | The constant's exact name, as text |
ordinal() |
instance | Its position (zero-based) in the declaration |
compareTo(E) |
instance | Comparison by ordinal |
toString() |
instance | By default the same as name(); it can be overridden |
// Walking all the constants
for (Severity s : Severity.values()) {
System.out.printf("%d -> %s%n", s.ordinal(), s.name());
}// From text to enum: useful when reading configuration or user input
Severity read = Severity.valueOf("SEVERE");
System.out.println(read == Severity.SEVERE); // true
// Severity bad = Severity.valueOf("severe"); // IllegalArgumentException at run timevalueOf is case-sensitive and throws an exception if it does not find the constant. Since exceptions are module 6, in the meantime a hand-written tolerant conversion method is useful:
/** Converts text to Severity without throwing exceptions. Returns ON_TIME if it does not match. */
public static Severity fromText(String text) {
if (text == null) { return ON_TIME; }
String clean = text.trim().toUpperCase().replace(' ', '_');
for (Severity s : values()) {
if (s.name().equals(clean)) { return s; }
}
return ON_TIME;
}And now the important warning: do not depend on ordinal().
// BAD: the logic depends on the POSITION in the declaration
if (severity.ordinal() >= 2) {
escalateIncident();
}That code works today. But if tomorrow somebody adds VERY_MINOR between ON_TIME and MINOR, all the ordinals shift and the >= 2 starts meaning something else without anything failing or warning. The same applies to storing ordinal() in a file or a database: reordering the constants corrupts the stored data.
The correct alternative is an explicit field:
public enum Severity {
ON_TIME(0),
MINOR(1),
SEVERE(2);
private final int level; // explicit, stable value
Severity(int level) { this.level = level; }
public int getLevel() { return level; }
}ordinal() exists because the JDK's specialised collections (EnumMap, EnumSet) use it internally. Your code should never touch it.
- Comparing enums:
== versus equals
== versus equalsWith enums, == is the correct and preferred form. It is the only situation in the course where this is true for objects.
Severity a = Severity.SEVERE;
Severity b = loan.classifySeverity();
if (a == b) { } // CORRECT and preferred
if (a.equals(b)) { } // works, but unnecessaryFour reasons, picking up what you learned in 01-05 and 03-09:
- There is only one instance of each constant. Reference equality and logical equality coincide by construction.
Enum.equalsisfinaland is implemented exactly asthis == other. ==is null-safe.a == nullgivesfalse;a.equals(b)withanull throws aNullPointerException.==gives type safety. Comparing two enums of different types with==does not compile; withequalsit compiles and always returnsfalse, hiding the error.- It is faster: a reference comparison versus a method call.
Severity s = Severity.MINOR;
Frequency f = Frequency.MONTHLY;
// if (s == f) // DOES NOT COMPILE: incomparable types. Perfect.
if (s.equals(f)) { } // compiles and is always false: a silent bug
enum with fields, constructor and methods
enum with fields, constructor and methodsThis is where Java's enums far surpass those of other languages. An enum constant can carry associated data.
Applied to the project: Book, Magazine and Dvd exist as classes because each has its own term and rate. But if that were the whole difference — only data, with no behaviour of their own and no exclusive fields — the entire hierarchy would be superfluous.
package com.nexussoftware.bibliotech.domain;
/**
* Catalogue material types, with their loan parameters.
*
* <p>Each constant carries its associated data: it replaces an entire
* hierarchy when the only thing that varies is values, not behaviour.</p>
*/
public enum MaterialType {
// Each constant invokes the constructor with ITS values
BOOK ("Book", 15, 0.25, "email"),
MAGAZINE("Magazine", 7, 0.10, "chat"),
DVD ("DVD", 3, 0.50, "phone"),
AUDIO ("Audiobook", 10, 0.15, "email"); // the semicolon is MANDATORY
private final String label;
private final int loanDays;
private final double dailyRate;
private final String noticeChannel;
/** An enum's constructor is implicitly private. */
MaterialType(String label, int loanDays,
double dailyRate, String noticeChannel) {
this.label = label;
this.loanDays = loanDays;
this.dailyRate = dailyRate;
this.noticeChannel = noticeChannel;
}
public String getLabel() { return label; }
public int getLoanDays() { return loanDays; }
public double getDailyRate() { return dailyRate; }
public String getNoticeChannel() { return noticeChannel; }
/** A business method belonging to the type. */
public double fineFor(int daysLate) {
return Math.min(Math.max(0, daysLate) * dailyRate, 20.0);
}
public boolean isShortTerm() { return loanDays <= 7; }
@Override
public String toString() { return label; }
}Syntax points to pin down:
- The constants come first, before any field or method.
- The semicolon after the last constant is mandatory if there is anything else in the body. It is the most common compilation error with enums.
- The constructor is implicitly
privateand cannot be anything else. It is invoked once per constant, when the class is loaded. - The fields must be
final. Technically mutable ones are allowed, but a mutable enum is a design error: the constant is unique and shared by the whole program.
And what can now be done with it:
System.out.printf("%-12s %-6s %-8s %-10s %s%n",
"TYPE", "TERM", "RATE", "CHANNEL", "FINE@17d");
for (MaterialType t : MaterialType.values()) {
System.out.printf("%-12s %-6d %-8.2f %-10s %.2f EUR%n",
t.getLabel(), t.getLoanDays(), t.getDailyRate(),
t.getNoticeChannel(), t.fineFor(17));
}TYPE TERM RATE CHANNEL FINE@17d Book 15 0.25 email 4.25 EUR Magazine 7 0.10 chat 1.70 EUR DVD 3 0.50 phone 8.50 EUR Audiobook 10 0.15 email 2.55 EUR
Does this replace the Material hierarchy? Not entirely, and that is the design lesson:
| Situation | Choose |
|---|---|
| The types differ only in values (term, rate, channel) | enum: four lines instead of four classes |
| The types have different fields of their own | Class hierarchy |
| The types have structurally different behaviour | Class hierarchy |
| Both | A hierarchy with an enum as a field |
In BiblioTech the third case holds: Book has author and publicationYear, Magazine has number and frequency, Dvd has durationMinutes. The hierarchy stays. But MaterialType is still useful as a field centralising the common parameters, and you will apply it in section 16.
enum with a body per constant
enum with a body per constantOne more turn of the screw: each constant can have its own implementation of a method. The syntax is a { ... } body after the constructor arguments.
package com.nexussoftware.bibliotech.domain;
/** Severity of the delay, with the action corresponding to each level. */
public enum Severity {
ON_TIME(0) {
@Override
public String recommendedAction() {
return "None. The loan is within its term.";
}
@Override
public boolean needsNotice() { return false; }
},
MINOR(1) {
@Override
public String recommendedAction() {
return "Send a reminder through the usual channel.";
}
@Override
public boolean needsNotice() { return true; }
},
SEVERE(2) {
@Override
public String recommendedAction() {
return "Escalate to the manager and block new loans.";
}
@Override
public boolean needsNotice() { return true; }
};
private final int level;
Severity(int level) { this.level = level; }
public int getLevel() { return level; }
/** Every constant MUST implement it: it is abstract. */
public abstract String recommendedAction();
/** Every constant implements it; it could have a default implementation. */
public abstract boolean needsNotice();
/** A method common to every constant. */
public boolean isMoreSevereThan(Severity other) {
return this.level > other.level;
}
}for (Severity s : Severity.values()) {
System.out.printf("%-12s level %d notice=%-5b %s%n",
s, s.getLevel(), s.needsNotice(), s.recommendedAction());
}
System.out.println("SEVERE more severe than MINOR: " + Severity.SEVERE.isMoreSevereThan(Severity.MINOR));ON_TIME level 0 notice=false None. The loan is within its term. MINOR level 1 notice=true Send a reminder through the usual channel. SEVERE level 2 notice=true Escalate to the manager and block new loans. SEVERE more severe than MINOR: true
What is really going on here? Each constant with a body is an anonymous subclass of the enum (04-04). You can check it:
System.out.println(Severity.MINOR.getClass().getName()); // ...Severity$2
System.out.println(Severity.class.getName()); // ...SeverityThe practical consequence is enormous: the compiler forces you to implement the abstract method in every constant. If tomorrow you add VERY_SEVERE without recommendedAction(), the code does not compile. Compare that with a switch over strings, where the new case simply falls into the default and nobody notices.
Body per constant or switch? Use a body per constant when the logic belongs to the value and varies in each one. Use common fields and methods when the logic is the same with different data. And if the body per constant starts running to twenty lines, the enum is probably taking on responsibilities that are not its own.
enum in a switch and exhaustiveness
enum in a switch and exhaustivenessEnums and the switch were made for each other. And there is a syntax detail that surprises people:
public String messageFor(Severity severity) {
return switch (severity) {
case ON_TIME -> "All in order."; // no 'Severity.'
case MINOR -> "Reminder sent.";
case SEVERE -> "Incident escalated.";
};
}Inside the case the constant is not qualified: you write MINOR, not Severity.MINOR. The compiler already knows the type of the switch expression, and qualifying it is in fact a compilation error.
And now the truly valuable part, picking up the expression switch of 02-03: exhaustiveness.
// An EXPRESSION switch over an enum: no 'default' needed
return switch (severity) {
case ON_TIME -> "All in order.";
case MINOR -> "Reminder sent.";
case SEVERE -> "Incident escalated.";
};Since an expression switch must always produce a value, the compiler verifies that you have covered every constant. If tomorrow you add VERY_SEVERE to the enum:
The compiler takes you by the hand to every place in the project that has to be updated. That is impossible with strings and it is the number one reason to use enums in the domain.
A warning about the default:
// With a default: compiles today and will compile tomorrow... doing the wrong thing
return switch (severity) {
case ON_TIME -> "All in order.";
case MINOR -> "Reminder sent.";
default -> "Incident escalated."; // VERY_SEVERE would fall here silently
};Rule: in an expression
switchover an enum, omit thedefaultwhenever you can. It is what gives you the exhaustiveness check, which is exactly what you came for.
In a classic statement switch (with case ... :) exhaustiveness is not checked, because there is no obligation to produce a value. Another reason to prefer the arrow form.
- The singleton with an
enum
enumA brief but well-known application: since the JVM guarantees a single instance of each constant, an enum with one value is the simplest and safest way to implement the Singleton pattern (formalised in 12-02), immune to reflection and serialisation.
public enum BiblioTechLog {
INSTANCE;
private int operations;
public void log(String operation) {
operations++;
System.out.println("[" + operations + "] " + operation);
}
}
// Usage:
BiblioTechLog.INSTANCE.log("Loan LN-0001 created");
- Records: what a
record is
record isTime to change tools. Look at this class, which exists only to carry three pieces of data:
public final class Card {
private final String title;
private final String author;
private final int year;
public Card(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getYear() { return year; }
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
Card other = (Card) o;
return year == other.year
&& Objects.equals(title, other.title)
&& Objects.equals(author, other.author);
}
@Override
public int hashCode() { return Objects.hash(title, author, year); }
@Override
public String toString() {
return "Card[title=" + title + ", author=" + author + ", year=" + year + "]";
}
}Almost forty lines, and the only real information is three names and three types. Everything else is mechanical, and being mechanical it is error-prone: forgetting a field in equals, not updating hashCode when adding one, leaving an outdated toString.
A record (Java 16) expresses exactly the same thing:
One line. And it is completely equivalent, with the three methods correctly implemented.
The declaration reads like this:
The parameters in the header are called components. A record is, by design, a transparent carrier of immutable data: transparent because its components are exactly its state and all of them can be read.
- What a
record generates automatically
record generates automaticallyFrom that single line, the compiler generates:
| Generated element | Detail |
|---|---|
One private final field per component |
Not accessible directly, not even from the record itself except by its name |
| Canonical constructor | Receives all the components in order and assigns them |
| One accessor per component | Named like the component: title(), not getTitle() |
equals(Object) |
Compares all the components; it fulfils the contract of 03-09 |
hashCode() |
Derived from all the components; consistent with equals |
toString() |
Card[title=Effective Java, author=Joshua Bloch, year=2018] |
Card c1 = new Card("Effective Java", "Joshua Bloch", 2018);
Card c2 = new Card("Effective Java", "Joshua Bloch", 2018);
System.out.println(c1.title()); // Effective Java (no 'get')
System.out.println(c1); // Card[title=Effective Java, ...]
System.out.println(c1.equals(c2)); // true
System.out.println(c1.hashCode() == c2.hashCode()); // true
System.out.println(c1 == c2); // false: they are different objectsNote the detail about the accessors: they are called title(), not getTitle(). It is not a whim: it is a deliberate signal from the language design that a record is not a JavaBean but a value. If you need the getXxx convention because a framework demands it (Hibernate, for instance, module 11), you can add it by hand, but it is usually a symptom that what you wanted there was an ordinary class.
And three important structural properties:
- A
recordis implicitlyfinal. It cannot be extended. - Its components are immutable. There are no setters and no way to reassign them.
- It inherits from
java.lang.Record, not fromObjectdirectly. That is why arecordcannot extend any other class (section 13).
- Compact constructor and validation
A record without validation would accept a null title or an impossible year. The solution is the compact constructor, an abbreviated form exclusive to records:
public record Card(String title, String author, int year) {
/**
* COMPACT constructor: no parameter list and no assignments.
* It runs before the fields are assigned, over the parameters.
*/
public Card {
if (title == null || title.isBlank()) {
title = "Untitled"; // the PARAMETER is reassigned
}
if (author == null || author.isBlank()) {
author = "Unknown";
}
if (year < 1450 || year > 2100) {
year = 0;
}
title = title.trim();
author = author.trim();
// do NOT write this.title = title; the compiler does it at the end
}
}Three rules of the compact constructor:
- It has no parameter list: you write
public Card {, notpublic Card(String title, ...) {. - Inside it you work with the parameters, not with the fields.
title = "Untitled"changes the parameter; the compiler assigns the fields at the end, automatically. - Do not write
this.title = title: it is unnecessary and confusing.
This is exactly the data sanitising you did by hand in the module 3 constructors, now in its natural place. As soon as you have exceptions (module 6), the usual thing will be to throw an IllegalArgumentException instead of substituting values; the compact constructor mechanism is the same.
- Additional constructors and methods of your own
A record's body accepts a great deal more than the compact constructor.
Additional constructors, which must delegate to the canonical one with this(...) (03-04):
public record Card(String title, String author, int year) {
public Card {
if (title == null || title.isBlank()) { title = "Untitled"; }
}
/** Card with no known year. */
public Card(String title, String author) {
this(title, author, 0); // delegating to the canonical one is MANDATORY
}
/** Card built from a catalogue material. */
public static Card from(Book book) {
return new Card(book.getTitle(), book.getAuthor(), book.getPublicationYear());
}
}Instance methods of your own, operating on the components:
public record Card(String title, String author, int year) {
public boolean isClassic() {
return year > 0 && year < 2000;
}
public String shortLabel() {
String shortened = title.length() > 20 ? title.substring(0, 17) + "..." : title;
return String.format("%-20s (%s)", shortened, year > 0 ? String.valueOf(year) : "n.d.");
}
/** An immutable-style "modifier": it returns a new copy. */
public Card withYear(int newYear) {
return new Card(title, author, newYear);
}
}That withYear is the standard pattern with immutable types (03-07): it does not modify, it creates a copy with the change. It is what String does with toUpperCase() and what you will do with LocalDate in 10-05.
Constants, static methods and nested types, all allowed:
public record Card(String title, String author, int year) {
public static final Card EMPTY = new Card("Untitled", "Unknown", 0);
public static int compare(Card a, Card b) { return a.title.compareTo(b.title); }
}Implementing interfaces, yes; extending classes, no:
public record Card(String title, String author, int year)
implements Comparable<Card> {
@Override
public int compareTo(Card other) {
return this.title.compareToIgnoreCase(other.title);
}
}And you can also override an accessor or a generated method, though it should be done sparingly:
public record Card(String title, String author, int year) {
@Override
public String toString() {
return title + " - " + author + (year > 0 ? " (" + year + ")" : "");
}
}
- What a
record CANNOT do and when not to use one
record CANNOT do and when not to use one| It cannot | Why |
|---|---|
| Extend a class | It already extends java.lang.Record |
| Be extended | It is implicitly final |
| Declare instance fields of its own | Its state is exactly its components; that is the transparency guarantee |
| Have mutable components | They are final by construction |
Be abstract |
It makes no sense in a data carrier |
That third point surprises a lot of people:
public record Card(String title, String author, int year) {
// private int accessCount; // DOES NOT COMPILE: an instance field
private static int cardsCreated; // IT DOES COMPILE: static ones are allowed
}The reason is the design principle: a record's state is its component list, without exceptions. Only that way can the generated equals, hashCode and toString be correct by construction.
When NOT to use a record:
- When the object has identity, not value. An
EmployeewithEMP-001is still the same employee even if their name changes; twoCards with the same data are the same card. The key question: are two objects with the same data the same object? If yes,record. - When the state must change. A
Loanchanges availability, accumulates incidents and registers returns. It is not a record. - When you want to hide the internal representation. A
recordexposes all its components. If you need to encapsulate the representation (03-07), use a class. - When you need inheritance. A
recordcan neither extend nor be extended. - When the public API must follow the
getXxxconvention because an old framework demands it.
flowchart TD
A["I need a data type"] --> B{"Does its state change over time?"}
B -- "Yes" --> C["Ordinary class"]
B -- "No" --> D{"Are two objects with the same data the same one?"}
D -- "No, it has its own identity" --> C
D -- "Yes, it is a value" --> E{"Do I need inheritance or hidden fields?"}
E -- "Yes" --> C
E -- "No" --> F["record"]
- Records in BiblioTech: DTO and value objects
Two concrete uses in the project.
Value object: Card. A bibliographic catalogue card, with no identity of its own.
package com.nexussoftware.bibliotech.domain;
/** Bibliographic catalogue card. Immutable value object. */
public record Card(String title, String author, int year) implements Comparable<Card> {
public Card {
title = (title == null || title.isBlank()) ? "Untitled" : title.trim();
author = (author == null || author.isBlank()) ? "Unknown" : author.trim();
year = (year < 1450 || year > 2100) ? 0 : year;
}
public Card(String title, String author) { this(title, author, 0); }
/** Factory from a catalogue book. */
public static Card from(Book book) {
return new Card(book.getTitle(), book.getAuthor(), book.getPublicationYear());
}
public boolean isClassic() { return year > 0 && year < 2000; }
@Override
public int compareTo(Card other) {
return this.title.compareToIgnoreCase(other.title);
}
}Output DTO: SessionSummary. An object carrying the result of a returns session from the service to the presentation layer.
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Severity;
/**
* Result of a returns session.
*
* <p>Transfer object (DTO): it groups computed data to deliver it
* to the presentation layer without exposing the domain.</p>
*/
public record SessionSummary(int returns,
double totalFines,
int severeIncidents,
Severity maxSeverity) {
public SessionSummary {
returns = Math.max(0, returns);
totalFines = Math.max(0.0, totalFines);
severeIncidents = Math.max(0, severeIncidents);
maxSeverity = (maxSeverity == null) ? Severity.ON_TIME : maxSeverity;
}
/** Empty session: the starting point of the accumulation. */
public static SessionSummary empty() {
return new SessionSummary(0, 0.0, 0, Severity.ON_TIME);
}
/** Returns a NEW summary with one more return. Immutable style. */
public SessionSummary plus(double fine, Severity severity) {
return new SessionSummary(
returns + 1,
totalFines + fine,
severeIncidents + (severity == Severity.SEVERE ? 1 : 0),
severity.isMoreSevereThan(maxSeverity) ? severity : maxSeverity);
}
public double averageFine() {
return returns == 0 ? 0.0 : totalFines / returns;
}
}And the two used together, with the enum and the record working side by side:
Loan[] returned = { l1, l2, l3, l4 };
int[] days = { 20, 40, 16, 12 };
SessionSummary summary = SessionSummary.empty();
for (int i = 0; i < returned.length; i++) {
double fine = returned[i].registerReturn(days[i]);
Severity severity = returned[i].classifySeverity();
summary = summary.plus(fine, severity); // reassigned: each 'plus' creates a new one
}
System.out.println(summary);
System.out.printf("Average fine: %.2f EUR%n", summary.averageFine());
System.out.println("Action: " + summary.maxSeverity().recommendedAction());SessionSummary[returns=4, totalFines=15.30, severeIncidents=2, maxSeverity=SEVERE] Average fine: 3.83 EUR Action: Escalate to the manager and block new loans.
Notice the toString() you did not write, the equals that would be correct if you needed it, and the last line: maxSeverity() returns a Severity, and recommendedAction() is called directly on it. An enum carrying its behaviour and a record carrying its data.
- Comparison table: class,
record and enum
record and enum| Dimension | Class | record |
enum |
|---|---|---|---|
| Number of instances | Unlimited | Unlimited | Fixed: one per constant |
| Mutable state | Yes | No | It should not |
| Constructor | Written by hand | Generated canonical + compact | Implicitly private |
equals/hashCode/toString |
By hand | Generated | Inherited from Enum, equals is final |
| Accessors | getXxx() by hand |
xxx() generated |
By hand |
| Can extend classes | Yes | No | No |
| Can be extended | Yes (if not final) |
No | No (except a body per constant) |
| Can implement interfaces | Yes | Yes | Yes |
| Instance fields of its own | Yes | No (components only) | Yes |
| Comparison | equals |
equals |
== |
Exhaustiveness in a switch |
No | No | Yes |
| Use it for | Entities with identity and state | Immutable values, DTOs | Closed sets of values |
| In BiblioTech | Material, Loan, Employee |
Card, SessionSummary |
Severity, MaterialType |
- Closing the module: the state of BiblioTech
Apply the enums to the project and the module is closed. Loan.Incident, which in 04-03 stored the severity as a String, moves to using the type:
public static class Incident {
private final int day;
private final String reason;
private final Severity severity; // previously: String
public Incident(int day, String reason, Severity severity) {
this.day = Math.max(0, day);
this.reason = (reason == null || reason.isBlank())
? "Unspecified" : reason.trim();
this.severity = (severity == null) ? Severity.MINOR : severity;
}
public int getDay() { return day; }
public String getReason() { return reason; }
public Severity getSeverity() { return severity; }
public boolean isSevere() { return severity == Severity.SEVERE; }
@Override
public String toString() {
return String.format("day %d - %s [%s]", day, reason, severity);
}
}And this is the complete state of the project at the end of module 4:
classDiagram
class Lendable {
<<interface>>
+lend() boolean
+returnItem() boolean
+isAvailable() boolean
+getLoanDays() int
+daysRemaining(int) int
}
class Notifiable {
<<interface>>
+getNoticeChannel() String
+buildNotice(int) String
}
class Material {
<<abstract>>
+calculateFine(int) double
+classifySeverity(int) Severity
+getType()* String
}
class Severity {
<<enumeration>>
ON_TIME
MINOR
SEVERE
+recommendedAction() String
}
class MaterialType {
<<enumeration>>
BOOK
MAGAZINE
DVD
}
class Card {
<<record>>
+title() String
+author() String
}
class SessionSummary {
<<record>>
+totalFines() double
}
Lendable <|.. Material
Notifiable <|.. Material
Lendable <|.. MeetingRoom
Material <|-- Book
Material <|-- Magazine
Material <|-- Dvd
Loan --> Material
Loan --> Employee
Loan *-- Incident
Incident --> Severity
Material --> Severity
Project inventory after module 4:
| Element | Kind | Notable public members |
|---|---|---|
Lendable |
interface | lend, returnItem, isAvailable, getLoanDays; default daysRemaining, isOverdue; static isValidTerm, countAvailable; MAX_TERM_DAYS |
Notifiable |
interface | getNoticeChannel, buildNotice; default urgentNotice, routineNotice; private header |
Material |
abstract class | abstract getType/getLoanDays/getDailyRate; final calculateDaysLate/calculateFine/classifySeverity; lend, returnItem, describe, equals, hashCode, toString |
Book, Magazine, Dvd |
concrete classes | Their three mandatory methods plus their own fields |
MeetingRoom |
class | Lendable without being a Material |
Employee |
class | canBorrow, registerLoan, registerReturn, getInitials, getHistory |
Employee.EmployeeHistory |
static nested | Accumulated counters |
Loan |
class | registerReturn, calculateFine, classifySeverity, recordIncident, getIncidents |
Loan.Incident |
static nested | getDay, getReason, getSeverity, isSevere |
Severity |
enum | ON_TIME, MINOR, SEVERE; getLevel, recommendedAction, needsNotice, isMoreSevereThan |
MaterialType |
enum | BOOK, MAGAZINE, DVD, AUDIO; getLabel, getLoanDays, getDailyRate, fineFor |
Card |
record | title(), author(), year(), isClassic, from(Book) |
SessionSummary |
record | returns(), totalFines(), maxSeverity(), plus, averageFine, empty() |
RateRule, MaterialFilter |
functional interfaces | One abstract method each |
Catalog, LoanManager |
services | They receive Predicate, Function, Consumer, Comparator |
ConsoleReceipt, MaterialReport |
presentation | Template Method in MaterialReport |
Common Mistakes and Tips
Forgetting the semicolon after the last constant. If the enum has fields or methods, the constant list ends in ;. It is the number one compilation error with enums.
Depending on ordinal(). Reordering the constants silently changes every ordinal. Use an explicit field.
Storing ordinal() in a file or a database. Worse still: it corrupts already-stored data. Store name(), which is stable, or a code of your own.
Putting a default in an expression switch over an enum. It cancels the exhaustiveness check, which is the main advantage. Leave it out.
Qualifying the constant inside the case. You write case MINOR ->, not case Severity.MINOR ->. The second form does not compile.
Using equals with enums. It works, but == is safer (against null), faster and catches comparisons between different types at compile time.
Writing getTitle() in a record. The generated accessor is called title(). If you write getTitle(), you are adding a method, not overriding anything.
Putting the parameter list in the compact constructor. public Card(String title, ...) { is the explicit canonical constructor, not the compact one; then you do have to assign the fields by hand.
Trying to declare an instance field in a record. It does not compile. Its state is exactly its components. static ones are allowed.
Using a record for an entity with identity. An Employee is not a value: two employees with the same name are not the same employee. Their equality goes by identifier, not by all the fields.
Tip: an enum for every closed set. States, types, channels, levels, roles, currencies, days of the week. If the possible values are fixed and known, it is an enum. Converting them later costs far more than starting out right.
Tip: a record for anything that is a value. Coordinates, amounts with a currency, ranges, calculation results, DTOs between layers, composite keys. You save code and you eliminate a source of bugs, because the generated equals and hashCode are correct by construction.
Tip: combine them. A record whose component is an enum is an extraordinarily expressive pairing, like SessionSummary(..., Severity maxSeverity).
Exercises
Exercises 2 and 3 build on the types from exercise 1, so it is best to do them in order.
Exercise 1: LoanStatus
Create an enum LoanStatus with the constants ACTIVE, OVERDUE, RETURNED and LOST. Each must carry a readable label and a boolean indicating whether it counts towards the employee's loan limit. Add an abstract String description() method implemented by every constant and an expression switch returning the action to take, without a default. Check what happens when you add a fifth constant.
Exercise 2: record CatalogLine
Create a record CatalogLine(String reference, String title, MaterialType type, boolean available) with a compact constructor that validates, a static method from(Material), a label() method returning a formatted line and an implementation of Comparable<CatalogLine> by title. Generate an array of lines from the catalogue and sort it.
Exercise 3: a report with an enum and a record
Write a method that walks an array of loans and returns a record SeverityReport(int onTime, int minor, int severe, double totalFines). Use an expression switch over Severity without a default and the immutable style (a plus(...) method returning a new copy). Print the report and the recommended action of the highest level detected.
Solutions
Solution 1
package com.nexussoftware.bibliotech.domain;
/** State a loan is in. */
public enum LoanStatus {
ACTIVE("Active", true) {
@Override public String description() {
return "The material is with the employee and within its term.";
}
},
OVERDUE("Overdue", true) {
@Override public String description() {
return "The term has been exceeded and a daily fine is accruing.";
}
},
RETURNED("Returned", false) {
@Override public String description() {
return "The material is back in the catalogue and available.";
}
},
LOST("Lost", false) {
@Override public String description() {
return "The material is presumed lost; the maximum fine applies.";
}
}; // <-- MANDATORY semicolon
private final String label;
private final boolean countsTowardsLimit;
LoanStatus(String label, boolean countsTowardsLimit) {
this.label = label;
this.countsTowardsLimit = countsTowardsLimit;
}
public String getLabel() { return label; }
public boolean countsTowardsLimit() { return countsTowardsLimit; }
/** Every constant MUST implement it. */
public abstract String description();
@Override public String toString() { return label; }
/** Action to take. An EXPRESSION switch with no default: exhaustiveness guaranteed. */
public String action() {
return switch (this) {
case ACTIVE -> "None. Review at the due date.";
case OVERDUE -> "Send a notice and compute the fine.";
case RETURNED -> "Archive the loan and release the material.";
case LOST -> "Apply the maximum fine and withdraw the material.";
};
}
public static void main(String[] args) {
System.out.printf("%-10s %-8s %s%n", "STATUS", "LIMIT", "ACTION");
for (LoanStatus s : LoanStatus.values()) {
System.out.printf("%-10s %-8b %s%n", s, s.countsTowardsLimit(), s.action());
}
System.out.println();
System.out.println("Detail of OVERDUE: " + LoanStatus.OVERDUE.description());
System.out.println("Comparison with ==: "
+ (LoanStatus.valueOf("OVERDUE") == LoanStatus.OVERDUE));
}
}STATUS LIMIT ACTION Active true None. Review at the due date. Overdue true Send a notice and compute the fine. Returned false Archive the loan and release the material. Lost false Apply the maximum fine and withdraw the material. Detail of OVERDUE: The term has been exceeded and a daily fine is accruing. Comparison with ==: true
What happens when you add a fifth constant. If you add RENEWED("Renewed", true), the compiler produces two errors, and both are exactly what you want:
error: RENEWED is not abstract and does not override abstract method description() error: the switch expression does not cover all possible input values
The first forces you to describe the new state; the second, to decide which action corresponds to it. Neither would exist with strings: "RENEWED" would have fallen into a silent default and the system would have done something incorrect without warning. This is the number one reason to use enums in the domain.
Solution 2
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
import java.util.Arrays;
import java.util.Comparator;
/** A catalogue line ready to display. Immutable value object. */
public record CatalogLine(String reference,
String title,
MaterialType type,
boolean available) implements Comparable<CatalogLine> {
/** Compact constructor: validates and normalises BEFORE the fields are assigned. */
public CatalogLine {
reference = (reference == null || reference.isBlank())
? "NO-REF" : reference.trim();
title = (title == null || title.isBlank())
? "Untitled" : title.trim();
type = (type == null) ? MaterialType.BOOK : type;
// 'available' is a boolean: it needs no validation
}
/** Factory from the domain: it translates the concrete class into the enum constant. */
public static CatalogLine from(Material m) {
MaterialType t = switch (m.getType()) {
case "Book" -> MaterialType.BOOK;
case "Magazine" -> MaterialType.MAGAZINE;
case "DVD" -> MaterialType.DVD;
case "Audiobook" -> MaterialType.AUDIO;
default -> MaterialType.BOOK;
};
return new CatalogLine(m.getReference(), m.getTitle(), t, m.isAvailable());
}
public String label() {
return String.format("%-16s %-24s %-11s %s",
reference, title, type.getLabel(),
available ? "FREE" : "ON LOAN");
}
@Override
public int compareTo(CatalogLine other) {
return this.title.compareToIgnoreCase(other.title);
}
public static void main(String[] args) {
Material[] catalog = {
new Dvd("Refactoring Live", "DVD-0007", 95),
new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018),
new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994)
};
catalog[1].lend();
// Transform the domain into presentation lines
CatalogLine[] lines = new CatalogLine[catalog.length];
for (int i = 0; i < catalog.length; i++) {
lines[i] = CatalogLine.from(catalog[i]);
}
// Natural order (Comparable): by title
Arrays.sort(lines);
System.out.println("--- Natural order (by title) ---");
for (CatalogLine l : lines) { System.out.println(" " + l.label()); }
// Alternative order with a Comparator and method references (04-06)
Arrays.sort(lines, Comparator.comparing(CatalogLine::type)
.thenComparing(CatalogLine::title));
System.out.println("--- By type and title ---");
for (CatalogLine l : lines) { System.out.println(" " + l.label()); }
// Generated equals and hashCode: they work without writing anything
CatalogLine a = new CatalogLine("DVD-0007", "Refactoring Live",
MaterialType.DVD, true);
CatalogLine b = CatalogLine.from(catalog[0]);
System.out.println("--- Equality by value ---");
System.out.println(" a.equals(b) = " + a.equals(b));
System.out.println(" toString = " + a);
}
}--- Natural order (by title) --- 978-0000000002 Design Patterns Book FREE 978-0000000001 Effective Java Book ON LOAN REV-2024-03 Java Magazine Magazine FREE DVD-0007 Refactoring Live DVD FREE --- By type and title --- 978-0000000002 Design Patterns Book FREE 978-0000000001 Effective Java Book ON LOAN REV-2024-03 Java Magazine Magazine FREE DVD-0007 Refactoring Live DVD FREE --- Equality by value --- a.equals(b) = true toString = CatalogLine[reference=DVD-0007, title=Refactoring Live, type=DVD, available=true]
Three observations. First: the "by type and title" order places the books before the magazine and the DVD because Comparator.comparing over an enum uses its natural order, which is the declaration order (BOOK, MAGAZINE, DVD, AUDIO); it is the only legitimate use of the ordinal, and the JDK does it for you. Second: a.equals(b) is true without a single line of equals having been written, because the record compares all its components. Third: CatalogLine::type and CatalogLine::title are references to the generated accessors, with no get.
Solution 3
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Severity;
import com.nexussoftware.bibliotech.domain.Loan;
public class SeverityAnalyser {
/** Count of loans by severity. Immutable value object. */
public record SeverityReport(int onTime, int minor, int severe, double totalFines) {
public SeverityReport {
onTime = Math.max(0, onTime);
minor = Math.max(0, minor);
severe = Math.max(0, severe);
totalFines = Math.max(0.0, totalFines);
}
public static SeverityReport empty() {
return new SeverityReport(0, 0, 0, 0.0);
}
/** Returns a NEW report with one more loan. Immutable style. */
public SeverityReport plus(Severity s, double fine) {
// EXPRESSION switch with no default: if a constant is added, it will not compile
return switch (s) {
case ON_TIME -> new SeverityReport(onTime + 1, minor, severe,
totalFines + fine);
case MINOR -> new SeverityReport(onTime, minor + 1, severe,
totalFines + fine);
case SEVERE -> new SeverityReport(onTime, minor, severe + 1,
totalFines + fine);
};
}
public int total() { return onTime + minor + severe; }
/** The highest level reached, deduced from the counts. */
public Severity maxSeverity() {
if (severe > 0) { return Severity.SEVERE; }
if (minor > 0) { return Severity.MINOR; }
return Severity.ON_TIME;
}
public String reportText() {
return String.format(
"Loans analysed: %d%n"
+ " On time: %d%n"
+ " Minor: %d%n"
+ " Severe: %d%n"
+ " Fines: %.2f EUR (average %.2f EUR)%n"
+ " Max level: %s -> %s",
total(), onTime, minor, severe, totalFines,
total() == 0 ? 0.0 : totalFines / total(),
maxSeverity(), maxSeverity().recommendedAction());
}
}
/** Analyses the given loans at their corresponding elapsed days. */
public static SeverityReport analyse(Loan[] loans, int[] days) {
SeverityReport report = SeverityReport.empty();
for (int i = 0; i < loans.length; i++) {
double fine = loans[i].getMaterial().calculateFine(days[i]);
Severity severity = loans[i].getMaterial().classifySeverity(days[i]);
report = report.plus(severity, fine); // each 'plus' creates a new one
}
return report;
}
}Employee marta = new Employee("Marta Ruiz", "EMP-001");
Employee diego = new Employee("Diego Alonso", "EMP-002");
Loan[] loans = {
new Loan(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018), marta, 100),
new Loan(new Dvd("Refactoring Live", "DVD-0007", 95), diego, 100),
new Loan(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"), marta, 100),
new Loan(new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994), diego, 100)
};
int[] days = { 20, 25, 9, 10 };
System.out.println(SeverityAnalyser.analyse(loans, days).reportText());Loans analysed: 4 On time: 1 Minor: 2 Severe: 1 Fines: 12.45 EUR (average 3.11 EUR) Max level: SEVERE -> Escalate to the manager and block new loans.
Three design points. First, the switch has no default: if tomorrow you add VERY_SEVERE to Severity, this method will not compile and the compiler will take you to the exact spot that needs reviewing. Second, plus(...) does not modify the report, it returns a new one; that is why the loop writes report = report.plus(...). It is the immutable style of 03-07, natural with records. Third, maxSeverity().recommendedAction() chains the enum with its behaviour per constant: the report does not contain a single if about the severity level.
Conclusion
You have closed the module with the two tools that turn loose data into types with meaning. You know that an enum is a type whose possible values form a closed set known at compile time, and what problems it solves compared with String or int constants: type safety — Severity.CATASTROPHIC does not compile, "CATASTROPHIC" does — autocompletion, associated behaviour and, above all, exhaustiveness: an expression switch with no default over an enum forces the compiler to take you by the hand to every point in the project that has to be updated when you add a constant. You know its implicit methods — values(), valueOf(), name(), ordinal() — and why ordinal() must never appear in your logic or in your stored data: reordering the constants silently changes everything. You know that with enums == is the correct comparison, safer against null and able to catch at compile time a comparison between different types that equals would hide.
You have mastered the enum with fields, constructor and methods, which replaces a whole hierarchy when the only thing that varies is values — and you know when it must not replace one, because in BiblioTech each format has fields of its own; and the enum with a body per constant, where each value implements an abstract method in its own way and the compiler demands that none is left unimplemented.
You know that a record declares in one line what used to take forty: it generates the canonical constructor, one accessor per component named after the component — title(), not getTitle() — and the three Object methods that were such hard work in 03-09, correct by construction. You use the compact constructor to validate without repeating the parameter list or assigning fields by hand, you add extra constructors delegating to the canonical one, methods of your own, constants and interfaces. And you know its limits and its criterion of use: a record neither extends nor is extended, its state is exactly its components, and the deciding question is always the same: are two objects with the same data the same object? If yes, it is a value and it is a record; if it has its own identity or changes over time — Employee, Loan — it is a class.
BiblioTech, after module 4, is a complete object-oriented project. Material is an abstract class that cannot be instantiated, forces every format to declare its type, its term and its rate, and applies a final calculateFine in the form of a Template Method. It signs two interfaces, Lendable and Notifiable, which a MeetingRoom can sign without joining the family. Incidents are an immutable static nested class, and severity is no longer a string but the Severity enum, which also carries the recommended action for each level. The catalogue is sorted and filtered with comparators, predicates and functions arriving as parameters, so that new criteria do not touch a line of existing code. And Card and SessionSummary carry data between layers as immutable values, without a single line of boilerplate.
And yet, look closely at any of the classes you have written and you will see the same patch repeated: Material[] catalog = new Material[10], Incident[] enlarged = Arrays.copyOf(incidents, incidents.length + 1), a fixed-size Loan[], a group search in a nested O(n²) loop because there is no way to look things up by key. Every time BiblioTech needs to store many things, it falls back on a fixed-size array that has to be copied whole to add an element, walked entirely to find one and trimmed by hand to return a result. You have been marking it as provisional lesson after lesson, and it is now the most urgent thing the project lacks.
In module 5, Data Structures and Collections, that disappears. You will start by really mastering arrays — including multidimensional ones and the Arrays class — and you will enter Java's Collections Framework: ArrayList for lists that grow on their own, LinkedList for fast insertions, HashMap for looking things up by key in constant time (and there you will finally see why equals and hashCode always had to go together), HashSet for sets without duplicates, queues, stacks and Deque, and the sorting and searching algorithms that will apply every comparator you have written in this module. BiblioTech's provisional arrays will become collections, and that twenty-line reportByType with nested loops will come down to three.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
