diff --git a/README.md b/README.md
index de45c9f..74ccd35 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ files.
| [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` |
| [`file-transfer/`](file-transfer) | [File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/) | multipart limits enforced by Tomcat (and ignored by `MockMvc`), the client-controlled filename, `Range` downloads, a 200 MB download and 150 MB upload under `-Xmx64m`, and S3 presigned URLs checked against a local SigV4 verifier (not a real S3) |
| [`project-structure/`](project-structure) | [Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/) | the same application written package-by-layer and package-by-feature with one HTTP API, the real source trees, 9 of 9 types public by layer vs 3 of 15 by feature, and the `javac` error that makes a feature boundary a compiler rule instead of a convention |
+| [`crud-mysql/`](crud-mysql) | [Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/) | a complete book API on a real MySQL 8.4.6 server with Flyway owning the schema and Hibernate set to `validate`, `ProblemDetail` errors with per-field validation messages, a duplicate ISBN caught by the database's unique key and mapped to `409` on create and on update, MySQL's own `Duplicate entry` message, and the SQL behind every request |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves. Where their captured output sits is not
diff --git a/crud-mysql/README.md b/crud-mysql/README.md
new file mode 100644
index 0000000..45a923e
--- /dev/null
+++ b/crud-mysql/README.md
@@ -0,0 +1,68 @@
+# crud-mysql
+
+Companion project for the article **[Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/)** on **[ankurm.com](https://ankurm.com)**.
+
+A complete Create / Read / Update / Delete API for books on **Spring Boot 4.1, Spring Data JPA (Hibernate),
+Flyway and MySQL 8.4**, with Bean Validation and RFC 9457 `ProblemDetail` errors. Every console block
+quoted in the article came out of `output/`, written by the test suite against a real MySQL server, so a
+claim that stops being true turns the build red.
+
+There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the
+article itself.
+
+## Versions
+
+| | |
+|---|---|
+| Spring Boot | 4.1.1 |
+| Spring Framework | 7.0.9 |
+| MySQL server used for `output/` | MySQL Community Server 8.4.6 (official Linux tarball, started directly; **not** Docker) |
+| MySQL driver | MySQL Connector/J 9.7.0 (what Boot 4.1.1 manages) |
+| JDK | 21 (OpenJDK 21.0.12) |
+| Maven | 3.9 |
+
+Only JDK 21 was used. `docker-compose.yml` was **not run** (the sandbox had no Docker).
+
+## Quickstart
+
+```bash
+docker compose up -d # or run scripts/mysql-setup.sql against a MySQL you already have
+mvn test # runs the scenarios and rewrites output/01-04
+mvn spring-boot:run # API on http://localhost:8080/api/books
+```
+
+Defaults: `jdbc:mysql://localhost:3306/library`, user `library`, password `library`. Override with
+`DB_URL`, `DB_USER` and `DB_PASSWORD`. The tests start the real application and delete all rows in the
+`book` table first, so point them at a database you can wipe.
+
+## Endpoints
+
+| Method and path | What it does | Success | Errors |
+|---|---|---|---|
+| `POST /api/books` | create | `201` + `Location` | `400` validation, `409` duplicate ISBN |
+| `GET /api/books/{id}` | read one | `200` | `404`, `400` (id not a number) |
+| `GET /api/books?author=&page=&size=` | list, paged, optional author filter | `200` | |
+| `PUT /api/books/{id}` | replace | `200` | `400`, `404`, `409` |
+| `DELETE /api/books/{id}` | delete | `204` | `404` |
+
+## Source layout
+
+| Path | What it is |
+|---|---|
+| `book/Book.java` | the JPA entity, mapped to the `book` table |
+| `book/BookRepository.java` | a Spring Data interface: no implementation to write |
+| `book/BookService.java` | transactions, paging, dirty checking for the update |
+| `book/BookController.java` | the HTTP layer |
+| `book/BookRequest.java`, `BookResponse.java`, `PageResponse.java` | what crosses the wire |
+| `error/ApiExceptionHandler.java` | every error becomes a `ProblemDetail` |
+| `resources/db/migration/V1__create_book.sql` | the schema, owned by Flyway |
+
+## Captured output
+
+| File | What it shows |
+|---|---|
+| `01-mysql-and-schema.txt` | the server and driver versions, the Hibernate dialect, the Flyway history and `SHOW CREATE TABLE` |
+| `02-crud-walkthrough.txt` | create, read, list (paged and filtered), update, delete: the requests are made with the JDK's `HttpClient` and printed as the equivalent curl commands, with the real responses |
+| `03-error-responses.txt` | validation, duplicate ISBN on create and on update, the message MySQL itself returned, 404, bad id, bad JSON |
+| `04-sql-per-request.txt` | the SQL Hibernate sent for each kind of request |
+| `05-real-curl.txt` | real `curl -i` against the packaged jar (headers included), written by `scripts/curl-demo.sh` |
diff --git a/crud-mysql/docker-compose.yml b/crud-mysql/docker-compose.yml
new file mode 100644
index 0000000..f37111c
--- /dev/null
+++ b/crud-mysql/docker-compose.yml
@@ -0,0 +1,22 @@
+# A MySQL 8.4 server for the application's defaults (database library, user library, password library).
+#
+# docker compose up -d
+#
+# NOT RUN when this module was written: the sandbox had no Docker. The captured output in output/ came from
+# MySQL Community Server 8.4.6 started directly from the official tarball with the same database, user and
+# password. If the compose file fails on your machine, `scripts/mysql-setup.sql` does the same thing by hand.
+services:
+ mysql:
+ image: mysql:8.4
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: rootpw
+ MYSQL_DATABASE: library
+ MYSQL_USER: library
+ MYSQL_PASSWORD: library
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-plibrary", "-ulibrary"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
diff --git a/crud-mysql/output/01-mysql-and-schema.txt b/crud-mysql/output/01-mysql-and-schema.txt
new file mode 100644
index 0000000..604c342
--- /dev/null
+++ b/crud-mysql/output/01-mysql-and-schema.txt
@@ -0,0 +1,22 @@
+# What the application is connected to and what Flyway built
+
+
+--- the server and the driver ---
+database : MySQL 8.4.6
+driver : MySQL Connector/J mysql-connector-j-9.7.0 (Revision: 0aade1f13bcc98faf7dda5c02e782481eb291f62)
+dialect : MySQLDialect
+ddl-auto : validate
+
+--- flyway_schema_history ---
+version=1 description=create book success=true
+
+--- SHOW CREATE TABLE book ---
+CREATE TABLE `book` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `title` varchar(200) NOT NULL,
+ `author` varchar(120) NOT NULL,
+ `isbn` varchar(13) NOT NULL,
+ `price` decimal(10,2) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_book_isbn` (`isbn`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
diff --git a/crud-mysql/output/02-crud-walkthrough.txt b/crud-mysql/output/02-crud-walkthrough.txt
new file mode 100644
index 0000000..6a0f340
--- /dev/null
+++ b/crud-mysql/output/02-crud-walkthrough.txt
@@ -0,0 +1,57 @@
+# Create, read, list, update, delete against MySQL. Each block is the curl command, then the status line, the Location header if any, and the body. The test uses a random port; it is printed as 8080.
+
+
+--- create three books ---
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}'
+HTTP 201 Location: http://localhost:8080/api/books/1
+{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
+
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}'
+HTTP 201 Location: http://localhost:8080/api/books/2
+{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}
+
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}'
+HTTP 201 Location: http://localhost:8080/api/books/3
+{"id":3,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}
+
+
+--- read one ---
+$ curl -i -X GET 'localhost:8080/api/books/1'
+HTTP 200
+{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
+
+
+--- list, two per page ---
+$ curl -i -X GET 'localhost:8080/api/books?page=0&size=2'
+HTTP 200
+{"content":[{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}],"page":0,"size":2,"totalElements":3,"totalPages":2}
+
+$ curl -i -X GET 'localhost:8080/api/books?page=1&size=2'
+HTTP 200
+{"content":[{"id":3,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}],"page":1,"size":2,"totalElements":3,"totalPages":2}
+
+
+--- filter by author ---
+$ curl -i -X GET 'localhost:8080/api/books?author=goetz'
+HTTP 200
+{"content":[{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}],"page":0,"size":5,"totalElements":1,"totalPages":1}
+
+
+--- update the price ---
+$ curl -i -X PUT 'localhost:8080/api/books/1' -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":42.00}'
+HTTP 200
+{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":42.00}
+
+
+--- delete, then ask for it again ---
+$ curl -i -X DELETE 'localhost:8080/api/books/3'
+HTTP 204
+
+$ curl -i -X GET 'localhost:8080/api/books/3'
+HTTP 404
+{"detail":"Book 3 not found","instance":"/api/books/3","status":404,"title":"Not Found"}
+
+
+--- what is in the table now (read with SQL, not through the API) ---
+id=1 title=Effective Java price=42.00
+id=2 title=Java Concurrency in Practice price=39.99
diff --git a/crud-mysql/output/03-error-responses.txt b/crud-mysql/output/03-error-responses.txt
new file mode 100644
index 0000000..279d179
--- /dev/null
+++ b/crud-mysql/output/03-error-responses.txt
@@ -0,0 +1,45 @@
+# What the client sees when something is wrong
+
+
+--- validation: three fields wrong at once ---
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"","author":"Someone","isbn":"123","price":-1}'
+HTTP 400
+{"detail":"Invalid request content.","instance":"/api/books","status":400,"title":"Bad Request","errors":{"isbn":"must be 13 digits starting with 978 or 979","price":"must be greater than or equal to 0.00","title":"must not be blank"}}
+
+
+--- duplicate ISBN on create ---
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}'
+HTTP 409
+{"detail":"A book with that ISBN already exists","instance":"/api/books","status":409,"title":"Conflict"}
+
+
+--- duplicate ISBN on update (book 2 takes book 1's ISBN) ---
+$ curl -i -X PUT 'localhost:8080/api/books/2' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780134685991","price":39.99}'
+HTTP 409
+{"detail":"A book with that ISBN already exists","instance":"/api/books/2","status":409,"title":"Conflict"}
+
+
+--- the message MySQL itself produced for the duplicate ---
+Duplicate entry '9780134685991' for key 'book.uk_book_isbn'
+
+--- unknown id ---
+$ curl -i -X GET 'localhost:8080/api/books/99'
+HTTP 404
+{"detail":"Book 99 not found","instance":"/api/books/99","status":404,"title":"Not Found"}
+
+$ curl -i -X DELETE 'localhost:8080/api/books/99'
+HTTP 404
+{"detail":"Book 99 not found","instance":"/api/books/99","status":404,"title":"Not Found"}
+
+
+--- the id is not a number ---
+$ curl -i -X GET 'localhost:8080/api/books/abc'
+HTTP 400
+{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/books/abc","status":400,"title":"Bad Request"}
+
+
+--- the body is not JSON ---
+$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{not json'
+HTTP 400
+{"detail":"Failed to read request","instance":"/api/books","status":400,"title":"Bad Request"}
+
diff --git a/crud-mysql/output/04-sql-per-request.txt b/crud-mysql/output/04-sql-per-request.txt
new file mode 100644
index 0000000..767353f
--- /dev/null
+++ b/crud-mysql/output/04-sql-per-request.txt
@@ -0,0 +1,23 @@
+# The SQL Hibernate sent for each request (recorded by a StatementInspector)
+
+
+--- create: POST /api/books -> HTTP 201 ---
+insert into book (author,isbn,price,title) values (?,?,?,?)
+
+--- read one: GET /api/books/1 -> HTTP 200 ---
+select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
+
+--- list page 0, size 2: GET /api/books?page=0&size=2 -> HTTP 200 ---
+select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 order by b1_0.id limit ?,?
+select count(b1_0.id) from book b1_0
+
+--- list filtered by author: GET /api/books?author=bloch -> HTTP 200 ---
+select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where upper(b1_0.author) like upper(?) escape '\\' order by b1_0.id limit ?
+
+--- update: PUT /api/books/1 -> HTTP 200 ---
+select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
+update book set author=?,isbn=?,price=?,title=? where id=?
+
+--- delete: DELETE /api/books/1 -> HTTP 204 ---
+select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
+delete from book where id=?
diff --git a/crud-mysql/output/05-real-curl.txt b/crud-mysql/output/05-real-curl.txt
new file mode 100644
index 0000000..1f211ae
--- /dev/null
+++ b/crud-mysql/output/05-real-curl.txt
@@ -0,0 +1,32 @@
+# Real curl -i output against the packaged jar (java -jar target/crud-mysql-1.0.0.jar), Date header removed
+
+$ curl -i -X POST localhost:8080/api/books -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}'
+HTTP/1.1 201
+Location: http://localhost:8080/api/books/1
+Content-Type: application/json
+Transfer-Encoding: chunked
+
+{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
+
+
+$ curl -i localhost:8080/api/books/1
+HTTP/1.1 200
+Content-Type: application/json
+Content-Length: 94
+
+{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
+
+
+$ curl -i -X DELETE localhost:8080/api/books/1
+HTTP/1.1 204
+
+
+
+$ curl -i localhost:8080/api/books/1
+HTTP/1.1 404
+Content-Type: application/problem+json
+Transfer-Encoding: chunked
+
+{"detail":"Book 1 not found","instance":"/api/books/1","status":404,"title":"Not Found"}
+
+
diff --git a/crud-mysql/pom.xml b/crud-mysql/pom.xml
new file mode 100644
index 0000000..a11ae4b
--- /dev/null
+++ b/crud-mysql/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ crud-mysql
+ 1.0.0
+ crud-mysql
+ Spring Boot 4.1 CRUD REST API on MySQL 8.4 with Spring Data JPA, Flyway, validation and ProblemDetail
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/crud-mysql/scripts/curl-demo.sh b/crud-mysql/scripts/curl-demo.sh
new file mode 100755
index 0000000..21dbfec
--- /dev/null
+++ b/crud-mysql/scripts/curl-demo.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Starts the packaged jar, makes real curl -i calls against it and stops it again. Raw output, headers included,
+# goes to output/05-real-curl.txt.
+#
+# ./scripts/curl-demo.sh
+#
+# Needs the same MySQL as the tests, and wipes the book table first.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+mvn -B -q package -DskipTests
+java -jar target/crud-mysql-1.0.0.jar --server.port=8080 --logging.level.root=WARN > target/app.log 2>&1 &
+PID=$!
+trap 'kill $PID 2>/dev/null || true' EXIT
+for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8080/api/books && break; sleep 1; done
+
+OUT=output/05-real-curl.txt
+H='Content-Type: application/json'
+{
+ echo "# Real curl -i output against the packaged jar (java -jar target/crud-mysql-1.0.0.jar), Date header removed"
+ echo
+ for cmd in \
+ "curl -i -s -X POST localhost:8080/api/books -H '$H' -d '{\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"isbn\":\"9780134685991\",\"price\":45.50}'" \
+ "curl -i -s localhost:8080/api/books/1" \
+ "curl -i -s -X DELETE localhost:8080/api/books/1" \
+ "curl -i -s localhost:8080/api/books/1"; do
+ echo "\$ ${cmd/ -s/}"
+ eval "$cmd" | tr -d '\r' | grep -v '^Date:'
+ echo; echo
+ done
+} > "$OUT"
+cat "$OUT"
diff --git a/crud-mysql/scripts/mysql-setup.sql b/crud-mysql/scripts/mysql-setup.sql
new file mode 100644
index 0000000..f82fdf4
--- /dev/null
+++ b/crud-mysql/scripts/mysql-setup.sql
@@ -0,0 +1,5 @@
+-- Creates the database and the user the application expects. Run as a MySQL administrator:
+-- mysql -uroot -p < scripts/mysql-setup.sql
+CREATE DATABASE IF NOT EXISTS library;
+CREATE USER IF NOT EXISTS 'library'@'%' IDENTIFIED BY 'library';
+GRANT ALL ON library.* TO 'library'@'%';
diff --git a/crud-mysql/scripts/run-all.sh b/crud-mysql/scripts/run-all.sh
new file mode 100755
index 0000000..1b45b17
--- /dev/null
+++ b/crud-mysql/scripts/run-all.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Regenerates every file under output/.
+#
+# ./scripts/run-all.sh
+#
+# Needs a JDK 21 or newer, Maven 3.9 and a MySQL 8.4 server reachable with the defaults in
+# src/main/resources/application.properties (localhost:3306, database library, user library, password
+# library), or the same values in DB_URL / DB_USER / DB_PASSWORD. All four transcripts are written by the
+# test suite, so every figure in the article is an assertion that fails the build if it stops being true.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+mvn -B test
+
+echo
+echo "output:"
+ls -1 output
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java b/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java
new file mode 100644
index 0000000..1351aa6
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.crud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class CrudApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(CrudApplication.class, args);
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java b/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java
new file mode 100644
index 0000000..b22c038
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java
@@ -0,0 +1,49 @@
+package com.ankurm.crud.book;
+
+import jakarta.persistence.*;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "book")
+public class Book {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY) // MySQL AUTO_INCREMENT
+ private Long id;
+
+ @Column(nullable = false, length = 200)
+ private String title;
+
+ @Column(nullable = false, length = 120)
+ private String author;
+
+ @Column(nullable = false, length = 13, unique = true)
+ private String isbn;
+
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal price;
+
+ protected Book() { // required by JPA
+ }
+
+ public Book(String title, String author, String isbn, BigDecimal price) {
+ this.title = title;
+ this.author = author;
+ this.isbn = isbn;
+ this.price = price;
+ }
+
+ public void change(String title, String author, String isbn, BigDecimal price) {
+ this.title = title;
+ this.author = author;
+ this.isbn = isbn;
+ this.price = price;
+ }
+
+ public Long getId() { return id; }
+ public String getTitle() { return title; }
+ public String getAuthor() { return author; }
+ public String getIsbn() { return isbn; }
+ public BigDecimal getPrice() { return price; }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java
new file mode 100644
index 0000000..adc6a2e
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java
@@ -0,0 +1,48 @@
+package com.ankurm.crud.book;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import java.net.URI;
+
+@RestController
+@RequestMapping("/api/books")
+public class BookController {
+ private final BookService service;
+
+ public BookController(BookService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ public PageResponse list(@RequestParam(required = false) String author,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size) {
+ return service.list(author, page, size);
+ }
+
+ @GetMapping("/{id}")
+ public BookResponse get(@PathVariable Long id) {
+ return service.get(id);
+ }
+
+ @PostMapping
+ public ResponseEntity create(@Valid @RequestBody BookRequest request) {
+ BookResponse created = service.create(request);
+ URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(created.id()).toUri();
+ return ResponseEntity.created(location).body(created);
+ }
+
+ @PutMapping("/{id}")
+ public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
+ return service.update(id, request);
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity delete(@PathVariable Long id) {
+ service.delete(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java
new file mode 100644
index 0000000..09d3aac
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java
@@ -0,0 +1,7 @@
+package com.ankurm.crud.book;
+
+public class BookNotFoundException extends RuntimeException {
+ public BookNotFoundException(Long id) {
+ super("Book " + id + " not found");
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java
new file mode 100644
index 0000000..1516ee2
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java
@@ -0,0 +1,10 @@
+package com.ankurm.crud.book;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface BookRepository extends JpaRepository {
+
+ Page findByAuthorContainingIgnoreCase(String author, Pageable pageable);
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java
new file mode 100644
index 0000000..7ba6240
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java
@@ -0,0 +1,17 @@
+package com.ankurm.crud.book;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+import java.math.BigDecimal;
+
+/** What a client may send. The id is never part of it: the database assigns it. */
+public record BookRequest(
+ @NotBlank @Size(max = 200) String title,
+ @NotBlank @Size(max = 120) String author,
+ @NotBlank @Pattern(regexp = "97[89]\\d{10}", message = "must be 13 digits starting with 978 or 979") String isbn,
+ @NotNull @DecimalMin(value = "0.00") BigDecimal price) {
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java
new file mode 100644
index 0000000..4d4de0c
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java
@@ -0,0 +1,10 @@
+package com.ankurm.crud.book;
+
+import java.math.BigDecimal;
+
+public record BookResponse(Long id, String title, String author, String isbn, BigDecimal price) {
+
+ static BookResponse from(Book b) {
+ return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getIsbn(), b.getPrice());
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java
new file mode 100644
index 0000000..478e256
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java
@@ -0,0 +1,52 @@
+package com.ankurm.crud.book;
+
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class BookService {
+ private static final int MAX_PAGE_SIZE = 50;
+
+ private final BookRepository repository;
+
+ public BookService(BookRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String author, int page, int size) {
+ PageRequest pageable = PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), MAX_PAGE_SIZE), Sort.by("id"));
+ var result = (author == null || author.isBlank())
+ ? repository.findAll(pageable)
+ : repository.findByAuthorContainingIgnoreCase(author, pageable);
+ return PageResponse.of(result.map(BookResponse::from));
+ }
+
+ @Transactional(readOnly = true)
+ public BookResponse get(Long id) {
+ return BookResponse.from(find(id));
+ }
+
+ @Transactional
+ public BookResponse create(BookRequest r) {
+ return BookResponse.from(repository.save(new Book(r.title(), r.author(), r.isbn(), r.price())));
+ }
+
+ @Transactional
+ public BookResponse update(Long id, BookRequest r) {
+ Book book = find(id);
+ book.change(r.title(), r.author(), r.isbn(), r.price()); // dirty checking issues the UPDATE at commit
+ return BookResponse.from(book);
+ }
+
+ @Transactional
+ public void delete(Long id) {
+ repository.delete(find(id));
+ }
+
+ private Book find(Long id) {
+ return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java b/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java
new file mode 100644
index 0000000..8fa6041
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java
@@ -0,0 +1,16 @@
+package com.ankurm.crud.book;
+
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+/**
+ * Our own page shape. Returning Spring Data's {@code Page} straight from a controller is discouraged,
+ * because its JSON layout is an implementation detail rather than a contract.
+ */
+public record PageResponse(List content, int page, int size, long totalElements, int totalPages) {
+
+ static PageResponse of(Page p) {
+ return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages());
+ }
+}
diff --git a/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java b/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java
new file mode 100644
index 0000000..8eb5d69
--- /dev/null
+++ b/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java
@@ -0,0 +1,51 @@
+package com.ankurm.crud.error;
+
+import com.ankurm.crud.book.BookNotFoundException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * One place that turns exceptions into RFC 9457 problem documents.
+ * ResponseEntityExceptionHandler already maps Spring MVC's own exceptions (bad JSON, bad path variable...)
+ * to ProblemDetail; we add the ones that belong to this application.
+ */
+@RestControllerAdvice
+public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
+
+ @ExceptionHandler(BookNotFoundException.class)
+ ProblemDetail notFound(BookNotFoundException e) {
+ return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
+ }
+
+ @ExceptionHandler(DataIntegrityViolationException.class)
+ ProblemDetail conflict(DataIntegrityViolationException e) {
+ String cause = String.valueOf(e.getMostSpecificCause().getMessage());
+ String detail = cause.contains("uk_book_isbn") ? "A book with that ISBN already exists" : "The change conflicts with existing data";
+ return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, detail);
+ }
+
+ /** The default 400 says "Invalid request content." and nothing else. Add which field failed and why. */
+ @Override
+ protected ResponseEntity