Intermediate30 min readMachine Coding Practicelive prototype

Library Management

The pure domain-modelling round. There is no clever algorithm here and no race to draw — what is being graded is whether you can carve a messy real-world domain into the right classes. And there is exactly one carve that separates a good answer from a bad one: a Book is not a book.

The idea

What it is

“Design a library management system.” It is the friendliest-sounding prompt in the set. Everybody has been in a library, nobody has to ask what a book is, and there is no concurrency puzzle waiting to ambush you.

That is exactly why it is dangerous. With no algorithm to hide behind, the interviewer is grading one thing: can you turn a messy real-world domain into classes that hold up? And this problem has a single specific carve that separates a good answer from a bad one — one that most candidates get wrong in the first three minutes and never recover from.

“Clean Code” — three physical copies shelf · rack R-12 B-001 B-002 B-003 BookItem one per copy — barcode, rack, acquired date, condition, status catalogue terminal search: title · author subject · published year “Clean Code” — 1 record 2 of 3 available a count over its items Catalog + Book a Book is the description — not the object you carry home issue desk 🧑‍🎓 🧑‍💼 Member Librarian both are Accounts Account roles two roles, one level — not a six-deep hierarchy BookLending B-002 → Meera · issued day 1 · due day 15 · returnedOn null every noun on this floor, labelled with the class it becomes
Look at the shelf and the terminal separately. The terminal knows about one “Clean Code”. The shelf holds three of them, and each has its own barcode. Those are two different classes, and everything else in this design follows from that.

The whole system in three sentences

A Book is a catalogue record — one per title. A BookItem is one physical copy of that title, with a barcode and a status. A BookLending is the act of one member taking one item home on one date — and because it is an object rather than a field, the library still has it next year.

What is actually being graded

  1. Did you split the title from the copy? Book versus BookItem. This is the single most common failure in this round, and there is no recovering from it at minute 45 — every method you have written will need changing.
  2. Is a loan an object, or a field? BookItem.borrowedBy can only hold the present. Fines, late-return history and member standing are all computed from loans that are finished.
  3. Do the rules live on the objects that own the data? member.canBorrow(today) versus a LibraryService that reads the member's loans, reads their fines, and decides for them.
  4. Is time an argument? returnItem(barcode, on) is testable in one line. A method that reads the clock internally means a fine calculation you cannot test — which means a fine calculation that is wrong.
  5. Does it run? Three titles, several copies, an issue, a return with a fine, and a reservation that intercepts a returned copy. Print it.

Mechanics

How it works

Step 1 · A Book is not a book

The instinct is one class. It has a title, an author, an ISBN — and, because you need to know whether someone can borrow it, an isAvailable boolean. It reads fine. It is wrong the moment the library buys a second copy.

✗ ONE CLASS — Book carries isAvailable Book - isbn, title, author - subject, publishedYear - isAvailable : boolean the library owns 5 copies · 4 are out isAvailable = ? true and false at once which copy did she take? unanswerable “1 of 5 available” cannot be expressed a barcode on a search hit nowhere to put it ✓ TWO CLASSES — description vs instance Book «catalogue record» “Clean Code” · isbn 978-0-13 exactly one, forever 1 ─ * B-001 B-002 B-003 B-004 B-005 BookItem — barcode · rack · condition · status “1 of 5 available” — a count over items she took B-003 — a specific object rack, condition and price live on the copy
Cover the right half and try to answer “which copy did she take?” from the left. You cannot — and neither can any method you write on top of it. Split this in minute 8, not minute 40.

Name the rule, because it transfers

The catalogue entry is a description; the copy is an instance. Once you can say that sentence you have already designed half a dozen other systems: Product versus the SKU versus the unit sitting in the warehouse; Show versus Seat versus Booking; FlightRoute versus a dated Flight. Interviewers ask this problem because the split generalises. More on the vocabulary in Domain modeling and Identifying entities, attributes & behaviors.

The tell that you have it right: search returns Books, borrowing returns a BookItem. Somebody searching wants titles. Somebody at the desk wants the object with a barcode on it. If your search() returns the same type your issue() returns, one of them is lying.

Step 2 · Clarify — 5 minutes

  • Multiple copies of the same title? — the only question that really matters. They will say yes. If they say no, ask “would you ever want to?”, because the answer shapes every class you are about to draw.
  • Can a member reserve a title when every copy is out? — say yes and keep it to a FIFO queue per title. It is four lines and it makes return interesting.
  • Fines? — yes, per day late, and ask whether the policy might change. That is your invitation to a strategy seam.
  • How many books can one member hold? — a number, plus the rules that go with it: expired membership, unpaid fines. These belong on Member.
  • Search by what? — title, author, subject, publication date. That is a Catalog with index maps, not a linear scan over every book.
  • Payments, branches, e-books, RFID gates, recommendations? — out of scope. Say it in one sentence and move.
✓ IN SCOPE — 60 minutes buys you this much many physical copies per title search: title · author · subject · year issue · return · renew fines, behind a swappable policy reservation: FIFO queue per title borrow limits, on Member two roles: Member · Librarian everything above is one class or one method ✗ OUT OF SCOPE — say it once, then stop payment gateways / card readers multiple branches e-books and audiobooks recommendations RFID gates, theft detection inter-library loans a deep Person class hierarchy name them so they become follow-ups, not surprises
The last line on the right is not a joke. A PersonStaffLibrarianSeniorLibrarian hierarchy has eaten twenty minutes of more than one candidate's round and added nothing the interviewer wanted.
NOUNS IN THE PROMPT → CLASSES ON THE BOARD what the interviewer said what you draw why “a book in the catalogue” Book one per title “the book on the shelf” BookItem one per copy “search for a book” Catalog index maps, not a scan “someone borrows it” Member holds its own rules “the person at the desk” Librarian a role, not a subtree “taking it home until the 15th” BookLending the relationship, as an object “waiting for a copy” Reservation FIFO per title “a fine of 5 a day” FinePolicy an interface, so it swaps “lost / being repaired” ItemStatus «enum» five states, not a boolean
Two rows do the work: the first two (title vs copy) and the sixth (the act of borrowing). Everything else on this table is bookkeeping you would get right anyway.

