From ba709737dee8ff5bc506a66a2fbc367aed6f7684 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:33:49 +0000 Subject: [PATCH] Add project-structure module: the same Boot 4.1 app laid out package-by-layer and package-by-feature, with the public surface counted and the javac boundary captured Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 --- README.md | 1 + project-structure/README.md | 51 +++++ project-structure/output/01-package-trees.txt | 43 +++++ .../output/02-public-surface.txt | 32 +++ .../output/03-compiler-boundary.txt | 8 + .../output/04-same-api-both-layouts.txt | 35 ++++ .../05-scan-starts-at-the-app-class.txt | 10 + .../output/06-default-package.txt | 14 ++ project-structure/pom.xml | 52 +++++ project-structure/scripts/run-all.sh | 15 ++ .../structure/byfeature/ByFeatureApp.java | 12 ++ .../ankurm/structure/byfeature/book/Book.java | 4 + .../structure/byfeature/book/BookCatalog.java | 8 + .../byfeature/book/BookController.java | 33 ++++ .../byfeature/book/BookNotFoundException.java | 9 + .../byfeature/book/BookRepository.java | 38 ++++ .../structure/byfeature/book/BookRequest.java | 7 + .../byfeature/book/BookResponse.java | 7 + .../structure/byfeature/book/BookService.java | 32 +++ .../ankurm/structure/byfeature/loan/Loan.java | 4 + .../byfeature/loan/LoanController.java | 28 +++ .../structure/byfeature/loan/LoanRequest.java | 7 + .../structure/byfeature/loan/LoanService.java | 32 +++ .../byfeature/shared/ApiException.java | 15 ++ .../byfeature/shared/ApiExceptionHandler.java | 16 ++ .../ankurm/structure/bylayer/ByLayerApp.java | 8 + .../bylayer/controller/BookController.java | 36 ++++ .../structure/bylayer/dto/BookRequest.java | 7 + .../structure/bylayer/dto/BookResponse.java | 9 + .../exception/ApiExceptionHandler.java | 15 ++ .../exception/BookNotFoundException.java | 7 + .../ankurm/structure/bylayer/model/Book.java | 4 + .../bylayer/repository/BookRepository.java | 39 ++++ .../bylayer/service/BookService.java | 30 +++ .../structure/scan/broken/app/BrokenApp.java | 8 + .../scan/broken/web/HelloController.java | 12 ++ .../ankurm/structure/scan/fixed/FixedApp.java | 8 + .../scan/fixed/web/HelloController.java | 12 ++ .../com/ankurm/structure/ScanTrapTests.java | 95 +++++++++ .../com/ankurm/structure/StructureTests.java | 182 ++++++++++++++++++ .../java/com/ankurm/structure/Transcript.java | 54 ++++++ 41 files changed, 1039 insertions(+) create mode 100644 project-structure/README.md create mode 100644 project-structure/output/01-package-trees.txt create mode 100644 project-structure/output/02-public-surface.txt create mode 100644 project-structure/output/03-compiler-boundary.txt create mode 100644 project-structure/output/04-same-api-both-layouts.txt create mode 100644 project-structure/output/05-scan-starts-at-the-app-class.txt create mode 100644 project-structure/output/06-default-package.txt create mode 100644 project-structure/pom.xml create mode 100755 project-structure/scripts/run-all.sh create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/ByFeatureApp.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/Book.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookCatalog.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookController.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookNotFoundException.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRepository.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookRequest.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookResponse.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/book/BookService.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/loan/Loan.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanController.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanRequest.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/loan/LoanService.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiException.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/byfeature/shared/ApiExceptionHandler.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/ByLayerApp.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/controller/BookController.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookRequest.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/dto/BookResponse.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/exception/ApiExceptionHandler.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/exception/BookNotFoundException.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/model/Book.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/repository/BookRepository.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/bylayer/service/BookService.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/scan/broken/app/BrokenApp.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/scan/broken/web/HelloController.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/scan/fixed/FixedApp.java create mode 100644 project-structure/src/main/java/com/ankurm/structure/scan/fixed/web/HelloController.java create mode 100644 project-structure/src/test/java/com/ankurm/structure/ScanTrapTests.java create mode 100644 project-structure/src/test/java/com/ankurm/structure/StructureTests.java create mode 100644 project-structure/src/test/java/com/ankurm/structure/Transcript.java 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 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); + } +}