Library Management
Separate the catalog from physical copies. A Book is metadata. A BookCopy is the thing on the shelf. This distinction drives every other design decision in the system.
Key Abstractions
Facade coordinating books, members, loans, and search
Catalog entity with title, author, ISBN. Has multiple physical copies.
Individual physical copy with its own availability status
Borrower with checkout limits and active loan tracking
Checkout record linking a member to a book copy with due date and fine calculation
Strategy interface for searching by title, author, or ISBN
Class Diagram
The Key Insight
The single most important modeling decision in a library system is separating Book from BookCopy. A Book is a catalog entry: title, author, ISBN. A BookCopy is a physical object sitting on a shelf. The library might own five copies of "Design Patterns" and three of them might be checked out right now.
If you merge these into one class, you are stuck. You cannot answer "how many copies are available?" without a clunky counter field. You cannot track which specific copy a member has. You cannot mark one copy as damaged while the others remain in circulation.
Once that separation is clean, the rest falls into place. Loans connect a Member to a BookCopy, not to a Book. Search operates on the catalog (Book level). Reservations queue up against a Book, and when any copy becomes available, the next person in line gets notified.
Requirements
Functional
- Add books to the catalog with multiple physical copies
- Register members with configurable checkout limits
- Check out available copies to members
- Return books with automatic fine calculation for overdue items
- Search by title, author, or ISBN
- Reserve books that are currently unavailable, with notification on return
Non-Functional
- Search strategy should be extensible without modifying the Library class
- Fine calculation should support different policies (flat rate, tiered, grace period)
- Reservation notifications should be decoupled from the return logic
Design Decisions
Why separate Book from BookCopy?
A library with 10,000 titles might have 40,000 physical copies. Tracking availability, damage, and location requires per-copy state. But search, catalog display, and metadata are per-title concerns. Conflating them means your search results carry copy-level details they do not need, and your checkout logic is tangled with catalog metadata.
Why Strategy for search instead of method overloading?
Three overloaded methods (searchByTitle, searchByAuthor, searchByISBN) work initially. But then someone wants compound search: title AND author. Or fuzzy search. Or full-text. Each new mode means a new method on Library. Strategy lets the caller compose search logic externally and pass it in. Library just runs whatever strategy it receives.
Why Observer for reservation notifications?
When a book is returned and someone is waiting, you need to notify them. Doing it inline means the return method knows about email, SMS, push notifications, and whatever comes next. Observer decouples the trigger (book returned) from the response (notification sent). Add a new channel by registering a new observer. The return logic stays the same.
Why is fine calculation on Loan, not on Library?
The fine depends on the due date and return date, both of which belong to the Loan. Library should not need to know the fine formula. If you want tiered fines later, you can extract a FineStrategy and inject it into Loan. The Library just calls loan.calculateFine() and gets a number back.
Interview Follow-ups
- "How would you handle multiple branches?" Add a Branch entity. Each BookCopy belongs to a branch. Inter-branch transfers move the copy and update its branch reference. Search can filter by branch or search across all.
- "What about ebook lending?" Ebooks have no physical copy, but they have license limits (e.g., 3 concurrent readers). Model EbookLicense as a virtual copy with availability based on active licenses, not physical presence.
- "How would you implement a recommendation system?" Track checkout history per member. Use collaborative filtering: members who checked out similar books tend to share interests. This stays separate from core library logic as its own service.
- "How do you handle lost books?" Add a LOST status to BookCopy. Charge a replacement fine to the member's account. The Loan closes, but the copy does not return to the available pool.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from collections import deque
4 from datetime import datetime, timedelta
5 import uuid
6
7
8 class BookCopy:
9 """Individual physical copy of a book."""
10
11 def __init__(self, isbn: str):
12 self.copy_id = str(uuid.uuid4())[:8]
13 self.isbn = isbn
14 self.available = True
15
16 def __repr__(self) -> str:
17 status = "available" if self.available else "checked out"
18 return f"Copy({self.copy_id}, {status})"
19
20
21 class Book:
22 """Catalog entity. One Book can have many BookCopy instances."""
23
24 def __init__(self, isbn: str, title: str, author: str):
25 self.isbn = isbn
26 self.title = title
27 self.author = author
28 self._copies: list[BookCopy] = []
29 self._reservation_queue: deque[str] = deque()
30
31 def add_copy(self, copy: BookCopy) -> None:
32 self._copies.append(copy)
33
34 def has_available_copy(self) -> bool:
35 return any(c.available for c in self._copies)
36
37 def get_available_copy(self) -> BookCopy | None:
38 for c in self._copies:
39 if c.available:
40 return c
41 return None
42
43 def total_copies(self) -> int:
44 return len(self._copies)
45
46 def available_copies(self) -> int:
47 return sum(1 for c in self._copies if c.available)
48
49 def add_reservation(self, member_id: str) -> None:
50 if member_id not in self._reservation_queue:
51 self._reservation_queue.append(member_id)
52
53 def next_reservation(self) -> str | None:
54 return self._reservation_queue.popleft() if self._reservation_queue else None
55
56 def has_reservations(self) -> bool:
57 return len(self._reservation_queue) > 0
58
59 def __repr__(self) -> str:
60 return f"Book({self.title} by {self.author}, {self.available_copies()}/{self.total_copies()} available)"
61
62
63 class Member:
64 """Library member with checkout limits."""
65
66 def __init__(self, member_id: str, name: str, max_books: int = 5):
67 self.id = member_id
68 self.name = name
69 self.max_books = max_books
70 self._active_loans: list[str] = []
71
72 def can_checkout(self) -> bool:
73 return len(self._active_loans) < self.max_books
74
75 def add_loan(self, loan_id: str) -> None:
76 self._active_loans.append(loan_id)
77
78 def remove_loan(self, loan_id: str) -> None:
79 self._active_loans.remove(loan_id)
80
81 def active_loan_count(self) -> int:
82 return len(self._active_loans)
83
84 def __repr__(self) -> str:
85 return f"Member({self.name}, {self.active_loan_count()}/{self.max_books} books)"
86
87
88 class Loan:
89 """Checkout record with fine calculation."""
90
91 FINE_PER_DAY = 25 # cents
92
93 def __init__(self, copy_id: str, member_id: str, loan_days: int = 14):
94 self.loan_id = str(uuid.uuid4())[:8]
95 self.copy_id = copy_id
96 self.member_id = member_id
97 self.checkout_date = datetime.now()
98 self.due_date = self.checkout_date + timedelta(days=loan_days)
99
100 def calculate_fine(self, return_date: datetime) -> int:
101 if return_date <= self.due_date:
102 return 0
103 overdue_days = (return_date - self.due_date).days
104 return overdue_days * self.FINE_PER_DAY
105
106 def __repr__(self) -> str:
107 return f"Loan({self.loan_id}, copy={self.copy_id}, due={self.due_date.date()})"
108
109
110 class SearchStrategy(ABC):
111 """Strategy interface for book searching."""
112
113 @abstractmethod
114 def search(self, books: list[Book], query: str) -> list[Book]: ...
115
116
117 class TitleSearch(SearchStrategy):
118 def search(self, books: list[Book], query: str) -> list[Book]:
119 q = query.lower()
120 return [b for b in books if q in b.title.lower()]
121
122
123 class AuthorSearch(SearchStrategy):
124 def search(self, books: list[Book], query: str) -> list[Book]:
125 q = query.lower()
126 return [b for b in books if q in b.author.lower()]
127
128
129 class ISBNSearch(SearchStrategy):
130 def search(self, books: list[Book], query: str) -> list[Book]:
131 return [b for b in books if b.isbn == query]
132
133
134 class LibraryObserver(ABC):
135 """Observer for library events like book availability."""
136
137 @abstractmethod
138 def on_book_available(self, book: Book, member_id: str) -> None: ...
139
140
141 class ConsoleObserver(LibraryObserver):
142 def on_book_available(self, book: Book, member_id: str) -> None:
143 print(f" [Notification] '{book.title}' is now available for member {member_id}")
144
145
146 class Library:
147 """Facade coordinating all library operations."""
148
149 def __init__(self):
150 self._books: dict[str, Book] = {}
151 self._members: dict[str, Member] = {}
152 self._loans: dict[str, Loan] = {}
153 self._copy_to_isbn: dict[str, str] = {}
154 self._observers: list[LibraryObserver] = []
155
156 def add_observer(self, observer: LibraryObserver) -> None:
157 self._observers.append(observer)
158
159 def add_book(self, book: Book, num_copies: int = 1) -> None:
160 self._books[book.isbn] = book
161 for _ in range(num_copies):
162 copy = BookCopy(book.isbn)
163 book.add_copy(copy)
164 self._copy_to_isbn[copy.copy_id] = book.isbn
165
166 def register_member(self, member: Member) -> None:
167 self._members[member.id] = member
168
169 def checkout(self, member_id: str, isbn: str) -> Loan:
170 member = self._members.get(member_id)
171 if member is None:
172 raise ValueError(f"Member {member_id} not found")
173
174 if not member.can_checkout():
175 raise ValueError(f"{member.name} has reached checkout limit ({member.max_books})")
176
177 book = self._books.get(isbn)
178 if book is None:
179 raise ValueError(f"Book {isbn} not found")
180
181 copy = book.get_available_copy()
182 if copy is None:
183 raise ValueError(f"No copies of '{book.title}' available")
184
185 copy.available = False
186 loan = Loan(copy.copy_id, member_id)
187 self._loans[loan.loan_id] = loan
188 member.add_loan(loan.loan_id)
189 print(f" Checked out: '{book.title}' (copy {copy.copy_id}) to {member.name}")
190 return loan
191
192 def return_book(self, loan_id: str, return_date: datetime | None = None) -> int:
193 loan = self._loans.get(loan_id)
194 if loan is None:
195 raise ValueError(f"Loan {loan_id} not found")
196
197 if return_date is None:
198 return_date = datetime.now()
199
200 fine = loan.calculate_fine(return_date)
201
202 # Mark copy as available
203 isbn = self._copy_to_isbn[loan.copy_id]
204 book = self._books[isbn]
205 for copy in book._copies:
206 if copy.copy_id == loan.copy_id:
207 copy.available = True
208 break
209
210 # Update member
211 member = self._members[loan.member_id]
212 member.remove_loan(loan_id)
213 del self._loans[loan_id]
214
215 print(f" Returned: '{book.title}' (copy {loan.copy_id}) by {member.name}, fine: {fine} cents")
216
217 # Check reservation queue
218 if book.has_reservations():
219 next_member = book.next_reservation()
220 for obs in self._observers:
221 obs.on_book_available(book, next_member)
222
223 return fine
224
225 def reserve_book(self, member_id: str, isbn: str) -> None:
226 book = self._books.get(isbn)
227 if book is None:
228 raise ValueError(f"Book {isbn} not found")
229 book.add_reservation(member_id)
230 print(f" Reservation placed: '{book.title}' for member {member_id}")
231
232 def search(self, strategy: SearchStrategy, query: str) -> list[Book]:
233 return strategy.search(list(self._books.values()), query)
234
235
236 if __name__ == "__main__":
237 library = Library()
238 library.add_observer(ConsoleObserver())
239
240 # Add books with multiple copies
241 print("--- Setup ---")
242 library.add_book(Book("978-0-13-468599-1", "The Pragmatic Programmer", "David Thomas"), num_copies=3)
243 library.add_book(Book("978-0-201-63361-0", "Design Patterns", "Gang of Four"), num_copies=2)
244 library.add_book(Book("978-0-596-51774-8", "JavaScript: The Good Parts", "Douglas Crockford"), num_copies=1)
245
246 # Register members
247 alice = Member("m1", "Alice", max_books=3)
248 bob = Member("m2", "Bob", max_books=2)
249 library.register_member(alice)
250 library.register_member(bob)
251
252 # Checkout
253 print("\n--- Checkouts ---")
254 loan1 = library.checkout("m1", "978-0-13-468599-1")
255 loan2 = library.checkout("m2", "978-0-201-63361-0")
256 loan3 = library.checkout("m1", "978-0-596-51774-8")
257
258 # Search by different strategies
259 print("\n--- Search ---")
260 results = library.search(TitleSearch(), "pragmatic")
261 print(f" Title search 'pragmatic': {results}")
262
263 results = library.search(AuthorSearch(), "crockford")
264 print(f" Author search 'crockford': {results}")
265
266 results = library.search(ISBNSearch(), "978-0-201-63361-0")
267 print(f" ISBN search: {results}")
268
269 # Reserve the last JS copy (already checked out)
270 print("\n--- Reservation ---")
271 library.reserve_book("m2", "978-0-596-51774-8")
272
273 # Return with fine (simulate overdue by adjusting due date)
274 print("\n--- Returns ---")
275 late_return = datetime.now() + timedelta(days=21)
276 fine = library.return_book(loan3.loan_id, late_return)
277 print(f" Fine amount: {fine} cents")
278
279 # Normal return
280 library.return_book(loan1.loan_id)
281
282 # Check member status
283 print(f"\n Alice: {alice}")
284 print(f" Bob: {bob}")
285
286 # Checkout limit
287 print("\n--- Checkout Limit ---")
288 library.checkout("m2", "978-0-13-468599-1")
289 try:
290 library.checkout("m2", "978-0-596-51774-8") # Bob already has 2
291 except ValueError as e:
292 print(f" Caught expected error: {e}")
293
294 print("\nAll scenarios completed.")Common Mistakes
- ✗Treating Book and BookCopy as one entity. Then you cannot track which specific copy is checked out or damaged.
- ✗Hardcoding checkout limits. Different membership tiers should have different limits.
- ✗Calculating fines at checkout time instead of return time. Fines depend on how late the book actually comes back.
- ✗Not handling the case where a member returns a book that someone else has reserved. The reservation queue needs to trigger automatically.
Key Points
- ✓Book vs BookCopy separation mirrors real libraries. Five people can request the same title because five copies exist independently.
- ✓Strategy pattern on search lets you add new search criteria without modifying the Library class.
- ✓Fine calculation is its own strategy. Flat rate per day, tiered rates, or grace periods are all just different implementations.
- ✓Observer pattern notifies members when a reserved book becomes available. No polling required.