Step 3 · A loan is its own object, not a field

You have split Book from BookItem. Now a member borrows a copy. The obvious move is two fields on the item: borrowedBy and dueDate. Set them on issue, null them on return. Done.

Then the interviewer asks “how many times has this member returned a book late?” and the design has no answer, because a field can only hold the present. The moment the copy comes back, everything you knew about that loan is gone.

The general form is worth memorising

When the relationship itself has attributes, it is a class, not a foreign key. A loan has an issue date, a due date, a return date and a fine — four attributes that belong to neither the member nor the copy, but to the pairing of them. That is the definition of an association class. See Association, Aggregation, Composition for the notation.

the difference in eight lines
// ✗ fields on the item — one loan at a time, no past, no fines to compute from
class BookItem {
    String borrowedBy;      // null when on the shelf
    int    dueDay;          // meaningless when borrowedBy is null
}

// ✓ the act of borrowing, as an entity with a lifecycle
class BookLending {
    final String barcode, memberId;
    final int issuedOn, dueOn;
    Integer returnedOn;                       // null while it is open
    boolean isOpen() { return returnedOn == null; }
    long daysLate(int on) { return Math.max(0, on - dueOn); }
}
// the CLOSED lendings are the history: late count, member standing, fines earned

Note what this buys you for free. member.openLoans() is a filter over the list. “Is this member a repeat offender?” is a filter over the same list. And the fine is computed from dueOn versus the day the copy actually came back — two fields on one object, rather than a subtraction spread across three classes.

Step 4 · Rules live on the object that owns the data

Here is the second place this round is lost. You have good classes, and then you write a LibraryService.issueBook() that reaches into all of them and decides. Two hundred lines, every rule in one method, and nothing on Member but getters.

✗ THE SERVICE DECIDES LibraryService issueBook(...) Member getters only if (m.getLoans().size() >= 5) refuse; if (m.getFines() > 10000) refuse; if (m.getExpiry().before(today)) refuse; three reaches into another object the rule is copied wherever it is needed Member cannot enforce its own invariants ✓ THE MEMBER DECIDES Library issue(...) Member owns the rules member.canBorrow(today) inside Member: openLoans() < MAX_LOANS fineOwed <= MAX_FINE membership not expired one call · one place to change the limit the librarian screen asks the same question
Count the arrows on the left: three reaches into someone else's data to make a decision that is not yours. That is Tell, Don't Ask and Law of Demeter failing in the same three lines — and the fix is one method.

The sentence that scores the point

“The five-book limit is a rule about a member, so it lives on Member. The service asks member.canBorrow(today) and does what it is told.” Say that out loud while you write the method. It is the difference between a class diagram and a script with objects in it.

The same test applies everywhere else. Can this item be loaned? is a question about the item — item.isAvailable(). How late is this? is a question about the lending — lending.daysLate(on). How much is that worth? is the only question the service genuinely owns, and it delegates that to a policy.

The class diagram

Library + issue(isbn, memberId, day) + returnItem(barcode, day) + reserve · renew · setFinePolicy Account «abstract» - id · name · role two roles, ONE level deep Catalog - byTitle : Map - byAuthor · bySubject + firstAvailable(isbn) Reservation - isbn · memberId · placedOn held in a FIFO queue per Book head gets the next returned copy Member - fineOwedMinor · expiresOn + canBorrow(today) the rule lives HERE Book «record» - isbn · title · author - subject · publishedYear ONE per title BookItem - barcode · rack · acquiredOn - priceMinor : long - status : ItemStatus «enum» ONE per physical copy BookLending - barcode · memberId - issuedOn · dueOn - returnedOn : nullable the relationship, as a class FinePolicy «interface» + fineFor(daysLate, priceMinor) Flat · Slab · Capped 1 * Librarian too 1 ─ * 1 ─ * 1 ─ * * ─ 1 queues on a Book priced by the green arrow — Book 1 ─ * BookItem — is the one the whole round is graded on notation: 1 ─ * means one to many dashed = uses, solid = holds
Two arrows carry the design: Book 1 ─ * BookItem (title vs copy) and Member 1 ─ * BookLending * ─ 1 BookItem (the loan as its own object, sitting between them). Notation: Class diagrams.

The item's five states

isAvailable: boolean cannot answer “where is B-003?” when the answer is “a member reported it lost” or “the spine is being repaired”. Somebody always asks about a lost book. Five states, an enum, and the legal transitions written down.

IN_REPAIR AVAILABLE LOANED RESERVED LOST issue() return · queue empty return · someone queued picked up hold expires declared lost damaged repaired a boolean cannot express five states — and someone always asks about a lost book the blue branch is the interesting one
The blue arrow is the one to point at unprompted: a returned copy does not automatically go back on the shelf. Notation: State diagrams; the pattern for enforcing it in code is State.

The flows, end to end

Search goes through the Catalog. Keep index maps — Map<String, List<Book>> for title words, author and subject — instead of a linear scan over every book. It is one line to say and it is the difference between “I would loop over all books” and a design. Search returns Books; availability is catalog.availableCount(isbn), a count over their items.

issue desk Library Catalog Member BookLending issue(“978-0-13”, “M-2”, day=12) firstAvailable(isbn) BookItem B-003 · AVAILABLE canBorrow(day=12) 2 of 5 loans · fine ₹0 · membership valid → true true — the rule lives there, not here new BookLending(B-003, M-2, issued 12, due 26) B-003.status = LOANED returns a BookItem — never a Book
Read the last line. issue() hands back a copy, with a barcode on it. Search hands back titles. If both return the same type, the Book / BookItem split has not really happened. Notation: Sequence diagrams.

