Skip to content

Commit c77092c

Browse files
committed
feat(source-layout): enforce schema-level architecture contracts with package-info markers
1 parent 24bd148 commit c77092c

4 files changed

Lines changed: 75 additions & 33 deletions

File tree

src/main/java/io/github/blueprintplatform/codegen/adapter/out/framework/springboot/java/source/SourceLayoutAdapter.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import io.github.blueprintplatform.codegen.domain.model.value.pkg.PackageName;
77
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedDirectory;
88
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedResource;
9+
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedTextResource;
10+
import java.nio.charset.StandardCharsets;
911
import java.nio.file.Path;
1012
import java.util.ArrayList;
1113
import java.util.List;
@@ -49,6 +51,14 @@ public class SourceLayoutAdapter implements SourceLayoutPort {
4951
Path.of("domain", "model"),
5052
Path.of("domain", "service"));
5153

54+
private static final List<String> HEX_TOP_LEVEL_FAMILIES =
55+
List.of("adapter", "application", "domain", "bootstrap");
56+
57+
private static final List<String> STANDARD_TOP_LEVEL_FAMILIES =
58+
List.of("controller", "service", "repository", "domain", "config");
59+
60+
private static final String PACKAGE_INFO_FILE = "package-info.java";
61+
5262
@Override
5363
public ArtifactKey artifactKey() {
5464
return ArtifactKey.SOURCE_LAYOUT;
@@ -65,12 +75,29 @@ public Iterable<? extends GeneratedResource> generate(ProjectBlueprint blueprint
6575
addDirectories(resources, COMMON_DIRS);
6676
addDirectories(resources, List.of(mainBasePackageDir, testBasePackageDir));
6777

68-
var segments = blueprint.getArchitecture().layout().isHexagonal() ? HEX_DIRS : STANDARD_DIRS;
78+
var isHex = blueprint.getArchitecture().layout().isHexagonal();
79+
80+
var segments = isHex ? HEX_DIRS : STANDARD_DIRS;
6981
addDirectoriesUnder(resources, mainBasePackageDir, segments);
7082

83+
addTopLevelPackageInfoMarkers(resources, mainBasePackageDir, packageName, isHex);
84+
7185
return List.copyOf(resources);
7286
}
7387

88+
private void addTopLevelPackageInfoMarkers(
89+
List<GeneratedResource> out,
90+
Path mainBasePackageDir,
91+
PackageName basePackage,
92+
boolean isHex) {
93+
var families = isHex ? HEX_TOP_LEVEL_FAMILIES : STANDARD_TOP_LEVEL_FAMILIES;
94+
for (var family : families) {
95+
var relativePath = mainBasePackageDir.resolve(family).resolve(PACKAGE_INFO_FILE);
96+
var content = "package " + basePackage.value() + "." + family + ";\n";
97+
out.add(new GeneratedTextResource(relativePath, content, StandardCharsets.UTF_8));
98+
}
99+
}
100+
74101
private Path resolveBasePackageDir(Path javaRoot, PackageName packageName) {
75102
var pkgPath = Path.of(packageName.value().replace('.', '/'));
76103
return javaRoot.resolve(pkgPath);

src/main/resources/templates/springboot/java/governance/standard/strict/StandardUnknownFamilyTest.java.ftl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import org.junit.jupiter.api.Assertions;
3434
* Contract note:
3535
* - If this test fails, at least one bounded context contains code that does not conform
3636
* to the generated STANDARD package contract (likely a rename-based escape).
37+
* - If this test fails due to no detected bounded context, it indicates guardrails are effectively disabled
38+
* (e.g. canonical family renamed, root structure altered, or no controller exists while strict guardrails are enabled).
3739
*/
3840
@AnalyzeClasses(
3941
packages = BASE_PACKAGE,
@@ -54,7 +56,12 @@ class StandardUnknownFamilyTest {
5456
var contexts = detectControllerContexts(classes);
5557

5658
if (contexts.isEmpty()) {
57-
return;
59+
Assertions.fail(
60+
"No STANDARD bounded context was detected under scope '" + BASE_PACKAGE + "'. "
61+
+ "Expected at least one context containing '" + CONTROLLER + "'. "
62+
+ "This may indicate that the root package or canonical family names were changed "
63+
+ "(or that no controller exists while strict guardrails are enabled)."
64+
);
5865
}
5966

6067
var violationsByContext = new TreeMap<String, Set<String>>();

src/test/java/io/github/blueprintplatform/codegen/adapter/out/framework/springboot/java/source/SourceLayoutAdapterTest.java

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import io.github.blueprintplatform.codegen.application.port.out.artifact.ArtifactKey;
66
import io.github.blueprintplatform.codegen.domain.model.ProjectBlueprint;
7-
import io.github.blueprintplatform.codegen.domain.model.value.architecture.ArchitectureGovernance;
87
import io.github.blueprintplatform.codegen.domain.model.value.architecture.ArchitectureSpec;
98
import io.github.blueprintplatform.codegen.domain.model.value.dependency.Dependencies;
109
import io.github.blueprintplatform.codegen.domain.model.value.identity.ArtifactId;
@@ -26,6 +25,7 @@
2625
import io.github.blueprintplatform.codegen.domain.model.value.tech.stack.TechStack;
2726
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedDirectory;
2827
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedResource;
28+
import io.github.blueprintplatform.codegen.domain.port.out.artifact.GeneratedTextResource;
2929
import java.nio.file.Path;
3030
import java.util.ArrayList;
3131
import java.util.List;
@@ -54,57 +54,55 @@ private static ProjectBlueprint blueprint(ProjectLayout layout) {
5454
new TechStack(Framework.SPRING_BOOT, BuildTool.MAVEN, Language.JAVA),
5555
new SpringBootJvmTarget(JavaVersion.JAVA_21, SpringBootVersion.V3_5));
5656

57-
ArchitectureSpec architecture =
58-
new ArchitectureSpec(layout, ArchitectureGovernance.none(), SampleCodeOptions.none());
57+
ArchitectureSpec architecture = new ArchitectureSpec(layout, null, SampleCodeOptions.none());
5958

6059
Dependencies dependencies = Dependencies.of(List.of());
6160

6261
return ProjectBlueprint.of(metadata, platform, architecture, dependencies);
6362
}
6463

65-
private static List<Path> toRelativePaths(Iterable<? extends GeneratedResource> resources) {
64+
private static List<Path> directoriesOf(Iterable<? extends GeneratedResource> resources) {
6665
List<Path> result = new ArrayList<>();
6766
StreamSupport.stream(resources.spliterator(), false)
68-
.forEach(
69-
r -> {
70-
assertThat(r).isInstanceOf(GeneratedDirectory.class);
71-
result.add(r.relativePath());
72-
});
67+
.filter(r -> r instanceof GeneratedDirectory)
68+
.forEach(r -> result.add(r.relativePath()));
69+
return result;
70+
}
71+
72+
private static List<Path> textFilesOf(Iterable<? extends GeneratedResource> resources) {
73+
List<Path> result = new ArrayList<>();
74+
StreamSupport.stream(resources.spliterator(), false)
75+
.filter(r -> r instanceof GeneratedTextResource)
76+
.forEach(r -> result.add(r.relativePath()));
7377
return result;
7478
}
7579

7680
@Test
7781
@DisplayName("artifactKey() should return SOURCE_LAYOUT")
7882
void artifactKey_shouldReturnSourceLayout() {
7983
SourceLayoutAdapter adapter = new SourceLayoutAdapter();
80-
8184
assertThat(adapter.artifactKey()).isEqualTo(ArtifactKey.SOURCE_LAYOUT);
8285
}
8386

8487
@Test
85-
@DisplayName(
86-
"generate() with STANDARD layout should create base/package directories and layered sub-packages")
87-
void generate_standardLayout_shouldCreateLayeredSubPackages() {
88+
@DisplayName("generate() with STANDARD layout should create directories and package-info markers")
89+
void generate_standardLayout_shouldCreateLayeredSubPackagesAndMarkers() {
8890
SourceLayoutAdapter adapter = new SourceLayoutAdapter();
8991
ProjectBlueprint blueprint = blueprint(ProjectLayout.STANDARD);
9092

9193
Iterable<? extends GeneratedResource> resources = adapter.generate(blueprint);
92-
List<Path> paths = toRelativePaths(resources);
9394

9495
Path mainBase = Path.of("src", "main", "java").resolve(BASE_PACKAGE_PATH);
9596
Path testBase = Path.of("src", "test", "java").resolve(BASE_PACKAGE_PATH);
9697

97-
List<Path> expected =
98+
List<Path> expectedDirs =
9899
List.of(
99-
// common dirs
100100
Path.of("src", "main", "java"),
101101
Path.of("src", "test", "java"),
102102
Path.of("src", "main", "resources"),
103103
Path.of("src", "test", "resources"),
104-
// base package dirs
105104
mainBase,
106105
testBase,
107-
// standard layered sub-packages (under main base package only)
108106
mainBase.resolve("controller"),
109107
mainBase.resolve(Path.of("controller", "dto")),
110108
mainBase.resolve("service"),
@@ -114,33 +112,38 @@ void generate_standardLayout_shouldCreateLayeredSubPackages() {
114112
mainBase.resolve(Path.of("domain", "model")),
115113
mainBase.resolve(Path.of("domain", "service")));
116114

117-
assertThat(paths).containsExactlyInAnyOrderElementsOf(expected).hasSize(expected.size());
115+
List<Path> expectedMarkers =
116+
List.of(
117+
mainBase.resolve(Path.of("controller", "package-info.java")),
118+
mainBase.resolve(Path.of("service", "package-info.java")),
119+
mainBase.resolve(Path.of("repository", "package-info.java")),
120+
mainBase.resolve(Path.of("domain", "package-info.java")),
121+
mainBase.resolve(Path.of("config", "package-info.java")));
122+
123+
assertThat(directoriesOf(resources)).containsExactlyInAnyOrderElementsOf(expectedDirs);
124+
assertThat(textFilesOf(resources)).containsExactlyInAnyOrderElementsOf(expectedMarkers);
118125
}
119126

120127
@Test
121128
@DisplayName(
122-
"generate() with HEXAGONAL layout should create base/package directories and hexagonal sub-packages")
123-
void generate_hexagonalLayout_shouldCreateHexagonalSubPackages() {
129+
"generate() with HEXAGONAL layout should create directories and package-info markers")
130+
void generate_hexagonalLayout_shouldCreateHexagonalSubPackagesAndMarkers() {
124131
SourceLayoutAdapter adapter = new SourceLayoutAdapter();
125132
ProjectBlueprint blueprint = blueprint(ProjectLayout.HEXAGONAL);
126133

127134
Iterable<? extends GeneratedResource> resources = adapter.generate(blueprint);
128-
List<Path> paths = toRelativePaths(resources);
129135

130136
Path mainBase = Path.of("src", "main", "java").resolve(BASE_PACKAGE_PATH);
131137
Path testBase = Path.of("src", "test", "java").resolve(BASE_PACKAGE_PATH);
132138

133-
List<Path> expected =
139+
List<Path> expectedDirs =
134140
List.of(
135-
// common dirs
136141
Path.of("src", "main", "java"),
137142
Path.of("src", "test", "java"),
138143
Path.of("src", "main", "resources"),
139144
Path.of("src", "test", "resources"),
140-
// base package dirs
141145
mainBase,
142146
testBase,
143-
// hexagonal sub-packages (under main base package only)
144147
mainBase.resolve("adapter"),
145148
mainBase.resolve(Path.of("adapter", "in")),
146149
mainBase.resolve(Path.of("adapter", "out")),
@@ -157,6 +160,14 @@ void generate_hexagonalLayout_shouldCreateHexagonalSubPackages() {
157160
mainBase.resolve(Path.of("domain", "port", "out")),
158161
mainBase.resolve(Path.of("domain", "service")));
159162

160-
assertThat(paths).containsExactlyInAnyOrderElementsOf(expected).hasSize(expected.size());
163+
List<Path> expectedMarkers =
164+
List.of(
165+
mainBase.resolve(Path.of("adapter", "package-info.java")),
166+
mainBase.resolve(Path.of("application", "package-info.java")),
167+
mainBase.resolve(Path.of("domain", "package-info.java")),
168+
mainBase.resolve(Path.of("bootstrap", "package-info.java")));
169+
170+
assertThat(directoriesOf(resources)).containsExactlyInAnyOrderElementsOf(expectedDirs);
171+
assertThat(textFilesOf(resources)).containsExactlyInAnyOrderElementsOf(expectedMarkers);
161172
}
162173
}

src/test/java/io/github/blueprintplatform/codegen/architecture/archunit/DependencyDirectionArchitectureTest.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ class DependencyDirectionArchitectureTest {
1919
private static final String APPLICATION_ROOT = BASE_PACKAGE + ".application..";
2020
private static final String APPLICATION_PORTS = BASE_PACKAGE + ".application.port..";
2121
private static final String ADAPTER_ROOT = BASE_PACKAGE + ".adapter..";
22-
private static final String BOOTSTRAP_ROOT = BASE_PACKAGE + ".bootstrap..";
23-
2422
@ArchTest
2523
static final ArchRule application_implementation_must_not_depend_on_adapters =
2624
noClasses()
@@ -32,7 +30,6 @@ class DependencyDirectionArchitectureTest {
3230
.dependOnClassesThat()
3331
.resideInAnyPackage(ADAPTER_ROOT)
3432
.allowEmptyShould(true);
35-
3633
@ArchTest
3734
static final ArchRule adapters_must_not_depend_on_application_implementation =
3835
noClasses()
@@ -41,7 +38,7 @@ class DependencyDirectionArchitectureTest {
4138
.should()
4239
.dependOnClassesThat(applicationImplementation())
4340
.allowEmptyShould(true);
44-
41+
private static final String BOOTSTRAP_ROOT = BASE_PACKAGE + ".bootstrap..";
4542
@ArchTest
4643
static final ArchRule bootstrap_must_not_be_depended_on =
4744
noClasses()

0 commit comments

Comments
 (0)