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
@@ -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);
}
}