Return is the flow worth drawing, because it branches. Close the lending, price the lateness — and then ask the reservation queue what should happen to the copy.

issue desk Library BookLending FinePolicy Reservations returnItem(“B-001”, day=25) close(returnedOn = 25) daysLate = 25 − 15 = 10 fineFor(10 days, priceMinor) 5000 paise = ₹50.00 → member.addFine(5000) head(isbn) …and then the branch that makes this flow worth drawing queue EMPTY B-001.status = AVAILABLE goes back on rack R-12 anyone can borrow it next queue has M-4 at the head B-001.status = RESERVED · heldFor = M-4 notify M-4 — a copy is waiting it never touches the shelf
The right-hand box is the answer people forget. A returned copy with a queue behind it goes to the head of that queue, not to the shelf — otherwise the next walk-in takes the book the reserver has been waiting three weeks for.

Reserve and renew, in four lines

Reserve is only offered when availableCount(isbn) == 0 — otherwise just borrow it. Keep a Deque<String> of member ids per ISBN; FIFO, no priorities, no expiry unless asked. Renew is allowed only when nobody is queued on that title: “you can keep it, because nobody is waiting.” The notification when a copy frees up is one Observer — say the word, do not build an event bus.

“Clean Code” 0 of 3 available B-001Asha · due 15 B-002Ravi · due 15 B-003Meera · due 15 reservation queue for isbn 978-0-13 — FIFO Devhead · placed day 1 Priyaplaced day 3 Samplaced day 6 B-001 comes back, day 25 returnItem(“B-001”, 25) → held for Dev · status = RESERVED Priya moves up to the head ✗ back on the shelf status = AVAILABLE the next walk-in takes it Dev waits another month reserve is offered only when the available count is zero — otherwise the member should simply borrow renew is refused while this queue is non-empty
Follow the two arrows out of “B-001 comes back”. Only one of them is correct, and choosing it is a single if at the end of returnItem().

Money and time — the two things people get casually wrong

  • Time is an argument. returnItem(barcode, on) and member.canBorrow(today) — never LocalDate.now() read inside the method. A ten-day-late fine is then a one-line test. A method that reads the clock can only be tested by waiting or by injecting a fake clock, and in 60 minutes you will do neither. Same rule Parking Lot used for billing.
  • Money is an integer in minor units. long fineMinor in paise or cents, never double. 0.1 + 0.2 is not 0.3, and a fine that is off by a hundredth is a bug report. Format only at the edge, when you print.
  • A fine policy is a Strategy. fineFor(daysLate, itemPriceMinor) behind an interface, with a flat per-day version, a slab version and a capped version. Swapping it must not change one character of returnItem(). Exactly the same seam as the pricing strategy in Parking Lot — say so, it shows you recognise the shape rather than memorised the problem.
  • Statuses are an enum with legal transitions, not free-text strings and not a boolean. AVAILABLE → LOANED → AVAILABLE is the happy path; RESERVED, LOST and IN_REPAIR are the ones that prove you thought about the real world.
the strategy seam
interface FinePolicy {
    /** daysLate is already clamped at 0. Money in minor units — paise, never double. */
    long fineFor(long daysLate, long itemPriceMinor);
    String label();
}

class FlatPerDayFine implements FinePolicy {          // 500 paise = 5.00 a day
    private final long perDayMinor;
    public long fineFor(long daysLate, long price) { return daysLate * perDayMinor; }
}

class SlabFine implements FinePolicy {                 // gentle first week, then not
    public long fineFor(long daysLate, long price) {
        long cheap = Math.min(daysLate, 7);
        return cheap * 200 + Math.max(0, daysLate - 7) * 1000;
    }
}

class CappedFine implements FinePolicy {               // never fine more than the cap
    private final FinePolicy inner; private final long capMinor;
    public long fineFor(long daysLate, long price) {
        return Math.min(inner.fineFor(daysLate, price), capMinor);
    }
}
// returnItem() calls policy.fineFor(...) and does not change when the policy does

What the design costs to extend

ADD THIS FEATURE → WHAT YOU TOUCH feature files touched cost a new fine policy (slabs) 1 new class, 0 edits free due-date reminders 1 observer on Library free borrow limit 5 → 8 1 constant on Member free reserve expiry after 3 days 1 field on Reservation + a sweep small several branches branchId on BookItem, filter in Catalog small e-books and audiobooks BookItem stops making sense a rethink the first five are cheap because Book / BookItem / BookLending each hold one idea the last one is honest: an e-book has no copy, no barcode and no rack — it is a licence with a concurrent-loan count, and saying so is the good answer
The bottom two lines are the ones to volunteer. Knowing where your abstraction stops is worth more than pretending it does not.

The 60 minutes

A 60-MINUTE BUDGET THAT ACTUALLY FINISHES 5 12 20 12 8 3 0 min 17 37 60 5 · clarify scope — “multiple copies of a title?” is the question 12 · entities + class diagram — the Book / BookItem split happens HERE 20 · code the core: Book, BookItem, Member, BookLending, Catalog 12 · issue, return with the reservation branch, reserve, renew 8 · fine policy + a main() that prints · 3 · follow-ups out loud
The orange block is load-bearing. If you have not split Book from BookItem by minute 17, the next 43 minutes are spent writing code you will have to change.

