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
183 lines
9.8 KiB
Java
183 lines
9.8 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|