Library Management System
Members, books, copies, borrowing, fines. The bread-and-butter OOD problem; the prompt that filters on basic discipline.
Context#
A public library lets members borrow physical books from a circulating collection, reserve titles that are out, and pays fines when items come back late. The problem is a foundational OOD prompt because it filters on a single, brutal modelling decision — separating the book (the abstract title) from the copy (the physical artefact on the shelf) — that exposes whether a candidate has actually thought about the domain or is mapping the prompt to flat tables.
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you distinguish Book from BookCopy? A
Bookis “The Pragmatic Programmer”; aBookCopyis barcodeLIB-00417of that book. Merging them is the most common failure. - Can you identify the entities — member, book, copy, lease (active borrow), reservation, fine — and the relationships?
- Can you produce a class diagram that uses the State pattern on the lease lifecycle?
- Can you defend fines as a separate policy rather than a hard-coded multiplication?
- Can you narrate a flow for a member who borrows, holds past due, and finally returns?
Requirements (functional and non-functional)#
Scoping is the heaviest-weighted moment of the round. The defaults below are what most interviewers expect; flag anything outside them so you can finish the design.
Functional — in scope.
- A library has members, a catalogue of books (titles), and many physical copies per book.
- A member can borrow any available copy of a book; the system creates a lease with a due date.
- A member can reserve a title that has no available copies; the next return is held for them.
- On return, the system closes the lease and, if past due, charges a fine computed by a fining policy.
- A member can hold at most
Kconcurrent leases (default 5) and cannot borrow with unpaid fines above a threshold.
Functional — out of scope (called out explicitly). E-book/digital licences, inter-library loans, multi-branch transfers, acquisitions and weeding workflows, staff role hierarchies beyond a single Librarian actor. Mentioned so the interviewer knows you saw them; not discussed unless asked.
Non-functional.
- Catalogue size on the order of 10⁵ titles, 10⁶ copies, 10⁴ members. In-process data structures suffice for the design; persistence is a separate concern.
- Borrow/return decisions in well under 100 ms — humans are waiting at a desk.
- Concurrency: the desk has several librarians transacting in parallel; two of them must not lease the same copy.
Use case diagram#
┌──────────────────┐ │ Member │ └────────┬─────────┘ │ ┌───────────────┼────────────────┐ ▼ ▼ ▼ [borrow copy] [reserve title] [pay fine] │ │ │ ▼ ▼ ▼ ┌──────────────────────────────────────────┐ │ Library Management System │ └──────────────┬───────────────────────────┘ ▲ │ ┌───────────────────┐ │ Librarian │ … return copy, register member, add title └───────────────────┘Two actors (Member, Librarian) and three primary member-side use cases. The librarian actor is explicit because return is initiated by the librarian (the member hands the copy over), not the member.
Class diagram#
┌──────────────────────────┐ │ Library │ ├──────────────────────────┤ │ catalogue: Catalogue │ │ members : MemberDirectory│ │ fining : FiningPolicy │ ├──────────────────────────┤ │ borrow(memberId, copyId) │ │ returnCopy(copyId) │ │ reserve(memberId, bookId)│ └──────────┬───────────────┘ │ ┌────────────────────┼────────────────────┐ ▼ ▼ ▼ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ Catalogue │ │ MemberDirectory │ │ FiningPolicy │◁── FlatRate, Tiered ├────────────────┤ ├──────────────────┤ ├────────────────┤ │ books │ │ members │ │ fineFor(lease) │ └────────┬───────┘ └────────┬─────────┘ └────────────────┘ │ 1..* │ 1..* ▼ ▼ ┌──────────────┐ ┌──────────────────┐ │ Book │ │ Member │ ├──────────────┤ ├──────────────────┤ │ isbn │ │ id, name │ │ title, author│ │ activeLeases │ │ copies: List<BookCopy> │ unpaidFines │ └──────┬───────┘ └──────────────────┘ │ 1..* ▼ ┌──────────────────┐ ┌────────────────────┐ │ BookCopy │ │ Lease │ ◇──── status: LeaseStatus ├──────────────────┤ ├────────────────────┤ (Active / Returned / Overdue) │ barcode │ │ id │ │ shelfLocation │ ◄───────│ copy : BookCopy │ │ available │ │ member : Member │ └──────────────────┘ │ borrowedAt, dueAt │ │ returnedAt? │ └────────────────────┘
┌────────────────────┐ │ Reservation │ ├────────────────────┤ │ member, book │ │ createdAt, status │ └────────────────────┘The load-bearing modelling decisions:
- Book vs BookCopy.
Bookcarries title-level attributes (ISBN, author, subject);BookCopycarries artefact-level attributes (barcode, shelf, condition). A reservation targets a book; a lease targets a copy. Merging them collapses both into one anaemic record and loses information the librarian needs. - State pattern on
Lease.status—Active → Returned(on-time path), orActive → Overdue → Returned(late path). The lease owns the transitions; the library does not switch on status. - Strategy pattern on
FiningPolicy—FlatRateFining,TieredFining(cheap for first week, expensive after). The library does not multiply rates inline.
What is not in the diagram and that is deliberate:
availablelives onBookCopy, not onBook. “Is a copy available” is a fact about the copy. “How many copies are available” is a fact derived from copies, computed when asked. There is no copy of that count anywhere — single source of truth.- No
Borrowingaggregate. The lease is the borrowing. A separate aggregate doubles the model.
Sequence diagram (key flows)#
The borrow flow:
Member Librarian Library Catalogue BookCopy Lease │ request(copy) │ │ │ │ │ │──────────────►│ │ │ │ │ │ │ borrow(m,c) │ │ │ │ │ │────────────►│ │ │ │ │ │ │ findCopy(c) │ │ │ │ │ │───────────────►│ │ │ │ │ │ copy │ │ │ │ │ │◄───────────────│ │ │ │ │ │ checkAvailable() │ │ │ │ │──────────────────────────────►│ │ │ │ │ yes │ │ │ │ │◄──────────────────────────────│ │ │ │ │ markUnavailable() │ │ │ │ │──────────────────────────────►│ │ │ │ │ new Lease(m, copy, now+14d) ────────────────►│ │ │ lease │ │ │ │ │◄────────────│ │ │The return flow, with the lease’s state transition and fine computation:
Member Librarian Library Lease FiningPolicy BookCopy │ hand back(copy)│ │ │ │ │ │───────────────►│ │ │ │ │ │ │ returnCopy(c)│ │ │ │ │ │─────────────►│ │ │ │ │ │ │ findLease(c)│ │ │ │ │ │────────────►│ │ │ │ │ │ markReturned(now) ────────►│ │ │ │ │ fineFor(lease) ──────────────────────────────►│ │ │ │ fine │ │ │ │◄──────────────────────────────────────────────│ │ │ │ if fine > 0 → member.addUnpaidFine(fine) │ │ │ │ copy.markAvailable() ────────────────────────►│ │ │ │ notifyReservationQueue(book) │Activity diagram (for non-trivial state)#
The lease lifecycle is where most subtle bugs live; the legal transitions:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌────────────────┐ │ Active │ └────────┬───────┘ │ ┌──────────────┼──────────────┐ │ │ │ returned due passed lost-copy flag before due │ │ │ ▼ ▼ │ ┌─────────────┐ ┌──────────────┐ │ │ Overdue │ │ Lost │ │ └──────┬──────┘ └──────┬───────┘ │ │ │ │ returned settled │ │ │ ▼ ▼ ▼ ┌────────────────┐ │ Returned │ └────────────────┘ (terminal — fine charged on entry from Overdue/Lost)Returned is terminal; a re-borrow of the same copy by the same member produces a brand-new Lease. The Lost branch covers a copy the member cannot return at all — the library charges replacement cost and closes the lease.
Java implementation#
A representative slice — the lease, the fining policy, and the library’s borrow/return — not the full system.
public enum LeaseStatus { ACTIVE, OVERDUE, LOST, RETURNED }
public final class BookCopy { private final String barcode; private final String shelfLocation; private boolean available = true;
public BookCopy(String barcode, String shelf) { this.barcode = barcode; this.shelfLocation = shelf; }
public synchronized boolean isAvailable() { return available; } public synchronized void markUnavailable() { if (!available) throw new IllegalStateException("Copy " + barcode + " already out"); this.available = false; } public synchronized void markAvailable() { this.available = true; } public String barcode() { return barcode; }}
public final class Lease { private final String id; private final BookCopy copy; private final Member member; private final Instant borrowedAt; private final Instant dueAt; private Instant returnedAt; private LeaseStatus status = LeaseStatus.ACTIVE;
public Lease(String id, BookCopy c, Member m, Instant borrowedAt, Instant dueAt) { this.id = id; this.copy = c; this.member = m; this.borrowedAt = borrowedAt; this.dueAt = dueAt; }
public void touch(Instant now) { if (status == LeaseStatus.ACTIVE && now.isAfter(dueAt)) status = LeaseStatus.OVERDUE; }
public void markReturned(Instant now) { if (status == LeaseStatus.RETURNED) throw new IllegalStateException("Already returned"); touch(now); this.returnedAt = now; this.status = LeaseStatus.RETURNED; }
public void markLost() { if (status == LeaseStatus.RETURNED) throw new IllegalStateException("Cannot lose a returned copy"); this.status = LeaseStatus.LOST; }
public LeaseStatus status() { return status; } public Instant dueAt() { return dueAt; } public Instant returnedAt() { return returnedAt; } public BookCopy copy() { return copy; } public Member member() { return member; }}
public interface FiningPolicy { Money fineFor(Lease lease);}
public final class TieredFining implements FiningPolicy { public Money fineFor(Lease lease) { if (lease.returnedAt() == null) return Money.zero(); long daysLate = Math.max(0, Duration.between(lease.dueAt(), lease.returnedAt()).toDays()); if (daysLate == 0) return Money.zero(); long cheap = Math.min(daysLate, 7); long pricey = Math.max(0, daysLate - 7); return Money.rupees(cheap * 5 + pricey * 20); }}
public final class Library { private final Catalogue catalogue; private final MemberDirectory members; private final FiningPolicy fining; private final Clock clock; private final int maxConcurrentLeases; private final Money fineThreshold;
public Library(Catalogue c, MemberDirectory m, FiningPolicy f, Clock clock, int maxConcurrentLeases, Money fineThreshold) { this.catalogue = c; this.members = m; this.fining = f; this.clock = clock; this.maxConcurrentLeases = maxConcurrentLeases; this.fineThreshold = fineThreshold; }
public synchronized Lease borrow(String memberId, String barcode) { Member member = members.find(memberId); if (member.activeLeases() >= maxConcurrentLeases) throw new BorrowLimitExceededException(memberId); if (member.unpaidFines().greaterThan(fineThreshold)) throw new UnpaidFinesException(memberId);
BookCopy copy = catalogue.findCopy(barcode); if (!copy.isAvailable()) throw new CopyUnavailableException(barcode);
copy.markUnavailable(); Instant now = clock.instant(); Lease lease = new Lease(UUID.randomUUID().toString(), copy, member, now, now.plus(14, ChronoUnit.DAYS)); member.addLease(lease); return lease; }
public synchronized Money returnCopy(String barcode) { Lease lease = catalogue.findActiveLeaseFor(barcode); lease.markReturned(clock.instant()); Money fine = fining.fineFor(lease); if (fine.isPositive()) lease.member().addUnpaidFine(fine); lease.copy().markAvailable(); lease.member().closeLease(lease); return fine; }}Notes the interviewer will look for:
Moneyis its own type. Even for a small slice, fines are notdouble. Currency and rounding live behind one type.Clockis injected. The fining computation depends on “now”; tests need to fast-forward without sleeping.Lease.touchis idempotent. Reading the status from a librarian’s view should also progressActive → Overdueif the due date has passed; otherwise the system silently mis-reports state.- The borrow guardrails (max leases, fines threshold) live in
Library, not onMember. They are library policy, not member identity.Membercarries the count and the unpaid total; the library decides whether to allow borrowing.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
Book and BookCopy are separate classes | The single most important modelling decision; conflating them loses information the librarian needs. | None at this size — keep them separate. |
available flag on BookCopy, no derived flag on Book | Single source of truth; Book.availableCount() computes on demand. | If reads become hot, cache the count and invalidate on borrow/return. |
State pattern on Lease | Active/Overdue/Returned/Lost have different transition rules. | None — pattern fits the shape. |
FiningPolicy as a strategy | Policy changes more often than the rest of the system. | None — this is the right shape now. |
| Reservation is a separate aggregate | Reservations and leases have different lifecycles; one aggregate would over-couple them. | None. |
| In-process state | No persistence requirement was given. | Adding a database means introducing repositories (BookRepository, MemberRepository, LeaseRepository). |
Single coarse synchronized on Library | Throughput is comfortably below the lock’s ceiling. | If transaction rates grow, partition the lock by aggregate (one per member or one per book). |
Likely follow-up extensions and the shape of the answer:
- Reservations going stale. A reservation expires
Ndays after the copy is offered. State machine on the reservation:Pending → Offered → (Picked up | Expired). Existing classes untouched. - Lost / damaged copies. The
Lostbranch in the lease’s state machine handles total loss; aConditionenum onBookCopytracks wear, and aDamagedstate removes the copy from circulation without deleting it (history matters). - Multiple branches. Promote
Libraryto one of manyBranchinstances under aLibrarySystem; the catalogue stays at the system level, the copies live per branch. Inter-branch transfers become a new aggregate. - Digital lending. A digital licence is a copy with infinite shelf availability and an enforced expiry on the lease itself (the file unlocks for 14 days). The base abstractions accommodate this with one new subclass —
DigitalCopyoverridesmarkUnavailableto be a no-op against quantity.
Mock interview follow-ups#
Questions interviewers reach for and the briefest correct answer:
- “Why split
BookfromBookCopy?” — Because they are different things. ABookis a title; aBookCopyis a physical artefact with a barcode and a shelf. A reservation targets the title; a lease targets the copy. Merging them loses information. - “What’s the state machine on
Lease?” —Active → (Overdue?) → Returned, with aLostbranch from either non-terminal state. Show the activity diagram. - “How do you compute fines?” — A
FiningPolicystrategy; the library asks the policy what to charge on return. The default tiered policy charges cheap for the first week late and expensive after. - “Two librarians try to lease the same copy.” —
Library.borrowissynchronized; the second call seescopy.isAvailable() == falseand throws. If contention is high, partition the lock per book. - “A member loses a copy entirely.” — The lease transitions to
Lost; the library charges the replacement cost; the copy is removed from the catalogue. TheLoststate is already in the activity diagram. - “How do reservations interact with returns?” — On
returnCopy, the library notifies the reservation queue for that book; the next reservation transitions toOffered. The notify is an observer hook so additional channels (email, SMS) can subscribe. - “Why isn’t
Vehicle-style polymorphism onBook?” — Books do not vary in behaviour, only in metadata. There is no method whose body would differ by book; an enum on subject would be the right level if needed.
Related#
- Parking Lot — the sibling Foundational case study; shares the State + Strategy pair.
- Elevator System — the Intermediate sibling; same shape, different domain.
- State Pattern — the lease’s lifecycle is the textbook example.
- Strategy Pattern —
FiningPolicyis the textbook example. - Approaching the OOD Interview — the meta-script that produced this writeup’s structure.