The follow-ups they will ask

  • “Now there are five branches.” → a branchId on BookItem (the copy lives somewhere; the title does not), and Catalog.availableCount(isbn, branchId). Then the real question behind it: can you return a copy to a different branch? Yes — the lending closes normally and the item's branchId changes. Say that; it proves you know which class owns location.
  • “What about e-books?” → the honest answer, not a fudge. An e-book has no barcode, no rack and no condition, so BookItem stops meaning anything. Model it as a licence with a concurrent-loan count: Book stays, BookItem is replaced by a DigitalLicence with maxConcurrent. This is a genuinely good question about where the abstraction ends.
  • “Two librarians issue the last copy at the same time.”firstAvailable() then setStatus(LOANED) is check-then-act, exactly the bug in Coffee Machine. The fix is the same shape: an atomic take on the item — one lock around find-and-claim, or a compare-and-set on the status. One paragraph. Do not turn this into the whole answer; the round is about modelling.
  • “Notify members when a reserved copy arrives, and before a book is due.” → the library publishes events, and an email or SMS notifier subscribes. Observer again, and it keeps notification code out of returnItem().
  • “Search is slow with two million books.” → the HashMap indexes stop being enough once you want prefix matching, typo tolerance and ranking. That is a search engine — an inverted index, Lucene or Elasticsearch — and the Catalog interface is the seam you would put it behind. Name the seam, do not design the engine.
  • “How do you know a member is a repeat offender?” → count the closed BookLendings with daysLate > 0. This is the payoff for making the loan an object, and it is worth pointing at when you answer.

How this round is lost

  • One Book class with isAvailable. The fatal one. Every method downstream is then wrong, and there is no time at minute 45 to redo it.
  • A LibraryService that contains every rule. Getters on Member, decisions in the service, and an anaemic model the interviewer will name out loud.
  • Loans as fields on the item. No history, no late count, no way to compute a fine after the copy is back on the shelf.
  • double for fines. It will be raised, and it is a free point you handed away.
  • A Person hierarchy four levels deep. Twenty minutes spent on AbstractLibraryUser and no working issue() at the end.
  • Forgetting the reservation branch on return. The copy goes back to the shelf and the member who waited three weeks watches a walk-in take it.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Three titles, eight physical copies, three members. Pick a member chip, then press 📕 Borrow on a title — the system picks a specific copy and that barcode turns 🟠 and moves onto her card. Borrow again and a different barcode goes out while the header counts down 2 of 3 available. When the last copy is gone the button becomes 🔖 Reserve. Now press 📗 Return on a loaned chip: because someone is queued, the copy does not go back to green — it turns 🔵 and is held for the head of the queue. ⏩ +7 days makes loans overdue and fines appear; the ⏱ Flat / 🪜 Slabs / 🧢 Capped chips re-price every fine without touching returnItem().

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Borrow a copy — and notice which one

Leave M-1 Asha selected and press 📕 Borrow on Clean Code. Watch three things at once: one specific barcode chip turns 🟠, that same barcode appears on Asha's card, and the title header drops to 2 of 3 available. The .callline says library.issue("978-0-13", "M-1", day=1) → BookItem#B-001. She has copy B-001 — not “the book”. That is the entire Book versus BookItem lesson, in one click.

try 02

Borrow the same title again

Select M-2 Ravi and press 📕 Borrow on the same title. A different barcode goes out — B-002 — and the header counts down again. Do it once more with M-3 Meera and the count hits 0 of 3 and the button changes to 🔖 Reserve. (📚 Empty the shelf does all three at once.) Ask yourself where any of this would live if Book had a single isAvailable boolean.

try 03

Reserve, then return into the queue

With every copy out, press 🔖 Reserve — the selected member joins a visible FIFO queue on the title. Now press 📗 Return on one of the 🟠 chips. The copy does not go green: it turns 🔵 and moves to the head of the queue, and the explain line says exactly that. Return another copy when the queue is empty and that one goes 🟢. One if at the end of returnItem() is the whole difference.

try 04

Make something overdue and price it three ways

Press ⏩ +7 days until the day counter passes a due date — overdue chips turn red and a fine appears on the member card. Now click 🪜 Slabs and then 🧢 Capped. Every open fine re-prices instantly and the explain line points out that returnItem() was never touched. That is Strategy doing its only job.

try 05

Hit the limit and read where the refusal came from

Press 🚫 Fill to limit with a member selected, then try to borrow again. The refusal names member.canBorrow(day)the rule is on Member, not in the service. Change it in your head to the ✗ version from the figure: if (service.getLoans(m).size() >= 5). How many places would you have to edit to raise the limit to eight?

try 06

Build it from memory

Blank file, in this order: ItemStatus enum → Book (isbn, title, author, subject) → BookItem (barcode, isbn, rack, price in minor units, status) → BookLending (barcode, memberId, issuedOn, dueOn, returnedOn) → Member with canBorrow(today)Catalog with index maps and firstAvailable(isbn)Library.issue / returnItem / reserve / renew → a main() that issues three copies, reserves, returns late and prints the fine. If your search() and your issue() return the same type, start again.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the books away and this is a catalogue of descriptions, a pool of instances of those descriptions, and an object recording who has which one and when. That triple shows up everywhere, and getting the first split right is what makes the other two possible.

  • E-commerceProduct describes it, a SKU narrows it, and the unit in the warehouse bin is the instance. “3 in stock” is a count over instances, exactly like “2 of 3 available”.
  • Ticketing — a Show is the description, a Seat for a given show is the instance, and a Booking is the association with dates and money on it.
  • AirlinesFlightRoute (AI-302, daily, BLR to DEL) versus a dated Flight you actually board. Merge them and you cannot cancel Tuesday's.
  • Car rental — a CarModel in the brochure versus the registered vehicle with a number plate and a service history.
  • Hotels — a RoomType versus room 412, and a Stay between a guest and a room across two dates.
  • Asset tracking of any kind — the laptop model your company buys versus the laptop with asset tag L-0912 sitting on someone's desk.

The two-sentence version to say out loud

“A Book is a catalogue record and a BookItem is a physical copy, so availability is a count over items and a loan always points at a specific barcode. And a loan is its own object rather than a field, because the finished ones are what fines and member standing are computed from.” Twenty seconds, and it is most of the round.

