diff --git a/README.md b/README.md
index 779e59a..de45c9f 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ files.
| [`testing-no-containers/`](testing-no-containers) | [Testing Spring Boot 4 Without Containers: WireMock, MockWebServer and @MockitoBean](https://ankurm.com/) | `@MockBean`'s total removal proven with a real `javac` failure, `@MockitoBean`/`@MockitoSpyBean` context-cache behavior proven with real `org.springframework.test.context.cache` DEBUG log lines, WireMock fault injection against a real `RestClient`, a real `NoClassDefFoundError` from mixing `wiremock` with `wiremock-jetty12` (fixed with `wiremock-standalone`), and MockWebServer's `SocketEffect.ShutdownConnection` for genuinely severing a connection mid-response |
| [`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 |
`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/project-structure/README.md b/project-structure/README.md
new file mode 100644
index 0000000..cf44533
--- /dev/null
+++ b/project-structure/README.md
@@ -0,0 +1,51 @@
+# project-structure
+
+Companion project for the article **[Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/)** on **[ankurm.com](https://ankurm.com)**.
+
+One small library application (books, plus loans in the feature layout) written twice, with the same
+HTTP API, so the only difference between the two is where the files live. Every console block quoted in
+the article came out of `output/`, written by the test suite, 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 |
+| JDK | 21 (OpenJDK 21.0.12) |
+| Maven | 3.9 |
+
+The module was compiled and run on JDK 21 only; it has not been run on JDK 25.
+
+## Quickstart
+
+```bash
+mvn test # runs everything and rewrites output/01-04
+./scripts/run-all.sh # same, then lists output/
+mvn spring-boot:run # starts the recommended (by-feature) layout on :8080
+```
+
+## Source layout
+
+| Package | What it is |
+|---|---|
+| `bylayer/` | package-by-layer: `controller/`, `service/`, `repository/`, `model/`, `dto/`, `exception/` |
+| `byfeature/book/` | the book feature: everything package-private except `BookCatalog`, the feature's public API |
+| `byfeature/loan/` | the loan feature: uses books only through `BookCatalog` |
+| `byfeature/shared/` | cross-cutting: `ApiException` and the one `@RestControllerAdvice` |
+
+Both applications have their own `@SpringBootApplication` (`ByLayerApp`, `ByFeatureApp`), and each scans
+only its own package, so the two never see each other's beans.
+
+## Captured output
+
+| File | What it shows |
+|---|---|
+| `01-package-trees.txt` | the real source tree of both layouts (9 files vs 15 files) |
+| `02-public-surface.txt` | every top-level type and whether it is `public`: 9 of 9 by layer, 3 of 15 by feature |
+| `03-compiler-boundary.txt` | the same one-line shortcut (`new BookRepository()` from another package): compiles by layer, rejected by `javac` by feature |
+| `04-same-api-both-layouts.txt` | five identical requests against both layouts, plus the loan feature's three requests |
diff --git a/project-structure/output/01-package-trees.txt b/project-structure/output/01-package-trees.txt
new file mode 100644
index 0000000..b9db8fa
--- /dev/null
+++ b/project-structure/output/01-package-trees.txt
@@ -0,0 +1,43 @@
+# The same application, two layouts (src/main/java/com/ankurm/structure)
+
+
+--- bylayer ---
+bylayer/
+ ByLayerApp.java
+ controller/
+ BookController.java
+ dto/
+ BookRequest.java
+ BookResponse.java
+ exception/
+ ApiExceptionHandler.java
+ BookNotFoundException.java
+ model/
+ Book.java
+ repository/
+ BookRepository.java
+ service/
+ BookService.java
+(9 source files)
+
+--- byfeature ---
+byfeature/
+ ByFeatureApp.java
+ book/
+ Book.java
+ BookCatalog.java
+ BookController.java
+ BookNotFoundException.java
+ BookRepository.java
+ BookRequest.java
+ BookResponse.java
+ BookService.java
+ loan/
+ Loan.java
+ LoanController.java
+ LoanRequest.java
+ LoanService.java
+ shared/
+ ApiException.java
+ ApiExceptionHandler.java
+(15 source files)
diff --git a/project-structure/output/02-public-surface.txt b/project-structure/output/02-public-surface.txt
new file mode 100644
index 0000000..d477699
--- /dev/null
+++ b/project-structure/output/02-public-surface.txt
@@ -0,0 +1,32 @@
+# Top-level types per layout and whether each one is public
+
+
+--- bylayer ---
+public bylayer.ByLayerApp
+public bylayer.controller.BookController
+public bylayer.dto.BookRequest
+public bylayer.dto.BookResponse
+public bylayer.exception.ApiExceptionHandler
+public bylayer.exception.BookNotFoundException
+public bylayer.model.Book
+public bylayer.repository.BookRepository
+public bylayer.service.BookService
+9 of 9 top-level types are public
+
+--- byfeature ---
+public byfeature.ByFeatureApp
+package-private byfeature.book.Book
+public byfeature.book.BookCatalog
+package-private byfeature.book.BookController
+package-private byfeature.book.BookNotFoundException
+package-private byfeature.book.BookRepository
+package-private byfeature.book.BookRequest
+package-private byfeature.book.BookResponse
+package-private byfeature.book.BookService
+package-private byfeature.loan.Loan
+package-private byfeature.loan.LoanController
+package-private byfeature.loan.LoanRequest
+package-private byfeature.loan.LoanService
+public byfeature.shared.ApiException
+package-private byfeature.shared.ApiExceptionHandler
+3 of 15 top-level types are public
diff --git a/project-structure/output/03-compiler-boundary.txt b/project-structure/output/03-compiler-boundary.txt
new file mode 100644
index 0000000..58af4c6
--- /dev/null
+++ b/project-structure/output/03-compiler-boundary.txt
@@ -0,0 +1,8 @@
+# Can code in one package reach another package's repository?
+
+
+--- by layer: controller/Shortcut.java reaches into repository/ ---
+compiled with 0 errors
+
+--- by feature: loan/Shortcut.java reaches into book/ ---
+error: com.ankurm.structure.byfeature.book.BookRepository is not public in com.ankurm.structure.byfeature.book; cannot be accessed from outside package
diff --git a/project-structure/output/04-same-api-both-layouts.txt b/project-structure/output/04-same-api-both-layouts.txt
new file mode 100644
index 0000000..63e4876
--- /dev/null
+++ b/project-structure/output/04-same-api-both-layouts.txt
@@ -0,0 +1,35 @@
+# The same requests against both layouts
+
+
+--- bylayer ---
+GET /books -> 200
+ [{"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","pages":384}]
+GET /books/1 -> 200
+ {"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416}
+GET /books/99 -> 404
+ {"detail":"Book 99 not found","instance":"/books/99","status":404,"title":"Not Found"}
+POST /books -> 201
+ {"id":3,"title":"Clean Code","author":"Robert Martin","pages":464}
+POST /books -> 400
+ {"timestamp":"2026-10-21T16:43:19.747Z","status":400,"error":"Bad Request","path":"/books"}
+
+--- byfeature ---
+GET /books -> 200
+ [{"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","pages":384}]
+GET /books/1 -> 200
+ {"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416}
+GET /books/99 -> 404
+ {"detail":"Book 99 not found","instance":"/books/99","status":404,"title":"Not Found"}
+POST /books -> 201
+ {"id":3,"title":"Clean Code","author":"Robert Martin","pages":464}
+POST /books -> 400
+ {"timestamp":"2026-10-21T16:43:20.250Z","status":400,"error":"Bad Request","path":"/books"}
+POST /loans -> 201
+ {"id":1,"bookId":2,"bookTitle":"Java Concurrency in Practice","member":"Asha"}
+POST /loans -> 404
+ {"detail":"Book 7 not found","instance":"/loans","status":404,"title":"Not Found"}
+GET /loans -> 200
+ [{"id":1,"bookId":2,"bookTitle":"Java Concurrency in Practice","member":"Asha"}]
+
+--- comparison ---
+status codes and bodies identical for the five shared requests: true
diff --git a/project-structure/output/05-scan-starts-at-the-app-class.txt b/project-structure/output/05-scan-starts-at-the-app-class.txt
new file mode 100644
index 0000000..6f2918b
--- /dev/null
+++ b/project-structure/output/05-scan-starts-at-the-app-class.txt
@@ -0,0 +1,10 @@
+# The controller is found only if it is in or below the @SpringBootApplication class's package
+
+
+--- app class in scan.broken.app, controller in scan.broken.web (a sibling) ---
+GET /hello -> 404
+HelloController registered as a bean: false
+
+--- app class in scan.fixed, controller in scan.fixed.web (below it) ---
+GET /hello -> 200
+HelloController registered as a bean: true
diff --git a/project-structure/output/06-default-package.txt b/project-structure/output/06-default-package.txt
new file mode 100644
index 0000000..7144d0e
--- /dev/null
+++ b/project-structure/output/06-default-package.txt
@@ -0,0 +1,14 @@
+# A @SpringBootApplication class in the default package (no package statement)
+
+source compiled:
+ import org.springframework.boot.autoconfigure.SpringBootApplication;
+ @SpringBootApplication
+ public class DefaultPackageApp {
+ }
+
+
+--- what Spring Boot logged about the default package ---
+WARNING ** : Your ApplicationContext is unlikely to start due to a @ComponentScan of the default package.
+
+--- result ---
+context FAILED to start: BeanDefinitionStoreException / root cause ConflictingBeanDefinitionException: Annotation-specified bean name 'bookController' for bean class [com.ankurm.structure.bylayer.controller.BookController] conflicts with existing, non-compatible bean definition of same name and class [com.ankurm.structure.byfeature.book.BookController]
diff --git a/project-structure/pom.xml b/project-structure/pom.xml
new file mode 100644
index 0000000..4cea1de
--- /dev/null
+++ b/project-structure/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ project-structure
+ 1.0.0
+ project-structure
+ The same Spring Boot 4 application laid out package-by-layer and package-by-feature
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ com.ankurm.structure.byfeature.ByFeatureApp
+
+
+
+
+
diff --git a/project-structure/scripts/run-all.sh b/project-structure/scripts/run-all.sh
new file mode 100755
index 0000000..0b5792c
--- /dev/null
+++ b/project-structure/scripts/run-all.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Regenerates every file under output/.
+#
+# ./scripts/run-all.sh
+#
+# Needs a JDK 21 or newer and Maven 3.9. 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/project-structure/src/main/java/com/ankurm/structure/byfeature/ByFeatureApp.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/ByFeatureApp.java
new file mode 100644
index 0000000..3bb7eda
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/ByFeatureApp.java
@@ -0,0 +1,12 @@
+package com.ankurm.structure.byfeature;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** Package-by-feature: one package per business capability. Only what other features need is public. */
+@SpringBootApplication
+public class ByFeatureApp {
+ public static void main(String[] args) {
+ SpringApplication.run(ByFeatureApp.class, args);
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/Book.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/Book.java
new file mode 100644
index 0000000..a2d42fb
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/Book.java
@@ -0,0 +1,4 @@
+package com.ankurm.structure.byfeature.book;
+
+record Book(long id, String title, String author, int pages) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookCatalog.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookCatalog.java
new file mode 100644
index 0000000..2793d70
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookCatalog.java
@@ -0,0 +1,8 @@
+package com.ankurm.structure.byfeature.book;
+
+import java.util.Optional;
+
+/** The book feature's public API. Everything else in this package is package-private. */
+public interface BookCatalog {
+ Optional titleOf(long id);
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookController.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookController.java
new file mode 100644
index 0000000..b4edc4f
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookController.java
@@ -0,0 +1,33 @@
+package com.ankurm.structure.byfeature.book;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/books")
+class BookController {
+ private final BookService service;
+
+ BookController(BookService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ List list() {
+ return service.list().stream().map(BookResponse::from).toList();
+ }
+
+ @GetMapping("/{id}")
+ BookResponse get(@PathVariable long id) {
+ return BookResponse.from(service.get(id));
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ BookResponse create(@Valid @RequestBody BookRequest request) {
+ return BookResponse.from(service.create(request));
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookNotFoundException.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookNotFoundException.java
new file mode 100644
index 0000000..4482152
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookNotFoundException.java
@@ -0,0 +1,9 @@
+package com.ankurm.structure.byfeature.book;
+
+import com.ankurm.structure.byfeature.shared.ApiException;
+
+class BookNotFoundException extends ApiException {
+ BookNotFoundException(long id) {
+ super(404, "Book " + id + " not found");
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRepository.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRepository.java
new file mode 100644
index 0000000..2ba1435
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRepository.java
@@ -0,0 +1,38 @@
+package com.ankurm.structure.byfeature.book;
+
+import org.springframework.stereotype.Repository;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Repository
+class BookRepository {
+ private final Map books = new ConcurrentHashMap<>();
+ private final AtomicLong ids = new AtomicLong();
+
+ BookRepository() {
+ save("Effective Java", "Joshua Bloch", 416);
+ save("Java Concurrency in Practice", "Brian Goetz", 384);
+ }
+
+ Book save(String title, String author, int pages) {
+ long id = ids.incrementAndGet();
+ Book book = new Book(id, title, author, pages);
+ books.put(id, book);
+ return book;
+ }
+
+ Optional findById(long id) {
+ return Optional.ofNullable(books.get(id));
+ }
+
+ List findAll() {
+ List all = new ArrayList<>(books.values());
+ all.sort((a, b) -> Long.compare(a.id(), b.id()));
+ return all;
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRequest.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRequest.java
new file mode 100644
index 0000000..47c7ce4
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRequest.java
@@ -0,0 +1,7 @@
+package com.ankurm.structure.byfeature.book;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+record BookRequest(@NotBlank String title, @NotBlank String author, @Min(1) int pages) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookResponse.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookResponse.java
new file mode 100644
index 0000000..4a0c58a
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookResponse.java
@@ -0,0 +1,7 @@
+package com.ankurm.structure.byfeature.book;
+
+record BookResponse(long id, String title, String author, int pages) {
+ static BookResponse from(Book b) {
+ return new BookResponse(b.id(), b.title(), b.author(), b.pages());
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookService.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookService.java
new file mode 100644
index 0000000..a6721cf
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookService.java
@@ -0,0 +1,32 @@
+package com.ankurm.structure.byfeature.book;
+
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+@Service
+class BookService implements BookCatalog {
+ private final BookRepository repository;
+
+ BookService(BookRepository repository) {
+ this.repository = repository;
+ }
+
+ List list() {
+ return repository.findAll();
+ }
+
+ Book get(long id) {
+ return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
+ }
+
+ Book create(BookRequest request) {
+ return repository.save(request.title(), request.author(), request.pages());
+ }
+
+ @Override
+ public Optional titleOf(long id) {
+ return repository.findById(id).map(Book::title);
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/Loan.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/Loan.java
new file mode 100644
index 0000000..14cdab2
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/Loan.java
@@ -0,0 +1,4 @@
+package com.ankurm.structure.byfeature.loan;
+
+record Loan(long id, long bookId, String bookTitle, String member) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanController.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanController.java
new file mode 100644
index 0000000..8a3f9c2
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanController.java
@@ -0,0 +1,28 @@
+package com.ankurm.structure.byfeature.loan;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/loans")
+class LoanController {
+ private final LoanService service;
+
+ LoanController(LoanService service) {
+ this.service = service;
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ Loan lend(@Valid @RequestBody LoanRequest request) {
+ return service.lend(request);
+ }
+
+ @GetMapping
+ List all() {
+ return service.all();
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanRequest.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanRequest.java
new file mode 100644
index 0000000..8ae9bb7
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanRequest.java
@@ -0,0 +1,7 @@
+package com.ankurm.structure.byfeature.loan;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+record LoanRequest(@Min(1) long bookId, @NotBlank String member) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanService.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanService.java
new file mode 100644
index 0000000..b5aa52f
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanService.java
@@ -0,0 +1,32 @@
+package com.ankurm.structure.byfeature.loan;
+
+import com.ankurm.structure.byfeature.book.BookCatalog;
+import com.ankurm.structure.byfeature.shared.ApiException;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Service
+class LoanService {
+ private final BookCatalog books; // the only thing loan knows about the book feature
+ private final List loans = new CopyOnWriteArrayList<>();
+ private final AtomicLong ids = new AtomicLong();
+
+ LoanService(BookCatalog books) {
+ this.books = books;
+ }
+
+ Loan lend(LoanRequest request) {
+ String title = books.titleOf(request.bookId())
+ .orElseThrow(() -> new ApiException(404, "Book " + request.bookId() + " not found"));
+ Loan loan = new Loan(ids.incrementAndGet(), request.bookId(), title, request.member());
+ loans.add(loan);
+ return loan;
+ }
+
+ List all() {
+ return List.copyOf(loans);
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiException.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiException.java
new file mode 100644
index 0000000..2a7df02
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiException.java
@@ -0,0 +1,15 @@
+package com.ankurm.structure.byfeature.shared;
+
+/** The one exception type a feature throws for "the client asked for something that is not there / not allowed". */
+public class ApiException extends RuntimeException {
+ private final int status;
+
+ public ApiException(int status, String message) {
+ super(message);
+ this.status = status;
+ }
+
+ public int status() {
+ return status;
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiExceptionHandler.java b/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiExceptionHandler.java
new file mode 100644
index 0000000..cb48a82
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiExceptionHandler.java
@@ -0,0 +1,16 @@
+package com.ankurm.structure.byfeature.shared;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/** Cross-cutting, so it lives in shared/ and nowhere else; spring finds it by scanning, so it need not be public. */
+@RestControllerAdvice
+class ApiExceptionHandler {
+
+ @ExceptionHandler(ApiException.class)
+ ProblemDetail handle(ApiException e) {
+ return ProblemDetail.forStatusAndDetail(HttpStatus.valueOf(e.status()), e.getMessage());
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/ByLayerApp.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/ByLayerApp.java
new file mode 100644
index 0000000..72617e6
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/ByLayerApp.java
@@ -0,0 +1,8 @@
+package com.ankurm.structure.bylayer;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** Package-by-layer: one package per technical role. Component scanning covers everything below this package. */
+@SpringBootApplication
+public class ByLayerApp {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/controller/BookController.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/controller/BookController.java
new file mode 100644
index 0000000..2507831
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/controller/BookController.java
@@ -0,0 +1,36 @@
+package com.ankurm.structure.bylayer.controller;
+
+import com.ankurm.structure.bylayer.dto.BookRequest;
+import com.ankurm.structure.bylayer.dto.BookResponse;
+import com.ankurm.structure.bylayer.service.BookService;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/books")
+public class BookController {
+ private final BookService service;
+
+ public BookController(BookService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ public List list() {
+ return service.list().stream().map(BookResponse::from).toList();
+ }
+
+ @GetMapping("/{id}")
+ public BookResponse get(@PathVariable long id) {
+ return BookResponse.from(service.get(id));
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public BookResponse create(@Valid @RequestBody BookRequest request) {
+ return BookResponse.from(service.create(request));
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookRequest.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookRequest.java
new file mode 100644
index 0000000..1361051
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookRequest.java
@@ -0,0 +1,7 @@
+package com.ankurm.structure.bylayer.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+public record BookRequest(@NotBlank String title, @NotBlank String author, @Min(1) int pages) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookResponse.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookResponse.java
new file mode 100644
index 0000000..1f02e75
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookResponse.java
@@ -0,0 +1,9 @@
+package com.ankurm.structure.bylayer.dto;
+
+import com.ankurm.structure.bylayer.model.Book;
+
+public record BookResponse(long id, String title, String author, int pages) {
+ public static BookResponse from(Book b) {
+ return new BookResponse(b.id(), b.title(), b.author(), b.pages());
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/ApiExceptionHandler.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/ApiExceptionHandler.java
new file mode 100644
index 0000000..776972e
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/ApiExceptionHandler.java
@@ -0,0 +1,15 @@
+package com.ankurm.structure.bylayer.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+ @ExceptionHandler(BookNotFoundException.class)
+ public ProblemDetail notFound(BookNotFoundException e) {
+ return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/BookNotFoundException.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/BookNotFoundException.java
new file mode 100644
index 0000000..0684ea3
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/exception/BookNotFoundException.java
@@ -0,0 +1,7 @@
+package com.ankurm.structure.bylayer.exception;
+
+public class BookNotFoundException extends RuntimeException {
+ public BookNotFoundException(long id) {
+ super("Book " + id + " not found");
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/model/Book.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/model/Book.java
new file mode 100644
index 0000000..e23ce27
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/model/Book.java
@@ -0,0 +1,4 @@
+package com.ankurm.structure.bylayer.model;
+
+public record Book(long id, String title, String author, int pages) {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/repository/BookRepository.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/repository/BookRepository.java
new file mode 100644
index 0000000..3b23470
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/repository/BookRepository.java
@@ -0,0 +1,39 @@
+package com.ankurm.structure.bylayer.repository;
+
+import com.ankurm.structure.bylayer.model.Book;
+import org.springframework.stereotype.Repository;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Repository
+public class BookRepository {
+ private final Map books = new ConcurrentHashMap<>();
+ private final AtomicLong ids = new AtomicLong();
+
+ public BookRepository() {
+ save("Effective Java", "Joshua Bloch", 416);
+ save("Java Concurrency in Practice", "Brian Goetz", 384);
+ }
+
+ public Book save(String title, String author, int pages) {
+ long id = ids.incrementAndGet();
+ Book book = new Book(id, title, author, pages);
+ books.put(id, book);
+ return book;
+ }
+
+ public Optional findById(long id) {
+ return Optional.ofNullable(books.get(id));
+ }
+
+ public List findAll() {
+ List all = new ArrayList<>(books.values());
+ all.sort((a, b) -> Long.compare(a.id(), b.id()));
+ return all;
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/bylayer/service/BookService.java b/project-structure/src/main/java/com/ankurm/structure/bylayer/service/BookService.java
new file mode 100644
index 0000000..d7382c8
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/bylayer/service/BookService.java
@@ -0,0 +1,30 @@
+package com.ankurm.structure.bylayer.service;
+
+import com.ankurm.structure.bylayer.dto.BookRequest;
+import com.ankurm.structure.bylayer.exception.BookNotFoundException;
+import com.ankurm.structure.bylayer.model.Book;
+import com.ankurm.structure.bylayer.repository.BookRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class BookService {
+ private final BookRepository repository;
+
+ public BookService(BookRepository repository) {
+ this.repository = repository;
+ }
+
+ public List list() {
+ return repository.findAll();
+ }
+
+ public Book get(long id) {
+ return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
+ }
+
+ public Book create(BookRequest request) {
+ return repository.save(request.title(), request.author(), request.pages());
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/scan/broken/app/BrokenApp.java b/project-structure/src/main/java/com/ankurm/structure/scan/broken/app/BrokenApp.java
new file mode 100644
index 0000000..804b41c
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/scan/broken/app/BrokenApp.java
@@ -0,0 +1,8 @@
+package com.ankurm.structure.scan.broken.app;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** Scans com.ankurm.structure.scan.broken.app and below. The controller lives in a sibling package, so it is never found. */
+@SpringBootApplication
+public class BrokenApp {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/scan/broken/web/HelloController.java b/project-structure/src/main/java/com/ankurm/structure/scan/broken/web/HelloController.java
new file mode 100644
index 0000000..eec3d24
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/scan/broken/web/HelloController.java
@@ -0,0 +1,12 @@
+package com.ankurm.structure.scan.broken.web;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+class HelloController {
+ @GetMapping("/hello")
+ String hello() {
+ return "hello";
+ }
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/scan/fixed/FixedApp.java b/project-structure/src/main/java/com/ankurm/structure/scan/fixed/FixedApp.java
new file mode 100644
index 0000000..4ebb0ea
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/scan/fixed/FixedApp.java
@@ -0,0 +1,8 @@
+package com.ankurm.structure.scan.fixed;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** Scans com.ankurm.structure.scan.fixed and everything below it, including web/. */
+@SpringBootApplication
+public class FixedApp {
+}
diff --git a/project-structure/src/main/java/com/ankurm/structure/scan/fixed/web/HelloController.java b/project-structure/src/main/java/com/ankurm/structure/scan/fixed/web/HelloController.java
new file mode 100644
index 0000000..ba2b57b
--- /dev/null
+++ b/project-structure/src/main/java/com/ankurm/structure/scan/fixed/web/HelloController.java
@@ -0,0 +1,12 @@
+package com.ankurm.structure.scan.fixed.web;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+class HelloController {
+ @GetMapping("/hello")
+ String hello() {
+ return "hello";
+ }
+}
diff --git a/project-structure/src/test/java/com/ankurm/structure/ScanTrapTests.java b/project-structure/src/test/java/com/ankurm/structure/ScanTrapTests.java
new file mode 100644
index 0000000..ee005e3
--- /dev/null
+++ b/project-structure/src/test/java/com/ankurm/structure/ScanTrapTests.java
@@ -0,0 +1,95 @@
+package com.ankurm.structure;
+
+import com.ankurm.structure.scan.broken.app.BrokenApp;
+import com.ankurm.structure.scan.fixed.FixedApp;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.boot.WebApplicationType;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.context.ConfigurableApplicationContext;
+
+import javax.tools.*;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** The two ways a project layout silently breaks component scanning. */
+@ExtendWith(OutputCaptureExtension.class)
+class ScanTrapTests {
+
+ @Test
+ void appClassInTheWrongPackage() throws Exception {
+ HttpClient http = HttpClient.newHttpClient();
+ try (Transcript t = new Transcript("05-scan-starts-at-the-app-class.txt",
+ "The controller is found only if it is in or below the @SpringBootApplication class's package")) {
+ for (var c : List.of(
+ new Object[]{"broken", BrokenApp.class, "app class in scan.broken.app, controller in scan.broken.web (a sibling)"},
+ new Object[]{"fixed", FixedApp.class, "app class in scan.fixed, controller in scan.fixed.web (below it)"})) {
+ try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder((Class>) c[1])
+ .properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN").run()) {
+ String base = "http://localhost:" + ctx.getEnvironment().getProperty("local.server.port");
+ HttpResponse r = http.send(HttpRequest.newBuilder(URI.create(base + "/hello")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ t.section((String) c[2]);
+ t.line("GET /hello -> %d", r.statusCode());
+ t.line("HelloController registered as a bean: %s", ctx.getBeanNamesForType(Object.class).length > 0
+ && java.util.Arrays.stream(ctx.getBeanDefinitionNames()).anyMatch(n -> n.equals("helloController")));
+ if (c[0].equals("broken")) assertThat(r.statusCode()).isEqualTo(404);
+ else assertThat(r.statusCode()).isEqualTo(200);
+ }
+ }
+ }
+ }
+
+ @Test
+ void appClassInTheDefaultPackage(CapturedOutput output) throws Exception {
+ String source = "import org.springframework.boot.autoconfigure.SpringBootApplication;\n"
+ + "@SpringBootApplication\n"
+ + "public class DefaultPackageApp {\n"
+ + "}\n";
+ Path out = Files.createTempDirectory("defaultpkg");
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ JavaFileObject file = new SimpleJavaFileObject(URI.create("string:///DefaultPackageApp.java"), JavaFileObject.Kind.SOURCE) {
+ @Override public CharSequence getCharContent(boolean ignore) { return source; }
+ };
+ boolean compiled = javac.getTask(null, null, null,
+ List.of("-proc:none", "-d", out.toString(), "-classpath", System.getProperty("java.class.path")), null, List.of(file)).call();
+ assertThat(compiled).isTrue();
+
+ try (Transcript t = new Transcript("06-default-package.txt", "A @SpringBootApplication class in the default package (no package statement)")) {
+ t.line("source compiled:");
+ for (String l : source.split("\n")) t.line(" %s", l);
+ t.blank();
+ Class> app;
+ try (URLClassLoader cl = new URLClassLoader(new URL[]{out.toUri().toURL()}, getClass().getClassLoader())) {
+ app = Class.forName("DefaultPackageApp", true, cl);
+ String result;
+ try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(app)
+ .web(WebApplicationType.NONE).properties("spring.main.banner-mode=off").run()) {
+ result = "context started with " + ctx.getBeanDefinitionCount() + " bean definitions";
+ } catch (Throwable e) {
+ Throwable root = e;
+ while (root.getCause() != null) root = root.getCause();
+ result = "context FAILED to start: " + e.getClass().getSimpleName() + " / root cause "
+ + root.getClass().getSimpleName() + ": " + String.valueOf(root.getMessage()).lines().findFirst().orElse("");
+ }
+ t.section("what Spring Boot logged about the default package");
+ output.getAll().lines().filter(l -> l.contains("default package") || l.contains("WARNING"))
+ .map(l -> l.replaceFirst("^\\S+\\s+", "")).forEach(t::line);
+ t.section("result");
+ t.line(result);
+ }
+ assertThat(output.getAll()).contains("default package");
+ }
+ }
+}
diff --git a/project-structure/src/test/java/com/ankurm/structure/StructureTests.java b/project-structure/src/test/java/com/ankurm/structure/StructureTests.java
new file mode 100644
index 0000000..b368ac7
--- /dev/null
+++ b/project-structure/src/test/java/com/ankurm/structure/StructureTests.java
@@ -0,0 +1,182 @@
+package com.ankurm.structure;
+
+import com.ankurm.structure.byfeature.ByFeatureApp;
+import com.ankurm.structure.bylayer.ByLayerApp;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.context.ConfigurableApplicationContext;
+
+import javax.tools.*;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.*;
+import java.util.*;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Each test writes one transcript under output/; the article quotes those files verbatim. */
+class StructureTests {
+
+ static final Path SRC = Path.of("src/main/java/com/ankurm/structure");
+ static final Path CLASSES = Path.of("target/classes");
+
+ // ---------------------------------------------------------------- 01 the two trees
+ @Test
+ void packageTrees() throws IOException {
+ try (Transcript t = new Transcript("01-package-trees.txt", "The same application, two layouts (src/main/java/com/ankurm/structure)")) {
+ for (String variant : List.of("bylayer", "byfeature")) {
+ t.section(variant);
+ Path root = SRC.resolve(variant);
+ List all;
+ try (Stream s = Files.walk(root)) {
+ all = s.sorted().toList();
+ }
+ int files = 0;
+ for (Path p : all) {
+ int depth = root.relativize(p).getNameCount();
+ String indent = " ".repeat(depth);
+ if (p.equals(root)) {
+ t.line("%s/", variant);
+ } else if (Files.isDirectory(p)) {
+ t.line("%s%s/", indent, p.getFileName());
+ } else {
+ t.line("%s%s", indent, p.getFileName());
+ files++;
+ }
+ }
+ t.line("(%d source files)", files);
+ assertThat(files).isGreaterThan(8);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- 02 what is public
+ @Test
+ void visibility() throws Exception {
+ Map publicCount = new LinkedHashMap<>();
+ try (Transcript t = new Transcript("02-public-surface.txt", "Top-level types per layout and whether each one is public")) {
+ for (String variant : List.of("bylayer", "byfeature")) {
+ t.section(variant);
+ Path root = CLASSES.resolve("com/ankurm/structure").resolve(variant);
+ List names = new ArrayList<>();
+ try (Stream s = Files.walk(root)) {
+ s.filter(p -> p.toString().endsWith(".class") && !p.getFileName().toString().contains("$"))
+ .sorted().forEach(p -> names.add(CLASSES.relativize(p).toString()
+ .replace('/', '.').replaceAll("\\.class$", "")));
+ }
+ int pub = 0;
+ for (String n : names) {
+ Class> c = Class.forName(n, false, getClass().getClassLoader());
+ boolean isPublic = java.lang.reflect.Modifier.isPublic(c.getModifiers());
+ if (isPublic) pub++;
+ t.line("%-14s %s", isPublic ? "public" : "package-private",
+ n.substring("com.ankurm.structure.".length()));
+ }
+ t.line("%d of %d top-level types are public", pub, names.size());
+ publicCount.put(variant, pub);
+ }
+ }
+ // package-by-feature exposes strictly less
+ assertThat(publicCount.get("byfeature")).isLessThan(publicCount.get("bylayer"));
+ }
+
+ // ---------------------------------------------------------------- 03 what the compiler enforces
+ @Test
+ void compilerBoundary() throws Exception {
+ try (Transcript t = new Transcript("03-compiler-boundary.txt", "Can code in one package reach another package's repository?")) {
+ t.section("by layer: controller/Shortcut.java reaches into repository/");
+ List layer = compile("com.ankurm.structure.bylayer.controller.Shortcut",
+ "package com.ankurm.structure.bylayer.controller;\n"
+ + "class Shortcut {\n"
+ + " Object repo = new com.ankurm.structure.bylayer.repository.BookRepository();\n"
+ + "}\n");
+ layer.forEach(t::line);
+ assertThat(layer).containsExactly("compiled with 0 errors");
+
+ t.section("by feature: loan/Shortcut.java reaches into book/");
+ List feature = compile("com.ankurm.structure.byfeature.loan.Shortcut",
+ "package com.ankurm.structure.byfeature.loan;\n"
+ + "class Shortcut {\n"
+ + " Object repo = new com.ankurm.structure.byfeature.book.BookRepository();\n"
+ + "}\n");
+ feature.forEach(t::line);
+ assertThat(feature.get(0)).contains("is not public").contains("cannot be accessed from outside package");
+ }
+ }
+
+ private List compile(String className, String source) throws IOException {
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ JavaFileObject file = new SimpleJavaFileObject(URI.create("string:///" + className.replace('.', '/') + ".java"),
+ JavaFileObject.Kind.SOURCE) {
+ @Override public CharSequence getCharContent(boolean ignore) { return source; }
+ };
+ Path out = Files.createTempDirectory("shortcut");
+ List opts = List.of("-proc:none", "-d", out.toString(), "-classpath", CLASSES.toString());
+ boolean ok = javac.getTask(null, null, diagnostics, opts, null, List.of(file)).call();
+ List lines = new ArrayList<>();
+ for (Diagnostic extends JavaFileObject> d : diagnostics.getDiagnostics()) {
+ if (d.getKind() == Diagnostic.Kind.ERROR) {
+ lines.add("error: " + d.getMessage(Locale.ENGLISH));
+ }
+ }
+ if (ok) lines.add("compiled with 0 errors");
+ return lines;
+ }
+
+ // ---------------------------------------------------------------- 04 both layouts expose one API
+ @Test
+ void sameApiBothLayouts() throws Exception {
+ HttpClient http = HttpClient.newHttpClient();
+ Map> results = new LinkedHashMap<>();
+ try (Transcript t = new Transcript("04-same-api-both-layouts.txt", "The same requests against both layouts")) {
+ for (var variant : List.of("bylayer", "byfeature")) {
+ Class> app = variant.equals("bylayer") ? ByLayerApp.class : ByFeatureApp.class;
+ try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(app)
+ .properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN").run()) {
+ String base = "http://localhost:" + ctx.getEnvironment().getProperty("local.server.port");
+ t.section(variant);
+ List seen = new ArrayList<>();
+ for (String[] call : new String[][]{
+ {"GET", "/books", null},
+ {"GET", "/books/1", null},
+ {"GET", "/books/99", null},
+ {"POST", "/books", "{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"pages\":464}"},
+ {"POST", "/books", "{\"title\":\"\",\"author\":\"x\",\"pages\":0}"}}) {
+ HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(base + call[1]));
+ if (call[2] == null) rb.GET();
+ else rb.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(call[2]));
+ HttpResponse r = http.send(rb.build(), HttpResponse.BodyHandlers.ofString());
+ String line = call[0] + " " + call[1] + " -> " + r.statusCode();
+ t.line(line);
+ t.line(" %s", r.body());
+ seen.add(call[0] + " " + call[1] + " " + r.statusCode()
+ + (r.statusCode() == 400 ? "" : " " + r.body().replaceAll("\"instance\":\"[^\"]*\",?", "")));
+ }
+ if (variant.equals("byfeature")) {
+ for (String[] call : new String[][]{
+ {"POST", "/loans", "{\"bookId\":2,\"member\":\"Asha\"}"},
+ {"POST", "/loans", "{\"bookId\":7,\"member\":\"Asha\"}"},
+ {"GET", "/loans", null}}) {
+ HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(base + call[1]));
+ if (call[2] == null) rb.GET();
+ else rb.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(call[2]));
+ HttpResponse r = http.send(rb.build(), HttpResponse.BodyHandlers.ofString());
+ t.line(call[0] + " " + call[1] + " -> " + r.statusCode());
+ t.line(" %s", r.body());
+ }
+ }
+ results.put(variant, seen);
+ }
+ }
+ t.section("comparison");
+ boolean same = results.get("bylayer").equals(results.get("byfeature"));
+ t.line("status codes and bodies identical for the five shared requests: %s", same);
+ assertThat(same).isTrue();
+ }
+ }
+}
diff --git a/project-structure/src/test/java/com/ankurm/structure/Transcript.java b/project-structure/src/test/java/com/ankurm/structure/Transcript.java
new file mode 100644
index 0000000..30ab957
--- /dev/null
+++ b/project-structure/src/test/java/com/ankurm/structure/Transcript.java
@@ -0,0 +1,54 @@
+package com.ankurm.structure;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Writes a numbered transcript under {@code output/} and echoes it to the console.
+ * Every console block quoted in the article comes out of one of these files verbatim.
+ */
+public final class Transcript implements AutoCloseable {
+
+ private final Path path;
+ private final StringWriter buffer = new StringWriter();
+ private final PrintWriter out = new PrintWriter(buffer);
+
+ public Transcript(String fileName, String title) {
+ this.path = Path.of("output", fileName);
+ out.println("# " + title);
+ out.println();
+ }
+
+ public Transcript line(String format, Object... args) {
+ out.println(args.length == 0 ? format : String.format(format, args));
+ return this;
+ }
+
+ public Transcript blank() {
+ out.println();
+ return this;
+ }
+
+ public Transcript section(String heading) {
+ out.println();
+ out.println("--- " + heading + " ---");
+ return this;
+ }
+
+ @Override
+ public void close() {
+ out.flush();
+ // Absolute paths of whoever ran the build are environment noise, not a finding.
+ String text = buffer.toString().replace(System.getProperty("user.dir"), "");
+ try {
+ Files.createDirectories(path.getParent());
+ Files.writeString(path, text);
+ } catch (IOException e) {
+ throw new IllegalStateException("could not write " + path, e);
+ }
+ System.out.print(text);
+ }
+}