packages) {
+
+ Assert.notNull(reference, "Reference package must not be null!");
+ Assert.notNull(packages, "Packages must not be null!");
+
+ var subPackages = packages.stream()
+ .filter(it -> it.isSubPackageOf(reference))
+ .toList();
+
+ return subPackages.isEmpty() ? NONE : new JavaPackages(subPackages).flatten();
+ }
+
+ /**
+ * Returns a {@link JavaPackages} instance that only contains the primary packages contained in the current
+ * {@link JavaPackages}. Any package that's a sub-package of any other package will get dropped.
+ *
+ * In other words for a list of {code com.foo}, {@code com.bar}, and {@code com.foo.bar}, only {@code com.foo} and
+ * {@code com.bar} will be retained.
+ *
+ * @return will never be {@literal null}.
+ */
+ JavaPackages flatten() {
+ return packages.isEmpty() ? this : new JavaPackages(removeSubPackages(packages));
+ }
+
+ /**
+ * Returns a stream of {@link JavaPackage}s.
+ *
+ * @return will never be {@literal null}.
+ */
+ Stream stream() {
+ return packages.stream();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Iterable#iterator()
+ */
+ @Override
+ public Iterator iterator() {
+ return packages.iterator();
+ }
+
+ /**
+ * Removes all sub-packages from the given list of packages.
+ *
+ * @param packages must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ private static List removeSubPackages(List packages) {
+
+ Assert.notNull(packages, "Packages must not be null!");
+
+ if (packages.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ var result = new ArrayList();
+
+ for (JavaPackage candidate : packages) {
+ if (result.stream().noneMatch(candidate::isSubPackageOf)) {
+ result.add(candidate);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java
index e5cb0fa2..80fac130 100644
--- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java
+++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java
@@ -65,7 +65,7 @@ public class NamedInterfaces implements Iterable {
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, true))
.and(ofAnnotatedPackages(basePackage))
- .and(ofAnnotatedTypes(basePackage));
+ .and(ofAnnotatedTypes(basePackage.getClasses()));
}
/**
@@ -106,7 +106,7 @@ public class NamedInterfaces implements Iterable {
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, false))
.and(ofAnnotatedPackages(basePackage))
- .and(ofAnnotatedTypes(basePackage));
+ .and(ofAnnotatedTypes(basePackage.getClasses()));
}
/**
@@ -242,11 +242,11 @@ public class NamedInterfaces implements Iterable {
return new NamedInterfaces(List.of(interfaces));
}
- private static List ofAnnotatedTypes(JavaPackage basePackage) {
+ private static List ofAnnotatedTypes(Classes classes) {
var mappings = new LinkedMultiValueMap();
- basePackage.stream() //
+ classes.stream() //
.filter(it -> !JavaPackage.isPackageInfoType(it)) //
.forEach(it -> {
diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java
new file mode 100644
index 00000000..67662eca
--- /dev/null
+++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.modulith.core;
+
+import org.springframework.util.Assert;
+
+/**
+ * The name of a Java package. Packages are sortable comparing their individual segments and deeper packages sorted
+ * last.
+ *
+ * @author Oliver Drotbohm
+ * @since 1.2
+ */
+class PackageName implements Comparable {
+
+ private final String name;
+ private final String[] segments;
+
+ /**
+ * Creates a new {@link PackageName} with the given name.
+ *
+ * @param name must not be {@literal null} or empty.
+ */
+ public PackageName(String name) {
+
+ Assert.hasText(name, "Name must not be null or empty!");
+
+ this.name = name;
+ this.segments = name.split("\\.");
+ }
+
+ /**
+ * Returns the length of the package name.
+ *
+ * @return will never be {@literal null}.
+ */
+ int length() {
+ return name.length();
+ }
+
+ /**
+ * Returns the raw name.
+ *
+ * @return will never be {@literal null}.
+ */
+ String getName() {
+ return name;
+ }
+
+ /**
+ * Returns whether the {@link PackageName} has the given {@link String} name.
+ *
+ * @param name must not be {@literal null} or empty.
+ */
+ boolean hasName(String name) {
+
+ Assert.hasText(name, "Name must not be null or empty!");
+
+ return this.name.equals(name);
+ }
+
+ /**
+ * Returns the last segment of a package name.
+ *
+ * @return will never be {@literal null}.
+ */
+ String getLocalName() {
+ return segments[segments.length - 1];
+ }
+
+ /**
+ * Returns the nested name in reference to the given base.
+ *
+ * @param base must not be {@literal null} or empty.
+ * @return will never be {@literal null}.
+ */
+ String getLocalName(String base) {
+
+ Assert.hasText(base, "Base must not be null or empty!");
+ Assert.isTrue(name.startsWith(base + "."),
+ () -> "Given name %s is not a parent of the current package %s!".formatted(base, name));
+
+ return name.substring(base.length() + 1);
+ }
+
+ /**
+ * Returns the filter expression to include all types including from nested packages.
+ *
+ * @return will never be {@literal null}.
+ */
+ String asFilter(boolean includeNested) {
+ return includeNested ? name.concat("..") : name;
+ }
+
+ /**
+ * Returns whether the current {@link PackageName} is the name of a parent package of the given one.
+ *
+ * @param reference must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ boolean isParentPackageOf(PackageName reference) {
+
+ Assert.notNull(reference, "Reference package name must not be null!");
+
+ return reference.name.startsWith(name + ".");
+ }
+
+ /**
+ * Returns whether the current {@link PackageName} is the name of a sub-package with the given name.
+ *
+ * @param reference must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ boolean isSubPackageOf(PackageName reference) {
+
+ Assert.notNull(reference, "Reference package name must not be null!");
+
+ return name.startsWith(reference.name + ".");
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Comparable#compareTo(java.lang.Object)
+ */
+ @Override
+ public int compareTo(PackageName o) {
+
+ for (var i = 0; i < segments.length; i++) {
+
+ if (o.segments.length <= i) {
+ return 1;
+ }
+
+ var segCompare = segments[i].compareTo(o.segments[i]);
+
+ if (segCompare != 0) {
+ return segCompare;
+ }
+ }
+
+ return segments.length - o.segments.length;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#toString()
+ */
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#equals(java.lang.Object)
+ */
+ @Override
+ public boolean equals(Object obj) {
+
+ if (obj == this) {
+ return true;
+ }
+
+ if (!(obj instanceof PackageName that)) {
+ return false;
+ }
+
+ return this.name.equals(that.name);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#hashCode()
+ */
+ @Override
+ public int hashCode() {
+ return name.hashCode();
+ }
+}
diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/Violations.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/Violations.java
index cd82a02f..0c05178d 100644
--- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/Violations.java
+++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/Violations.java
@@ -112,41 +112,37 @@ public class Violations extends RuntimeException {
Assert.notNull(violation, "Exception must not be null!");
- if (violations.isEmpty()) {
- return new Violations(List.of(violation));
- }
-
- if (violations.contains(violation)) {
- return this;
- }
-
- List newExceptions = new ArrayList<>(violations.size() + 1);
- newExceptions.addAll(violations);
- newExceptions.add(violation);
-
- return new Violations(newExceptions);
+ return new Violations(unionByMessage(violations, List.of(violation)));
}
Violations and(Violations other) {
-
- if (violations.isEmpty()) {
- return new Violations(other.violations);
- }
-
- var newExceptions = new ArrayList<>(violations);
-
- for (Violation candidate : other.violations) {
- if (!violations.contains(candidate)) {
- newExceptions.add(candidate);
- }
- }
-
- return new Violations(newExceptions);
+ return new Violations(unionByMessage(violations, other.violations));
}
Violations and(String violation) {
return and(new Violation(violation));
}
+ private List unionByMessage(List left, List right) {
+
+ if (left.isEmpty()) {
+ return right;
+ }
+
+ if (right.isEmpty()) {
+ return left;
+ }
+
+ var result = new ArrayList<>(left);
+
+ var messages = left.stream().map(Violation::message).toList();
+
+ right.stream()
+ .filter(it -> !messages.contains(it.message()))
+ .forEach(result::add);
+
+ return result.size() == left.size() ? left : result;
+ }
+
static record Violation(String message) {}
}
diff --git a/spring-modulith-core/src/test/java/example/empty/EmptyApplication.java b/spring-modulith-core/src/test/java/empty/EmptyApplication.java
similarity index 92%
rename from spring-modulith-core/src/test/java/example/empty/EmptyApplication.java
rename to spring-modulith-core/src/test/java/empty/EmptyApplication.java
index ed855a13..072be4eb 100644
--- a/spring-modulith-core/src/test/java/example/empty/EmptyApplication.java
+++ b/spring-modulith-core/src/test/java/empty/EmptyApplication.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package example.empty;
+package empty;
import org.springframework.modulith.Modulithic;
@@ -21,6 +21,4 @@ import org.springframework.modulith.Modulithic;
* @author Oliver Drotbohm
*/
@Modulithic
-public class EmptyApplication {
-
-}
+public class EmptyApplication {}
diff --git a/spring-modulith-core/src/test/java/example/invalid/Invalid.java b/spring-modulith-core/src/test/java/example/invalid/Invalid.java
new file mode 100644
index 00000000..c119da40
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/invalid/Invalid.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.invalid;
+
+import example.ni.nested.InNested;
+import example.ni.nested.b.first.InNestedBFirst;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class Invalid {
+ InNested invalid;
+ InNestedBFirst invalidToo;
+}
diff --git a/spring-modulith-core/src/test/java/jmolecules/package-info.java b/spring-modulith-core/src/test/java/example/jmolecules/package-info.java
similarity index 57%
rename from spring-modulith-core/src/test/java/jmolecules/package-info.java
rename to spring-modulith-core/src/test/java/example/jmolecules/package-info.java
index 618d6bc6..7e327123 100644
--- a/spring-modulith-core/src/test/java/jmolecules/package-info.java
+++ b/spring-modulith-core/src/test/java/example/jmolecules/package-info.java
@@ -1,2 +1,2 @@
@org.jmolecules.ddd.annotation.Module
-package jmolecules;
+package example.jmolecules;
diff --git a/spring-modulith-core/src/test/java/example/ni/RootType.java b/spring-modulith-core/src/test/java/example/ni/RootType.java
index 4af78b61..2a9b94fa 100644
--- a/spring-modulith-core/src/test/java/example/ni/RootType.java
+++ b/spring-modulith-core/src/test/java/example/ni/RootType.java
@@ -15,7 +15,17 @@
*/
package example.ni;
+import example.ni.nested.b.first.InNestedBFirst;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.stereotype.Component;
+
/**
* @author Oliver Drotbohm
*/
-public interface RootType {}
+@Component
+@RequiredArgsConstructor
+public class RootType {
+
+ final InNestedBFirst inNestedBFirst;
+}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/InNested.java b/spring-modulith-core/src/test/java/example/ni/nested/InNested.java
new file mode 100644
index 00000000..c8a836f1
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/InNested.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.ni.nested;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class InNested {}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/a/InNestedA.java b/spring-modulith-core/src/test/java/example/ni/nested/a/InNestedA.java
new file mode 100644
index 00000000..05649e54
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/a/InNestedA.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.ni.nested.a;
+
+import example.ni.internal.Internal;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class InNestedA {
+ Internal internal;
+}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/InNestedB.java b/spring-modulith-core/src/test/java/example/ni/nested/b/InNestedB.java
new file mode 100644
index 00000000..1cd8047b
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/b/InNestedB.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.ni.nested.b;
+
+import example.ni.RootType;
+import example.ni.nested.b.first.InNestedBFirst;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@Component
+@RequiredArgsConstructor
+public class InNestedB {
+
+ final InNestedBFirst downward;
+ final RootType upward;
+}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/first/InNestedBFirst.java b/spring-modulith-core/src/test/java/example/ni/nested/b/first/InNestedBFirst.java
new file mode 100644
index 00000000..7fd3368c
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/b/first/InNestedBFirst.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.ni.nested.b.first;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@Component
+public class InNestedBFirst {
+
+}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/first/package-info.java b/spring-modulith-core/src/test/java/example/ni/nested/b/first/package-info.java
new file mode 100644
index 00000000..b34055f0
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/b/first/package-info.java
@@ -0,0 +1,2 @@
+@org.springframework.modulith.ApplicationModule
+package example.ni.nested.b.first;
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/second/InNestedBSecond.java b/spring-modulith-core/src/test/java/example/ni/nested/b/second/InNestedBSecond.java
new file mode 100644
index 00000000..16746458
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/b/second/InNestedBSecond.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package example.ni.nested.b.second;
+
+import example.ni.nested.InNested;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class InNestedBSecond {
+ InNested inNested;
+}
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java b/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java
new file mode 100644
index 00000000..dcf898a7
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java
@@ -0,0 +1,2 @@
+@org.springframework.modulith.ApplicationModule
+package example.ni.nested.b.second;
diff --git a/spring-modulith-core/src/test/java/example/ni/nested/package-info.java b/spring-modulith-core/src/test/java/example/ni/nested/package-info.java
new file mode 100644
index 00000000..376f3177
--- /dev/null
+++ b/spring-modulith-core/src/test/java/example/ni/nested/package-info.java
@@ -0,0 +1,2 @@
+@org.springframework.modulith.ApplicationModule
+package example.ni.nested;
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java
new file mode 100644
index 00000000..195762df
--- /dev/null
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.modulith.core;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link ApplicationModules}.
+ *
+ * @author Oliver Drotbohm
+ */
+class ApplicationModulesUnitTests {
+
+ ApplicationModules modules = TestUtils.of("example", "example.ninvalid");
+
+ @Test // GH 578
+ void discoversComplexModuleArrangement() {
+
+ assertThat(modules)
+ .extracting(ApplicationModule::getName)
+ .containsExactlyInAnyOrder(
+ "invalid",
+ "jmolecules",
+ "ni",
+ "ni.nested",
+ "ni.nested.b.first",
+ "ni.nested.b.second",
+ "springbean");
+ }
+
+ @Test // GH 578
+ void detectsModuleNesting() {
+
+ var ni = modules.getModuleByName("ni").orElseThrow();
+ var nested = modules.getModuleByName("ni.nested").orElseThrow();
+ var inner = modules.getModuleByName("ni.nested.b.first").orElseThrow();
+
+ assertThat(inner.getParentModule(modules)).hasValue(nested);
+ assertThat(nested.getParentModule(modules)).hasValue(ni);
+ assertThat(ni.getParentModule(modules)).isEmpty();
+
+ assertThat(ni.getDirectlyNestedModules(modules))
+ .extracting(ApplicationModule::getName)
+ .containsExactlyInAnyOrder("ni.nested");
+
+ assertThat(ni.getNestedModules(modules))
+ .extracting(ApplicationModule::getName)
+ .containsExactlyInAnyOrder("ni.nested", "ni.nested.b.first", "ni.nested.b.second");
+ }
+
+ @Test // GH 578
+ void detectsInvalidReferenceToNestedModule() {
+
+ var violations = modules.detectViolations();
+ var messages = violations.getMessages();
+
+ assertThat(messages)
+ .hasSize(3)
+ .satisfiesExactlyInAnyOrder(
+ it -> assertThat(it).contains("Invalid", "'invalid'", "'ni.nested'"),
+ it -> assertThat(it).contains("Invalid", "'invalid'", "'ni.nested.b.first'"),
+ it -> assertThat(it).contains("Invalid", "'ni'", "'ni.nested.b.first'"));
+ }
+}
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/JavaPackageUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/JavaPackageUnitTests.java
new file mode 100644
index 00000000..4a87327e
--- /dev/null
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/JavaPackageUnitTests.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.modulith.core;
+
+import static org.assertj.core.api.Assertions.*;
+
+import example.ni.nested.a.InNestedA;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.core.importer.ImportOption;
+
+/**
+ * Unit tests for {@link JavaPackage}.
+ *
+ * @author Oliver Drotbohm
+ */
+class JavaPackageUnitTests {
+
+ static final JavaClasses ALL_CLASSES = new ClassFileImporter() //
+ .withImportOption(ImportOption.Predefined.ONLY_INCLUDE_TESTS)
+ .importPackages("example");
+
+ Classes classes = Classes.of(ALL_CLASSES);
+ JavaPackage pkg = JavaPackage.of(classes, "example.ni");
+
+ @Test // GH-578
+ void detectsDirectSubPackages() throws Exception {
+
+ assertThat(pkg.getLocalName()).isEqualTo("ni");
+ assertThat(pkg.getDirectSubPackages()) //
+ .extracting(JavaPackage::getLocalName) //
+ .containsExactlyInAnyOrder("api", "internal", "nested", "ontype", "spi");
+ }
+
+ @Test // GH-578
+ void detectsAllSubPackages() {
+
+ var subPackages = pkg.getSubPackages();
+
+ assertThat(subPackages.stream()
+ .map(JavaPackage::getPackageName)
+ .map(it -> it.getLocalName("example.ni")))
+ .containsExactly(
+ "api",
+ "internal",
+ "nested",
+ "nested.a",
+ "nested.b",
+ "nested.b.first",
+ "nested.b.second",
+ "ontype",
+ "spi");
+ }
+
+ @Test // GH-578
+ void detectsFlattenedSubPackages() {
+
+ var subPackages = pkg.getSubPackages();
+
+ assertThat(subPackages.flatten().stream()
+ .map(JavaPackage::getPackageName)
+ .map(it -> it.getLocalName("example.ni")))
+ .containsExactlyInAnyOrder("api", "internal", "nested", "ontype", "spi");
+ }
+
+ @Test // GH-578
+ void considersExclusionsForClassesLookup() {
+
+ assertThat(pkg.contains(InNestedA.class.getName()));
+
+ var nestedA = JavaPackage.of(classes, "example.ni.nested.a");
+ assertThat(nestedA.contains(InNestedA.class.getName()));
+
+ assertThat(pkg.getClasses(List.of(nestedA)).stream().map(JavaClass::getName))
+ .doesNotContain(InNestedA.class.getName());
+ }
+
+ @Test // GH-578
+ void detectsSubPackage() {
+
+ var classes = Classes.of(ALL_CLASSES);
+ var root = JavaPackage.of(classes, "example.ni");
+ var direct = JavaPackage.of(classes, "example.ni.internal");
+ var nested = JavaPackage.of(classes, "example.ni.internal.nested");
+
+ assertThat(direct.isSubPackageOf(root)).isTrue();
+ assertThat(nested.isSubPackageOf(root)).isTrue();
+ assertThat(nested.isSubPackageOf(direct)).isTrue();
+ }
+
+ @Test // GH-578
+ void samePackagesConsideredEqual() {
+
+ var first = JavaPackage.of(classes, "example.ni");
+ var second = JavaPackage.of(classes, "example.ni");
+
+ assertThat(first.equals(second)).isTrue();
+ assertThat(second.equals(first)).isTrue();
+ }
+}
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleDetectionStrategyUnitTest.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleDetectionStrategyUnitTest.java
index 151ec1b6..8ba94093 100644
--- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleDetectionStrategyUnitTest.java
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleDetectionStrategyUnitTest.java
@@ -34,11 +34,12 @@ class ModuleDetectionStrategyUnitTest {
var classes = new ClassFileImporter() //
.withImportOption(new ImportOption.OnlyIncludeTests()) //
- .importPackages("jmolecules");
+ .importPackages("example.jmolecules");
- var javaPackage = JavaPackage.of(Classes.of(classes), "jmolecules");
+ var javaPackage = JavaPackage.of(Classes.of(classes), "example");
+ var expected = javaPackage.getSubPackage("jmolecules").orElseThrow();
assertThat(ApplicationModuleDetectionStrategy.explicitlyAnnotated().getModuleBasePackages(javaPackage))
- .containsExactly(javaPackage);
+ .containsExactly(expected);
}
}
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleUnitTest.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleUnitTest.java
index 44236eee..dfb8db58 100644
--- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleUnitTest.java
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ModuleUnitTest.java
@@ -33,8 +33,6 @@ import com.acme.withatbean.SampleAggregate;
import com.acme.withatbean.TestEvents.JMoleculesAnnotated;
import com.acme.withatbean.TestEvents.JMoleculesImplementing;
import com.tngtech.archunit.core.domain.JavaClass;
-import com.tngtech.archunit.core.domain.JavaClasses;
-import com.tngtech.archunit.core.importer.ClassFileImporter;
/**
* Unit tests for {@link ApplicationModule}.
@@ -45,10 +43,7 @@ import com.tngtech.archunit.core.importer.ClassFileImporter;
class ModuleUnitTest {
String packageName = "com.acme.withatbean";
- JavaClasses classes = new ClassFileImporter().importPackages(packageName);
- JavaPackage javaPackage = JavaPackage.of(Classes.of(classes), packageName);
-
- ApplicationModule module = new ApplicationModule(javaPackage, false);
+ ApplicationModule module = TestUtils.getApplicationModule(packageName);
@Test
public void considersExternalSpringBeans() {
@@ -61,8 +56,10 @@ class ModuleUnitTest {
@Test
void discoversPublishedEvents() {
- JavaClass jMoleculesAnnotated = classes.get(JMoleculesAnnotated.class);
- JavaClass jMoleculesImplementing = classes.get(JMoleculesImplementing.class);
+ var classes = module.getClasses();
+
+ JavaClass jMoleculesAnnotated = classes.getRequiredClass(JMoleculesAnnotated.class);
+ JavaClass jMoleculesImplementing = classes.getRequiredClass(JMoleculesImplementing.class);
List events = module.getPublishedEvents();
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java
index 853a630a..33fc28a9 100644
--- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java
@@ -23,6 +23,11 @@ import example.ni.api.ApiType;
import example.ni.internal.AdditionalSpiType;
import example.ni.internal.DefaultedNamedInterfaceType;
import example.ni.internal.Internal;
+import example.ni.nested.InNested;
+import example.ni.nested.a.InNestedA;
+import example.ni.nested.b.InNestedB;
+import example.ni.nested.b.first.InNestedBFirst;
+import example.ni.nested.b.second.InNestedBSecond;
import example.ni.ontype.Exposed;
import example.ni.spi.SpiType;
import example.ninvalid.InvalidDefaultNamedInterface;
@@ -62,7 +67,8 @@ class NamedInterfacesUnitTests {
var javaPackage = TestUtils.getPackage(InvalidDefaultNamedInterface.class);
- assertThatIllegalStateException().isThrownBy(() -> NamedInterfaces.discoverNamedInterfaces(javaPackage))
+ assertThatIllegalStateException()
+ .isThrownBy(() -> NamedInterfaces.discoverNamedInterfaces(javaPackage))
.withMessageContaining("named interface defaulting")
.withMessageContaining(InvalidDefaultNamedInterface.class.getSimpleName());
}
@@ -76,7 +82,9 @@ class NamedInterfacesUnitTests {
assertThat(interfaces).map(NamedInterface::getName)
.containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "api", "spi", "kpi", "internal", "ontype");
- assertInterfaceContains(interfaces, NamedInterface.UNNAMED_NAME, RootType.class, Internal.class);
+ assertInterfaceContains(interfaces, NamedInterface.UNNAMED_NAME,
+ RootType.class, Internal.class, InNested.class, InNestedA.class, InNestedB.class, InNestedBFirst.class,
+ InNestedBSecond.class);
}
@Test // GH-595
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java
new file mode 100644
index 00000000..e3085a31
--- /dev/null
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.modulith.core;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link PackageName}.
+ *
+ * @author Oliver Drotbohm
+ */
+class PackageNameUnitTests {
+
+ @Test // GH-578
+ void sortsPackagesByNameAndDepth() {
+
+ var comAcme = new PackageName("com.acme");
+ var comAcmeA = new PackageName("com.acme.a");
+ var comAcmeAFirst = new PackageName("com.acme.a.first");
+ var comAcmeAFirstOne = new PackageName("com.acme.a.first.one");
+ var comAcmeASecond = new PackageName("com.acme.a.second");
+ var comAcmeB = new PackageName("com.acme.b");
+
+ assertThat(List.of(comAcmeAFirstOne, comAcmeB, comAcmeASecond, comAcmeAFirst, comAcme, comAcmeA)
+ .stream()
+ .sorted((l, r) -> -l.compareTo(r))
+ .map(it -> it.getLocalName("com")))
+ .containsExactly("acme.b", "acme.a.second", "acme.a.first.one", "acme.a.first", "acme.a", "acme");
+ }
+}
diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java
index 98bdd3f1..d2afd0e8 100644
--- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java
+++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java
@@ -46,6 +46,17 @@ public class TestUtils {
private static Supplier classes = SingletonSupplier
.of(() -> Classes.of(imported.get()).that(IS_MODULE_TYPE));
+ /**
+ * Creates an {@link ApplicationModules} instance from the given package but only inspecting the test code.
+ *
+ * @param basePackage must not be {@literal null} or empty.
+ * @return will never be {@literal null}.
+ * @since 1.3
+ */
+ public static ApplicationModules of(String basePackage, String... ignoredPackages) {
+ return of(ModulithMetadata.of(basePackage), JavaClass.Predicates.resideInAnyPackage(ignoredPackages));
+ }
+
/**
* Returns all {@link Classes} of this module.
*
@@ -78,12 +89,12 @@ public class TestUtils {
return JavaPackage.of(TestUtils.getClasses(packageType), packageType.getPackageName());
}
- public static ApplicationModules of(String basePackage, String... ignoredPackages) {
- return of(ModulithMetadata.of(basePackage), JavaClass.Predicates.resideInAnyPackage(ignoredPackages));
- }
-
public static ApplicationModule getApplicationModule(String packageName) {
- return new ApplicationModule(getPackage(packageName), false);
+
+ var pkg = getPackage(packageName);
+ var source = ApplicationModuleSource.from(pkg, pkg.getLocalName());
+
+ return new ApplicationModule(source);
}
private static JavaPackage getPackage(String name) {
diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java
index 8ab2a28c..dc6c79de 100644
--- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java
+++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java
@@ -575,7 +575,8 @@ public class Documenter {
ComponentView componentView = createComponentView(options);
componentView.setTitle(getDefaultedSystemName());
- addComponentsToView(() -> modules.stream(), componentView, options, it -> {});
+ addComponentsToView(() -> modules.stream().filter(Predicate.not(modules::hasParent)), componentView,
+ options, it -> {});
return render(componentView, options);
}
diff --git a/src/docs/antora/antora-playbook.yml b/src/docs/antora/antora-playbook.yml
index f6baf178..e0825b55 100644
--- a/src/docs/antora/antora-playbook.yml
+++ b/src/docs/antora/antora-playbook.yml
@@ -1,11 +1,6 @@
antora:
extensions:
- - '@springio/antora-extensions/partial-build-extension'
- - require: '@springio/antora-extensions/latest-version-extension'
- - require: '@springio/antora-extensions/inject-collector-cache-config-extension'
- - '@antora/collector-extension'
- - '@antora/atlas-extension'
- - require: '@springio/antora-extensions/root-component-extension'
+ - require: '@springio/antora-extensions'
root_component_name: 'modulith'
site:
title: Spring Modulith
@@ -36,4 +31,4 @@ runtime:
format: pretty
ui:
bundle:
- url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.3.7/ui-bundle.zip
+ url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.16/ui-bundle.zip
diff --git a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc
index 18c312b3..5c5bbb2a 100644
--- a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc
+++ b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc
@@ -158,7 +158,7 @@ icon:cubes[] Example
├─ **icon:cube[] example.order**
| └─ icon:plus-circle[role=green] OrderManagement.java
└─ icon:cube[] example.order.internal
- └─ icon:plus-circle[role=green] SomethingOrderInternal.java
+ └─ icon:plus-circle[role=red] SomethingOrderInternal.java
----
In such an arrangement, the `order` package is considered an API package.
@@ -169,8 +169,47 @@ Note how `SomethingOrderInternal` is a public type, likely because `OrderManagem
This unfortunately means that it can also be referred to from other packages such as the `inventory` one.
In this case, the Java compiler is not of much use to prevent these illegal references.
-[[modules.advanced.open]]
-==== Open Application Modules
+[[modules.nested]]
+=== Nested Application Modules
+
+As of version 1.3, Spring Modulith application modules can contain nested modules.
+This allows governing the internal structure in case a module contains parts to be logically separated in turn.
+To define nested application modules, explicitly annotate packages that are supposed to constitute with `@ApplicationModule`.
+
+[source, subs="macros, quotes"]
+----
+icon:cubes[] Example
+└─ icon:folder[] src/main/java
+ ├─ icon:cube[] example
+ | └─ icon:plus-circle[role=green] Application.java
+ ├─ icon:cube[] example.inventory
+ | ├─ icon:plus-circle[role=green] InventoryManagement.java
+ | └─ icon:minus-circle[role=red] SomethingInventoryInternal.java
+ ├─ icon:cube[] example.inventory.internal
+ | └─ icon:plus-circle[role=red] SomethingInventoryInternal.java
+ ├─ icon:cube[] example.inventory.nested
+ | ├─ icon:coffee[] package-info.java // @ApplicationModule
+ | └─ icon:plus-circle[role=yellow] NestedApi.java
+ ├─ icon:cube[] example.inventory.nested.internal
+ | └─ icon:minus-circle[role=red] NestedInternal.java
+ └─ icon:cube[] example.order
+ ├─ icon:plus-circle[role=green] OrderManagement.java
+ └─ icon:minus-circle[role=red] SomethingOrderInternal.java
+
+----
+
+In this example `inventory` is an application module as described xref:fundamentals.adoc#modules.simple[above].
+The `@ApplicationModule` annotation on the `nested` package caused that to become a nested application module in turn.
+In that arrangement, the following access rules apply:
+
+* The code in _Nested_ is only available from _Inventory_, i.e. only any of the `SomethingInventoryInternal` types can access `NestedApi`. `NestedInternal` is only accessible from `NestedApi`.
+* Any code in the _Nested_ module can access code in parent modules, even internal.
+I.e., both `NestedApi` and `NestedInternal` can access `inventory.internal.SomethingInventoryInternal`.
+* Code from nested modules can also access exposed types by top-level application modules.
+Any code in `nested` (or any sub-packages) can access `OrderManagement`.
+
+[[modules.open, modules.advanced.open]]
+=== Open Application Modules
The arrangement described xref:fundamentals.adoc#modules.advanced[above] are considered closed as they only expose types to other modules that are actively selected for exposure.
When applying Spring Modulith to legacy applications, hiding all types located in nested packages from other modules might be inadequate or require marking all those packages for exposure, too.