Where this design stops working

  • When the thing has no physical instance. E-books, streaming, software licences — there is no copy to give a barcode to. The model becomes a licence with a concurrent-use count, and forcing BookItem onto it is worse than admitting the boundary.
  • When one process is no longer the whole library. Two desks in two buildings claiming the last copy need the store to do the atomic take, not an in-process check-then-set. The modelling is unchanged; the claim step is not.
  • When search stops being lookup. Index maps answer “author equals Martin”. They do not answer “books a bit like this one, ranked”. That is a different system behind the same Catalog interface.
  • When the rules stop being a member's business. A national inter-library policy is not an invariant of one Member object, and pushing it there would be Tell, Don't Ask applied past the point where it helps.

If you only remember one thing

The catalogue entry is a description; the copy is an instance. Split them in minute 8, and every later question — how many are available, which one did she take, where is it shelved, who had it last year — has an obvious place to live. Merge them, and none of those questions has an answer at all.

What it gives you

  • Splitting Book from BookItem makes availability a count rather than a boolean, so “2 of 3 available”, per-copy racks, conditions and barcodes all have somewhere natural to live.
  • Modelling the loan as a BookLending with a nullable returnedOn keeps the full history, so fines, late counts and member standing are queries over data you already have.
  • Putting canBorrow() on Member means the limit, the fine ceiling and the expiry check exist in exactly one place, and every screen that needs the answer asks the same question.
  • A FinePolicy interface lets flat, slab and capped pricing be swapped without returnItem() changing at all — the same seam as a pricing strategy anywhere else.
  • Passing the date into issue(), returnItem() and canBorrow() makes every money calculation a one-line unit test instead of something you can only observe by waiting.

Common mistakes

  • Two classes where beginners expect one means more objects to create and keep consistent, and a bulk import of a thousand titles now has to create items too.
  • Keeping every closed BookLending forever grows without bound; a real system needs archiving, and the design says nothing about when history stops being worth keeping.
  • A single FIFO reservation queue per title has no expiry and no priority, so a reserver who never collects can hold a copy out of circulation indefinitely.
  • The in-process find-then-claim on an item is check-then-act, and it is only safe because this design assumes one process — two desks need an atomic take at the store.
  • The BookItem abstraction is genuinely physical, so digital formats do not fit it at all and require a parallel model rather than a subclass.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;
import java.util.function.BiConsumer;

/*
 * Dates are plain day numbers so the demo is deterministic. Real code uses
 * LocalDate — what matters in the round is that the date is PASSED IN,
 * never read from a clock inside the method.
 */

enum ItemStatus { AVAILABLE, RESERVED, LOANED, LOST, IN_REPAIR }

/** The CATALOGUE RECORD. One per title, however many copies the library owns. */
record Book(String isbn, String title, String author, String subject, int publishedYear) {}

/** ONE PHYSICAL COPY — the thing a member actually carries home. */
final class BookItem {
    final String barcode, isbn, rack;
    final int acquiredOn;
    final long priceMinor;                  // paise — integer minor units, never double
    private ItemStatus status = ItemStatus.AVAILABLE;
    private String heldFor;                 // member id, only while RESERVED

    BookItem(String barcode, String isbn, String rack, int acquiredOn, long priceMinor) {
        this.barcode = barcode; this.isbn = isbn; this.rack = rack;
        this.acquiredOn = acquiredOn; this.priceMinor = priceMinor;
    }

    ItemStatus status()   { return status; }
    String heldFor()      { return heldFor; }
    boolean isAvailable() { return status == ItemStatus.AVAILABLE; }

    // the small state machine — a boolean cannot express five states
    void loanOut() {
        if (status != ItemStatus.AVAILABLE && status != ItemStatus.RESERVED)
            throw new IllegalStateException(barcode + " is " + status);
        status = ItemStatus.LOANED; heldFor = null;
    }
    void shelve()                 { status = ItemStatus.AVAILABLE; heldFor = null; }
    void holdFor(String memberId) { status = ItemStatus.RESERVED;  heldFor = memberId; }
    void markLost()               { status = ItemStatus.LOST;      heldFor = null; }
    void sendForRepair()          { status = ItemStatus.IN_REPAIR; heldFor = null; }
}

/** The relationship IS an object. The closed ones are the library's memory. */
final class BookLending {
    static final int LOAN_DAYS = 14;
    final String barcode, memberId;
    final int issuedOn, dueOn;
    private Integer returnedOn;
    private long fineMinor;

    BookLending(String barcode, String memberId, int issuedOn) {
        this.barcode = barcode; this.memberId = memberId;
        this.issuedOn = issuedOn; this.dueOn = issuedOn + LOAN_DAYS;
    }
    boolean isOpen()      { return returnedOn == null; }
    long daysLate(int on) { return Math.max(0, on - dueOn); }
    long fineMinor()      { return fineMinor; }
    void close(int on, long fine) { returnedOn = on; fineMinor = fine; }
}

final class Money {
    static String of(long minor) { return "Rs." + (minor / 100) + "." + String.format("%02d", minor % 100); }
}

/* ---------------- the strategy seam: pricing lateness ---------------- */

interface FinePolicy {
    long fineFor(long daysLate, long itemPriceMinor);   // daysLate is already clamped at 0
    String label();
}

final class FlatPerDayFine implements FinePolicy {
    private final long perDayMinor;
    FlatPerDayFine(long perDayMinor) { this.perDayMinor = perDayMinor; }
    public long fineFor(long daysLate, long price) { return daysLate * perDayMinor; }
    public String label() { return "flat " + Money.of(perDayMinor) + "/day"; }
}

final class SlabFine implements FinePolicy {
    public long fineFor(long daysLate, long price) {
        long gentle = Math.min(daysLate, 7);
        return gentle * 200 + Math.max(0, daysLate - 7) * 1000;
    }
    public String label() { return "slabs 2.00 then 10.00"; }
}

