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;
+ }
+}
diff --git a/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java b/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java
new file mode 100644
index 0000000..de6a4b7
--- /dev/null
+++ b/library-management/src/main/java/com/ankurm/library/core/LoanPolicy.java
@@ -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.
+ *
+ * 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;
+ }
+}
diff --git a/library-management/src/main/resources/application.properties b/library-management/src/main/resources/application.properties
new file mode 100644
index 0000000..baf2292
--- /dev/null
+++ b/library-management/src/main/resources/application.properties
@@ -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
diff --git a/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java b/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java
new file mode 100644
index 0000000..f7fd5c3
--- /dev/null
+++ b/library-management/src/test/java/com/ankurm/library/api/BorrowRaceTest.java
@@ -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 memberIds = seedMembers("v");
+
+ List 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 memberIds = seedMembers("n");
+ CyclicBarrier readDone = new CyclicBarrier(THREADS);
+
+ List 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 seedMembers(String prefix) {
+ List 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 runConcurrently(Action action) throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ CyclicBarrier start = new CyclicBarrier(THREADS);
+ List> 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 outcomes = new ArrayList<>();
+ for (Future f : futures) {
+ outcomes.add(f.get(30, TimeUnit.SECONDS));
+ }
+ pool.shutdown();
+ return outcomes;
+ }
+}
diff --git a/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java b/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java
new file mode 100644
index 0000000..7a76ff2
--- /dev/null
+++ b/library-management/src/test/java/com/ankurm/library/api/LibraryApiTest.java
@@ -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\":\"asha@example.com\"}", 201);
+ call("POST", "/api/members", "{\"name\":\"Ravi\",\"email\":\"ravi@example.com\"}", 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();
+ }
+}
diff --git a/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java b/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java
new file mode 100644
index 0000000..18dd645
--- /dev/null
+++ b/library-management/src/test/java/com/ankurm/library/api/QueryCountTest.java
@@ -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();
+ }
+}
diff --git a/library-management/src/test/java/com/ankurm/library/api/Transcript.java b/library-management/src/test/java/com/ankurm/library/api/Transcript.java
new file mode 100644
index 0000000..c9a1330
--- /dev/null
+++ b/library-management/src/test/java/com/ankurm/library/api/Transcript.java
@@ -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);
+ }
+ }
+}
diff --git a/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java b/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java
new file mode 100644
index 0000000..92ddcfb
--- /dev/null
+++ b/library-management/src/test/java/com/ankurm/library/core/LoanPolicyTest.java
@@ -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));
+ }
+}