Low Level Design

Design a Library Management System

A full low-level design for a library — rack-based book storage, dual borrow flows (by book ID or copy ID), multi-attribute search, borrow-log tracking, and a thin facade that separates the public API from the core engine.

August 16, 2026·18 min read

Problem Description#

Design a Library Management System that automates cataloguing, checkout, return, and search operations for a physical library.

A library is a richer domain than it first appears. Multiple copies of the same book exist and must be tracked independently. Racks enforce physical placement rules — one book title per rack, so the same book can't appear twice in the same rack. Borrowing a book can happen two ways: grab any available copy of a given title, or reserve a specific copy by ID. Every borrow action must be logged with dates, and users face hard limits on how many books they may hold.

This is a classic hard LLD interview problem: it involves a rich domain model, multiple interacting entities, two distinct borrow flows that share common steps (Template Method), pluggable search (Strategy), a facade layer, and careful attention to invariant enforcement throughout.


Clarify Requirements#

Before designing, ask these questions in an interview:

Functional

  • How many copies of the same book can exist, and can the same title appear on the same rack?
  • Can a user borrow by book title/ID (any copy) or must they always specify a copy?
  • What is the borrow period, and is there a late-fee model?
  • What happens when a user tries to borrow beyond their limit?
  • What happens when all copies of a book are borrowed and a user tries to borrow it?
  • Which attributes should search support — ID, title, author, publisher?
  • When a book copy is returned, where does it go back on the shelf?

Non-functional

  • Should concurrent borrow requests be handled safely?
  • Does the borrow log need to be persisted or is in-memory sufficient?
  • Is the CLI interaction model fixed, or will a REST API be added later?
  • Should removing the last copy of a book auto-remove the book record?

Final Requirements#

After clarification, here's what we'll build:

  • A library is initialised with N racks; each rack holds at most one copy of any given book title
  • addBook creates a Book record and places each BookCopy into the first available rack (ascending rack order)
  • borrowBook(bookId) finds the lowest-rack available copy of a book and marks it borrowed; borrowBookCopy(copyId) borrows a specific copy — both share common validation steps
  • Due date is set to 14 days from the borrow date; every borrow action creates a LibraryBorrowedLog entry
  • Users have a borrow limit of 5 books; attempts beyond the limit are rejected
  • returnBookCopy clears borrow details, marks the log, and places the copy in the first available rack
  • searchBooks supports four attributes: book_id, title, author, publisher
  • LibraryManagementInterface is a thin facade that parses string inputs and delegates to the core LibraryManagementSystem engine
  • Removing the last copy of a book automatically removes the book record

Core Entities#

EntityResponsibility
BookImmutable catalogue record — ID, title, authors, publishers, copy ID list
BookCopyExtends Book; adds copy-specific state — copy ID, rack number, borrower, due date
RackPhysical shelf unit — holds a list of BookCopy objects; prevents the same book title appearing twice via a Set<String> of book IDs
LibraryUserUser record — ID, name, borrow limit, list of currently borrowed copies
LibraryBorrowedLogAudit record per borrow — borrow date, due date, return date (null until returned)
LibraryManagementSystemCore engine — owns all maps and racks; implements every operation; enforces all invariants
LibraryManagementInterfacePublic facade — parses comma-separated string params, delegates to the engine
LibraryManagementSystemSimulationCLI entry point — reads commands from stdin, dispatches to the facade

Patterns Used#

1. Facade — LibraryManagementInterface#

The facade absorbs input parsing (splitting comma-separated strings into List<String>) and exposes a friendly API. External callers — the CLI simulation, a future REST controller — never touch LibraryManagementSystem directly. This keeps the engine's API strongly typed while the facade handles the messy string world.

LibraryManagementSystemSimulation
  └─ LibraryManagementInterface   ← facade: parses strings, delegates
       └─ LibraryManagementSystem ← engine: all business logic

2. Template Method — dual borrow flows#