final class CappedFine implements FinePolicy {
    private final FinePolicy inner; private final long capMinor;
    CappedFine(FinePolicy inner, long capMinor) { this.inner = inner; this.capMinor = capMinor; }
    public long fineFor(long daysLate, long price) {
        return Math.min(inner.fineFor(daysLate, price), Math.min(capMinor, price));
    }
    public String label() { return inner.label() + ", capped at " + Money.of(capMinor); }
}

/* ---------------- accounts: two roles, ONE level deep ---------------- */

abstract class Account {
    final String id, name;
    Account(String id, String name) { this.id = id; this.name = name; }
    abstract String role();
}

final class BorrowRefused extends RuntimeException {
    BorrowRefused(String why) { super(why); }
}

final class Member extends Account {
    static final int  MAX_LOANS = 5;
    static final long MAX_FINE_MINOR = 10_000;            // Rs.100.00
    private final List<BookLending> lendings = new ArrayList<>();
    private final int membershipExpiresOn;
    private long fineOwedMinor;

    Member(String id, String name, int membershipExpiresOn) {
        super(id, name); this.membershipExpiresOn = membershipExpiresOn;
    }
    String role() { return "MEMBER"; }

    long openLoans()     { return lendings.stream().filter(BookLending::isOpen).count(); }
    long lateReturns()   { return lendings.stream().filter(l -> !l.isOpen() && l.fineMinor() > 0).count(); }
    long fineOwedMinor() { return fineOwedMinor; }
    List<BookLending> lendings() { return List.copyOf(lendings); }

    /** THE RULE LIVES HERE. The service asks; it does not reach in and decide. */
    void assertCanBorrow(int today) {
        if (today > membershipExpiresOn)
            throw new BorrowRefused(name + ": membership expired on day " + membershipExpiresOn);
        if (openLoans() >= MAX_LOANS)
            throw new BorrowRefused(name + ": loan limit reached (" + MAX_LOANS + " of " + MAX_LOANS + ")");
        if (fineOwedMinor > MAX_FINE_MINOR)
            throw new BorrowRefused(name + ": outstanding fine " + Money.of(fineOwedMinor));
    }
    boolean canBorrow(int today) {
        try { assertCanBorrow(today); return true; } catch (BorrowRefused e) { return false; }
    }
    void addLending(BookLending l) { lendings.add(l); }
    void addFine(long minor)       { fineOwedMinor += minor; }
    void payFine(long minor)       { fineOwedMinor = Math.max(0, fineOwedMinor - minor); }
}

final class Librarian extends Account {
    Librarian(String id, String name) { super(id, name); }
    String role() { return "LIBRARIAN"; }
    void addItem(Library library, BookItem item) { library.catalog().addItem(item); }
    BookItem issueFor(Library library, String isbn, String memberId, int today) {
        return library.issue(isbn, memberId, today);
    }
}

/* ---------------- search: index maps, not a linear scan ---------------- */

final class Catalog {
    private final Map<String, Book> byIsbn = new LinkedHashMap<>();
    private final Map<String, List<Book>> byTitleWord = new HashMap<>();
    private final Map<String, List<Book>> byAuthor = new HashMap<>();
    private final Map<String, List<Book>> bySubject = new HashMap<>();
    private final Map<String, List<BookItem>> itemsByIsbn = new LinkedHashMap<>();
    private final Map<String, BookItem> byBarcode = new HashMap<>();

    void addBook(Book b) {
        byIsbn.put(b.isbn(), b);
        for (String w : b.title().toLowerCase().split(" "))
            byTitleWord.computeIfAbsent(w, k -> new ArrayList<>()).add(b);
        byAuthor.computeIfAbsent(b.author().toLowerCase(), k -> new ArrayList<>()).add(b);
        bySubject.computeIfAbsent(b.subject().toLowerCase(), k -> new ArrayList<>()).add(b);
        itemsByIsbn.computeIfAbsent(b.isbn(), k -> new ArrayList<>());
    }
    void addItem(BookItem item) {
        itemsByIsbn.computeIfAbsent(item.isbn, k -> new ArrayList<>()).add(item);
        byBarcode.put(item.barcode, item);
    }

    Book book(String isbn)         { return byIsbn.get(isbn); }
    Collection<Book> allBooks()    { return byIsbn.values(); }
    List<BookItem> items(String isbn) { return itemsByIsbn.getOrDefault(isbn, List.of()); }
    BookItem item(String barcode) {
        BookItem i = byBarcode.get(barcode);
        if (i == null) throw new IllegalArgumentException("unknown barcode " + barcode);
        return i;
    }

    // SEARCH RETURNS BOOKS — titles. Availability is a COUNT over their items.
    List<Book> searchByTitle(String word)   { return byTitleWord.getOrDefault(word.toLowerCase(), List.of()); }
    List<Book> searchByAuthor(String a)     { return byAuthor.getOrDefault(a.toLowerCase(), List.of()); }
    List<Book> searchBySubject(String s)    { return bySubject.getOrDefault(s.toLowerCase(), List.of()); }
    List<Book> searchByYear(int year) {
        List<Book> out = new ArrayList<>();
        for (Book b : byIsbn.values()) if (b.publishedYear() == year) out.add(b);
        return out;
    }
    long availableCount(String isbn) { return items(isbn).stream().filter(BookItem::isAvailable).count(); }

    // ISSUE RETURNS AN ITEM — a specific copy with a barcode on it.
    Optional<BookItem> firstAvailable(String isbn) {
        return items(isbn).stream().filter(BookItem::isAvailable).findFirst();
    }
    Optional<BookItem> heldFor(String isbn, String memberId) {
        return items(isbn).stream()
                .filter(i -> i.status() == ItemStatus.RESERVED && memberId.equals(i.heldFor()))
                .findFirst();
    }
}

/* ---------------- the facade that orchestrates, and decides nothing ---------------- */

final class Library {
    private final Catalog catalog = new Catalog();
    private final Map<String, Member> members = new LinkedHashMap<>();
    private final Map<String, Deque<String>> reservations = new HashMap<>();
    private final List<BookLending> lendings = new ArrayList<>();
    private final List<BiConsumer<String, String>> listeners = new ArrayList<>();
    private FinePolicy finePolicy;

