Java

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

  1. Book: borrowCopy and recordReturn methods, invariant-enforcing, no raw assignments anywhere.
  2. Member: borrow and giveBack methods, limit enforced; one added field, a stable id.
  3. Loan: closes only through the LoanStatus transition matrix from Part 1.
  4. LoanService: borrow and returnBook use cases, pre-checking both invariants before mutating.
  5. An in-memory event log; a NotificationService handles events with an exhaustive switch.
  6. CatalogStore: save and load the book catalog to a text file with try-with-resources.
  7. 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

  1. Does the rule govern one object’s state? Entity method, throwing on violation. Governs several objects? Service, pre-checking before it mutates anything.
  2. Is the failure bad data or bad timing? IllegalArgumentException for bad data at construction; IllegalStateException for right data at the wrong moment.
  3. Can the mutation sequence leave half a state? Then pre-check everything first; transactions arrive in Part 8 to make this airtight.
  4. Do others need to react to what happened? Emit an event; never let them call your internals.
  5. 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.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *