Technology · ssh / indianDevelopers
LLD 04 — Design a Library Management System: Books, Copies, Loans and Reservations
A library system becomes interesting the moment three copies of the same book exist. Learn how to model titles, physical copies, loans and reservations without putting every responsibility into one class.
Imagine a library owns three copies of Designing Data-Intensive Applications.
One is on Shelf A.
One has been borrowed by Alice.
One has been reserved for Bob.
How many books does the library have?
The answer depends on what we mean by book.
There is one title in the catalogue, but there are three physical copies that can independently move through the lending system.
That distinction sounds small. It is actually the modelling decision that determines whether the rest of a Library Management System stays clean.
In this LLD, we will build a system that can:
maintain a catalogue of books;
store multiple physical copies of the same book;
register library members;
check out available copies;
return borrowed copies;
enforce a borrowing limit;
calculate due dates;
allow members to reserve a title when no copy is available;
assign a returned copy to the next waiting reservation.
We will keep database persistence, payments, librarians, notifications, search ranking, damaged-book workflows and multi-branch transfers outside the first version.
The point is not to model every feature a real city library might have.
The point is to get the boundaries right.
Start With the Most Important Distinction: Book vs BookCopy
Suppose our library stores:
Book:
ISBN = 9781449373320
Title = Designing Data-Intensive Applications
Author = Martin Kleppmann
The library might own three copies:
COPY-101
COPY-102
COPY-103
All three refer to the same bibliographic book, but their states can differ:
COPY-101 → AVAILABLE
COPY-102 → LOANED
COPY-103 → HELD
If we put an isBorrowed boolean directly inside Book, what would it mean?
One copy could be borrowed while another remains on the shelf.
So the model should separate:
Book
↓ describes
BookCopy
Book contains information about the title.
BookCopy contains information about a particular physical item.
That is the hinge on which the rest of the design turns.

Model the Catalogue Entity
For our purposes, a book is mostly immutable metadata:
public record Book(
String isbn,
String title,
String author
) {
public Book {
Objects.requireNonNull(isbn);
Objects.requireNonNull(title);
Objects.requireNonNull(author);
}
}
Notice what is missing:
borrowed
dueDate
borrower
available
Those properties do not describe the title itself.
They belong to lending records or physical copies.
A Physical Copy Owns Its Availability State
A copy needs its own identifier.
A barcode works nicely:
public enum BookCopyStatus {
AVAILABLE,
LOANED,
HELD
}
public final class BookCopy {
private final String barcode;
private final Book book;
private BookCopyStatus status =
BookCopyStatus.AVAILABLE;
private String heldForMemberId;
public BookCopy(
String barcode,
Book book) {
this.barcode =
Objects.requireNonNull(barcode);
this.book =
Objects.requireNonNull(book);
}
public String getBarcode() {
return barcode;
}
public Book getBook() {
return book;
}
public BookCopyStatus getStatus() {
return status;
}
public String getHeldForMemberId() {
return heldForMemberId;
}
public void markLoaned() {
if (status != BookCopyStatus.AVAILABLE
&& status != BookCopyStatus.HELD) {
throw new IllegalStateException(
"Copy cannot be loaned"
);
}
status = BookCopyStatus.LOANED;
heldForMemberId = null;
}
public void markAvailable() {
status = BookCopyStatus.AVAILABLE;
heldForMemberId = null;
}
public void holdFor(String memberId) {
if (status != BookCopyStatus.AVAILABLE) {
throw new IllegalStateException(
"Only an available copy can be held"
);
}
status = BookCopyStatus.HELD;
heldForMemberId =
Objects.requireNonNull(memberId);
}
}
The important invariant is:
One physical copy can have only one availability state at a time.
Member Is Separate From Loan
A member represents a person who can borrow books.
public record Member(
String id,
String name
) {
public Member {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
}
}
Again, we do not put:
currentBook
dueDate
loanStartedAt
inside Member.
A member can have several loans.
Those belong to another entity.
A Loan Records One Borrowing Event
A loan connects:
member
+
physical copy
+
checkout time
+
due date
+
return time
public final class Loan {
private final String id;
private final String memberId;
private final String copyBarcode;
private final Instant borrowedAt;
private final Instant dueAt;
private Instant returnedAt;
public Loan(
String id,
String memberId,
String copyBarcode,
Instant borrowedAt,
Instant dueAt) {
this.id = Objects.requireNonNull(id);
this.memberId =
Objects.requireNonNull(memberId);
this.copyBarcode =
Objects.requireNonNull(copyBarcode);
this.borrowedAt =
Objects.requireNonNull(borrowedAt);
this.dueAt =
Objects.requireNonNull(dueAt);
}
public String getId() {
return id;
}
public String getMemberId() {
return memberId;
}
public String getCopyBarcode() {
return copyBarcode;
}
public Instant getBorrowedAt() {
return borrowedAt;
}
public Instant getDueAt() {
return dueAt;
}
public Instant getReturnedAt() {
return returnedAt;
}
public boolean isActive() {
return returnedAt == null;
}
public void markReturned(Instant time) {
if (!isActive()) {
throw new IllegalStateException(
"Loan has already been returned"
);
}
returnedAt =
Objects.requireNonNull(time);
}
}
This also preserves history.
If Alice borrows the same physical copy five times over three years, we have five separate Loan records rather than repeatedly overwriting one field on BookCopy.
That difference becomes extremely important once auditing or persistence enters the system.
Lending Rules Are Policies
Suppose the current rules say:
Maximum active loans = 5
Loan duration = 14 days
Those values will almost certainly change someday.
Maybe students receive 10 books.
Maybe reference books have a 2-day duration.
Maybe premium members get longer loans.
So we should keep lending rules out of the central service.
public interface LoanPolicy {
int maximumActiveLoans(Member member);
Instant calculateDueDate(
Member member,
BookCopy copy,
Instant borrowedAt
);
}
A simple policy:
public final class StandardLoanPolicy
implements LoanPolicy {
@Override
public int maximumActiveLoans(
Member member) {
return 5;
}
@Override
public Instant calculateDueDate(
Member member,
BookCopy copy,
Instant borrowedAt) {
return borrowedAt.plus(
14,
ChronoUnit.DAYS
);
}
}
This is a legitimate use of Strategy because lending rules really are capable of changing independently from catalogue and copy management.
Reservations Need Their Own Lifecycle
A common shortcut is to put:
boolean reserved;
inside Book.
But that immediately raises another question:
Reserved by whom?
And what if five people are waiting?
A reservation is its own domain object.
public enum ReservationStatus {
WAITING,
READY,
FULFILLED,
CANCELLED
}
public final class Reservation {
private final String id;
private final String memberId;
private final String isbn;
private final Instant createdAt;
private ReservationStatus status =
ReservationStatus.WAITING;
private String assignedCopyBarcode;
public Reservation(
String id,
String memberId,
String isbn,
Instant createdAt) {
this.id = Objects.requireNonNull(id);
this.memberId =
Objects.requireNonNull(memberId);
this.isbn = Objects.requireNonNull(isbn);
this.createdAt =
Objects.requireNonNull(createdAt);
}
public String getId() {
return id;
}
public String getMemberId() {
return memberId;
}
public String getIsbn() {
return isbn;
}
public Instant getCreatedAt() {
return createdAt;
}
public ReservationStatus getStatus() {
return status;
}
public String getAssignedCopyBarcode() {
return assignedCopyBarcode;
}
public void markReady(String barcode) {
if (status != ReservationStatus.WAITING) {
throw new IllegalStateException(
"Reservation is not waiting"
);
}
assignedCopyBarcode = barcode;
status = ReservationStatus.READY;
}
public void markFulfilled() {
if (status != ReservationStatus.READY) {
throw new IllegalStateException(
"Reservation is not ready"
);
}
status = ReservationStatus.FULFILLED;
}
public void cancel() {
if (status == ReservationStatus.FULFILLED) {
throw new IllegalStateException(
"Fulfilled reservation cannot be cancelled"
);
}
status = ReservationStatus.CANCELLED;
}
}
The reservation is for a book title first.
Only when a copy becomes available do we assign a specific barcode.
That mirrors the actual business question:
“I want the next available copy of this book.”
not:
“I want COPY-103 specifically.”
Repositories Keep Storage Out of the Domain Logic
For an interview-scale design, interfaces are enough:
public interface BookCopyRepository {
Optional<BookCopy> findByBarcode(
String barcode
);
Optional<BookCopy> findAvailableByIsbn(
String isbn
);
void save(BookCopy copy);
}
public interface LoanRepository {
Optional<Loan> findActiveByCopyBarcode(
String barcode
);
int countActiveLoansForMember(
String memberId
);
void save(Loan loan);
}
public interface ReservationRepository {
Optional<Reservation> findNextWaiting(
String isbn
);
Optional<Reservation> findReadyForMember(
String memberId,
String isbn
);
void save(Reservation reservation);
}
These interfaces do not mean we need PostgreSQL immediately.
We could implement them using:
HashMap
Database
External servicewithout changing the core lending rules.
LendingService Coordinates the Workflow
This is where the use cases come together.
public final class LendingService {
private final BookCopyRepository copies;
private final LoanRepository loans;
private final ReservationRepository reservations;
private final LoanPolicy loanPolicy;
private final Clock clock;
public LendingService(
BookCopyRepository copies,
LoanRepository loans,
ReservationRepository reservations,
LoanPolicy loanPolicy,
Clock clock) {
this.copies = copies;
this.loans = loans;
this.reservations = reservations;
this.loanPolicy = loanPolicy;
this.clock = clock;
}
public synchronized Loan checkout(
Member member,
String barcode) {
BookCopy copy = copies
.findByBarcode(barcode)
.orElseThrow(() ->
new IllegalArgumentException(
"Unknown copy"
)
);
validateLoanLimit(member);
if (copy.getStatus()
== BookCopyStatus.HELD) {
if (!member.getId().equals(
copy.getHeldForMemberId())) {
throw new IllegalStateException(
"Copy is reserved for another member"
);
}
} else if (copy.getStatus()
!= BookCopyStatus.AVAILABLE) {
throw new IllegalStateException(
"Copy is not available"
);
}
Instant now = clock.instant();
Instant dueAt =
loanPolicy.calculateDueDate(
member,
copy,
now
);
copy.markLoaned();
Loan loan = new Loan(
UUID.randomUUID().toString(),
member.getId(),
barcode,
now,
dueAt
);
copies.save(copy);
loans.save(loan);
reservations
.findReadyForMember(
member.getId(),
copy.getBook().isbn()
)
.ifPresent(reservation -> {
reservation.markFulfilled();
reservations.save(reservation);
});
return loan;
}
public synchronized void returnBook(
String barcode) {
Loan loan = loans
.findActiveByCopyBarcode(barcode)
.orElseThrow(() ->
new IllegalStateException(
"No active loan for copy"
)
);
BookCopy copy = copies
.findByBarcode(barcode)
.orElseThrow();
loan.markReturned(
clock.instant()
);
copy.markAvailable();
loans.save(loan);
Optional<Reservation> next =
reservations.findNextWaiting(
copy.getBook().isbn()
);
if (next.isPresent()) {
Reservation reservation =
next.get();
copy.holdFor(
reservation.getMemberId()
);
reservation.markReady(
copy.getBarcode()
);
reservations.save(
reservation
);
}
copies.save(copy);
}
public synchronized Reservation reserve(
Member member,
Book book) {
Optional<BookCopy> available =
copies.findAvailableByIsbn(
book.isbn()
);
if (available.isPresent()) {
throw new IllegalStateException(
"A copy is currently available"
);
}
Reservation reservation =
new Reservation(
UUID.randomUUID().toString(),
member.getId(),
book.isbn(),
clock.instant()
);
reservations.save(reservation);
return reservation;
}
private void validateLoanLimit(
Member member) {
int activeLoans =
loans.countActiveLoansForMember(
member.getId()
);
int maximum =
loanPolicy.maximumActiveLoans(
member
);
if (activeLoans >= maximum) {
throw new IllegalStateException(
"Borrowing limit reached"
);
}
}
}
That service owns the workflow, not all underlying rules.
The checkout path is:
find physical copy
↓
check borrowing limit
↓
check copy availability / hold ownership
↓
calculate due date
↓
create loan
↓
mark copy LOANED
The return path is more interesting:
find active loan
↓
close loan
↓
make copy AVAILABLE
↓
waiting reservation?
/ \
NO YES
│ │
leave copy assign copy
available to next member
↓
HELD

Why Clock Is Injected
You may have noticed this:
Clock clockinstead of scattering:
Instant.now()through the service.
That is a small decision with a big testing benefit.
A test can use:
Clock.fixed(
Instant.parse("2026-08-20T10:00:00Z"),
ZoneOffset.UTC
)
and reliably verify:
borrowedAt
dueAt
reservation order
without depending on the actual wall clock.
This is the sort of detail that makes an interview design feel like software rather than a UML exercise.
What Happens When Two Members Try to Borrow the Same Copy?
This is where the in-memory example has an important limitation.
Suppose:
Alice → checkout COPY-101
Bob → checkout COPY-101
at the same moment.
Within one process, synchronizing the service method makes the operation effectively sequential.
One request changes:
AVAILABLE → LOANED
before the next request validates the copy.
But if we deploy three backend instances, Java's synchronized no longer protects the shared database.
The production invariant becomes:
Only one transaction may successfully transition a particular copy from AVAILABLE to LOANED.
That could be enforced using an atomic database update such as:
UPDATE book_copy
SET status = 'LOANED'
WHERE barcode = ?
AND status = 'AVAILABLE';
and requiring exactly one row to be updated.
The exact persistence solution belongs more naturally to backend/HLD discussion.
The LLD lesson is identifying which state transition must be atomic.

