Practice: Model a Library System with OOP (Part 1 – Domain Design)
Executive Summary
Model a library system in Java as three packages: model holds value objects and entities, event holds a sealed hierarchy of domain events, and the root holds the demo. Isbn is a record with a validating compact constructor. In contrast, Book and Member are classes with private state, constructor-enforced invariants, and read-only accessors, because their state changes and their identity is not their content. LoanStatus is an enum with legal transitions, and the LibraryEvent family is a sealed interface over three records, so handlers stay exhaustive. Validation fires at birth everywhere, so an invalid Isbn, a blank title, or a negative copy count never exists long enough to matter. The complete model compiles into roughly ten small files, and every decision in it traces to a Part 2 article: value versus entity from the records and classes discussions, packages from encapsulation, events from sealed hierarchies.
The Problem Statement: A Library System in Java
A small library system in Java tracks books, members, and the events between them. A book has an ISBN, a title, an author, and a number of copies, some of which are currently available. A member has a name, an active flag, and a borrow limit. Loans move through a fixed set of statuses. Everything that happens, borrowing, returning, reserving, is an event other parts of the system may need to react to.
Part 1’s deliverable is the design itself: types that make invalid states unrepresentable and package boundaries that make the model readable. There is deliberately almost no behavior, because behavior added to a broken model multiplies the breakage. In contrast, behavior added to a clean model is trivial.
Requirements
- Three packages: com.imraan.library.model, com.imraan.library.event, and the root for the demo.
- Isbn as a value object: validated (13 digits after stripping hyphens), normalized, immutable.
- Book as an entity: private state, all constructor arguments validated, copies tracked, reads only.
- Member as an entity: borrow limit of 5, active flag, no behavior beyond the invariant check.
- LoanStatus as an enum with a transition matrix: ACTIVE can become RETURNED or LOST; nothing else moves.
- A sealed LibraryEvent interface with record variants: BookBorrowed, BookReturned, ReservationPlaced.
- A demo main that constructs the model, proves validation fires, and prints the family exhaustively.
Step 1: Design the Language Before the Code
Every noun in the problem statement gets one row in this table. Also, every row names the construct before any code is written. This table is the design; the rest of the article is transcription:
| Concept | Construct | Why this construct |
|---|---|---|
| Isbn | record | Immutable value; identity is content; validated at birth |
| Book | class | Identity plus mutable copy counts; state changes later |
| Member | class | Mutable borrow count and active flag; identity is not content |
| LoanStatus | enum | Fixed set with transitions; exhaustive switches ahead |
| LibraryEvent family | sealed interface + records | Closed set of data-shaped variants, handled exhaustively |
| BORROW_LIMIT | public static final constant | A domain rule stated once, as data |
Read the table as decisions, not syntax. Isbn is a value because two Isbns with the same digits are the same ISBN, everywhere, forever. Book is an entity because a specific copy count changes and a Book keeps its identity while it changes. The event family is sealed because the library, not its customers, decides what can happen; Part 2 will add handlers that must not miss a variant.
Step 2: The Package Skeleton
The packages article taught that a package is a boundary and a namespace, so the design leads with structure. Create this tree in IntelliJ (packages map to folders automatically):
com/
imraan/
library/
Demo.java
model/
Isbn.java
Book.java
Member.java
LoanStatus.java
event/
LibraryEvent.java
BookBorrowed.java
BookReturned.java
ReservationPlaced.java
Model types stay separate from event types on purpose: the model is what the library is, events are what happens to it. Handlers in Part 2 will depend on the event package without touching entity internals, which is exactly the seam this split creates.
Step 3: The Value Object, Isbn
Isbn is the domain’s first value object, so it is a record with a validating compact constructor, exactly the shape the records article recommended. Validation normalizes first (strip hyphens), then checks (13 digits), then lets the compiler store the normalized form:
package com.imraan.library.model;
public record Isbn(String value) {
public Isbn {
var digits = value.replace("-", "");
if (digits.length() != 13) {
throw new IllegalArgumentException("ISBN must have 13 digits: " + value);
}
for (int i = 0; i < digits.length(); i++) {
if (!Character.isDigit(digits.charAt(i))) {
throw new IllegalArgumentException("ISBN must be only digits: " + value);
}
}
value = digits; // store the normalized form
}
}
Two consequences follow immediately, both good. Any code receiving an Isbn can trust it completely, because an invalid one never existed. And two Isbns built from “978-0-13-468599-1” and “9780134685991” are equal, because normalization runs before the generated equals compares content.
Step 4: The Entities, Book and Member
Book and Member are entities: their state changes over time (copies come and go, members borrow and return), so they are classes with private fields and constructor-enforced invariants. Per Part 1’s scope, their methods are reads plus one invariant question; Part 2 adds the mutations.
package com.imraan.library.model;
public final class Book {
private final Isbn isbn;
private final String title;
private final String author;
private final int totalCopies;
private int availableCopies;
public Book(Isbn isbn, String title, String author, int totalCopies) {
if (isbn == null) throw new IllegalArgumentException("isbn is required");
if (title == null || title.isBlank()) throw new IllegalArgumentException("title is required");
if (author == null || author.isBlank()) throw new IllegalArgumentException("author is required");
if (totalCopies < 1) throw new IllegalArgumentException("at least one copy required");
this.isbn = isbn;
this.title = title;
this.author = author;
this.totalCopies = totalCopies;
this.availableCopies = totalCopies;
}
public Isbn isbn() { return isbn; }
public String title() { return title; }
public String author() { return author; }
public int totalCopies() { return totalCopies; }
public int availableCopies() { return availableCopies; }
@Override
public String toString() {
return title + " by " + author + " [" + isbn.value() + "] "
+ availableCopies + "/" + totalCopies + " available";
}
}
package com.imraan.library.model;
public final class Member {
public static final int BORROW_LIMIT = 5;
private final String name;
private int borrowedCount;
private boolean active;
public Member(String name) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("name is required");
this.name = name;
this.borrowedCount = 0;
this.active = true;
}
public String name() { return name; }
public int borrowedCount() { return borrowedCount; }
public boolean active() { return active; }
public boolean canBorrow() {
return active && borrowedCount < BORROW_LIMIT;
}
@Override
public String toString() {
return name + " (borrowed " + borrowedCount + ", "
+ (active ? "active" : "inactive") + ")";
}
}
Notice what is absent: no setters. The encapsulation article’s rule holds. When Part 2 needs to change availableCopies or borrowedCount, those changes will arrive as named behavior (borrowCopy, recordReturn), never as raw assignment. That single decision is the difference between a domain and a struct with methods.
Step 5: The Status Enum
LoanStatus is a fixed set with rules, so it gets the enum treatment from the enums article, including the transition matrix inside the type:
package com.imraan.library.model;
public enum LoanStatus {
ACTIVE, RETURNED, LOST;
public boolean canTransitionTo(LoanStatus next) {
return switch (this) {
case ACTIVE -> next == RETURNED || next == LOST;
case RETURNED, LOST -> false;
};
}
}
The matrix is deliberately small and final states are truly final: a returned book never becomes lost retroactively in this domain. When Part 2 adds loan handling, every transition goes through canTransitionTo. Also, the exhaustive switch means adding a DAMAGED constant later fails the build everywhere it is unhandled, not in a customer’s overdue notice.
Step 6: The Sealed Event Family
Everything that happens in the library is an event. Also, the library decides the complete set, so the family is sealed with record variants, exactly as the sealed classes article prescribed. Four small files, one per public type:
package com.imraan.library.event;
import com.imraan.library.model.Isbn;
public sealed interface LibraryEvent permits BookBorrowed, BookReturned, ReservationPlaced { }
package com.imraan.library.event;
import com.imraan.library.model.Isbn;
public record BookBorrowed(String memberId, Isbn isbn) implements LibraryEvent { }
// BookReturned and ReservationPlaced are the same two-line shape:
public record BookReturned(String memberId, Isbn isbn) implements LibraryEvent { }
public record ReservationPlaced(String memberId, Isbn isbn) implements LibraryEvent { }
Each variant is pure data with validated Isbns inside, and the permits list closes the set. When Part 2’s handlers switch over LibraryEvent, there is no default to absorb a forgotten variant. Also, adding FinePaid next sprint breaks exactly the files that need updating.
Step 7: The Demo That Proves the Design
Part 1 closes with a main that constructs the model, demonstrates the invariants firing, and dispatches an event exhaustively:
package com.imraan.library;
import com.imraan.library.event.BookBorrowed;
import com.imraan.library.event.LibraryEvent;
import com.imraan.library.model.Book;
import com.imraan.library.model.Isbn;
import com.imraan.library.model.Member;
public class Demo {
public static void main(String[] args) {
var effective = new Book(new Isbn("978-0-13-468599-1"),
"Effective Java", "Joshua Bloch", 2);
System.out.println(effective);
var ada = new Member("Ada");
System.out.println(ada.name() + " can borrow: " + ada.canBorrow());
try {
new Isbn("not-a-real-isbn");
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
LibraryEvent event = new BookBorrowed("m1", effective.isbn());
System.out.println(describe(event));
}
static String describe(LibraryEvent event) {
return switch (event) {
case BookBorrowed b -> "borrowed: " + b.isbn().value() + " by " + b.memberId();
case BookReturned b -> "returned: " + b.isbn().value() + " by " + b.memberId();
case ReservationPlaced r -> "reserved: " + r.isbn().value() + " by " + r.memberId();
};
}
}
Effective Java by Joshua Bloch [9780134685991] 2/2 available
Ada can borrow: true
rejected: ISBN must have 13 digits: not-a-real-isbn
borrowed: 9780134685991 by m1
Run it, then break it on purpose: pass a blank title, a negative copy count, a null Isbn. Every failure should be an IllegalArgumentException at birth with a message that names the rule. In fact, that is the design working.
Extensions to Try Yourself
- Add a Loan entity linking a memberId, an Isbn, and a LoanStatus, with the constructor rejecting anything but ACTIVE at birth.
- Add a fourth event, FinePaid, and watch the build fail in the demo’s switch: the sealed guarantee, experienced firsthand.
- Add a Money record (long cents, currency) for future fines, with validation, and think through why it belongs in the model package.
- Model a Hold with an expiry date. You will meet the date-time tools in Part 3, so a placeholder comment is fine for now.
How Real Systems Do This
This build is a compressed rehearsal of how production backends actually begin. A payments service is built the same way: value objects like Money and Isbn-shaped identifiers, entities like Account, fixed-set enums for statuses, and sealed event families for the audit trail. The code that handles a checkout does not differ in kind from the describe switch you just wrote. Instead, it differs in scale.
The event package previews an idea worth naming now: event sourcing, where the system stores the events and derives state by replaying them. Full event sourcing is beyond this course, but the seam it requires, immutable domain events in a closed family, is exactly what you built. Also, the official OOP concepts trail walks the same vocabulary of state and behavior you just exercised.
In my experience, the model versus behavior split is also the best review discipline a team can adopt. Every pull request is easier to judge when you can ask two questions separately: are the types right, and are the rules right? Part 1 of this build answered only the first; Part 2 answers the second against a model that holds still.
Decision Framework for a Library System in Java
Run every domain noun through these questions, in order.
- Is its identity its content? Record (value object), validated in a compact constructor.
- Does it change over time while keeping identity? Class, private state, invariants in the constructor.
- Is it a fixed set known at compile time? Enum, transitions inside the type.
- Is it something that happens rather than something that exists? Record variant in a sealed event family.
- Is it a rule with one value? Constant, stated once.
- Is it coordination between the above? Part 2’s services, not the model.
When NOT to Use This
- Do not add behavior early. A borrow method written before the model is stable hard-codes decisions you have not made. So, this build’s read-only entities force the design to settle first.
- Do not model every noun. Hold, Fine, and Shelf exist in the real domain. However, this iteration needs three entities and three events; the model earns each addition by proving a use for it.
- Do not split into packages before you have three related types per boundary. A package with one class is a folder, not a design.
Common Mistakes
- Making Book a record. Copy counts change, so a value type forces a new Book on every borrow, erasing identity and confusing every future reader.
- Validating after construction, in the caller. An invariant that lives anywhere but the constructor is a suggestion, and production treats suggestions as options.
- Normalizing after validating in Isbn. Strip the hyphens first, or “978-0-13-468599-1” fails the digit check despite being valid.
- Putting the sealed event interface in the model package. Events reference the model, but they are a different dependency direction; the package boundary keeps handlers from reaching into entities.
- Adding setters in Part 1 “for convenience”. Every setter is behavior smuggled in early, and Part 2 exists to design it properly.
- Skipping the break-it-on-purpose pass. If you never saw the IllegalArgumentException messages, you have not tested the invariants; you have admired them.
Key Takeaways
- Design the language before the code: every noun gets a construct decision, written down before any file exists.
- Values are records with validating compact constructors; entities are classes with private state and constructor invariants.
- Fixed sets are enums with the rules inside; happenings are record variants under a sealed interface.
- Validation at birth means every object downstream is trustworthy without a single check.
- Package boundaries encode dependency direction: model is what the library is, event is what happens to it.
- No setters, no early behavior: Part 2 adds mutations as named methods against a stable model.
- Break the model on purpose; the IllegalArgumentException messages are the design’s unit tests.
FAQ
What is domain modeling in Java?
Translating the business nouns into types: values as records, entities as classes, fixed sets as enums, events as sealed hierarchies. The model states what exists and what must be true; behavior states what happens.
Should a Book be a class or a record in Java?
A class, because available copies change while the book keeps its identity. Records fit values like Isbn, where the content is the identity and nothing about the value ever changes.
Why model events as a sealed hierarchy?
Because the library, not external code, decides what can happen. Sealing lets handlers switch exhaustively with no default, so adding an event later fails the build exactly where handling is missing.
How should packages be structured for a domain?
By dependency direction: model for what the domain is, event for what happens, services (in Part 2) for the coordination. Keep each package to one reason to change.
What comes in Part 2 of this practice build?
Behavior and persistence: borrowing and returning as methods that enforce limits and copy counts, a service layer that emits the events, and the first taste of storing the model.
Conclusion
You designed a domain the way production teams do: value versus entity decided per noun, rules moved into constructors and enums, events sealed into a family the compiler can check, and packages drawn along the dependency lines. The model has almost no behavior, and that is the point: it holds still.
Part 2 adds the verbs. Borrowing, returning, limits, and persistence all land on this model in the next article, and because the design settled first, the behavior will fit like it was always there.
Model first, move second. Behavior on a broken model is just bugs with better names.
Last updated on 27 September 2026.
