Library Management System in Java: Console Version to Spring Boot REST API
Build a library management system in Java in three stages: one runnable file, then proper classes, then a Spring Boot 4 REST API with JPA. Includes a real race condition (8 loans for 1 copy), the N+1 query trap, and every output captured from a real run.
The library management system is one of the most common college mini-projects: add books, register members, lend a book, take it back, charge a fine if it is late. It is a classic because it has just enough rules to be interesting and just enough objects to practise object-oriented design.
The usual stopping point is a console menu that forgets everything when you close it. This article takes that same project three steps further, in one continuous flow: first the whole system in a single Java file you can run in ten seconds, then the same logic split into proper classes, then a Spring Boot 4 REST API backed by a database, so a browser, a phone app or curl can use it. You do not need to know Spring. You need to know what a class and a method are.
The part most tutorials skip is what goes wrong when the console toy meets real users. Two people borrowing the last copy at the same moment is not a theory problem: I ran it, and without one annotation the library lent a single copy to eight people. That, a hidden pile of SQL queries, and a few surprises from Spring Boot 4 get the room they deserve below.
Verified against a real run. JDK 25.0.5 (Temurin, LTS), Spring Boot 4.1.2 (4.1.0 went GA on 10 June 2026), Spring Framework 7.0.10, Hibernate ORM 7.4.13.Final, Spring Data JPA 4.1.2, Jakarta Persistence 3.2.0, Tomcat 11.0.27, H2 2.4.240, Maven 3.9.11. Every output block below is copied from a file in the companion module, and the tests that produce those files also assert the numbers.
Start with one file: the whole library in under 100 lines
A library has three nouns: books (with a number of copies), members, and loans (this member has this book until this date). It has four verbs: add a book, add a member, borrow, return. Everything else is bookkeeping.
The smallest thing that works keeps all three nouns in plain Java collections and reads one command per line from the keyboard. Download LibraryConsole.java and the matching demo input, put them in one folder, and run it. No Maven, no IDE: since Java 11 the java launcher can compile and run a single source file.
The heart of the file is the borrow method. Read it as a checklist: does the book exist, does the member exist, is a copy free, is the member under the loan limit? Only if all four pass does a loan get created. This is LibraryConsole.java, shown trimmed to that method.
static void borrow(String isbn, int memberId, LocalDate on) {
Book book = books.get(isbn);
if (book == null) { System.out.println("ERROR: no such book " + isbn); return; }
if (memberId < 1 || memberId > members.size()) { System.out.println("ERROR: no such member #" + memberId); return; }
Member member = members.get(memberId - 1);
if (book.available == 0) { System.out.println("ERROR: no copies of '" + book.title + "' are available"); return; }
long active = loans.stream().filter(l -> l.returned == null && l.member.equals(member)).count();
if (active >= MAX_LOANS) { System.out.println("ERROR: " + member.name() + " already has " + active + " active loans (max " + MAX_LOANS + ")"); return; }
Loan loan = new Loan();
loan.id = loans.size() + 1; loan.book = book; loan.member = member; loan.due = on.plusDays(LOAN_DAYS);
loans.add(loan);
book.available--;
System.out.println("Loan #" + loan.id + ": " + member.name() + " borrowed '" + book.title + "', due " + loan.due);
}
Here is what the demo input produces. Notice the three refusals: a duplicate ISBN, a second person asking for the only copy, and a second return of the same loan. The fine of Rs 10 is five days late at Rs 2 a day. This is the real output, copied from 01-console-structured.txt (the single-file and the structured version print exactly the same thing, which I checked with diff).
> add-book 978-0134685991|Effective Java|Joshua Bloch|2
Added book: Effective Java (2 copies)
> add-book 978-0596009205|Head First Java|Kathy Sierra|1
Added book: Head First Java (1 copy)
> add-book 978-0134685991|Effective Java|Joshua Bloch|2
ERROR: ISBN 978-0134685991 already exists
> add-member Asha
Added member #1: Asha
> add-member Ravi
Added member #2: Ravi
> borrow 978-0596009205 1 2026-11-01
Loan #1: Asha borrowed 'Head First Java', due 2026-11-15
> borrow 978-0596009205 2 2026-11-01
ERROR: no copies of 'Head First Java' are available
> borrow 978-0134685991 1 2026-11-01
Loan #2: Asha borrowed 'Effective Java', due 2026-11-15
> report
Effective Java by Joshua Bloch: 1 available
Head First Java by Kathy Sierra: 0 available
ON LOAN #1: 'Head First Java' -> Asha, due 2026-11-15
ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15
> return 1 2026-11-20
Loan #1 returned on 2026-11-20, fine Rs 10
> return 1 2026-11-21
ERROR: loan #1 was already returned on 2026-11-20
> borrow 978-0596009205 2 2026-11-21
Loan #3: Ravi borrowed 'Head First Java', due 2026-12-05
> report
Effective Java by Joshua Bloch: 1 available
Head First Java by Kathy Sierra: 0 available
ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15
ON LOAN #3: 'Head First Java' -> Ravi, due 2026-12-05
> quit
Why the console version feels finished but is not. The data lives in two HashMap-style collections inside one running program. Close the program and every book and loan is gone; run two copies and they cannot see each other; and only one person at a time can type at the keyboard. Each of those limits is what the next two stages remove.
Going deeper: why a Scanner loop is a fine way to start
A command loop reading from standard input makes the program reproducible: the same input file always gives the same output, so you can diff two versions of your program. That property is what let me prove that the one-file version and the later class-based version behave identically. If you later add a menu with numbers and prompts, keep the command-per-line mode as well so you can still replay a session.
ConsoleApp.java: the class-based version of this loop, with the command table in its Javadoc
Stage two: pull the rules out of the code that uses them
In the single file, the numbers 14, 3 and 2 are tangled into borrow and giveBack. That is fine until you need the same rules in a second place, which is exactly what happens in stage three. So the first refactor is to move the rules into a class that knows nothing about consoles, collections or databases.
The diagram shows what changes and what does not across the three stages. Storage changes (maps, then a database) and the doorway changes (keyboard, then HTTP). The amber box, the lending rules, is written once and used by both later stages without a single edit. That is the real lesson of the exercise: separate what your system decides from where it keeps data and how it talks to people.
Here is the whole rules class, from LoanPolicy.java. Every method is a pure function: same inputs, same output, no side effects, trivially testable.
public final class LoanPolicy {
public static final int LOAN_DAYS = 14;
public static final int MAX_ACTIVE_LOANS = 3;
public static final int FINE_PER_DAY = 2; // rupees
private LoanPolicy() {
}
public static LocalDate dueOn(LocalDate borrowedOn) {
return borrowedOn.plusDays(LOAN_DAYS);
}
/** Fine in rupees: zero on or before the due date, then FINE_PER_DAY for each day late. */
public static int fine(LocalDate dueOn, LocalDate returnedOn) {
long daysLate = ChronoUnit.DAYS.between(dueOn, returnedOn);
return daysLate > 0 ? (int) daysLate * FINE_PER_DAY : 0;
}
public static boolean mayBorrow(int activeLoans) {
return activeLoans < MAX_ACTIVE_LOANS;
}
}
LoanPolicyTest pins the fine table, and writes it to 02-loan-policy.txt. The boundary matters: returning on the due date itself costs nothing, and the first rupee appears the day after.
borrowed 2026-11-01, due 2026-11-15 (14 days)
returned 2026-11-12 ( -3 days vs due) -> fine Rs 0
returned 2026-11-15 ( +0 days vs due) -> fine Rs 0
returned 2026-11-16 ( +1 days vs due) -> fine Rs 2
returned 2026-11-20 ( +5 days vs due) -> fine Rs 10
returned 2026-12-15 (+30 days vs due) -> fine Rs 60
Going deeper: the structured console classesLibrary keeps the same maps as the single file but as an instance, so it can be created fresh in a test. Loan computes its due date by calling LoanPolicy.dueOn in its constructor instead of doing the arithmetic itself, and borrow calls LoanPolicy.mayBorrow with the member’s active-loan count. Compare it with the single-file borrow above: the checks are in the same order and the messages are the same, only the numbers have moved out.
Library.java: in-memory domain with borrow, giveBack, report
Stage three: the same library as a web service with a database
Three things have to change to put this behind HTTP. Data moves from maps into database tables, so it survives requests. Objects become entities: classes the persistence layer maps to rows. And the keyboard loop is replaced by a controller that receives JSON, passes it to a service where the rules run inside a database transaction.
Spring Boot 4 splits what used to be one big web starter into smaller pieces, so the dependency you want is now named spring-boot-starter-webmvc, and each technology has its own test starter. The pom.xml needs just these:
A Book now carries a count of copies and one unusual field, @Version, which looks like bookkeeping and turns out to be the most important line in the whole project (section below). This is from Book.java:
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String isbn;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private int totalCopies;
private int availableCopies;
@Version
private long version;
A Loan links one book to one member. Both links are marked LAZY, meaning “do not fetch the book or member until someone actually reads it”. That is the right default and it also sets a trap, which the N+1 section shows. From Loan.java:
@Entity
public class Loan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Book book;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Member member;
private LocalDate borrowedOn;
private LocalDate dueOn;
private LocalDate returnedOn;
private int fine;
Now the request path.
The diagram traces POST /api/loans. The controller only turns JSON into a Java object and back; the service does the work. The service method is the console’s borrow rewritten against repositories, with the rules still coming from LoanPolicy. From LoanService.java:
@Transactional
public LoanView borrow(BorrowRequest r) {
Member member = members.findById(r.memberId())
.orElseThrow(() -> new NotFoundException("no member with id " + r.memberId()));
Book book = books.findById(r.bookId())
.orElseThrow(() -> new NotFoundException("no book with id " + r.bookId()));
if (book.getAvailableCopies() == 0) {
throw new ConflictException("no copies of '" + book.getTitle() + "' are available");
}
if (loans.existsByMemberIdAndBookIdAndReturnedOnIsNull(member.getId(), book.getId())) {
throw new RuleViolationException(member.getName() + " already has '" + book.getTitle() + "' on loan");
}
long active = loans.countByMemberIdAndReturnedOnIsNull(member.getId());
if (!LoanPolicy.mayBorrow((int) active)) {
throw new RuleViolationException(member.getName() + " already has " + active
+ " active loans (max " + LoanPolicy.MAX_ACTIVE_LOANS + ")");
}
LocalDate today = LocalDate.now(clock);
book.checkOut(); // dirty checking turns this into UPDATE book ... WHERE id=? AND version=?
return LoanView.of(loans.save(new Loan(book, member, today, LoanPolicy.dueOn(today))));
}
One line deserves a second look: book.checkOut() just decrements a field on a Java object. There is no save call. Hibernate notices at commit time that the object changed (“dirty checking”) and sends the UPDATE itself. The controller methods are thin by comparison, from LibraryController.java:
Run it with scripts/run.sh, then scripts/api-demo.sh walks the same story with curl. This is the real transcript from 07-curl-demo.txt against a real Tomcat, trimmed to the interesting calls. Look at the 400: it names every bad field, and the 409 reads like a sentence a user could act on.
> POST /books {"isbn":"","title":"","author":"Nobody","copies":0}
{"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","isbn":"must not be blank","title":"must not be blank"}}
< HTTP 400
> POST /loans {"memberId":1,"bookId":2}
{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":null,"fine":0}
< HTTP 201
> POST /loans {"memberId":2,"bookId":2}
{"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"}
< HTTP 409
Pick the status code on purpose. The API answers 404 when an id does not exist, 409 when the request is fine but the library’s current state says no (no copies left, already returned), and 422 when lending rules forbid it (the fourth loan). The tests in 03-rest-api.txt cover every one of them, so a client can rely on the difference.
Going deeper: full LoanService, entities and error mapping
The service also contains addBook, addMember, giveBack and the list methods. giveBack refuses a return dated before the loan began, which matters because the API lets a librarian back-date a return with {"returnedOn":"2026-11-20"}. Errors leave through a single @RestControllerAdvice that emits RFC 9457 application/problem+json, so every failure has the same shape.
Dtos.java: request and response records with Bean Validation
README.md: endpoint table and the list of every captured file
What breaks first: two people, one last copy
A single-user console cannot have this bug. A web service can, because two requests run at the same time. Both read “available copies: 1”, both decide “fine, I may borrow”, both write “available copies: 0”. Each request, viewed alone, followed the rules. Together they lent one book twice.
The upper lane is the failure: both borrowers read the same stale value, so the second write is based on something that is no longer true. The lower lane is the fix. Every UPDATE includes where id=? and version=?, and bumps the version. The second borrower’s update finds no row with the version it read, so it changes nothing and Hibernate raises an error instead of overwriting.
I ran both situations with eight threads released at the same instant, in BorrowRaceTest.java. First the real service, where Book has @Version. Real output from 05-race-with-version.txt:
8 threads borrow the last copy at once (Book has @Version)
successful borrows : 1
refused by optimistic-lock failure : 6
refused for any other reason : 1
loan rows in DB : 1
availableCopies : 0
Then the same eight threads against a read-check-write that does exactly what a JPA entity without@Version does: read the count, check it, write count minus one. Real output from 06-race-without-version.txt:
8 threads borrow the last copy at once (read, check, write: no @Version)
successful borrows : 8
loan rows in DB : 8 <- one copy, 8 loans
availableCopies : 0 <- the counter says everything is fine
Read the last line twice. The counter says 0, which looks perfectly healthy, while the loan table holds eight rows for one physical book. Nothing crashed and nothing logged a warning. That is the dangerous kind of bug.
The failure is fingerprinted by the numbers, not by an error. If your loan count can exceed your total copies and nothing ever threw an exception, you are missing a version column (or a lock). In the protected run the seven refused borrowers are turned away for one of two reasons: they lost the version check, or they started late enough to find no copy left. The split varies from run to run, which is why the transcript counts the two separately.
Going deeper: how the naive race is built, and what a loser sees
The naive variant lives in the test so it can never ship by accident. Every thread reads the count, waits at a barrier until all eight have read, and only then writes. That barrier is what makes the lost update deterministic instead of a lucky hit. From BorrowRaceTest.java:
List<String> outcomes = runConcurrently(i -> tx.executeWithoutResult(s -> {
int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single();
if (available == 0) {
throw new IllegalStateException("none left");
}
await(readDone); // everyone has now read available = 1
jdbc.sql("update book set available_copies = ? where id = ?").params(available - 1, bookId).update();
jdbc.sql("insert into loan (book_id, member_id, borrowed_on, due_on, fine) values (?, ?, ?, ?, 0)")
.params(bookId, memberIds.get(i), LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15)).update();
}));
The loser of the real race gets a Spring ObjectOptimisticLockingFailureException, which the advice turns into a 409 telling the caller to retry:
/** Two people changed the same Book row at once; the loser is told to retry. */
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail raced(ObjectOptimisticLockingFailureException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"someone else changed this book at the same moment, please retry");
}
You wrote six lines in borrow. Here is what reaches the database, captured by turning on Hibernate’s SQL logging for a single borrow (scripts/sql-trace.sh produced 09-borrow-sql.txt). It maps one to one onto the request-flow diagram earlier.
select m1_0.id,m1_0.email,m1_0.name from member m1_0 where m1_0.id=?
select b1_0.id,b1_0.author,b1_0.available_copies,b1_0.isbn,b1_0.title,b1_0.total_copies,b1_0.version from book b1_0 where b1_0.id=?
select l1_0.id from loan l1_0 where l1_0.member_id=? and l1_0.book_id=? and l1_0.returned_on is null fetch first ? rows only
select count(l1_0.id) from loan l1_0 where l1_0.member_id=? and l1_0.returned_on is null
insert into loan (book_id,borrowed_on,due_on,fine,member_id,returned_on,id) values (?,?,?,?,?,?,default)
update book set author=?,available_copies=?,isbn=?,title=?,total_copies=?,version=? where id=? and version=?
Two details are worth knowing. The final statement is the version check from the race section, visible in plain text. And it updates every column of the book (author, isbn, title and so on), not only available_copies, because Hibernate’s default is to write the whole row; that is harmless here and a reason to look at @DynamicUpdate only if you have very wide tables.
The N+1 trap: LAZY is not free
Remember that Loan holds lazy links to its book and member. Listing loans for a screen that shows “Head First Java borrowed by Asha” touches both, one row at a time. With five loans, I counted what Hibernate actually sent, using its statistics counter in QueryCountTest.java. Real output from 04-query-count.txt:
5 loans, each with its own book and its own member
findAllByOrderByIdAsc() then read book title + member name: 11 SQL statements
findAllWithDetails() (join fetch) then the same reads: 1 SQL statement
LoanService.listLoans() as called by GET /api/loans: 1 SQL statement
Eleven statements to show five rows: one for the loans, then one per book and one per member. That is “N+1” in a slightly worse form (two lazy links, so 1 + 2N). With 500 loans on a page it would be 1001 queries. The fix is to ask for everything in one statement with JOIN FETCH, which is what GET /api/loans uses. From Repositories.java:
interface LoanRepository extends JpaRepository<Loan, Long> {
long countByMemberIdAndReturnedOnIsNull(Long memberId);
boolean existsByMemberIdAndBookIdAndReturnedOnIsNull(Long memberId, Long bookId);
/** The innocent-looking version: loans only, book and member are lazy proxies. */
List<Loan> findAllByOrderByIdAsc();
/** The same list in one SQL statement: JOIN FETCH pulls book and member in with each loan. */
@Query("select l from Loan l join fetch l.book join fetch l.member order by l.id")
List<Loan> findAllWithDetails();
}
Going deeper: why not just make the links EAGER?
Because eager fetching decides for every query that you want the book and member, including the ones that need neither. You trade one slow screen for many slightly slow ones. Keep associations lazy and fetch what a given use case needs, with JOIN FETCH or an entity graph. Two related ankurm.com articles cover the next steps: Spring Data JPA projections return only the columns a screen needs, and Spring Boot 4 test slices show how to test a layer in isolation.
Three smaller surprises from building this on Boot 4
These each cost a few minutes while writing the companion module, so they are worth a line.
A validation failure came back with no body. My first MockMvc test posted a book with an empty title and zero copies and got a 400 with an empty response body, so a client would have no idea which field was wrong. I did not check what a real container would have produced without my handler, so I only claim the MockMvc result. The fix is a handler that names each bad field, from ApiExceptionHandler.java:
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException e) {
Map<String, String> errors = new TreeMap<>();
e.getBindingResult().getFieldErrors().forEach(f -> errors.putIfAbsent(f.getField(), f.getDefaultMessage()));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "request body failed validation");
problem.setProperty("errors", errors);
return problem;
}
Spring Boot warns about a default you should decide on. When spring.jpa.open-in-view is left unset, Boot keeps a database session open for the whole web request, so a lazy link can load while the page renders. That hides the N+1 problem above. The module sets it to false explicitly in application.properties; to see the warning I started the app with no configuration at all. Real output from 08-open-in-view-warning.txt:
WARN 2278 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
Package and starter names moved. The web starter is spring-boot-starter-webmvc, the tests use spring-boot-starter-webmvc-test and spring-boot-starter-data-jpa-test, and @AutoConfigureMockMvc now lives in org.springframework.boot.webmvc.test.autoconfigure. I guessed that package name from the new module naming pattern and the compiler accepted it, which is the check I trust. If a Boot 3 tutorial fails to compile on Boot 4, start there.
Should you build it this way?
For a semester project: yes, and keep all three stages in your report. Examiners see a working console app, then a design argument (rules separated from storage), then a real concurrency fix. That is a stronger story than a menu with a database bolted on.
For a real library: not as written. The H2 database here lives in memory and resets on every start, so swap in PostgreSQL or MySQL before storing anything you care about. There is also no login, no search, no reservation queue, and the fine is calculated but never collected. And optimistic locking is the right bet when conflicts are rare; if one popular title is borrowed all day, a pessimistic lock or a retry loop is worth the extra code.
No Comments yet!