borrowBook(bookId) and borrowBookCopy(copyId) share the same structure:

  1. Validate the user (exists? within borrow limit?)
  2. Locate the target copy
  3. Compute due date, create a LibraryBorrowedLog, mark the copy borrowed, add to user's list

Step 2 is the only variation — one searches by book ID across racks, the other looks up a copy directly by ID. Both paths converge on the same steps 3–4, avoiding duplication.

3. Strategy — searchBooks by attribute#

searchBooks(attribute, value) dispatches to one of four private methods based on the attribute string. Each method (searchByBookId, searchByTitle, searchByAuthor, searchByPublisher) applies its own matching logic against copyIdToBookCopy. Adding a new search dimension (e.g., isbn) means adding one private method and one case — the public signature never changes.

4. Factory-like construction — addBook#

LibraryManagementSystem.addBook() is responsible for creating BookCopy instances from the raw copy-ID list. Callers never instantiate BookCopy directly; the engine controls object creation, assigns rack numbers, and registers copies in both the rack and the copy map.

5. Composition — Library owns everything#

LibraryManagementSystem holds four maps and a log list. Rack holds a list of BookCopy objects plus a duplicate-prevention set. LibraryUser holds a list of borrowed copies. No entity reaches up into the library to mutate global state — the engine orchestrates all cross-entity operations.


Code#

Domain models — Book and BookCopy#

Book is an immutable catalogue record. BookCopy extends it and adds mutable borrow state — the copy's current rack number, who borrowed it, and when it's due.

java
import java.util.List;

public class Book {
    private final String bookId;
    private final String title;
    private final List<String> authors;
    private final List<String> publishingCompany;
    private final List<String> bookCopyIds;

    public Book(String bookId, String title, List<String> authors,
                List<String> publishingCompany, List<String> bookCopyIds) {
        this.bookId = bookId;
        this.title = title;
        this.authors = authors;
        this.publishingCompany = publishingCompany;
        this.bookCopyIds = bookCopyIds;
    }

    public String getBookId()                    { return bookId; }
    public String getTitle()                     { return title; }
    public List<String> getAuthors()             { return authors; }
    public List<String> getPublishingCompany()   { return publishingCompany; }
    public List<String> getBookCopyIds()         { return bookCopyIds; }
}

Supporting models — User, Rack, BorrowedLog#

LibraryUser guards the borrow limit in addBorrowedBook(). Rack prevents duplicate book titles with a Set<String> of book IDs. LibraryBorrowedLog is the audit trail — one entry per borrow transaction, updated when the book is returned.

java
import java.util.ArrayList;
import java.util.List;

public class LibraryUser {
    private final String userId;
    private final String name;
    private final Integer borrowLimit;
    private final List<BookCopy> borrowedBooks;

    public LibraryUser(String userId, String name, Integer borrowLimit) {
        this.userId       = userId;
        this.name         = name;
        this.borrowLimit  = borrowLimit;
        this.borrowedBooks = new ArrayList<>();
    }

    public String getUserId()               { return userId; }
    public String getName()                 { return name; }
    public Integer getBorrowLimit()         { return borrowLimit; }
    public List<BookCopy> getBorrowedBooks(){ return borrowedBooks; }

    public void addBorrowedBook(BookCopy bookCopy) {
        if (borrowedBooks.size() < borrowLimit) {
            borrowedBooks.add(bookCopy);
        } else {
            System.out.println("[ERROR]   | User " + name + " has reached the borrow limit.");
        }
    }
}

Core engine — LibraryManagementSystem#

The engine owns all data structures and enforces every invariant. addBook places copies into the lowest-numbered available racks. Both borrow flows validate the user and log the transaction before marking the copy. returnBookCopy clears borrow state and re-shelves the copy. searchBooks dispatches to the right private search method based on the attribute key.

java
import java.util.*;

public class LibraryManagementSystem {
    private final String libraryId;
    private final Integer borrowLimitPerUser = 5;
    private final Integer totalRacks;
    private final Map<Integer, Rack> racks;             // rackNumber → Rack
    private final Map<String, BookCopy> copyIdToBookCopy; // copyId → BookCopy
    private final Map<String, LibraryUser> userIdToLibraryUser;
    private final Map<String, Book> bookIdToBook;
    private final List<LibraryBorrowedLog> borrowedLogs;

