Add library-management module: console app evolved into a Spring Boot 4 REST API

Stage 2 console classes and the shared LoanPolicy, then a Spring Boot 4.1.2 + JPA API
(H2, @Version optimistic locking, problem+json errors). Tests write output/ transcripts
while asserting the same numbers; scripts regenerate everything.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4DPL6dtpFg4U4LHc2
This commit is contained in:
Claude
2026-11-14 21:21:16 +05:30
parent b9aeb78b24
commit 26ece9cc9b
38 changed files with 1444 additions and 0 deletions
+1
View File
@@ -21,6 +21,7 @@ One module per article. This repository currently holds:
| [`dynamic-programming`](dynamic-programming/) | Dynamic Programming in Java for Beginners: Memoization vs Tabulation (10 Classic Problems) | this commit | | [`dynamic-programming`](dynamic-programming/) | Dynamic Programming in Java for Beginners: Memoization vs Tabulation (10 Classic Problems) | this commit |
| [`fresher-qa`](fresher-qa/) | Top 50 Java Interview Questions for Freshers (2026) with Simple Answers | this commit | | [`fresher-qa`](fresher-qa/) | Top 50 Java Interview Questions for Freshers (2026) with Simple Answers | this commit |
| [`stack-queue`](stack-queue/) | Stack and Queue Problems in Java: Balanced Parentheses, Next Greater Element, Min Stack | this commit | | [`stack-queue`](stack-queue/) | Stack and Queue Problems in Java: Balanced Parentheses, Next Greater Element, Min Stack | this commit |
| [`library-management`](library-management/) | Library Management System in Java: Console Version to Spring Boot REST API | this commit |
## `collections` module ## `collections` module
+77
View File
@@ -0,0 +1,77 @@
# library-management
Companion code for the ankurm.com article **Library Management System in Java: Console Version
to Spring Boot REST API**. One module, three stages: the same lending rules, first in plain Java,
then split into classes, then served over HTTP with Spring Boot 4 and JPA.
Every number, status code and SQL line quoted in the article comes from a file in
[`output/`](output/). The tests write those files while asserting the same numbers, so the
transcripts cannot drift from the code.
## Versions
| Component | Version | Verified against |
|---|---|---|
| JDK | **25.0.5** (Temurin, LTS) | `java -version` |
| Spring Boot | **4.1.2** (4.1.0 went GA on 2026-06-10) | `maven-metadata.xml` on Maven Central |
| Spring Framework | 7.0.10 | `mvn dependency:list` |
| Hibernate ORM | 7.4.13.Final | `mvn dependency:list` |
| Spring Data JPA | 4.1.2 | `mvn dependency:list` |
| Tomcat (embedded) | 11.0.27 | `mvn dependency:list` |
| H2 | 2.4.240 | `mvn dependency:list` |
| Maven | 3.9.11 | `mvn -v` |
## The three stages
| Stage | Where | Run it |
|---|---|---|
| 1. One file, no build tool | `LibraryConsole.java` (attached to the article, not stored here) | `java LibraryConsole.java < scripts/demo-input.txt` |
| 2. Split into classes, rules extracted | `src/main/java/com/ankurm/library/console/` + `core/LoanPolicy.java` | `mvn -q compile && java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt` |
| 3. Spring Boot REST API with JPA | `src/main/java/com/ankurm/library/api/` + `core/LoanPolicy.java` | `scripts/run.sh` |
`core/LoanPolicy.java` (14-day loans, 3 active loans, Rs 2 a day fine) is shared by stages 2 and 3
and did not change when the app moved to a database.
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH"
mvn test # 6 tests; also regenerates output/02 to output/06
scripts/run.sh # API on http://localhost:8080 (in-memory H2, clean library per start)
scripts/api-demo.sh # in another terminal: curl walk-through
```
The first Maven run must be online so the plugins are cached; `-o` works afterwards.
`scripts/run-all.sh` regenerates every file in `output/`.
## Endpoints
| Method and path | Purpose | Refusals |
|---|---|---|
| `POST /api/books` | add a book with N copies | 400 bad fields, 409 duplicate ISBN |
| `GET /api/books` | list books with copies available | |
| `POST /api/members` | register a member | 400 bad fields, 409 duplicate email |
| `POST /api/loans` | borrow: `{"memberId":1,"bookId":2}` | 404 unknown id, 409 no copies, 422 limit or duplicate loan |
| `POST /api/loans/{id}/return` | return, optional `{"returnedOn":"2026-11-20"}` | 409 already returned, 422 before borrow date |
| `GET /api/loans` | all loans, one SQL statement | |
Errors are RFC 9457 `application/problem+json`.
## Captured output
| File | What it shows | Produced by |
|---|---|---|
| `01-console-structured.txt` | stage 2 run of `scripts/demo-input.txt` (identical to stage 1) | `run-all.sh` |
| `02-loan-policy.txt` | the fine table | `LoanPolicyTest` |
| `03-rest-api.txt` | every success and refusal over MockMvc | `LibraryApiTest` |
| `04-query-count.txt` | 11 SQL statements versus 1 for the same list | `QueryCountTest` |
| `05-race-with-version.txt` | 8 threads, last copy, `@Version`: one winner | `BorrowRaceTest` |
| `06-race-without-version.txt` | same race without `@Version`: 8 loans for 1 copy | `BorrowRaceTest` |
| `07-curl-demo.txt` | the same API on a real Tomcat, driven with curl (dates are the day it ran) | `scripts/api-demo.sh` |
| `08-open-in-view-warning.txt` | the Boot default this module overrides | `scripts/open-in-view-warning.sh` |
| `09-borrow-sql.txt` | the exact SQL one borrow sends, including `where id=? and version=?` | `scripts/sql-trace.sh` |
Timing-dependent detail: in `05` the 7 refused borrows split between "optimistic-lock failure"
(a thread read the book before the winner committed) and "any other reason" (a thread started late
and found no copy left). Over repeated runs I saw splits ranging from 7/0 to 4/3, which is why the
transcript counts them separately. The invariants (1 winner, 1 loan, 0 copies left) are asserted.
@@ -0,0 +1,33 @@
> 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
@@ -0,0 +1,6 @@
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
+57
View File
@@ -0,0 +1,57 @@
> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
< 201 {"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2}
> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
< 409 {"detail":"ISBN 978-0134685991 already exists","instance":"/api/books","status":409,"title":"Conflict"}
> POST /api/books {"isbn":"978-0000000000","title":"","author":"Nobody","copies":0}
< 400 {"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","title":"must not be blank"}}
> POST /api/books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}
< 201 {"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}
> POST /api/books {"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","copies":1}
< 201 {"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":1}
> POST /api/books {"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","copies":1}
< 201 {"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":1}
> POST /api/members {"name":"Asha","email":"[email protected]"}
< 201 {"id":1,"name":"Asha","email":"[email protected]"}
> POST /api/members {"name":"Ravi","email":"[email protected]"}
< 201 {"id":2,"name":"Ravi","email":"[email protected]"}
> POST /api/loans {"memberId":1,"bookId":2}
< 201 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
> POST /api/loans {"memberId":2,"bookId":2}
< 409 {"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"}
> POST /api/loans {"memberId":99,"bookId":1}
< 404 {"detail":"no member with id 99","instance":"/api/loans","status":404,"title":"Not Found"}
> POST /api/loans/1/return {"returnedOn":"2026-11-20"}
< 200 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10}
> POST /api/loans/1/return {"returnedOn":"2026-11-21"}
< 409 {"detail":"loan 1 was already returned on 2026-11-20","instance":"/api/loans/1/return","status":409,"title":"Conflict"}
> POST /api/loans {"memberId":1,"bookId":1}
< 201 {"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
> POST /api/loans {"memberId":1,"bookId":3}
< 201 {"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
> POST /api/loans {"memberId":1,"bookId":4}
< 201 {"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
> POST /api/loans {"memberId":1,"bookId":2}
< 422 {"detail":"Asha already has 3 active loans (max 3)","instance":"/api/loans","status":422,"title":"Unprocessable Content"}
> GET /api/books
< 200 [{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":1},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1},{"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":0},{"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":0}]
> GET /api/loans
< 200 [{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10},{"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}]
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,6 @@
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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,40 @@
> POST /books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2}
< HTTP 201
> POST /books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}
{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}
< HTTP 201
> 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 /members {"name":"Asha","email":"[email protected]"}
{"id":1,"name":"Asha","email":"[email protected]"}
< HTTP 201
> POST /members {"name":"Ravi","email":"not-an-email"}
{"detail":"request body failed validation","instance":"/api/members","status":400,"title":"Bad Request","errors":{"email":"must be a well-formed email address"}}
< HTTP 400
> POST /members {"name":"Ravi","email":"[email protected]"}
{"id":2,"name":"Ravi","email":"[email protected]"}
< HTTP 201
> 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
> POST /loans/1/return
{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":"2026-11-14","fine":0}
< HTTP 200
> GET /books
[{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}]
< HTTP 200
@@ -0,0 +1,2 @@
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
INFO 2278 --- [ main] c.ankurm.library.api.LibraryApplication : Started LibraryApplication in 5.911 seconds (process running for 6.599)
@@ -0,0 +1,6 @@
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=?
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm.interviewlab</groupId>
<artifactId>library-management</artifactId>
<version>1.0.0</version>
<name>library-management</name>
<description>Library management: a plain-Java console app evolved into a Spring Boot 4 + JPA REST API</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Drives a running API (scripts/run.sh --detach) with curl: the same story as the console demo,
# against a real servlet container. Dates in the output are "today", so they differ per run.
set -uo pipefail
BASE=${BASE:-http://localhost:8080/api}
call() { # method path [json]
echo "> $1 $2${3:+ $3}"
if [ -n "${3:-}" ]; then
curl -s -X "$1" "$BASE$2" -H 'Content-Type: application/json' -d "$3" -w '\n< HTTP %{http_code}\n\n'
else
curl -s -X "$1" "$BASE$2" -w '\n< HTTP %{http_code}\n\n'
fi
}
call POST /books '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}'
call POST /books '{"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}'
call POST /books '{"isbn":"","title":"","author":"Nobody","copies":0}'
call POST /members '{"name":"Asha","email":"[email protected]"}'
call POST /members '{"name":"Ravi","email":"not-an-email"}'
call POST /members '{"name":"Ravi","email":"[email protected]"}'
call POST /loans '{"memberId":1,"bookId":2}'
call POST /loans '{"memberId":2,"bookId":2}'
call POST /loans/1/return
call GET /books
+14
View File
@@ -0,0 +1,14 @@
add-book 978-0134685991|Effective Java|Joshua Bloch|2
add-book 978-0596009205|Head First Java|Kathy Sierra|1
add-book 978-0134685991|Effective Java|Joshua Bloch|2
add-member Asha
add-member Ravi
borrow 978-0596009205 1 2026-11-01
borrow 978-0596009205 2 2026-11-01
borrow 978-0134685991 1 2026-11-01
report
return 1 2026-11-20
return 1 2026-11-21
borrow 978-0596009205 2 2026-11-21
report
quit
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Starts the API with NO application.properties, to capture the warning Spring Boot prints when
# spring.jpa.open-in-view is left at its default. Our real config sets it to false explicitly.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q -DskipTests package
mkdir -p /tmp/empty-config
timeout 25 java -jar target/library-management-1.0.0.jar --spring.config.location=file:/tmp/empty-config/ --server.port=0 2>&1 \
| grep -E 'open-in-view|Started LibraryApplication' | sed -E 's/^[0-9T:.+-]+ +//' || true
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Needs JDK 25 and Maven; the first run must be online.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f output/*.txt
# 01: the single-file console version (the copy attached to the article lives in the WordPress media library)
mkdir -p /tmp/lm && cp scripts/demo-input.txt /tmp/lm/
echo "01 console (structured) ..."
mvn -B -q -DskipTests compile
java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt 2>/dev/null > output/01-console-structured.txt
# 02-06: written by the tests themselves, so each file is also an assertion
echo "02-06 tests ..."
mvn -B -q test
# 07: the real server, driven with curl
echo "07 curl demo ..."
scripts/run.sh --detach
scripts/api-demo.sh > output/07-curl-demo.txt
scripts/stop.sh
# 08: the Boot default we override
echo "08 open-in-view warning ..."
scripts/open-in-view-warning.sh > output/08-open-in-view-warning.txt
# 09: the SQL one borrow produces
echo "09 borrow SQL ..."
scripts/sql-trace.sh > output/09-borrow-sql.txt
ls output
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Starts the REST API on :8080 (in-memory H2, so every start is a clean library).
# Usage: scripts/run.sh foreground
# scripts/run.sh --detach background, waits until /api/books answers
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q -DskipTests package
if [ "${1:-}" = "--detach" ]; then
setsid nohup java -jar target/library-management-1.0.0.jar > /tmp/library-api.log 2>&1 < /dev/null &
for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8080/api/books && exit 0; sleep 1; done
echo "API did not start; see /tmp/library-api.log" >&2; exit 1
fi
exec java -jar target/library-management-1.0.0.jar
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Starts the API with Hibernate SQL logging, performs one borrow, and prints only the SQL that
# the borrow produced. Look for "version" in the UPDATE: that is optimistic locking at work.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q -DskipTests package
LOG=/tmp/library-sql.log
setsid nohup java -jar target/library-management-1.0.0.jar --server.port=8081 --logging.level.org.hibernate.SQL=DEBUG > "$LOG" 2>&1 < /dev/null &
for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8081/api/books && break; sleep 1; done
J='Content-Type: application/json'
curl -s -o /dev/null -H "$J" -d '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}' localhost:8081/api/books
curl -s -o /dev/null -H "$J" -d '{"name":"Asha","email":"[email protected]"}' localhost:8081/api/members
START=$(wc -l < "$LOG")
curl -s -o /dev/null -H "$J" -d '{"memberId":1,"bookId":1}' localhost:8081/api/loans
sleep 1
tail -n +"$((START + 1))" "$LOG" | grep 'org.hibernate.SQL' | sed -E 's/^.*org.hibernate.SQL +: //'
scripts/stop.sh
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Stops the API started by run.sh --detach. Only processes whose command is "java" match, so
# this script can never match (and kill) the shell that is running it.
for p in $(ps -eo pid,comm,args | awk '$2 == "java" && /library-management-1.0.0.jar/ {print $1}'); do kill "$p"; done
@@ -0,0 +1,49 @@
package com.ankurm.library.api;
import com.ankurm.library.api.Exceptions.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.TreeMap;
/** Turns exceptions into RFC 9457 problem+json bodies instead of stack traces. */
@RestControllerAdvice
class ApiExceptionHandler {
/** 400: names each bad field. Without this the 400 has an empty body in MockMvc. */
@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;
}
@ExceptionHandler(NotFoundException.class)
ProblemDetail notFound(NotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(ConflictException.class)
ProblemDetail conflict(ConflictException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler(RuleViolationException.class)
ProblemDetail rule(RuleViolationException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, e.getMessage());
}
/** 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");
}
}
@@ -0,0 +1,52 @@
package com.ankurm.library.api;
import jakarta.persistence.*;
/**
* A title in the catalogue with a count of copies. The {@code @Version} column is what stops two
* people borrowing the last copy at the same moment: see BorrowRaceTest.
*/
@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;
protected Book() {
}
public Book(String isbn, String title, String author, int copies) {
this.isbn = isbn;
this.title = title;
this.author = author;
this.totalCopies = copies;
this.availableCopies = copies;
}
public Long getId() { return id; }
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getTotalCopies() { return totalCopies; }
public int getAvailableCopies() { return availableCopies; }
public long getVersion() { return version; }
void checkOut() { availableCopies--; }
void checkIn() { availableCopies++; }
}
@@ -0,0 +1,51 @@
package com.ankurm.library.api;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
/** Request and response shapes. Entities never leave the service layer. */
public final class Dtos {
private Dtos() {
}
public record BookRequest(@NotBlank String isbn, @NotBlank String title, @NotBlank String author,
@Min(1) int copies) {
}
public record BookView(Long id, String isbn, String title, String author, int totalCopies,
int availableCopies) {
static BookView of(Book b) {
return new BookView(b.getId(), b.getIsbn(), b.getTitle(), b.getAuthor(), b.getTotalCopies(),
b.getAvailableCopies());
}
}
public record MemberRequest(@NotBlank String name, @NotBlank @Email String email) {
}
public record MemberView(Long id, String name, String email) {
static MemberView of(Member m) {
return new MemberView(m.getId(), m.getName(), m.getEmail());
}
}
public record BorrowRequest(@NotNull Long memberId, @NotNull Long bookId) {
}
/** returnedOn is optional: omit it to return "today", supply it to back-date a return. */
public record ReturnRequest(LocalDate returnedOn) {
}
public record LoanView(Long id, String bookTitle, String memberName, LocalDate borrowedOn, LocalDate dueOn,
LocalDate returnedOn, int fine) {
static LoanView of(Loan l) {
return new LoanView(l.getId(), l.getBook().getTitle(), l.getMember().getName(), l.getBorrowedOn(),
l.getDueOn(), l.getReturnedOn(), l.getFine());
}
}
}
@@ -0,0 +1,29 @@
package com.ankurm.library.api;
/** Business-rule failures, each mapped to an HTTP status in ApiExceptionHandler. */
public final class Exceptions {
private Exceptions() {
}
/** 404: the thing the caller named does not exist. */
public static class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
/** 409: the request is fine, but the current state of the library says no. */
public static class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}
/** 422: the member is not allowed to do this under the lending rules. */
public static class RuleViolationException extends RuntimeException {
public RuleViolationException(String message) {
super(message);
}
}
}
@@ -0,0 +1,22 @@
package com.ankurm.library.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.time.Clock;
/** Stage 3 entry point: {@code mvn spring-boot:run} serves the REST API on port 8080. */
@SpringBootApplication
public class LibraryApplication {
public static void main(String[] args) {
SpringApplication.run(LibraryApplication.class, args);
}
/** Injected rather than calling LocalDate.now() so tests can control "today". */
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
}
@@ -0,0 +1,52 @@
package com.ankurm.library.api;
import com.ankurm.library.api.Dtos.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class LibraryController {
private final LoanService service;
LibraryController(LoanService service) {
this.service = service;
}
@PostMapping("/books")
@ResponseStatus(HttpStatus.CREATED)
BookView addBook(@Valid @RequestBody BookRequest request) {
return service.addBook(request);
}
@GetMapping("/books")
List<BookView> books() {
return service.listBooks();
}
@PostMapping("/members")
@ResponseStatus(HttpStatus.CREATED)
MemberView addMember(@Valid @RequestBody MemberRequest request) {
return service.addMember(request);
}
@PostMapping("/loans")
@ResponseStatus(HttpStatus.CREATED)
LoanView borrow(@Valid @RequestBody BorrowRequest request) {
return service.borrow(request);
}
@PostMapping("/loans/{id}/return")
LoanView giveBack(@PathVariable Long id, @RequestBody(required = false) ReturnRequest request) {
return service.giveBack(id, request == null ? null : request.returnedOn());
}
@GetMapping("/loans")
List<LoanView> loans() {
return service.listLoans();
}
}
@@ -0,0 +1,48 @@
package com.ankurm.library.api;
import jakarta.persistence.*;
import java.time.LocalDate;
/** One member borrowing one copy of one book. Both associations are LAZY on purpose. */
@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;
protected Loan() {
}
public Loan(Book book, Member member, LocalDate borrowedOn, LocalDate dueOn) {
this.book = book;
this.member = member;
this.borrowedOn = borrowedOn;
this.dueOn = dueOn;
}
public Long getId() { return id; }
public Book getBook() { return book; }
public Member getMember() { return member; }
public LocalDate getBorrowedOn() { return borrowedOn; }
public LocalDate getDueOn() { return dueOn; }
public LocalDate getReturnedOn() { return returnedOn; }
public int getFine() { return fine; }
void markReturned(LocalDate on, int fine) {
this.returnedOn = on;
this.fine = fine;
}
}
@@ -0,0 +1,92 @@
package com.ankurm.library.api;
import com.ankurm.library.api.Dtos.*;
import com.ankurm.library.api.Exceptions.*;
import com.ankurm.library.core.LoanPolicy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
/** The same four operations as the console Library, now on top of a database. */
@Service
public class LoanService {
private final BookRepository books;
private final MemberRepository members;
private final LoanRepository loans;
private final Clock clock;
LoanService(BookRepository books, MemberRepository members, LoanRepository loans, Clock clock) {
this.books = books;
this.members = members;
this.loans = loans;
this.clock = clock;
}
@Transactional
public BookView addBook(BookRequest r) {
if (books.existsByIsbn(r.isbn())) {
throw new ConflictException("ISBN " + r.isbn() + " already exists");
}
return BookView.of(books.save(new Book(r.isbn(), r.title(), r.author(), r.copies())));
}
@Transactional
public MemberView addMember(MemberRequest r) {
if (members.existsByEmail(r.email())) {
throw new ConflictException("email " + r.email() + " is already registered");
}
return MemberView.of(members.save(new Member(r.name(), r.email())));
}
@Transactional(readOnly = true)
public List<BookView> listBooks() {
return books.findAll().stream().map(BookView::of).toList();
}
@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))));
}
@Transactional
public LoanView giveBack(Long loanId, LocalDate returnedOn) {
Loan loan = loans.findById(loanId).orElseThrow(() -> new NotFoundException("no loan with id " + loanId));
if (loan.getReturnedOn() != null) {
throw new ConflictException("loan " + loanId + " was already returned on " + loan.getReturnedOn());
}
LocalDate on = returnedOn != null ? returnedOn : LocalDate.now(clock);
if (on.isBefore(loan.getBorrowedOn())) {
throw new RuleViolationException("cannot return on " + on + ", before it was borrowed on "
+ loan.getBorrowedOn());
}
loan.getBook().checkIn();
loan.markReturned(on, LoanPolicy.fine(loan.getDueOn(), on));
return LoanView.of(loan);
}
@Transactional(readOnly = true)
public List<LoanView> listLoans() {
return loans.findAllWithDetails().stream().map(LoanView::of).toList();
}
}
@@ -0,0 +1,29 @@
package com.ankurm.library.api;
import jakarta.persistence.*;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String email;
protected Member() {
}
public Member(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
@@ -0,0 +1,28 @@
package com.ankurm.library.api;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
interface BookRepository extends JpaRepository<Book, Long> {
boolean existsByIsbn(String isbn);
}
interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsByEmail(String email);
}
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();
}
@@ -0,0 +1,62 @@
package com.ankurm.library.console;
import java.time.LocalDate;
import java.util.Scanner;
/**
* Stage 2 entry point. Reads one command per line from stdin, so a demo run is reproducible:
* {@code java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt}
*
* <pre>
* add-book isbn|title|author|copies
* add-member name
* borrow isbn memberId [yyyy-mm-dd]
* return loanId [yyyy-mm-dd]
* report
* quit
* </pre>
*/
public class ConsoleApp {
public static void main(String[] args) {
Library library = new Library();
Scanner in = new Scanner(System.in);
while (in.hasNextLine()) {
String line = in.nextLine().trim();
if (line.isEmpty()) {
continue;
}
System.out.println("> " + line);
String[] cmd = line.split(" ", 2);
String rest = cmd.length > 1 ? cmd[1] : "";
try {
switch (cmd[0]) {
case "add-book" -> {
String[] p = rest.split("\\|");
System.out.println(library.addBook(p[0], p[1], p[2], Integer.parseInt(p[3])));
}
case "add-member" -> System.out.println(library.addMember(rest));
case "borrow" -> {
String[] p = rest.split(" ");
System.out.println(library.borrow(p[0], Integer.parseInt(p[1]), dateOrToday(p, 2)));
}
case "return" -> {
String[] p = rest.split(" ");
System.out.println(library.giveBack(Integer.parseInt(p[0]), dateOrToday(p, 1)));
}
case "report" -> library.report().forEach(System.out::println);
case "quit" -> {
return;
}
default -> System.out.println("ERROR: unknown command '" + cmd[0] + "'");
}
} catch (RuntimeException e) {
System.out.println("ERROR: bad input (" + e.getClass().getSimpleName() + ")");
}
}
}
private static LocalDate dateOrToday(String[] parts, int index) {
return parts.length > index ? LocalDate.parse(parts[index]) : LocalDate.now();
}
}
@@ -0,0 +1,118 @@
package com.ankurm.library.console;
import com.ankurm.library.core.LoanPolicy;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Stage 2: the console app's domain, split out of the single-file version.
* Everything lives in memory in plain collections; the rules come from {@link LoanPolicy}.
*/
public class Library {
public static class Book {
final String isbn;
final String title;
final String author;
int availableCopies;
Book(String isbn, String title, String author, int copies) {
this.isbn = isbn;
this.title = title;
this.author = author;
this.availableCopies = copies;
}
}
public record Member(int id, String name) {
}
public static class Loan {
final int id;
final Book book;
final Member member;
final LocalDate borrowedOn;
final LocalDate dueOn;
LocalDate returnedOn;
Loan(int id, Book book, Member member, LocalDate borrowedOn) {
this.id = id;
this.book = book;
this.member = member;
this.borrowedOn = borrowedOn;
this.dueOn = LoanPolicy.dueOn(borrowedOn);
}
boolean active() {
return returnedOn == null;
}
}
private final Map<String, Book> books = new LinkedHashMap<>();
private final Map<Integer, Member> members = new LinkedHashMap<>();
private final List<Loan> loans = new ArrayList<>();
public String addBook(String isbn, String title, String author, int copies) {
if (books.containsKey(isbn)) {
return "ERROR: ISBN " + isbn + " already exists";
}
books.put(isbn, new Book(isbn, title, author, copies));
return "Added book: " + title + " (" + copies + (copies == 1 ? " copy)" : " copies)");
}
public String addMember(String name) {
int id = members.size() + 1;
members.put(id, new Member(id, name));
return "Added member #" + id + ": " + name;
}
public String borrow(String isbn, int memberId, LocalDate on) {
Book book = books.get(isbn);
Member member = members.get(memberId);
if (book == null) {
return "ERROR: no such book " + isbn;
}
if (member == null) {
return "ERROR: no such member #" + memberId;
}
if (book.availableCopies == 0) {
return "ERROR: no copies of '" + book.title + "' are available";
}
long active = loans.stream().filter(l -> l.active() && l.member.equals(member)).count();
if (!LoanPolicy.mayBorrow((int) active)) {
return "ERROR: " + member.name() + " already has " + active + " active loans (max "
+ LoanPolicy.MAX_ACTIVE_LOANS + ")";
}
Loan loan = new Loan(loans.size() + 1, book, member, on);
loans.add(loan);
book.availableCopies--;
return "Loan #" + loan.id + ": " + member.name() + " borrowed '" + book.title + "', due " + loan.dueOn;
}
public String giveBack(int loanId, LocalDate on) {
if (loanId < 1 || loanId > loans.size()) {
return "ERROR: no such loan #" + loanId;
}
Loan loan = loans.get(loanId - 1);
if (!loan.active()) {
return "ERROR: loan #" + loanId + " was already returned on " + loan.returnedOn;
}
loan.returnedOn = on;
loan.book.availableCopies++;
int fine = LoanPolicy.fine(loan.dueOn, on);
return "Loan #" + loanId + " returned on " + on + (fine > 0 ? ", fine Rs " + fine : ", no fine");
}
public List<String> report() {
List<String> lines = new ArrayList<>();
books.values().forEach(b -> lines.add(" " + b.title + " by " + b.author + ": " + b.availableCopies
+ " available"));
loans.stream().filter(Loan::active).forEach(l -> lines.add(" ON LOAN #" + l.id + ": '" + l.book.title
+ "' -> " + l.member.name() + ", due " + l.dueOn));
return lines;
}
}
@@ -0,0 +1,34 @@
package com.ankurm.library.core;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
/**
* The library's lending rules, as pure functions with no framework in sight.
*
* <p>This is the one class that survives the move from the console app to the Spring Boot API
* completely unchanged. Both {@code console.Library} and {@code api.LoanService} call it.
*/
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;
}
}
@@ -0,0 +1,4 @@
spring.application.name=library-management
spring.datasource.url=jdbc:h2:mem:library;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create
spring.jpa.open-in-view=false
@@ -0,0 +1,142 @@
package com.ankurm.library.api;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.support.TransactionTemplate;
import com.ankurm.library.api.Dtos.BorrowRequest;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Eight people try to borrow the one remaining copy at the same instant.
* First with the real LoanService (Book has @Version), then with a version-less
* read-check-write that does exactly what a JPA entity without @Version would do.
*/
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:race")
class BorrowRaceTest {
private static final int THREADS = 8;
@Autowired BookRepository books;
@Autowired MemberRepository members;
@Autowired LoanService service;
@Autowired JdbcClient jdbc;
@Autowired TransactionTemplate tx;
@Test
void withVersionColumnExactlyOneBorrowerWins() throws Exception {
Book book = books.save(new Book("race-1", "Last Copy", "Somebody", 1));
List<Long> memberIds = seedMembers("v");
List<String> outcomes = runConcurrently(i -> service.borrow(new BorrowRequest(memberIds.get(i), book.getId())));
long wins = outcomes.stream().filter(o -> o.equals("OK")).count();
long lost = outcomes.stream().filter(o -> o.equals("ObjectOptimisticLockingFailureException")).count();
long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(book.getId()).query(Long.class).single();
int available = books.findById(book.getId()).orElseThrow().getAvailableCopies();
assertEquals(1, wins);
assertEquals(1, loansInDb);
assertEquals(0, available);
new Transcript("05-race-with-version.txt")
.line(THREADS + " threads borrow the last copy at once (Book has @Version)")
.line(row("successful borrows", wins))
.line(row("refused by optimistic-lock failure", lost))
.line(row("refused for any other reason", THREADS - wins - lost))
.line(row("loan rows in DB", loansInDb))
.line(row("availableCopies", available))
.save();
}
@Test
void withoutVersionEveryoneWinsAndTheCopyIsLent() throws Exception {
long bookId = jdbc.sql("insert into book (isbn, title, author, total_copies, available_copies, version) "
+ "values ('race-2', 'Naive Copy', 'Somebody', 1, 1, 0)").update() == 1
? jdbc.sql("select id from book where isbn = 'race-2'").query(Long.class).single() : -1;
List<Long> memberIds = seedMembers("n");
CyclicBarrier readDone = new CyclicBarrier(THREADS);
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();
}));
long wins = outcomes.stream().filter(o -> o.equals("OK")).count();
long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(bookId).query(Long.class).single();
int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single();
assertEquals(THREADS, wins);
assertEquals(THREADS, loansInDb);
assertEquals(0, available);
new Transcript("06-race-without-version.txt")
.line(THREADS + " threads borrow the last copy at once (read, check, write: no @Version)")
.line("successful borrows : " + wins)
.line("loan rows in DB : " + loansInDb + " <- one copy, " + loansInDb + " loans")
.line("availableCopies : " + available + " <- the counter says everything is fine")
.save();
}
private static String row(String label, long value) {
return String.format("%-36s: %d", label, value);
}
private List<Long> seedMembers(String prefix) {
List<Long> ids = new ArrayList<>();
for (int i = 0; i < THREADS; i++) {
ids.add(members.save(new Member(prefix + i, prefix + i + "@example.com")).getId());
}
return ids;
}
private static void await(CyclicBarrier barrier) {
try {
barrier.await(10, TimeUnit.SECONDS);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private interface Action {
void run(int index) throws Exception;
}
private List<String> runConcurrently(Action action) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
CyclicBarrier start = new CyclicBarrier(THREADS);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < THREADS; i++) {
int index = i;
futures.add(pool.submit(() -> {
await(start);
try {
action.run(index);
return "OK";
} catch (Throwable e) {
return e.getClass().getSimpleName();
}
}));
}
List<String> outcomes = new ArrayList<>();
for (Future<String> f : futures) {
outcomes.add(f.get(30, TimeUnit.SECONDS));
}
pool.shutdown();
return outcomes;
}
}
@@ -0,0 +1,84 @@
package com.ankurm.library.api;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/** The console demo's story, replayed over HTTP against the real controllers, service and H2. */
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:api")
@AutoConfigureMockMvc
class LibraryApiTest {
/** Freezes "today" at 2026-11-01 so due dates in the transcript never drift. */
@TestConfiguration
static class FixedClock {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-11-01T06:00:00Z"), ZoneId.of("Asia/Kolkata"));
}
}
@Autowired
MockMvc mvc;
private final Transcript t = new Transcript("03-rest-api.txt");
private MvcResult call(String method, String path, String json, int expectedStatus) throws Exception {
var request = method.equals("GET") ? get(path) : post(path);
if (json != null) {
request = request.contentType(MediaType.APPLICATION_JSON).content(json);
}
MvcResult result = mvc.perform(request).andReturn();
String body = result.getResponse().getContentAsString();
t.line("> " + method + " " + path + (json != null ? " " + json : ""));
t.line("< " + result.getResponse().getStatus() + " " + (body.isEmpty() ? "(no body)" : body));
t.line("");
assertEquals(expectedStatus, result.getResponse().getStatus(), path + " -> " + body);
return result;
}
@Test
void borrowReturnFineAndEveryWayItCanBeRefused() throws Exception {
call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 201);
call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 409);
call("POST", "/api/books", "{\"isbn\":\"978-0000000000\",\"title\":\"\",\"author\":\"Nobody\",\"copies\":0}", 400);
call("POST", "/api/books", "{\"isbn\":\"978-0596009205\",\"title\":\"Head First Java\",\"author\":\"Kathy Sierra\",\"copies\":1}", 201);
call("POST", "/api/books", "{\"isbn\":\"978-0132350884\",\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"copies\":1}", 201);
call("POST", "/api/books", "{\"isbn\":\"978-0321356680\",\"title\":\"Effective Java 2e\",\"author\":\"Joshua Bloch\",\"copies\":1}", 201);
call("POST", "/api/members", "{\"name\":\"Asha\",\"email\":\"[email protected]\"}", 201);
call("POST", "/api/members", "{\"name\":\"Ravi\",\"email\":\"[email protected]\"}", 201);
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 201);
call("POST", "/api/loans", "{\"memberId\":2,\"bookId\":2}", 409);
call("POST", "/api/loans", "{\"memberId\":99,\"bookId\":1}", 404);
call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-20\"}", 200);
call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-21\"}", 409);
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":1}", 201);
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":3}", 201);
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":4}", 201);
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 422);
MvcResult books = call("GET", "/api/books", null, 200);
assertTrue(books.getResponse().getContentAsString().contains("\"availableCopies\":1"));
call("GET", "/api/loans", null, 200);
t.save();
}
}
@@ -0,0 +1,69 @@
package com.ankurm.library.api;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Counts the SQL statements Hibernate actually sends. LAZY does not mean "free". */
@SpringBootTest(properties = {"spring.datasource.url=jdbc:h2:mem:count",
"spring.jpa.properties.hibernate.generate_statistics=true"})
class QueryCountTest {
private static final int LOANS = 5;
@Autowired BookRepository books;
@Autowired MemberRepository members;
@Autowired LoanRepository loans;
@Autowired LoanService service;
@Autowired EntityManagerFactory emf;
@Autowired TransactionTemplate tx;
@Test
void listingLoansCostsOnePlusTwoNLazilyAndOneWithJoinFetch() {
for (int i = 1; i <= LOANS; i++) {
Book b = books.save(new Book("isbn-" + i, "Book " + i, "Author " + i, 1));
Member m = members.save(new Member("Member " + i, "m" + i + "@example.com"));
loans.save(new Loan(b, m, LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15)));
}
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.clear();
tx.executeWithoutResult(s -> loans.findAllByOrderByIdAsc()
.forEach(l -> {
l.getBook().getTitle();
l.getMember().getName();
}));
long lazy = stats.getPrepareStatementCount();
stats.clear();
tx.executeWithoutResult(s -> loans.findAllWithDetails()
.forEach(l -> {
l.getBook().getTitle();
l.getMember().getName();
}));
long fetched = stats.getPrepareStatementCount();
stats.clear();
service.listLoans();
long viaService = stats.getPrepareStatementCount();
assertEquals(1 + 2 * LOANS, lazy);
assertEquals(1, fetched);
assertEquals(1, viaService);
new Transcript("04-query-count.txt")
.line(LOANS + " loans, each with its own book and its own member")
.line("findAllByOrderByIdAsc() then read book title + member name: " + lazy + " SQL statements")
.line("findAllWithDetails() (join fetch) then the same reads: " + fetched + " SQL statement")
.line("LoanService.listLoans() as called by GET /api/loans: " + viaService + " SQL statement")
.save();
}
}
@@ -0,0 +1,35 @@
package com.ankurm.library.api;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes {@code output/NN-name.txt}. Every test that produces a number quoted in the article
* also asserts it, so the transcript and the assertion can never disagree.
*/
public final class Transcript {
private final StringBuilder text = new StringBuilder();
private final String file;
public Transcript(String file) {
this.file = file;
}
public Transcript line(String s) {
text.append(s).append('\n');
return this;
}
public void save() {
try {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, text.toString());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,36 @@
package com.ankurm.library.core;
import com.ankurm.library.api.Transcript;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
class LoanPolicyTest {
@Test
void fineIsTwoRupeesForEachDayAfterTheDueDate() {
LocalDate borrowed = LocalDate.of(2026, 11, 1);
LocalDate due = LoanPolicy.dueOn(borrowed);
assertEquals(LocalDate.of(2026, 11, 15), due);
Transcript t = new Transcript("02-loan-policy.txt");
t.line("borrowed " + borrowed + ", due " + due + " (" + LoanPolicy.LOAN_DAYS + " days)");
int[][] cases = {{-3, 0}, {0, 0}, {1, 2}, {5, 10}, {30, 60}};
for (int[] c : cases) {
LocalDate returned = due.plusDays(c[0]);
int fine = LoanPolicy.fine(due, returned);
assertEquals(c[1], fine);
t.line(String.format("returned %s (%+3d days vs due) -> fine Rs %d", returned, c[0], fine));
}
t.save();
}
@Test
void thirdLoanIsAllowedFourthIsNot() {
assertTrue(LoanPolicy.mayBorrow(0));
assertTrue(LoanPolicy.mayBorrow(2));
assertFalse(LoanPolicy.mayBorrow(3));
}
}