Library Management System

Members, books, copies, borrowing, fines. The bread-and-butter OOD problem; the prompt that filters on basic discipline.

System Foundational
12 min read
ood case-study library state-pattern strategy-pattern

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 Book is “The Pragmatic Programmer”; a BookCopy is barcode LIB-00417 of 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 K concurrent 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. Book carries title-level attributes (ISBN, author, subject); BookCopy carries 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.statusActive → Returned (on-time path), or Active → Overdue → Returned (late path). The lease owns the transitions; the library does not switch on status.
  • Strategy pattern on FiningPolicyFlatRateFining, TieredFining (cheap for first week, expensive after). The library does not multiply rates inline.

What is not in the diagram and that is deliberate:

  • available lives on BookCopy, not on Book. “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 Borrowing aggregate. 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:

  • Money is its own type. Even for a small slice, fines are not double. Currency and rounding live behind one type.
  • Clock is injected. The fining computation depends on “now”; tests need to fast-forward without sleeping.
  • Lease.touch is idempotent. Reading the status from a librarian’s view should also progress Active → Overdue if the due date has passed; otherwise the system silently mis-reports state.
  • The borrow guardrails (max leases, fines threshold) live in Library, not on Member. They are library policy, not member identity. Member carries the count and the unpaid total; the library decides whether to allow borrowing.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
Book and BookCopy are separate classesThe 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 BookSingle source of truth; Book.availableCount() computes on demand.If reads become hot, cache the count and invalidate on borrow/return.
State pattern on LeaseActive/Overdue/Returned/Lost have different transition rules.None — pattern fits the shape.
FiningPolicy as a strategyPolicy changes more often than the rest of the system.None — this is the right shape now.
Reservation is a separate aggregateReservations and leases have different lifecycles; one aggregate would over-couple them.None.
In-process stateNo persistence requirement was given.Adding a database means introducing repositories (BookRepository, MemberRepository, LeaseRepository).
Single coarse synchronized on LibraryThroughput 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 N days after the copy is offered. State machine on the reservation: Pending → Offered → (Picked up | Expired). Existing classes untouched.
  • Lost / damaged copies. The Lost branch in the lease’s state machine handles total loss; a Condition enum on BookCopy tracks wear, and a Damaged state removes the copy from circulation without deleting it (history matters).
  • Multiple branches. Promote Library to one of many Branch instances under a LibrarySystem; 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 — DigitalCopy overrides markUnavailable to be a no-op against quantity.

Mock interview follow-ups#

Questions interviewers reach for and the briefest correct answer:

  • “Why split Book from BookCopy?” — Because they are different things. A Book is a title; a BookCopy is 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 a Lost branch from either non-terminal state. Show the activity diagram.
  • “How do you compute fines?” — A FiningPolicy strategy; 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.borrow is synchronized; the second call sees copy.isAvailable() == false and 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. The Lost state 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 to Offered. The notify is an observer hook so additional channels (email, SMS) can subscribe.
  • “Why isn’t Vehicle-style polymorphism on Book?” — 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.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.