A well-designed system separates domain modeling, inventory management, borrowing logic, and orchestration. This separation ensures that the system remains clean, extensible, and easy to evolve with features like reservations, fines, and digital catalogs.
A library consists of a collection of books, each having multiple copies. Users can search for books, issue available copies, and return them after use. The system must track availability, manage borrow transactions, and enforce rules such as borrowing limits and due dates.
Domain Modeling
Book
A Book represents the abstract definition of a title, independent of its physical copies.ISBN stands for International Standard Book Number. It is a unique identifier assigned to each book edition and format, used globally to distinguish one book from another.
@Entity
class Book {
private final String isbn;
private final String title;
private final String author;
public Book(String isbn, String title, String author) {
this.isbn = isbn;
this.title = title;
this.author = author;
}
// Getters
}
This separation allows multiple copies of the same book to exist without duplicating metadata.
Book Copy
A BookCopy represents a physical instance of a book and maintains its availability state.import java.util.UUID;
enum BookStatus {
AVAILABLE,
ISSUED,
RESERVED
}
@Entity
class BookCopy {
private final UUID id;
private final Book book;
private BookStatus status;
public BookCopy(UUID id, Book book) {
this.id = id;
this.book = book;
this.status = BookStatus.AVAILABLE;
}
public boolean isAvailable() {
return this.status == BookStatus.AVAILABLE;
}
public void markReserved() {
this.status = BookStatus.RESERVED;
}
public void markIssued() {
this.status = BookStatus.ISSUED;
}
public void markReturned() {
this.status = BookStatus.AVAILABLE;
}
}
ISSUED means the book copy is physically given to a user. The copy is no longer in the library and cannot be accessed by anyone else until it is returned.
RESERVED means the book copy is set aside for a user, but not yet collected. The copy is still in the library, but others are not allowed to take it because someone has priority over it.
This design ensures that availability is tracked at the copy level, not the book level.
User
A User represents a library member who can borrow books.@Entity
class User {
private final String id;
private final String name;
private final int maxBooksAllowed;
public User(String id, String name, int maxBooksAllowed) {
this.id = id;
this.name = name;
this.maxBooksAllowed = maxBooksAllowed;
}
// Getters
}
Borrow Transaction
Each issue/return operation is modeled as a transaction, ensuring proper tracking and auditing.import java.time.Instant;
import java.util.UUID;
@Entity
class BorrowTransaction {
private final UUID id;
private final BookCopy bookCopy;
private final User user;
private final Instant issueTime;
private Instant returnTime;
public BorrowTransaction(UUID id, BookCopy bookCopy, User user) {
this.id = id;
this.bookCopy = bookCopy;
this.user = user;
this.issueTime = Instant.now();
}
public void markReturned() {
this.returnTime = Instant.now();
}
public boolean isActive() {
return returnTime == null;
}
}
This approach ensures that every borrow action is recorded, enabling features like history tracking and fine calculation.
Catalog
The Catalog enables searching books efficiently.import java.util.*;
@Component
class Catalog {
private final Map> titleIndex = new HashMap<>();
private final Map> authorIndex = new HashMap<>();
private final BookRepository repository;
public Catalog(BookRepository repository) {
this.repository = repository;
}
// Called once on application startup
public void initialize() {
List books = repository.findAll();
for (Book book : books) {
index(book);
}
}
// Internal indexing logic
private void index(Book book) {
titleIndex
.computeIfAbsent(book.getTitle(), k -> new ArrayList<>())
.add(book);
authorIndex
.computeIfAbsent(book.getAuthor(), k -> new ArrayList<>())
.add(book);
}
// Write-through: update DB + cache
public void addBook(Book book) {
repository.save(book); // persist first (source of truth)
index(book); // then update in-memory index
}
public List searchByTitle(String title) {
return titleIndex.getOrDefault(title, Collections.emptyList());
}
public List searchByAuthor(String author) {
return authorIndex.getOrDefault(author, Collections.emptyList());
}
}
This avoids scanning all books and provides fast lookup.
Borrowing Strategy
The core challenge is managing how books are issued while ensuring availability and enforcing user constraints. A simple approach is to issue the first available copy, but a robust system must also validate whether the user has reached their borrowing limit and whether the book is already reserved.When a user requests a book, the system identifies an available copy and assigns it to the user. The copy's status is updated, and a BorrowTransaction is created. This ensures that the system maintains a consistent record of issued books.
If no available copy exists, the system may allow users to reserve the book, transitioning a copy to RESERVED state. Reservations should be modeled as a separate entity with FIFO ordering.
On return, the transaction is marked complete, and the book copy is made available again. This transition ensures that the system accurately reflects real-world inventory changes.
Concurrency Considerations
In a real-world library system, multiple users may attempt to borrow the same book copy at nearly the same time. The core challenge is not just handling parallel requests, but ensuring that the system maintains a consistent and correct state even under contention. Without proper safeguards, two users could both see a copy as available and end up borrowing the same physical book, which is a classic race condition.Consider a scenario where two users request the same copy simultaneously. Both requests read the current state as AVAILABLE, and both proceed to mark it as ISSUED. If these operations are not coordinated, both transactions may succeed, leading to duplicate issuance. This breaks the fundamental invariant that a single copy can only be held by one user at a time.
To prevent this, the system must ensure that the transition of a book copy's state—from AVAILABLE → ISSUED—happens atomically. Atomicity guarantees that the check ("is this available?") and the update ("mark as issued") occur as a single indivisible operation.
At the database level, this is typically enforced using conditional updates or locking mechanisms. For example, a safe approach is:
UPDATE book_copy
SET status = 'ISSUED'
WHERE id = :copyId AND status = 'AVAILABLE';
If this query updates exactly one row, the borrow succeeds. If it updates zero rows, it means another transaction has already taken the copy. This pattern ensures only one winner without requiring heavy locks.
Alternatively, pessimistic locking can be used, where the system locks the row before checking availability. This guarantees correctness but may reduce throughput due to blocking. A more scalable approach is optimistic concurrency control, where the system attempts the update and retries only if a conflict occurs.
Beyond allocation, concurrency must also be handled during returns and reservations. For example, returning a book should not accidentally overwrite a state where the copy was already reserved by another user. Similarly, reservation systems must ensure that priority is respected without allowing multiple users to claim the same reserved copy.
At the application level, it is important to avoid relying solely on in-memory checks like
isAvailable(), because they are inherently stale under concurrency. The database must always act as the final authority for state transitions.
System Orchestration
The LibraryService coordinates all operations and enforces business rules.import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class LibraryService {
private final LibraryRepository repository;
public LibraryService(LibraryRepository repository) {
this.repository = repository;
}
@Transactional
public BorrowTransaction borrowBook(User user, UUID copyId) {
// Step 1: Enforce user borrowing limit
if (repository.countActiveBorrowings(user.getId()) >= user.getMaxBooksAllowed()) {
throw new RuntimeException("Borrow limit exceeded");
}
// Step 2: Atomic DB allocation
boolean success = repository.markIssuedIfAvailable(copyId);
if (!success) {
throw new RuntimeException("Book not available");
}
// Step 3: Fetch allocated copy and create transaction
BookCopy copy = repository.findById(copyId);
return new BorrowTransaction(UUID.randomUUID(), copy, user);
}
@Transactional
public void returnBook(BorrowTransaction transaction) {
repository.markAvailable(transaction.getBookCopy().getId());
transaction.markReturned();
}
}
This layer ensures that all operations follow a consistent flow and business constraints are enforced centrally. User limit enforcement should ideally be atomic at DB level to avoid race conditions.
-- Example idea
UPDATE user
SET borrowed_count = borrowed_count + 1
WHERE id = :userId
AND borrowed_count < max_allowed;
Conclusion
Designing a library management system demonstrates how to manage shared resources with proper state transitions and constraints. The focus is on accurate inventory tracking, clear separation of concerns, and robust transaction handling.A strong design ensures that the system remains consistent under concurrent usage while being flexible enough to support future features such as reservations, fine calculation, and digital borrowing.
Join the discussion