Why Returning a Book Is More Than Setting available = true
This is an easy detail to miss.
Suppose Bob has been waiting for a title.
Alice returns the only copy.
A naïve implementation does:
LOANED → AVAILABLEand stops.
Before Bob acts, another customer could borrow it.
Our design instead asks:
Is anybody already waiting?
If so:
LOANED
↓ return
AVAILABLE
↓ immediately assign
HELD
and the reservation changes:
WAITING → READY
That is why reservations should participate in the return workflow rather than living as an unrelated list somewhere.
What About Fines?
We intentionally left fines outside the core implementation.
If the interviewer asks for them, do not calculate them directly inside Loan.
Introduce another policy:
public interface FinePolicy {
long calculateFine(
Loan loan,
Instant returnedAt
);
}
Then implementations might represent:
NoFinePolicy
DailyFinePolicy
MembershipBasedFinePolicy
Again, the rule can vary independently from the historical loan itself.
What If the Library Has Multiple Branches?
Now our model needs another concept:
LibraryBranch
and each BookCopy should belong to one branch.
A title remains global catalogue information:
Book
while a copy becomes something like:
BookCopy
├── barcode
├── book
├── branch
└── status
Transfers between branches can then become their own workflow.
Notice that Book still does not need to change.
That is a good sign that the title/copy distinction was correct.
Common Design Mistakes
Mistake 1: One Book object per physical copy
That duplicates:
ISBN
title
author
and makes it difficult to talk about “all copies of this title.”
Mistake 2: Putting borrower information inside Book
book.setBorrowedBy(member);
A title can have several physical copies borrowed by several people simultaneously.
Mistake 3: Replacing loan history every time a book is returned
A completed Loan is useful historical information.
Return should close the loan, not erase it.
Mistake 4: boolean reserved
Reservations have:
member
position/order
status
creation time
possibly an assigned copyThat is an entity, not a boolean.
Mistake 5: Making Library a god class
Avoid:
Library
├── search books
├── maintain inventory
├── issue books
├── calculate fines
├── send email
├── create members
├── process payments
├── maintain reservations
└── persist everythingA library is the business context.
It does not need to implement every business capability personally.
How I Would Present This in an Interview
I would not start with twenty classes.
The first thing I would say is:
“I want to distinguish a catalogue book from a physical copy because one title can have multiple independently borrowable copies.”
That immediately establishes the most important modelling decision.
Then draw:
Book
│
└── 1:N ── BookCopy
Member
│
└── Loan ── BookCopy
Member
│
└── Reservation ── BookAfter that, implement one complete workflow:
checkoutThen:
returnThen add reservations.
A reasonable 45-minute flow is:
0–5 min
requirements
5–10 min
Book vs BookCopy + domain model
10–15 min
Loan and Reservation lifecycle
15–30 min
checkout + return implementation
30–35 min
LoanPolicy
35–40 min
reservation queue
40–45 min
concurrency + extensionsDo not spend the first twenty minutes drawing getters.
The Core Lesson From LLD 04
The hardest part of this problem is not the checkout method.
It is recognizing that similar-sounding words can represent completely different domain concepts.
A Book answers:
What title is this?
A BookCopy answers:
Which physical item is this?
A Loan answers:
Who currently borrowed that item, and until when?
A Reservation answers:
Who is waiting for the next suitable copy of this title?
And LendingService answers:
How do those objects participate in one business workflow?
That distinction is useful far beyond libraries.
The same modelling pattern appears in:
Product vs InventoryItem
Movie vs Screening
HotelRoomType vs PhysicalRoom
Course vs CourseOffering
VehicleModel vs RentalVehicleGood LLD often starts by discovering that two things which sound almost identical are actually different entities with different lifecycles.
The library problem makes that lesson impossible to miss.

Conversation
Comments
Sign in to join the conversation.