Skip to main content
Low-Level Design · lesson 7 of 18
Open the Low-Level Design page →
Low-Level Design intermediate Lesson 7 of 18

Design a Library Management System: Borrowing and Returning Books

Real date arithmetic decides whether a returned book is late and what the fine is — not a string comparison, and not a guess.

By SkillByExample · Published

lld oop python library-system

A library system’s actual complexity isn’t the catalog — it’s computing “late” correctly.

Borrowing, returning, and fines

from datetime import date, timedelta

class Book:
    def __init__(self, isbn, title, copies):
        self.isbn = isbn
        self.title = title
        self.total_copies = copies
        self.available = copies

class Library:
    LOAN_DAYS = 14
    FINE_PER_DAY = 10

    def __init__(self):
        self.catalog = {}
        self.loans = {}  # (member_id, isbn) -> due_date

    def add_book(self, book):
        self.catalog[book.isbn] = book

    def borrow(self, member_id, isbn, today):
        book = self.catalog.get(isbn)
        if book is None:
            print(f"No such book: {isbn}")
            return
        if book.available <= 0:
            print(f"'{book.title}' has no copies available")
            return
        book.available -= 1
        due_date = today + timedelta(days=self.LOAN_DAYS)
        self.loans[(member_id, isbn)] = due_date
        print(f"{member_id} borrowed '{book.title}', due {due_date}")

    def return_book(self, member_id, isbn, today):
        key = (member_id, isbn)
        due_date = self.loans.pop(key, None)
        if due_date is None:
            print(f"{member_id} has no active loan for {isbn}")
            return
        book = self.catalog[isbn]
        book.available += 1
        if today > due_date:
            days_late = (today - due_date).days
            fine = days_late * self.FINE_PER_DAY
            print(f"{member_id} returned '{book.title}' {days_late} day(s) late. Fine: {fine}")
        else:
            print(f"{member_id} returned '{book.title}' on time")

lib = Library()
lib.add_book(Book("978-1", "Clean Code", 1))

lib.borrow("alice", "978-1", date(2026, 1, 1))
lib.borrow("bob", "978-1", date(2026, 1, 2))
lib.return_book("alice", "978-1", date(2026, 1, 20))
lib.borrow("bob", "978-1", date(2026, 1, 21))
$ python library.py
alice borrowed 'Clean Code', due 2026-01-15
'Clean Code' has no copies available
alice returned 'Clean Code' 5 day(s) late. Fine: 50
bob borrowed 'Clean Code', due 2026-02-04

Bob’s first borrow attempt fails — the library’s only copy is with Alice. Once Alice returns it 5 days late (2026-01-20 vs. the 2026-01-15 due date), Bob’s second attempt succeeds, and his own due date is computed fresh from his borrow date, not Alice’s.

Practice

1. Borrow the same title with two available copies.
from datetime import date, timedelta

class Book:
    def __init__(self, isbn, title, copies):
        self.isbn = isbn
        self.title = title
        self.available = copies

class Library:
    LOAN_DAYS = 14
    def __init__(self):
        self.catalog = {}
        self.loans = {}
    def add_book(self, book):
        self.catalog[book.isbn] = book
    def borrow(self, member_id, isbn, today):
        book = self.catalog.get(isbn)
        if book.available <= 0:
            print(f"'{book.title}' has no copies available")
            return
        book.available -= 1
        due_date = today + timedelta(days=self.LOAN_DAYS)
        self.loans[(member_id, isbn)] = due_date
        print(f"{member_id} borrowed '{book.title}', due {due_date}. Copies left: {book.available}")

lib = Library()
lib.add_book(Book("978-2", "The Pragmatic Programmer", 2))
lib.borrow("alice", "978-2", date(2026, 1, 1))
lib.borrow("bob", "978-2", date(2026, 1, 1))
lib.borrow("carol", "978-2", date(2026, 1, 1))
$ python two_copies.py
alice borrowed 'The Pragmatic Programmer', due 2026-01-15. Copies left: 1
bob borrowed 'The Pragmatic Programmer', due 2026-01-15. Copies left: 0
'The Pragmatic Programmer' has no copies available