    Library(FinePolicy finePolicy) { this.finePolicy = finePolicy; }

    Catalog catalog()                      { return catalog; }
    FinePolicy finePolicy()                { return finePolicy; }
    void setFinePolicy(FinePolicy p)       { finePolicy = p; }          // returnItem() unchanged
    void addMember(Member m)               { members.put(m.id, m); }
    void onNotify(BiConsumer<String, String> l) { listeners.add(l); }   // observer, one line

    BookItem issue(String isbn, String memberId, int today) {
        Member m = member(memberId);
        BookItem item = catalog.heldFor(isbn, memberId)                  // your held copy comes first
                .or(() -> catalog.firstAvailable(isbn))
                .orElseThrow(() -> new IllegalStateException("no copy of " + isbn + " is available"));
        m.assertCanBorrow(today);                                        // ASK — do not decide for it
        if (item.status() == ItemStatus.RESERVED) dequeue(isbn, memberId);
        item.loanOut();
        BookLending lending = new BookLending(item.barcode, memberId, today);
        lendings.add(lending);
        m.addLending(lending);
        return item;
    }

    /** Returns the fine charged, in minor units. */
    long returnItem(String barcode, int on) {
        BookItem item = catalog.item(barcode);
        BookLending lending = openLendingFor(barcode);
        long fine = finePolicy.fineFor(lending.daysLate(on), item.priceMinor);
        lending.close(on, fine);
        member(lending.memberId).addFine(fine);

        Deque<String> queue = reservations.get(item.isbn);               // THE BRANCH
        if (queue != null && !queue.isEmpty()) {
            String next = queue.peekFirst();
            item.holdFor(next);                                          // NOT back on the shelf
            notifyMember(next, "copy " + barcode + " of " + item.isbn + " is waiting for you");
        } else {
            item.shelve();
        }
        return fine;
    }

    int reserve(String isbn, String memberId) {
        if (catalog.availableCount(isbn) > 0)
            throw new IllegalStateException("a copy is on the shelf — borrow it instead of reserving");
        Deque<String> q = reservations.computeIfAbsent(isbn, k -> new ArrayDeque<>());
        if (!q.contains(memberId)) q.addLast(memberId);
        return new ArrayList<>(q).indexOf(memberId) + 1;
    }

    /** Allowed only when nobody is waiting for the title. */
    BookLending renew(String barcode, int on) {
        BookItem item = catalog.item(barcode);
        Deque<String> q = reservations.get(item.isbn);
        if (q != null && !q.isEmpty())
            throw new IllegalStateException("cannot renew: " + q.size() + " reservation(s) waiting on " + item.isbn);
        BookLending current = openLendingFor(barcode);
        long fine = finePolicy.fineFor(current.daysLate(on), item.priceMinor);
        current.close(on, fine);
        Member m = member(current.memberId);
        m.addFine(fine);
        BookLending fresh = new BookLending(barcode, current.memberId, on);
        lendings.add(fresh);
        m.addLending(fresh);
        return fresh;
    }

    int queueDepth(String isbn) { return reservations.getOrDefault(isbn, new ArrayDeque<>()).size(); }
    Member member(String id) {
        Member m = members.get(id);
        if (m == null) throw new IllegalArgumentException("unknown member " + id);
        return m;
    }
    Collection<Member> allMembers() { return members.values(); }

    private void dequeue(String isbn, String memberId) {
        Deque<String> q = reservations.get(isbn);
        if (q != null) { q.remove(memberId); if (q.isEmpty()) reservations.remove(isbn); }
    }
    private BookLending openLendingFor(String barcode) {
        for (BookLending l : lendings) if (l.isOpen() && l.barcode.equals(barcode)) return l;
        throw new IllegalStateException(barcode + " is not on loan");
    }
    private void notifyMember(String memberId, String message) {
        for (BiConsumer<String, String> l : listeners) l.accept(memberId, message);
    }
}

public class Main {
    static Library library;

