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 <[email protected]> Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>project-structure</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>project-structure</name>
|
||||
<description>The same Spring Boot 4 application laid out package-by-layer and package-by-feature</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- two applications live in this module; `mvn spring-boot:run` starts the recommended one -->
|
||||
<mainClass>com.ankurm.structure.byfeature.ByFeatureApp</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+15
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.structure.byfeature.book;
|
||||
|
||||
record Book(long id, String title, String author, int pages) {
|
||||
}
|
||||
@@ -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<String> titleOf(long id);
|
||||
}
|
||||
+33
@@ -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<BookResponse> 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));
|
||||
}
|
||||
}
|
||||
+9
@@ -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");
|
||||
}
|
||||
}
|
||||
+38
@@ -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<Long, Book> 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<Book> findById(long id) {
|
||||
return Optional.ofNullable(books.get(id));
|
||||
}
|
||||
|
||||
List<Book> findAll() {
|
||||
List<Book> all = new ArrayList<>(books.values());
|
||||
all.sort((a, b) -> Long.compare(a.id(), b.id()));
|
||||
return all;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<Book> 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<String> titleOf(long id) {
|
||||
return repository.findById(id).map(Book::title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.structure.byfeature.loan;
|
||||
|
||||
record Loan(long id, long bookId, String bookTitle, String member) {
|
||||
}
|
||||
+28
@@ -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<Loan> all() {
|
||||
return service.all();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<Loan> 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<Loan> all() {
|
||||
return List.copyOf(loans);
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
+36
@@ -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<BookResponse> 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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+15
@@ -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());
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.structure.bylayer.exception;
|
||||
|
||||
public class BookNotFoundException extends RuntimeException {
|
||||
public BookNotFoundException(long id) {
|
||||
super("Book " + id + " not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.structure.bylayer.model;
|
||||
|
||||
public record Book(long id, String title, String author, int pages) {
|
||||
}
|
||||
+39
@@ -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<Long, Book> 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<Book> findById(long id) {
|
||||
return Optional.ofNullable(books.get(id));
|
||||
}
|
||||
|
||||
public List<Book> findAll() {
|
||||
List<Book> all = new ArrayList<>(books.values());
|
||||
all.sort((a, b) -> Long.compare(a.id(), b.id()));
|
||||
return all;
|
||||
}
|
||||
}
|
||||
@@ -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<Book> 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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
+12
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
+12
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Path> all;
|
||||
try (Stream<Path> 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<String, Integer> 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<String> names = new ArrayList<>();
|
||||
try (Stream<Path> 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<String> 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<String> 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<String> compile(String className, String source) throws IOException {
|
||||
JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
|
||||
DiagnosticCollector<JavaFileObject> 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<String> 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<String> 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<String, List<String>> 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<String> 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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"), "<project-structure>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user