    public LibraryManagementSystem(String libraryId, Integer totalRacks) {
        this.libraryId  = libraryId;
        this.totalRacks = totalRacks;
        this.racks      = new HashMap<>();
        for (int i = 1; i <= totalRacks; i++) {
            racks.put(i, new Rack(i));
        }
        this.copyIdToBookCopy    = new HashMap<>();
        this.userIdToLibraryUser = new HashMap<>();
        this.bookIdToBook        = new HashMap<>();
        this.borrowedLogs        = new ArrayList<>();
    }

    public Integer getBorrowLimitPerUser() { return borrowLimitPerUser; }

    // ── Add / Remove ─────────────────────────────────────────────────────────

    public void addBook(String bookId, String title, List<String> authorList,
                        List<String> publishingCompanyList, List<String> bookCopyIdList) {
        Book book = new Book(bookId, title, authorList, publishingCompanyList, bookCopyIdList);

        // Verify enough racks are available (one per copy)
        int availableRackCount = 0;
        for (Rack rack : racks.values()) {
            if (rack.checkAvailabilityForBook(new BookCopy("temp", book, rack.getRackNumber()))) {
                availableRackCount++;
            }
        }
        if (availableRackCount < bookCopyIdList.size()) {
            System.out.println("[ERROR]   | Not enough racks for book ID: " + bookId);
            return;
        }

        List<Integer> rackNumbers = new ArrayList<>();
        for (String copyId : bookCopyIdList) {
            for (int i = 1; i <= totalRacks; i++) {
                Rack rack = racks.get(i);
                BookCopy newBookCopy = new BookCopy(copyId, book, rack.getRackNumber());
                if (rack.checkAvailabilityForBook(newBookCopy)) {
                    rack.addBookCopy(newBookCopy);
                    copyIdToBookCopy.put(copyId, newBookCopy);
                    rackNumbers.add(rack.getRackNumber());
                    break;
                }
            }
        }
        bookIdToBook.put(bookId, book);
        System.out.println("[INFO]    | Added book to racks: " + rackNumbers);
    }

    public void removeBookCopy(String copyId) {
        BookCopy bookCopy = copyIdToBookCopy.get(copyId);
        if (bookCopy == null) {
            System.out.println("[ERROR]   | Invalid Book Copy ID");
            return;
        }
        Rack rack = racks.get(bookCopy.getRackNumber());
        if (rack != null) {
            rack.removeBookCopy(copyId);
            copyIdToBookCopy.remove(copyId);
            System.out.println("[INFO]    | Removed book copy: " + copyId + " from rack: " + rack.getRackNumber());

            boolean anyCopyLeft = copyIdToBookCopy.values().stream()
                    .anyMatch(c -> c.getBookId().equals(bookCopy.getBookId()));
            if (!anyCopyLeft) {
                bookIdToBook.remove(bookCopy.getBookId());
                System.out.println("[INFO]    | No copies left for Book ID: " + bookCopy.getBookId() + ". Book removed.");
            }
        }
    }

    public void addUser(String userId, String name) {
        LibraryUser user = new LibraryUser(userId, name, borrowLimitPerUser);
        userIdToLibraryUser.put(userId, user);
        System.out.println("[INFO]    | Added user: " + name + " with User ID: " + userId);
    }

    // ── Borrow ───────────────────────────────────────────────────────────────

    /** Borrow any available copy of a book by book ID (lowest rack preferred). */
    public void borrowBook(String bookId, String userId, Date borrowDate) {
        LibraryUser user = userIdToLibraryUser.get(userId);
        if (!verifyUser(user, true)) return;
        if (!bookIdToBook.containsKey(bookId)) {
            System.out.println("[ERROR]   | Invalid Book ID");
            return;
        }

        BookCopy availableCopy = null;
        outer:
        for (int i = 1; i <= totalRacks; i++) {
            for (BookCopy copy : racks.get(i).getBookCopyList()) {
                if (copy.getBookId().equals(bookId) && copy.isAvailable()) {
                    availableCopy = copy;
                    break outer;
                }
            }
        }
        if (availableCopy == null) {
            System.out.println("[ERROR]   | No available copies for Book ID: " + bookId);
            return;
        }
        completeBorrow(availableCopy, user, borrowDate);
    }

