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:
Claude
2026-10-21 16:43:45 +00:00
parent 008211f2fa
commit ba709737de
41 changed files with 1039 additions and 0 deletions
+1
View File
@@ -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
+51
View File
@@ -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]
+52
View File
@@ -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>
+15
View File
@@ -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);
}
@@ -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));
}
}
@@ -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");
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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 {
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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) {
}
@@ -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 {
}
@@ -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 {
}
@@ -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);
}
}