Domain-modeling case study
Library management
Three members want "Clean Code" this week. That is one catalog title, several physical copies, and several loans. A boolean checkedOut on the catalog entry already mixes search metadata with the shelf.
Library management separates a (searchable metadata) from a (barcode and availability) and a (member bound to a copy with due dates). The design question is always: what is loaned, and who may borrow it?
The split that saves you
: catalog entry
, title, authors. You search this. You never loan this.
: the thing on the shelf
Barcode and status. A attaches a member to a copy, not a title.
What actually happens when every copy is out
After the hero split, the next story is the waitlist. A waits on a when every is out. When a copy returns, the hold is fulfilled against that barcode, not "any Clean Code." Due dates and fines are values on the . Member is an entity with borrowing limits: an invariant, not a comment in LibraryManager.
Search, email reminders, and fine calculation do not belong on one . Use a for books, copies, and loans. The use-case controller is checkout and return. Read repository and entity vs value object.
Who owns which rule
Once titles and copies are separate, each type must own one job. Put the wrong flag on and three copies plus a waitlist cannot be represented.
- ISBN uniqueness in the catalog so search stays honest.
- Title and authors as data you query, never mutate for loans.
- Never a checkedOut flag; availability belongs on copies only.
- AVAILABLE / ON_LOAN / RESERVED: the shelf state machine.
- Barcode identity so two copies of one ISBN stay distinct.
- Checkout is a state change here, not on the catalog Book.
Member
- Borrowing limit (e.g. five open loans) as a hard invariant.
- Identity for the loan so who borrowed stays always clear.
- Not a list of ISBNs; open loans point at copy ids.
&
- Due date, renew once unless reserved: commands on Loan.
- Fine as Money so currency and amount stay together.
- Waitlist on the Book, fulfill on a Copy barcode.
Simple rule of thumb
Loan barcodes, never s. If a fact is availability, due date, or fine, it does not live on (see ownership cards above).
Copy lifecycle
Those ownership rules show up as transitions. "Hold for pickup" and "renew once unless reserved" are state changes on and , not new flags on .
Read the diagram definition
stateDiagram-v2
[*] --> Available
Available --> OnLoan: checkout
Available --> Reserved: hold for member
Reserved --> OnLoan: pickup
OnLoan --> Available: return
OnLoan --> OnLoan: renew if no hold
OnLoan --> Reserved: return with waitlistJava API sketch
The sketch makes equality the trap. Two of the same are not the same copy. Two amounts of the same fine are the same value. Compare copies by id; compare money by amount and currency. See equality and identity.
class Book {
private final Isbn isbn;
private final String title;
private final List<Author> authors;
}
class BookCopy {
private final CopyId id;
private final Isbn isbn;
private CopyStatus status;
void checkoutTo(MemberId member) { /* AVAILABLE → ON_LOAN */ }
void markReturned() { /* ON_LOAN → AVAILABLE or RESERVED */ }
}
class Loan {
private final LoanId id;
private final CopyId copyId;
private final MemberId memberId;
private final DueDate due;
private Money fine = Money.zero("INR");
boolean isOverdue(Instant now) { return now.isAfter(due.instant()); }
}
class LibraryService {
Loan checkout(Isbn isbn, MemberId member) { /* find copy, check limit, open loan */ }
void returnCopy(CopyId copy) { /* close loan, fulfill reservation */ }
}A walkthrough, in order
You do not start by drawing LibraryManager. You start by checking out one and saying who changed. 9780132350884 is still one after checkout. Copy C-441 is ON_LOAN. A row exists with a DueDate ten days from .now(). Member M-9's open-loan count went from 2 to 3. If any of those facts landed on the wrong type, stop and rename.
- Catalog first. Book is findable by ISBN with no status field. Search is a query on a Book repository, not a loop in checkout.
- Copies next. Seed three copies; checkout picks AVAILABLE. The other two stay AVAILABLE. That test dies if you modeled checkedOut on Book.
- Member limit. Fill five loans; the sixth rejects. The check lives on Member or a policy that asks Member.openLoans(), not a magic number in LibraryService.
- Return + waitlist. Place a reservation while all copies are out. Return fulfills the oldest hold onto that barcode. Book still has no boolean.
Numbers to know
- Three copies of one ISBN: two loans leave one AVAILABLE (see seam test).
- Member limit example: five open loans; sixth checkout must reject.
- Due date: ten days from Clock.now() on the new Loan row.
Fine calculation is on the when says overdue. Do not store a double on Member. Do not email from Loan.returnCopy(). A reminder job is an adapter that queries overdue loans. Keeping email and reminders outside Loan is how the domain stays testable when they add a one-day grace period.
Tests that prove the seams
@Test
void threeCopiesOneTitle() {
Catalog cat = Catalog.with("9780132350884", 3);
Loan a = cat.checkout(ISBN, M1);
Loan b = cat.checkout(ISBN, M2);
assertNotEquals(a.copyId(), b.copyId());
assertEquals(1, cat.available(ISBN));
}What still fails, and what a follow-up changes
The walkthrough still fails if you skip a seam (loan the , ignore the member limit, or treat last-copy checkout as unlocked). Keep these cases next to the happy path.
Invalid cases
Checkout when member is at limit
Member invariant. Do not open a sixth loan and fix it later.
Checkout a copy already ON_LOAN
Illegal transition. Two members cannot hold the same barcode.
Loan the Book (ISBN) directly
A title is not on loan. Find an AVAILABLE copy or reserve.
Renew while another member is waiting
Reservation wins. Renew on Loan must check the waitlist.
Return with a negative fine from wall clock
Inject Clock. Overdue math must be testable without waiting overnight.
Concurrency note
Two librarians checking out the last is the same race as parking spots: if (copy.isAvailable()) checkout() is not atomic. Checkout must be one transition on BookCopy (or a lock around find-available-and-mark). This is still in-process machine coding: one library process, not a national catalog cluster.
What the follow-up changes
- Hold for pickup: Reservation entity plus copy status RESERVED; expiry uses Clock.
- Renew once unless reserved: command on Loan; counts and waitlist stay on right objects.
- Inter-library loan: another branch copy is still a BookCopy with a location field.
Wrap-up
You have seen the Book versus BookCopy split, the ownership rules, and the seams. Use this check in a round, then follow the related notes.
How to explain this in an interview
- Split first. Say is catalog, is shelf, and binds member to copy.
- Prove three copies. One ISBN, three barcodes; two checkouts leave one AVAILABLE (see seam test above).
- Defend the limit. Member open-loan count is an invariant; sixth loan rejects instead of fixing later.
- Name the race. Last-copy checkout is atomic on BookCopy, same story as parking occupy.
Related
Key takeaway
You are done when borrow and return update copy availability correctly, overdue fines use , a member cannot borrow beyond the limit, and versus stay separate (see Who owns which rule above).