Two copies means two successful borrows before the third is refused — available reaching 0 is what triggers the rejection, not a hardcoded “one copy” assumption anywhere in borrow().

2. Confirm returning a book with no active loan is rejected cleanly.
from datetime import date

class Library:
    def __init__(self):
        self.loans = {}
    def return_book(self, member_id, isbn, today):
        key = (member_id, isbn)
        due_date = self.loans.pop(key, None)
        if due_date is None:
            print(f"{member_id} has no active loan for {isbn}")
            return

lib = Library()
lib.return_book("dave", "978-1", date(2026, 1, 1))
$ python bad_return.py
dave has no active loan for 978-1

dict.pop(key, None) is what makes this safe — it returns None for a missing key instead of raising a KeyError, so the rejection is a normal, expected code path rather than a crash.

3. Confirm an on-time return charges no fine.
from datetime import date, timedelta

class Book:
    def __init__(self, isbn, title, copies):
        self.isbn = isbn
        self.title = title
        self.available = copies

class Library:
    LOAN_DAYS = 14
    FINE_PER_DAY = 10
    def __init__(self):
        self.catalog = {}
        self.loans = {}
    def add_book(self, book):
        self.catalog[book.isbn] = book
    def borrow(self, member_id, isbn, today):
        book = self.catalog[isbn]
        book.available -= 1
        due_date = today + timedelta(days=self.LOAN_DAYS)
        self.loans[(member_id, isbn)] = due_date
        print(f"{member_id} borrowed '{book.title}', due {due_date}")
    def return_book(self, member_id, isbn, today):
        due_date = self.loans.pop((member_id, isbn))
        book = self.catalog[isbn]
        book.available += 1
        if today > due_date:
            days_late = (today - due_date).days
            print(f"{member_id} returned '{book.title}' {days_late} day(s) late. Fine: {days_late * self.FINE_PER_DAY}")
        else:
            print(f"{member_id} returned '{book.title}' on time")

lib = Library()
lib.add_book(Book("978-3", "Refactoring", 1))
lib.borrow("erin", "978-3", date(2026, 1, 1))
lib.return_book("erin", "978-3", date(2026, 1, 10))
$ python on_time.py
erin borrowed 'Refactoring', due 2026-01-15
erin returned 'Refactoring' on time

2026-01-10 is before the 2026-01-15 due date, so today > due_date is False and the fine branch never runs.

Next: an expense-splitting app that has to compute the minimum number of payments to settle a group’s balances.

Frequently Asked Questions

Why is the loan key (member_id, isbn) a tuple instead of two separate dictionaries?
A loan only makes sense as the combination of who borrowed what — Alice borrowing book X and Bob borrowing book X are two independent loans that must be tracked separately, and a tuple key naturally expresses that composite identity. Two separate dicts (one for member->book, one for book->member) would need to be kept in sync manually and could drift apart.
Why pass today as a parameter instead of calling date.today() inside the methods?
Passing the date in makes every example in this lesson deterministic and reproducible — running it next year produces the exact same output shown here, which is a hard requirement for this site's format. It's also simply better design: a class that depends on the wall clock is much harder to test, since a test can't control what 'today' is without this kind of injection.
What happens if available copies is negative — can that happen here?
No — `borrow()` checks `book.available <= 0` and refuses before decrementing, so `available` can never go below zero through this code path. This is the same invariant-protection pattern from the very first lesson in this track (the vending machine), applied to a different domain.
Why does the fine calculation use (today - due_date).days instead of subtracting date objects directly?
Subtracting two `date` objects in Python already returns a `timedelta`, and `.days` extracts the integer day count from it — `today - due_date` alone would print as something like `timedelta(days=5)`, not the plain integer `5` this lesson needs for the fine math (`days_late * FINE_PER_DAY`).