Practice: Model a Library System with OOP (Part 2 – Behavior and Persistence)
Executive Summary
Add behavior and persistence in Java to the domain, starting with behavior as named entity methods: Book gains borrowCopy and recordReturn, Member gains borrow and giveBack, each throwing IllegalStateException on violated invariants, and Loan closes through the transition matrix. Add one field to Member, a stable id, because entities that get persisted need identity. A LoanService coordinates: pre-check both invariants, then mutate both objects, then record the Loan and emit an event, so a failure never leaves half a mutation behind. A NotificationService handles events through an exhaustive switch. Also, a CatalogStore persists books to a text file with try-with-resources, the minimum needed before Part 3’s File I/O article expands the topic. The demo proves the limits fire, the events flow, the data round-trips, and every rule violation lands as an exception at birth with a message naming the rule.
The Problem Statement
The library needs its verbs. A member borrows a book when copies exist and the member is under the borrow limit. A return closes the loan and frees the copy. Every borrow and return is an event that other parts of the system react to. Also, the catalog must survive a restart by saving to and loading from disk. Part 2 adds all of it without breaking a single Part 1 file except one small, realistic growth step.
Requirements
- Book: borrowCopy and recordReturn methods, invariant-enforcing, no raw assignments anywhere.
- Member: borrow and giveBack methods, limit enforced; one added field, a stable id.
- Loan: closes only through the LoanStatus transition matrix from Part 1.
- LoanService: borrow and returnBook use cases, pre-checking both invariants before mutating.
- An in-memory event log; a NotificationService handles events with an exhaustive switch.
- CatalogStore: save and load the book catalog to a text file with try-with-resources.
- A demo main that proves limits fire, events flow, and the catalog round-trips through disk.
Step 1: Behavior on the Entities, Not Around Them
Wrong code first, showing where the rules must not live:
// WRONG: the rule lives in the caller, and every future caller must remember it
if (book.availableCopies() > 0) {
book.borrowCopy();
member.borrow();
}
// forget the check once, and the library lends books it does not have
// RIGHT: the method enforces its own invariant
book.borrowCopy(); // throws when no copies remain
member.borrow(); // throws at the limit
The delta: a rule in the caller is a favor the caller can forget. In contrast, a rule in the method is physics. Add these methods to the Part 1 classes, exactly as written in the classes article tradition:
// inside Book (com.imraan.library.model)
void borrowCopy() {
if (availableCopies == 0) {
throw new IllegalStateException("no copies available: " + title);
}
availableCopies--;
}
void recordReturn() {
if (availableCopies == totalCopies) {
throw new IllegalStateException("all copies already returned: " + title);
}
availableCopies++;
}
// inside Member (com.imraan.library.model)
void borrow() {
if (!canBorrow()) {
throw new IllegalStateException("cannot borrow (limit or inactive): " + name);
}
borrowedCount++;
}
void giveBack() {
if (borrowedCount == 0) {
throw new IllegalStateException("nothing to return: " + name);
}
borrowedCount--;
}
IllegalStateException means “right call, wrong moment”, which is precisely what these are. Meanwhile, IllegalArgumentException stays reserved for bad arguments, a distinction Part 3’s exceptions article sharpens into a policy.
Step 2: One Small Model Growth, the Member id
Part 2 needs to identify members on events and loans, so Member gains a stable id. Also, its constructor becomes Member(String id, String name) with the same blank-checks as before, plus id() accessor. This is the honest kind of model change: entities that are referenced or persisted grow identity. Update Part 1’s file, rerun Part 1’s demo, and it still passes. After all, a design is stable when adding a field does not ripple.
Step 3: The Loan Entity and Its Transitions
Loans are born ACTIVE and close through the matrix Part 1 defined, so the behavior is one method that asks the enum:
package com.imraan.library.model;
public final class Loan {
private final String memberId;
private final Isbn isbn;
private LoanStatus status;
public Loan(String memberId, Isbn isbn) {
if (memberId == null || memberId.isBlank()) {
throw new IllegalArgumentException("memberId is required");
}
if (isbn == null) throw new IllegalArgumentException("isbn is required");
this.memberId = memberId;
this.isbn = isbn;
this.status = LoanStatus.ACTIVE; // loans are born active, always
}
public String memberId() { return memberId; }
public Isbn isbn() { return isbn; }
public LoanStatus status() { return status; }
public void close(LoanStatus next) {
if (!status.canTransitionTo(next)) {
throw new IllegalStateException("illegal transition: " + status + " to " + next);
}
this.status = next;
}
}
Step 4: The LoanService and the Ordering Trap
The service layer is where use cases live: it introduces the actors, but the rules stay with the entities, as the encapsulation article insisted. However, one trap decides the whole method’s shape. Wrong code first:
// WRONG: mutate, then discover the second rule fails
public Loan borrow(Member member, Book book) {
member.borrow(); // succeeded
book.borrowCopy(); // throws: no copies
// the member is now borrowing a phantom book
...
}
// RIGHT: pre-check both invariants, then mutate both
public Loan borrow(Member member, Book book) {
if (!member.canBorrow()) {
throw new IllegalStateException(member.name() + " cannot borrow");
}
if (book.availableCopies() == 0) {
throw new IllegalStateException("no copies of " + book.title());
}
member.borrow();
book.borrowCopy();
var loan = new Loan(member.id(), book.isbn());
loans.add(loan);
events.add(new BookBorrowed(member.id(), book.isbn()));
return loan;
}
The delta: a check-then-mutate sequence cannot leave half a borrow behind. The entity methods still enforce their own rules (defense in depth). However, the service’s pre-checks mean the happy path runs or nothing does. Here is the complete service:
package com.imraan.library.service;
import com.imraan.library.event.BookBorrowed;
import com.imraan.library.event.BookReturned;
import com.imraan.library.event.LibraryEvent;
import com.imraan.library.model.Book;
import com.imraan.library.model.Loan;
import com.imraan.library.model.LoanStatus;
import com.imraan.library.model.Member;
import java.util.ArrayList;
import java.util.List;
public final class LoanService {
private final List<Loan> loans = new ArrayList<>();
private final List<LibraryEvent> events = new ArrayList<>(); // in-memory log
public Loan borrow(Member member, Book book) {
if (!member.canBorrow()) {
throw new IllegalStateException(member.name() + " cannot borrow");
}
if (book.availableCopies() == 0) {
throw new IllegalStateException("no copies of " + book.title());
}
member.borrow();
book.borrowCopy();
var loan = new Loan(member.id(), book.isbn());
loans.add(loan);
events.add(new BookBorrowed(member.id(), book.isbn()));
return loan;
}
public void returnBook(Member member, Book book, Loan loan) {
loan.close(LoanStatus.RETURNED); // throws on illegal transitions
book.recordReturn();
member.giveBack();
events.add(new BookReturned(member.id(), book.isbn()));
}
public List<LibraryEvent> events() {
return List.copyOf(events); // unmodifiable view: callers react, never rewrite
}
}
After all, the events list is the seam Part 1 built for. In production the same add becomes a message to a queue or a row in an audit table. The in-memory list is the smallest honest version of that idea, and Part 8’s persistence articles grow it into a real store.
Step 5: The Event Handler
Events are facts. In contrast, reaction is a separate job, so a NotificationService handles the family with the exhaustive switch Part 1 promised:
package com.imraan.library.service;
import com.imraan.library.event.LibraryEvent;
public final class NotificationService {
private int notifications = 0;
public void handle(LibraryEvent event) {
String message = switch (event) {
case BookBorrowed b -> "loan slip for " + b.memberId() + ": " + b.isbn().value();
case BookReturned b -> "thank-you note to " + b.memberId();
case ReservationPlaced r -> "hold confirmation to " + r.memberId();
}; // no default: adding an event fails this build until it is handled
System.out.println("notify: " + message);
notifications++;
}
public int notificationsSent() {
return notifications;
}
}
Add a FinePaid event from Part 1’s extension list right now and watch this file fail to compile: that is the sealed guarantee converting a would-be silent gap into a build error, exactly as advertised in the sealed classes article.
Step 6: Persistence, the Minimum Honest Version
The catalog must survive a restart. A file is the smallest honest store for a single-process tool, and it doubles as a preview of Part 3’s File I/O article, using try-with-resources so the stream closes even when writing throws, as the try-with-resources lesson requires:
package com.imraan.library.store;
import com.imraan.library.model.Book;
import com.imraan.library.model.Isbn;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
public final class CatalogStore {
public void save(List<Book> books, String path) throws IOException {
try (var out = new PrintWriter(new FileWriter(path))) {
for (Book book : books) {
out.println(book.isbn().value() + "|" + book.title()
+ "|" + book.author() + "|" + book.totalCopies());
}
}
}
public List<Book> load(String path) throws IOException {
var books = new ArrayList<Book>();
try (var reader = new BufferedReader(new FileReader(path))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split("\\|"); // pipe is a regex character
books.add(new Book(new Isbn(parts[0]), parts[1], parts[2],
Integer.parseInt(parts[3])));
}
}
return books;
}
}
Two deliberate choices are worth naming. The pipe needs escaping in split because it is a regex character, a lesson straight from the strings article. And the format is simple text, not Java object serialization, because the serialization article later in the course explains why hand-rolled or built-in object graphs on disk are a security and compatibility hazard. Instead, a plain format with validation at reconstruction (the Book constructor revalidates everything on load) is the honest default. The essential classes I/O trail and its character streams lesson expand every line of this store in Part 3.
Step 7: The Demo That Proves All of It
package com.imraan.library;
import com.imraan.library.event.LibraryEvent;
import com.imraan.library.model.Book;
import com.imraan.library.model.Isbn;
import com.imraan.library.model.Member;
import com.imraan.library.service.LoanService;
import com.imraan.library.service.NotificationService;
import com.imraan.library.store.CatalogStore;
import java.io.IOException;
import java.util.List;
public class Demo2 {
public static void main(String[] args) throws IOException {
var book = new Book(new Isbn("9780134685991"), "Effective Java", "Joshua Bloch", 1);
var ada = new Member("m1", "Ada");
var service = new LoanService();
var notifier = new NotificationService();
var loan = service.borrow(ada, book);
System.out.println("after borrow: " + book);
try {
service.borrow(ada, book); // the only copy is out
} catch (IllegalStateException e) {
System.out.println("rejected: " + e.getMessage());
}
service.returnBook(ada, book, loan);
System.out.println("after return: " + book);
for (LibraryEvent event : service.events()) {
notifier.handle(event);
}
System.out.println("notifications sent: " + notifier.notificationsSent());
var store = new CatalogStore();
store.save(List.of(book), "catalog.txt");
List<Book> loaded = store.load("catalog.txt");
System.out.println("loaded from disk: " + loaded.get(0));
}
}
$ java com.imraan.library.Demo2
after borrow: Effective Java by Joshua Bloch [9780134685991] 0/1 available
rejected: no copies of Effective Java
after return: Effective Java by Joshua Bloch [9780134685991] 1/1 available
notify: loan slip for m1: 9780134685991
notify: thank-you note to m1
notifications sent: 2
loaded from disk: Effective Java by Joshua Bloch [9780134685991] 1/1 available
Extensions to Try Yourself
- Make LoanService reject a second active Loan for the same member and book, and decide whether that rule belongs in the service or the entities.
- Change the event log to also write each event to a file, one line per event, and replay it on startup.
- Add a Fine record and a FineIssued event, then watch the exhaustive switches force you to handle it everywhere.
- Break the ordering trap on purpose: remove the service pre-checks, borrow the last copy with a member at the limit, and observe the half-mutation. Then restore the pre-checks.
How Real Systems Do This
Overall, this build is a faithful miniature of production architecture. The entities carry the rules. The service layer coordinates use cases; the event log is an audit trail other components consume without touching entity state; and the store is the persistence seam. When Part 8 replaces CatalogStore with JDBC, nothing above it changes, because the service already speaks to an interface-shaped idea: save books, load books, hide the how.
In fact, the ordering trap is not a toy problem. In my experience, its production version is a ticketing service that decremented seats before charging the card: when the charge failed, the seats were gone and the customer was not. As a result, the compensation code grew for two years before anyone named the real bug. Check-then-mutate, or a transaction, which Part 8’s transaction article adds properly, is the entire difference.
Similarly, the event log previews how systems decouple reactions. A real library backend would publish BookBorrowed to a queue, and notification, analytics, and fine-calculation services would subscribe independently. Your NotificationService is one subscriber; the exhaustive switch guarantees it stays honest as the family grows.
Decision Framework
- Does the rule govern one object’s state? Entity method, throwing on violation. Governs several objects? Service, pre-checking before it mutates anything.
- Is the failure bad data or bad timing? IllegalArgumentException for bad data at construction; IllegalStateException for right data at the wrong moment.
- Can the mutation sequence leave half a state? Then pre-check everything first; transactions arrive in Part 8 to make this airtight.
- Do others need to react to what happened? Emit an event; never let them call your internals.
- File or database for the store? File for a single-process tool; a database the moment two processes share the state. Part 8 shows the JDBC version.
When NOT to Use This
- Do not add raw accessors to let services inspect and assign fields. Instead, a question method like canBorrow() states the rule, while a setter hides it.
- Do not put behavior into events. Events are facts with data. However, the moment they carry logic, the handlers stop being testable and the sealed family becomes a service locator.
- Do not persist object graphs with Java serialization for convenience. The serialization article covers the attack surface. Instead, a text format with constructor validation is smaller and safer until Part 8 makes it a database.
Common Mistakes
- Mutating before checking the second rule. However, the half-borrow is invisible in tests that use fresh objects every time, which is exactly where it hides in production.
- Catching IllegalStateException and continuing. The exception means a domain rule refused the operation; swallowing it turns a rule into a log line.
- Adding a default to the handler’s switch. The sealed family exists to make unhandled events impossible; default makes them invisible again.
- Returning the internal events list. Callers would append their own history; the List.copyOf barrier keeps the log a fact.
- A persistence format that ignores the delimiter appearing in data. A title containing the pipe character corrupts the row. In practice, real systems escape or use structured formats, and Part 3’s CSV practice explores exactly this boundary.
- Forgetting to close streams by hand: try-with-resources is not a style preference, it is the difference between a file handle leak and none.
Key Takeaways
- Rules live with the state they protect: borrow limits on Member, copy counts on Book, transitions on Loan.
- Services coordinate use cases: pre-check every invariant before mutating anything, so failure leaves no half-state behind.
- IllegalArgumentException marks bad data; IllegalStateException marks right data at the wrong moment. Both refuse, neither apologizes.
- Events are facts; handlers react. An in-memory log previews the queue or audit table production uses.
- Exhaustive switches over the sealed family keep every handler honest as the event set grows.
- Persist with a plain format and revalidate on load; the constructors are your file-integrity check.
- try-with-resources closes streams even when writes fail; adopt it now, before Part 3 makes it mandatory.
FAQ
Where should business rules live in OOP?
With the state they protect. A rule about one object’s state belongs on that object as a method that enforces it; a rule coordinating several objects belongs in a service that pre-checks before mutating.
What is a service layer in Java?
A set of classes that implement use cases: borrow, returnBook. It owns no domain rules, coordinates entities that do, records events, and exposes a seam so persistence and delivery details stay swappable underneath.
How do I persist Java objects without a database?
Write a plain format to a file with try-with-resources and rebuild objects through their validating constructors on load. The constructors become the integrity check, and no serialization machinery is involved.
Why throw IllegalStateException instead of returning boolean?
A boolean silently invites callers to ignore the failure, and the failure is a domain rule being refused. An exception is unmissable and carries the rule in its message; boolean results fit genuinely optional questions like canBorrow().
How do entities notify other parts of the system?
They emit immutable events through a service-owned log; subscribers handle them with exhaustive switches. Direct calls between entities and handlers couple things that production needs decoupled.
Conclusion: Behavior and Persistence in Java
Part 2 closed the loop on behavior and persistence in Java: the Part 1 model gained behavior that lives exactly where it belongs, a service that coordinates without owning rules, an event family with handlers the compiler keeps honest, and a persistence seam small enough to replace wholesale in Part 8. Object-oriented Java is now yours in full: nouns that carry their rules, verbs that respect them, and a compiler that referees.
Part 3 changes the scale of your data. The next article opens the Collections Framework with List and Set, and your library’s in-memory ArrayList becomes one choice among many, each with performance and behavior trade-offs you will learn to price.
Rules live with the state they protect. Services just introduce the actors.
Last updated on 9 September 2026.