    /** Borrow a specific copy by its copy ID. */
    public void borrowBookCopy(String bookCopyId, String userId, Date borrowDate) {
        LibraryUser user = userIdToLibraryUser.get(userId);
        if (!verifyUser(user, true)) return;

        BookCopy bookCopy = copyIdToBookCopy.get(bookCopyId);
        if (bookCopy == null) {
            System.out.println("[ERROR]   | Invalid Book Copy ID");
            return;
        }
        if (!bookCopy.isAvailable()) {
            System.out.println("[ERROR]   | Book Copy ID: " + bookCopyId + " is not available");
            return;
        }
        completeBorrow(bookCopy, user, borrowDate);
    }

    /** Shared steps for both borrow flows: log, mark copy, update user. */
    private void completeBorrow(BookCopy copy, LibraryUser user, Date borrowDate) {
        Date dueDate = new Date(borrowDate.getTime() + (14L * 24 * 60 * 60 * 1000));
        LibraryBorrowedLog log = new LibraryBorrowedLog(
                UUID.randomUUID().toString(), copy, user, borrowDate, dueDate);
        borrowedLogs.add(log);
        copy.setBorrowedDetails(user, dueDate);
        user.addBorrowedBook(copy);
        System.out.println("[INFO]    | " + user.getName() + " borrowed copy ID: "
                + copy.getCopyId() + ", Due: " + dueDate + ", Rack: " + copy.getRackNumber());
    }

    // ── Return ───────────────────────────────────────────────────────────────

    public void returnBookCopy(String bookCopyId) {
        BookCopy bookCopy = copyIdToBookCopy.get(bookCopyId);
        if (bookCopy == null) {
            System.out.println("[ERROR]   | Invalid Book Copy ID");
            return;
        }
        for (LibraryBorrowedLog log : borrowedLogs) {
            if (log.getBookCopy().getCopyId().equals(bookCopyId) && log.getReturnDate() == null) {
                log.markAsReturned(new Date());
                break;
            }
        }
        LibraryUser user = bookCopy.getBorrowedBy();
        bookCopy.setBorrowedDetails(null, null);
        if (user != null) user.getBorrowedBooks().remove(bookCopy);
        returnBookCopyToRack(bookCopy);
        System.out.println("[INFO]    | Copy ID: " + bookCopyId + " returned to rack: " + bookCopy.getRackNumber());
    }

    private void returnBookCopyToRack(BookCopy bookCopy) {
        for (int i = 1; i <= totalRacks; i++) {
            Rack rack = racks.get(i);
            if (rack.checkAvailabilityForBook(bookCopy)) {
                rack.addBookCopy(bookCopy);
                bookCopy.setRackNumber(i);
                return;
            }
        }
        System.out.println("[WARN]    | No rack available for returned copy: " + bookCopy.getCopyId());
    }

    // ── Query ────────────────────────────────────────────────────────────────

    public void printBorrowedBooks(String userId) {
        if (!verifyUser(userIdToLibraryUser.get(userId))) return;
        LibraryUser user = userIdToLibraryUser.get(userId);
        List<BookCopy> borrowedBooks = user.getBorrowedBooks();
        if (borrowedBooks.isEmpty()) {
            System.out.println("[INFO]    | " + user.getName() + " has no borrowed books.");
            return;
        }
        System.out.println("[INFO]    | " + user.getName() + " borrowed:");
        for (BookCopy copy : borrowedBooks) {
            System.out.println("[DETAILS] | Copy: " + copy.getCopyId()
                    + " Title: " + copy.getTitle() + " Due: " + copy.getDueDate());
        }
    }