    public static void main(String[] args) {
        library = new Library(new FlatPerDayFine(500));                  // Rs.5.00 a day
        library.onNotify((memberId, msg) -> System.out.println("    notify " + memberId + ": " + msg));

        Catalog catalog = library.catalog();
        catalog.addBook(new Book("978-0-13", "Clean Code", "Robert C. Martin", "software", 2008));
        catalog.addBook(new Book("978-0-20", "The Pragmatic Programmer", "Andrew Hunt", "software", 1999));
        catalog.addBook(new Book("978-0-21", "Design Patterns", "Erich Gamma", "software", 1994));

        Librarian raj = new Librarian("L-1", "Raj");
        raj.addItem(library, new BookItem("B-001", "978-0-13", "R-12", 0, 70000));
        raj.addItem(library, new BookItem("B-002", "978-0-13", "R-12", 0, 70000));
        raj.addItem(library, new BookItem("B-003", "978-0-13", "R-12", 0, 70000));
        raj.addItem(library, new BookItem("P-001", "978-0-20", "R-07", 0, 60000));
        raj.addItem(library, new BookItem("P-002", "978-0-20", "R-07", 0, 60000));
        raj.addItem(library, new BookItem("D-001", "978-0-21", "R-03", 0, 90000));
        raj.addItem(library, new BookItem("D-002", "978-0-21", "R-03", 0, 90000));
        raj.addItem(library, new BookItem("D-003", "978-0-21", "R-03", 0, 90000));

        library.addMember(new Member("M-1", "Asha",  400));
        library.addMember(new Member("M-2", "Ravi",  400));
        library.addMember(new Member("M-3", "Meera", 400));
        library.addMember(new Member("M-4", "Dev",   400));

        System.out.println("=== search: title word \"clean\" — search returns BOOKS ===");
        for (Book b : catalog.searchByTitle("clean")) show(b);

        System.out.println("\n=== day 1: three members borrow the same title ===");
        borrow("978-0-13", "M-1", 1);
        borrow("978-0-13", "M-2", 1);
        borrow("978-0-13", "M-3", 1);
        show(catalog.book("978-0-13"));

        System.out.println("\n=== day 1: Dev wants it too ===");
        borrow("978-0-13", "M-4", 1);
        System.out.println("  reserve -> position " + library.reserve("978-0-13", "M-4") + " in the queue");

        System.out.println("\n=== day 1: Ravi fills his card ===");
        borrow("978-0-20", "M-2", 1);
        borrow("978-0-20", "M-2", 1);
        borrow("978-0-21", "M-2", 1);
        borrow("978-0-21", "M-2", 1);
        borrow("978-0-21", "M-2", 1);          // the 6th — refused by Member, not by the service

        System.out.println("\n=== day 10: Meera tries to renew B-003 ===");
        try { library.renew("B-003", 10); }
        catch (RuntimeException e) { System.out.println("  renew refused: " + e.getMessage()); }

        System.out.println("\n=== day 25: Asha returns B-001, 10 days late ===");
        long fine = library.returnItem("B-001", 25);
        BookItem b001 = catalog.item("B-001");
        System.out.println("  fine " + Money.of(fine) + " (" + library.finePolicy().label() + ")");
        System.out.println("  B-001 -> " + b001.status() + ", held for " + b001.heldFor() + "  <- NOT back on the shelf");
        show(catalog.book("978-0-13"));

        System.out.println("\n=== the same 10 days, priced by three policies ===");
        for (FinePolicy p : List.of(new FlatPerDayFine(500), new SlabFine(),
                                    new CappedFine(new FlatPerDayFine(500), 2000)))
            System.out.printf("  %-34s %s%n", p.label(), Money.of(p.fineFor(10, 70000)));
        System.out.println("  returnItem() was never touched — that is the strategy seam");

        System.out.println("\n=== day 25: Dev collects the copy held for him ===");
        borrow("978-0-13", "M-4", 25);
        System.out.println("  queue depth for 978-0-13 is now " + library.queueDepth("978-0-13"));

        System.out.println("\n=== members ===");
        for (Member m : library.allMembers())
            System.out.printf("  %-6s %-6s loans %d/%d  fine %-10s late returns %d%n",
                    m.id, m.name, m.openLoans(), Member.MAX_LOANS, Money.of(m.fineOwedMinor()), m.lateReturns());
    }

    static void borrow(String isbn, String memberId, int day) {
        try {
            BookItem item = library.issue(isbn, memberId, day);
            System.out.println("  " + memberId + " -> " + item.barcode + " (due day " + (day + BookLending.LOAN_DAYS) + ")");
        } catch (RuntimeException e) {
            System.out.println("  refused: " + e.getMessage());
        }
    }

    static void show(Book b) {
        Catalog c = library.catalog();
        System.out.printf("  %-26s %-18s %d of %d available%n",
                b.title(), b.author(), c.availableCount(b.isbn()), c.items(b.isbn()).size());
    }
}

/* ------------------------- expected output -------------------------
=== search: title word "clean" — search returns BOOKS ===
  Clean Code                 Robert C. Martin   3 of 3 available

=== day 1: three members borrow the same title ===
  M-1 -> B-001 (due day 15)
  M-2 -> B-002 (due day 15)
  M-3 -> B-003 (due day 15)
  Clean Code                 Robert C. Martin   0 of 3 available

=== day 1: Dev wants it too ===
  refused: no copy of 978-0-13 is available
  reserve -> position 1 in the queue

=== day 1: Ravi fills his card ===
  M-2 -> P-001 (due day 15)
  M-2 -> P-002 (due day 15)
  M-2 -> D-001 (due day 15)
  M-2 -> D-002 (due day 15)
  refused: Ravi: loan limit reached (5 of 5)

=== day 10: Meera tries to renew B-003 ===
  renew refused: cannot renew: 1 reservation(s) waiting on 978-0-13

=== day 25: Asha returns B-001, 10 days late ===
    notify M-4: copy B-001 of 978-0-13 is waiting for you
  fine Rs.50.00 (flat Rs.5.00/day)
  B-001 -> RESERVED, held for M-4  <- NOT back on the shelf
  Clean Code                 Robert C. Martin   0 of 3 available

=== the same 10 days, priced by three policies ===
  flat Rs.5.00/day                   Rs.50.00
  slabs 2.00 then 10.00              Rs.44.00
  flat Rs.5.00/day, capped at Rs.20.00 Rs.20.00
  returnItem() was never touched — that is the strategy seam

=== day 25: Dev collects the copy held for him ===
  M-4 -> B-001 (due day 39)
  queue depth for 978-0-13 is now 0

=== members ===
  M-1    Asha   loans 0/5  fine Rs.50.00  late returns 1
  M-2    Ravi   loans 5/5  fine Rs.0.00    late returns 0
  M-3    Meera  loans 1/5  fine Rs.0.00    late returns 0
  M-4    Dev    loans 1/5  fine Rs.0.00    late returns 0
------------------------------------------------------------------- */

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

A library owns five copies of the same title. Why is a single Book class with an isAvailable boolean wrong?

question 02 / 08

Why model borrowing as a BookLending object rather than borrowedBy and dueDate fields on BookItem?

question 03 / 08

Where should the “a member may hold at most five books” rule live?

question 04 / 08

A copy is returned and three members are queued on that title. What happens to the copy?

question 05 / 08

Why should returnItem(barcode, on) take the date as an argument instead of reading the clock inside?

question 06 / 08

Fines might be flat per day, slab-based, or capped. What is the right structure?

question 07 / 08

Why is ItemStatus an enum with five values rather than an isAvailable boolean?

question 08 / 08

The interviewer asks how you would add e-books. What is the strongest answer?

0/8 answered