    public void searchBooks(String attribute, String value) {
        if (attribute == null || value == null || value.trim().isEmpty()) {
            System.out.println("[ERROR]   | Provide a valid attribute and value");
            return;
        }
        switch (attribute) {
            case "book_id"   -> searchByBookId(value);
            case "title"     -> searchByTitle(value);
            case "author"    -> searchByAuthor(value);
            case "publisher" -> searchByPublisher(value);
            default -> System.out.println("[ERROR]   | Allowed attributes: book_id, title, author, publisher");
        }
    }

    private void searchByBookId(String bookId) {
        boolean found = false;
        for (BookCopy copy : copyIdToBookCopy.values()) {
            if (copy.getBookId().equalsIgnoreCase(bookId)) { printBookCopyDetails(copy); found = true; }
        }
        if (!found) System.out.println("[ERROR]   | No books found for Book ID: " + bookId);
    }

    private void searchByTitle(String title) {
        boolean found = false;
        for (BookCopy copy : copyIdToBookCopy.values()) {
            if (copy.getTitle().equalsIgnoreCase(title)) { printBookCopyDetails(copy); found = true; }
        }
        if (!found) System.out.println("[ERROR]   | No books found for title: " + title);
    }

    private void searchByAuthor(String author) {
        boolean found = false;
        for (BookCopy copy : copyIdToBookCopy.values()) {
            if (copy.getAuthors() != null && copy.getAuthors().stream()
                    .anyMatch(a -> a.equalsIgnoreCase(author))) {
                printBookCopyDetails(copy); found = true;
            }
        }
        if (!found) System.out.println("[ERROR]   | No books found for author: " + author);
    }

    private void searchByPublisher(String publisher) {
        boolean found = false;
        for (BookCopy copy : copyIdToBookCopy.values()) {
            if (copy.getPublishingCompany() != null && copy.getPublishingCompany().stream()
                    .anyMatch(p -> p.equalsIgnoreCase(publisher))) {
                printBookCopyDetails(copy); found = true;
            }
        }
        if (!found) System.out.println("[ERROR]   | No books found for publisher: " + publisher);
    }

    private void printBookCopyDetails(BookCopy copy) {
        Book book       = copy.getBook();
        String authors  = String.join(", ", book.getAuthors());
        String pubs     = String.join(", ", book.getPublishingCompany());
        String borrower = copy.getBorrowedBy() != null ? copy.getBorrowedBy().getUserId() : "N/A";
        String dueDate  = "N/A";
        for (LibraryBorrowedLog log : borrowedLogs) {
            if (log.getBookCopy().equals(copy) && log.getReturnDate() == null) {
                dueDate = log.getDueDate().toString(); break;
            }
        }
        System.out.printf("Copy: %s | Book: %s | Title: %s | Authors: %s | Publishers: %s | Rack: %d | BorrowedBy: %s | Due: %s%n",
                copy.getCopyId(), book.getBookId(), book.getTitle(), authors, pubs,
                copy.getRackNumber(), borrower, dueDate);
    }

    public void displayRacks() {
        System.out.println("Current rack state:");
        for (Rack rack : racks.values()) rack.displayBookCopies();
    }

    // ── Validation helpers ───────────────────────────────────────────────────

    private boolean verifyUser(LibraryUser user) { return verifyUser(user, false); }

    private boolean verifyUser(LibraryUser user, boolean checkLimit) {
        if (user == null) { System.out.println("[ERROR]   | Invalid User ID"); return false; }
        if (checkLimit && user.getBorrowedBooks().size() >= borrowLimitPerUser) {
            System.out.println("[ERROR]   | User has reached the borrow limit"); return false;
        }
        return true;
    }
}

Facade and CLI simulation#

LibraryManagementInterface parses comma-delimited strings into lists, then delegates. LibraryManagementSystemSimulation is the interactive CLI driver — reads commands from stdin and dispatches to the facade.

java
import java.util.Arrays;
import java.util.Date;
import java.util.List;

public class LibraryManagementInterface {
    private final LibraryManagementSystem libraryManagementSystem;

    public LibraryManagementInterface(String libraryId, Integer totalRacks) {
        this.libraryManagementSystem = new LibraryManagementSystem(libraryId, totalRacks);
        System.out.println("[INFO]    | Created library with " + totalRacks + " racks");
    }

    public void addBook(String bookId, String title, String authors,
                        String publishingCompanies, String bookCopyIds) {
        List<String> authorList          = Arrays.asList(authors.split(","));
        List<String> publishingCompanyList = Arrays.asList(publishingCompanies.split(","));
        List<String> bookCopyIdList      = Arrays.asList(bookCopyIds.split(","));
        libraryManagementSystem.addBook(bookId, title, authorList, publishingCompanyList, bookCopyIdList);
    }

    public void removeBookCopy(String copyId)                          { libraryManagementSystem.removeBookCopy(copyId); }
    public void addUser(String userId, String name)                    { libraryManagementSystem.addUser(userId, name); }
    public void borrowBook(String bookId, String userId, Date date)    { libraryManagementSystem.borrowBook(bookId, userId, date); }
    public void borrowBookCopy(String copyId, String userId, Date date){ libraryManagementSystem.borrowBookCopy(copyId, userId, date); }
    public void returnBookCopy(String bookCopyId)                      { libraryManagementSystem.returnBookCopy(bookCopyId); }
    public void printBorrowedBooks(String userId)                      { libraryManagementSystem.printBorrowedBooks(userId); }
    public void searchBooks(String attribute, String value)            { libraryManagementSystem.searchBooks(attribute, value); }
    public void displayRacks()                                         { libraryManagementSystem.displayRacks(); }
    public void getBorrowLimitPerUser() {
        System.out.println("[DETAILS] | Borrow Limit: " + libraryManagementSystem.getBorrowLimitPerUser());
    }
}

Class Diagram#


Extendible — Follow Ups#

1. Late fee calculation#

Add a FinePolicy interface with a calculateFine(Date dueDate, Date returnDate) → BigDecimal method. LibraryManagementSystem.returnBookCopy() calls the policy after marking the log. A StandardFinePolicy charges a fixed daily rate; a GracePeriodFinePolicy waives fees for the first 3 days. Swap policies at construction time without touching the return logic.

2. Reservation queue#

When no copy is available, a user should be able to join a waiting list. Add a Map<String, Queue<LibraryUser>> bookIdToWaitlist in LibraryManagementSystem. When returnBookCopy is called, check the queue: if non-empty, automatically borrow the copy to the head of the queue rather than placing it back on a rack. This is the Observer pattern applied to book availability.

3. Thread-safe concurrent borrow#

Two users borrowing the last copy simultaneously can cause a race. Wrap the borrow methods with synchronized(this) or use ReentrantLock per book ID. A finer-grained approach locks on the BookCopy object itself — only the copy being borrowed is locked, not the whole engine.

4. Search via Strategy objects#

Replace the switch inside searchBooks with a Map<String, SearchStrategy> where SearchStrategy is a @FunctionalInterface taking (Collection<BookCopy>, String) → List<BookCopy>. Register each attribute strategy at construction time. Adding a new searchable attribute becomes a one-line map.put(...) call — no switch to modify.

5. Persistence with Repository pattern#

Extract a BookCopyRepository, UserRepository, and BorrowLogRepository interface from LibraryManagementSystem. The current in-memory HashMap becomes the default implementation; a JdbcBookCopyRepository can swap in for production. The engine depends only on the interfaces, so the storage layer is fully swappable.

6. Book copy inherits vs. holds a Book#

The current BookCopy extends Book creates an IS-A relationship where composition (HAS-A) would be more accurate — a copy is not a book, it holds a reference to one. A follow-up refactor removes the extends Book, drops the super(...) constructor call, and exposes all book properties through getBook().getXxx() delegation. This is a SOLID Liskov Substitution improvement: no copy can be passed where a Book is expected.