From 7838204c25b5bca3620d33e71892ad9df3ac4e83 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Mon, 6 Nov 2023 18:26:07 +0100 Subject: [PATCH] GH-578 - Support for nested application modules. The ApplicationModules bootstrap now triggers the module base package detection, followed by a new, additional pass of detecting nested application module packages. Those packages are now added to the ones we create ApplicationModule instances for and also handed into the module instance creation step as exclusions to make sure that parent modules do not include code residing in sub-modules. The bootstrap of ApplicationModules now uses a dedicated ApplicationModuleSource to allow calculating a default module name relative to the application base package. Each module now operates on the Classes instance obtained from the JavaPackage instance that constitutes the module's base package but filtered by the given exclusions. --- pom.xml | 10 +- .../modulith/core/ApplicationModule.java | 207 ++++++++++++++++-- .../core/ApplicationModuleSource.java | 69 ++++++ .../modulith/core/ApplicationModules.java | 85 +++++-- .../modulith/core/JavaPackage.java | 163 ++++++++++++-- .../modulith/core/JavaPackages.java | 126 +++++++++++ .../modulith/core/NamedInterfaces.java | 8 +- .../modulith/core/PackageName.java | 192 ++++++++++++++++ .../modulith/core/Violations.java | 50 ++--- .../{example => }/empty/EmptyApplication.java | 6 +- .../test/java/example/invalid/Invalid.java | 27 +++ .../jmolecules/package-info.java | 2 +- .../src/test/java/example/ni/RootType.java | 12 +- .../test/java/example/ni/nested/InNested.java | 21 ++ .../java/example/ni/nested/a/InNestedA.java | 25 +++ .../java/example/ni/nested/b/InNestedB.java | 33 +++ .../ni/nested/b/first/InNestedBFirst.java | 26 +++ .../ni/nested/b/first/package-info.java | 2 + .../ni/nested/b/second/InNestedBSecond.java | 25 +++ .../ni/nested/b/second/package-info.java | 2 + .../java/example/ni/nested/package-info.java | 2 + .../core/ApplicationModulesUnitTests.java | 79 +++++++ .../modulith/core/JavaPackageUnitTests.java | 119 ++++++++++ .../core/ModuleDetectionStrategyUnitTest.java | 7 +- .../modulith/core/ModuleUnitTest.java | 13 +- .../core/NamedInterfacesUnitTests.java | 12 +- .../modulith/core/PackageNameUnitTests.java | 47 ++++ .../modulith/core/TestUtils.java | 21 +- .../modulith/docs/Documenter.java | 3 +- src/docs/antora/antora-playbook.yml | 9 +- .../modules/ROOT/pages/fundamentals.adoc | 45 +++- 31 files changed, 1315 insertions(+), 133 deletions(-) create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackages.java create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java rename spring-modulith-core/src/test/java/{example => }/empty/EmptyApplication.java (92%) create mode 100644 spring-modulith-core/src/test/java/example/invalid/Invalid.java rename spring-modulith-core/src/test/java/{ => example}/jmolecules/package-info.java (57%) create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/InNested.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/a/InNestedA.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/b/InNestedB.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/b/first/InNestedBFirst.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/b/first/package-info.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/b/second/InNestedBSecond.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java create mode 100644 spring-modulith-core/src/test/java/example/ni/nested/package-info.java create mode 100644 spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java create mode 100644 spring-modulith-core/src/test/java/org/springframework/modulith/core/JavaPackageUnitTests.java create mode 100644 spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java diff --git a/pom.xml b/pom.xml index 16e2d85b..837cd810 100644 --- a/pom.xml +++ b/pom.xml @@ -299,11 +299,11 @@ limitations under the License. true - @antora/atlas-extension@1.0.0-alpha.1 - @antora/collector-extension@1.0.0-alpha.3 - @asciidoctor/tabs@1.0.0-beta.3 - @springio/antora-extensions@1.5.0 - @springio/asciidoctor-extensions@1.0.0-alpha.9 + @antora/atlas-extension@1.0.0-alpha.2 + @antora/collector-extension@1.0.0-beta.2 + @asciidoctor/tabs@1.0.0-beta.6 + @springio/antora-extensions@1.13.1 + @springio/asciidoctor-extensions@1.0.0-alpha.12 asciidoctor-kroki ../src/docs/antora/antora-playbook.yml diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java index bbb1ea51..1e72fa4b 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java @@ -18,12 +18,14 @@ package org.springframework.modulith.core; import static com.tngtech.archunit.base.DescribedPredicate.*; import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; import static java.lang.System.*; +import static java.util.Comparator.*; import static org.springframework.modulith.core.SyntacticSugar.*; import static org.springframework.modulith.core.Types.JavaXTypes.*; import static org.springframework.modulith.core.Types.SpringDataTypes.*; import static org.springframework.modulith.core.Types.SpringTypes.*; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -57,12 +59,15 @@ import com.tngtech.archunit.core.domain.SourceCodeLocation; * * @author Oliver Drotbohm */ -public class ApplicationModule { +public class ApplicationModule implements Comparable { /** * The base package of the {@link ApplicationModule}. */ private final JavaPackage basePackage; + private final Classes classes; + private final JavaPackages exclusions; + private final ApplicationModuleInformation information; /** @@ -70,32 +75,46 @@ public class ApplicationModule { * or implicitly. */ private final NamedInterfaces namedInterfaces; - private final boolean useFullyQualifiedModuleNames; + private final ApplicationModuleSource source; private final Supplier springBeans; private final Supplier aggregateRoots; private final Supplier> valueTypes; private final Supplier> publishedEvents; + /** + * Creates a new {@link ApplicationModule} from the given {@link ApplicationModuleSource}. + * + * @param source must not be {@literal null}. + */ + ApplicationModule(ApplicationModuleSource source) { + this(source, JavaPackages.NONE); + } + /** * Creates a new {@link ApplicationModule} for the given base package and whether to use fully-qualified module names. * - * @param basePackage must not be {@literal null}. - * @param useFullyQualifiedModuleNames + * @param source must not be {@literal null}. + * @param exclusions must not be {@literal null}. */ - ApplicationModule(JavaPackage basePackage, boolean useFullyQualifiedModuleNames) { + ApplicationModule(ApplicationModuleSource source, JavaPackages exclusions) { - Assert.notNull(basePackage, "Base package must not be null!"); + Assert.notNull(source, "Base package must not be null!"); + Assert.notNull(exclusions, "Exclusions must not be null!"); + JavaPackage basePackage = source.moduleBasePackage(); + + this.source = source; this.basePackage = basePackage; + this.exclusions = exclusions; + this.classes = basePackage.getClasses(exclusions); this.information = ApplicationModuleInformation.of(basePackage); this.namedInterfaces = isOpen() ? NamedInterfaces.forOpen(basePackage) : NamedInterfaces.discoverNamedInterfaces(basePackage); - this.useFullyQualifiedModuleNames = useFullyQualifiedModuleNames; - this.springBeans = SingletonSupplier.of(() -> filterSpringBeans(basePackage)); - this.aggregateRoots = SingletonSupplier.of(() -> findAggregateRoots(basePackage)); + this.springBeans = SingletonSupplier.of(() -> filterSpringBeans(classes)); + this.aggregateRoots = SingletonSupplier.of(() -> findAggregateRoots(classes)); this.valueTypes = SingletonSupplier .of(() -> findArchitecturallyEvidentType(ArchitecturallyEvidentType::isValueObject)); this.publishedEvents = SingletonSupplier.of(() -> findPublishedEvents()); @@ -125,7 +144,7 @@ public class ApplicationModule { * @return will never be {@literal null} or empty. */ public String getName() { - return useFullyQualifiedModuleNames ? basePackage.getName() : basePackage.getLocalName(); + return source.moduleName(); } /** @@ -274,10 +293,20 @@ public class ApplicationModule { FormatableType.of(type).getAbbreviatedFullName(this), getName()))); } + /** + * Returns whether the current module contains the given type. + * + * @param type must not be {@literal null}. + */ public boolean contains(JavaClass type) { - return basePackage.contains(type); + return classes.contains(type); } + /** + * Returns whether the current module contains the given type. + * + * @param type can be {@literal null}. + */ public boolean contains(@Nullable Class type) { return type != null && getType(type.getName()).isPresent(); } @@ -292,7 +321,7 @@ public class ApplicationModule { Assert.hasText(candidate, "Candidate must not be null or emtpy!"); - return basePackage.stream() + return classes.stream() .filter(hasSimpleOrFullyQualifiedName(candidate)) .findFirst(); } @@ -363,9 +392,28 @@ public class ApplicationModule { builder.append("\n"); + if (modules != null) { + modules.getParentOf(this).ifPresent(it -> { + builder.append("> Parent module: ").append(it.getName()).append("\n"); + }); + } + builder.append("> Logical name: ").append(getName()).append('\n'); builder.append("> Base package: ").append(basePackage.getName()).append('\n'); + builder.append("> Excluded packages: "); + + if (!exclusions.iterator().hasNext()) { + builder.append("none").append('\n'); + } else { + + builder.append('\n'); + + exclusions.stream().forEach(it -> { + builder.append(" - ").append(it.getName()).append('\n'); + }); + } + if (namedInterfaces.hasExplicitInterfaces()) { builder.append("> Named interfaces:\n"); @@ -475,6 +523,23 @@ public class ApplicationModule { return information.isOpen(); } + /** + * Returns whether the given type is contained in any of the parent modules of the current one. + * + * @param type must not be {@literal null}. + * @param modules must not be {@literal null}. + * @since 1.3 + */ + boolean containsTypeInAnyParent(JavaClass type, ApplicationModules modules) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(modules, "ApplicationModules must not be null!"); + + return modules.getParentOf(this) + .filter(it -> it.contains(type) || it.containsTypeInAnyParent(type, modules)) + .isPresent(); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) @@ -490,13 +555,13 @@ public class ApplicationModule { return false; } - return Objects.equals(this.basePackage, that.basePackage) // + return Objects.equals(this.source, that.source) + && Objects.equals(this.basePackage, that.basePackage) // && Objects.equals(this.aggregateRoots, that.aggregateRoots) // && Objects.equals(this.information, that.information) // && Objects.equals(this.namedInterfaces, that.namedInterfaces) // && Objects.equals(this.publishedEvents, that.publishedEvents) // && Objects.equals(this.springBeans, that.springBeans) // - && Objects.equals(this.useFullyQualifiedModuleNames, that.useFullyQualifiedModuleNames) // && Objects.equals(this.valueTypes, that.valueTypes); } @@ -506,8 +571,17 @@ public class ApplicationModule { */ @Override public int hashCode() { - return Objects.hash(basePackage, aggregateRoots, information, namedInterfaces, publishedEvents, springBeans, - useFullyQualifiedModuleNames, valueTypes); + return Objects.hash(source, basePackage, aggregateRoots, information, namedInterfaces, publishedEvents, springBeans, + valueTypes); + } + + /* + * (non-Javadoc) + * @see java.lang.Comparable#compareTo(java.lang.Object) + */ + @Override + public int compareTo(ApplicationModule o) { + return getBasePackage().compareTo(o.getBasePackage()); } private List findPublishedEvents() { @@ -515,7 +589,7 @@ public class ApplicationModule { DescribedPredicate isEvent = implement(JMoleculesTypes.DOMAIN_EVENT) // .or(isAnnotatedWith(JMoleculesTypes.AT_DOMAIN_EVENT)); - return basePackage.that(isEvent).stream() // + return classes.that(isEvent).stream() // .map(EventType::new).toList(); } @@ -537,7 +611,7 @@ public class ApplicationModule { private Stream getAllModuleDependencies(ApplicationModules modules) { - return basePackage.stream() // + return classes.stream() // .flatMap(it -> getModuleDependenciesOf(it, modules)); } @@ -590,7 +664,7 @@ public class ApplicationModule { return modules.contains(dependency) && !contains(dependency); } - private Classes findAggregateRoots(JavaPackage source) { + private Classes findAggregateRoots(Classes source) { return source.stream() // .map(it -> ArchitecturallyEvidentType.of(it, getSpringBeansInternal())) @@ -599,11 +673,78 @@ public class ApplicationModule { .collect(Classes.toClasses()); } + /** + * Returns the current module's immediate parent module, if present. + * + * @param modules must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + Optional getParentModule(ApplicationModules modules) { + + Assert.notNull(modules, "ApplicationModules must not be null!"); + + var byPackageDepth = comparing(ApplicationModule::getBasePackage, JavaPackage.reverse()); + + return modules.stream() + .filter(it -> basePackage.isSubPackageOf(it.getBasePackage())) + .sorted(byPackageDepth) + .findFirst(); + } + + /** + * Returns the {@link ApplicationModule}s directly nested inside the current one. + * + * @param modules must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + Collection getDirectlyNestedModules(ApplicationModules modules) { + + Assert.notNull(modules, "ApplicationModules must not be null!"); + + return doGetNestedModules(modules, false); + } + + /** + * Returns all of the current {@link ApplicationModule}'s nested {@link ApplicationModule}s including ones contained + * in nested modules in turn. + * + * @param modules must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + Collection getNestedModules(ApplicationModules modules) { + + Assert.notNull(modules, "ApplicationModules must not be null!"); + + return doGetNestedModules(modules, true); + } + + /** + * @return the classes + */ + Classes getClasses() { + return classes; + } + private String getQualifiedName(NamedInterface namedInterface) { return namedInterface.getQualifiedName(getName()); } - private static Classes filterSpringBeans(JavaPackage source) { + private Collection doGetNestedModules(ApplicationModules modules, boolean recursive) { + + var result = modules.stream() + .filter(it -> it.getParentModule(modules).filter(this::equals).isPresent()); + + if (recursive) { + result = result.flatMap(it -> Stream.concat(Stream.of(it), it.getNestedModules(modules).stream())); + } + + return result.toList(); + } + + private static Classes filterSpringBeans(Classes source) { Map> collect = source.that(isConfiguration()).stream() // .flatMap(it -> it.getMethods().stream()) // @@ -632,7 +773,7 @@ public class ApplicationModule { var springBeansInternal = getSpringBeansInternal(); - return basePackage.stream() + return classes.stream() .map(it -> ArchitecturallyEvidentType.of(it, springBeansInternal)) .filter(selector) .map(ArchitecturallyEvidentType::getType) @@ -907,6 +1048,9 @@ public class ApplicationModule { private static final List INJECTION_TYPES = Arrays.asList(AT_AUTOWIRED, AT_RESOURCE, AT_INJECT); + private static final String INVALID_SUB_MODULE_REFERENCE = "Invalid sub-module reference from module '%s' to module '%s' (via %s -> %s)!"; + private static final String INTERNAL_REFERENCE = "Module '%s' depends on non-exposed type %s within module '%s'!"; + private final JavaClass source, target; private final String description; private final DependencyType type; @@ -1043,15 +1187,32 @@ public class ApplicationModule { return violations; } + if (originModule.containsTypeInAnyParent(target, modules)) { + return violations; + } + if (!targetModule.isExposed(target)) { - var violationText = "Module '%s' depends on non-exposed type %s within module '%s'!" + var violationText = INTERNAL_REFERENCE .formatted(originModule.getName(), target.getName(), targetModule.getName()); return violations.and(new Violation(violationText + lineSeparator() + description)); } - return violations; + // Parent child relationships + + return targetModule.getParentModule(modules) + .filter(it -> !it.equals(originModule)) + .map(__ -> { + + var violationText = INVALID_SUB_MODULE_REFERENCE + .formatted(originModule.getName(), targetModule.getName(), + FormatableType.of(source).getAbbreviatedFullName(originModule), + FormatableType.of(target).getAbbreviatedFullName(targetModule)); + + return violations.and(new Violation(violationText)); + }) + .orElse(violations); } ApplicationModule getExistingModuleOf(JavaClass javaClass, ApplicationModules modules) { diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java new file mode 100644 index 00000000..d26d75c9 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java @@ -0,0 +1,69 @@ +/* + * 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 java.util.stream.Stream; + +import org.springframework.util.Assert; + +/** + * The source of an {@link ApplicationModule}. Essentially a {@link JavaPackage} and associated naming strategy for the + * module. This will be used when constructing sources from a base package and an + * {@link ApplicationModuleDetectionStrategy} so that the names of the module to be created for the detected packages + * become the trailing name underneath the base package. For example, scanning from {@code com.acme}, an + * {@link ApplicationModule} located in {@code com.acme.foo.bar} would be named {@code foo.bar}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +record ApplicationModuleSource( + JavaPackage moduleBasePackage, + String moduleName) { + + /** + * Returns a {@link Stream} of {@link ApplicationModuleSource}s by applying the given + * {@link ApplicationModuleDetectionStrategy} to the given base package. + * + * @param pkg must not be {@literal null}. + * @param strategy must not be {@literal null}. + * @param fullyQualifiedModuleNames whether to use fully qualified module names. + * @return will never be {@literal null}. + */ + public static Stream from(JavaPackage pkg, ApplicationModuleDetectionStrategy strategy, + boolean fullyQualifiedModuleNames) { + + Assert.notNull(pkg, "Base package must not be null!"); + Assert.notNull(strategy, "ApplicationModuleDetectionStrategy must not be null!"); + + return strategy.getModuleBasePackages(pkg) + .flatMap(it -> it.andSubPackagesAnnotatedWith(org.springframework.modulith.ApplicationModule.class)) + .map(it -> new ApplicationModuleSource(it, fullyQualifiedModuleNames ? it.getName() : pkg.getTrailingName(it))); + } + + /** + * Creates a new {@link ApplicationModuleSource} for the given {@link JavaPackage} and name. + * + * @param pkg must not be {@literal null}. + * @param name must not be {@literal null} or empty. + * @return will never be {@literal null}. + */ + public static ApplicationModuleSource from(JavaPackage pkg, String name) { + + Assert.hasText(name, "Name must not be null or empty!"); + + return new ApplicationModuleSource(pkg, name); + } +} diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java index 02ae5831..07fb0ec8 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java @@ -138,10 +138,19 @@ public class ApplicationModules implements Iterable { Classes classes = Classes.of(allClasses); var strategy = ApplicationModuleDetectionStrategyLookup.getStrategy(); - this.modules = packages.stream() // + var sources = packages.stream() // .map(it -> JavaPackage.of(classes, it)) - .flatMap(strategy::getModuleBasePackages) // - .map(it -> new ApplicationModule(it, useFullyQualifiedModuleNames)) // + .flatMap(it -> ApplicationModuleSource.from(it, strategy, useFullyQualifiedModuleNames)) + .distinct() + .collect(Collectors.toUnmodifiableSet()); + + this.modules = sources.stream() // + .map(it -> { + + return new ApplicationModule(it, + JavaPackages.onlySubPackagesOf(it.moduleBasePackage(), + sources.stream().map(ApplicationModuleSource::moduleBasePackage).toList())); // + }) .collect(toMap(ApplicationModule::getName, Function.identity())); this.rootPackages = packages.stream() // @@ -154,9 +163,13 @@ public class ApplicationModules implements Iterable { this.sharedModules = Collections.emptySet(); - this.orderedNames = JGRAPHT_PRESENT // - ? TopologicalSorter.topologicallySortModules(this) // - : modules.values().stream().map(ApplicationModule::getName).toList(); + Supplier> fallback = () -> modules.values().stream() + .map(ApplicationModule::getName) + .sorted() + .toList(); + + this.orderedNames = Optional.ofNullable(JGRAPHT_PRESENT ? TopologicalSorter.topologicallySortModules(this) : null) + .orElseGet(fallback); } /** @@ -511,6 +524,30 @@ public class ApplicationModules implements Iterable { }; } + /** + * Returns the parent {@link ApplicationModule} if the given one has one. + * + * @param module must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + public Optional getParentOf(ApplicationModule module) { + + Assert.notNull(module, "ApplicationModule must not be null!"); + + return module.getParentModule(this); + } + + /** + * Returns whether the given {@link ApplicationModule} has a parent one. + * + * @param module must not be {@literal null}. + * @since 1.3 + */ + public boolean hasParent(ApplicationModule module) { + return getParentOf(module).isPresent(); + } + /* * (non-Javadoc) * @see java.lang.Iterable#iterator() @@ -528,6 +565,7 @@ public class ApplicationModules implements Iterable { public String toString() { return this.stream() + .sorted() .map(it -> it.toString(this)) .collect(Collectors.joining("\n")); } @@ -542,7 +580,7 @@ public class ApplicationModules implements Iterable { var result = SlicesRuleDefinition.slices() // .assignedFrom(new ApplicationModulesSliceAssignment()) .should().beFreeOfCycles() // - .evaluate(allClasses.that(resideInAPackage(rootPackage.getName().concat("..")))); + .evaluate(allClasses.that(resideInAPackage(rootPackage.asFilter()))); return result.getFailureReport(); } @@ -623,14 +661,11 @@ public class ApplicationModules implements Iterable { * @return will never be {@literal null}. * @since 1.1 */ - private static ApplicationModule rootModuleFor(JavaPackage javaPackage) { + private static ApplicationModule rootModuleFor(JavaPackage pkg) { - return new ApplicationModule(javaPackage, true) { + var source = ApplicationModuleSource.from(pkg, "root:" + pkg.getName()); - @Override - public String getName() { - return "root:" + super.getName(); - } + return new ApplicationModule(source, JavaPackages.NONE) { @Override public boolean isRootModule() { @@ -726,21 +761,25 @@ public class ApplicationModules implements Iterable { */ private static class TopologicalSorter { + @Nullable private static List topologicallySortModules(ApplicationModules modules) { Graph graph = new DefaultDirectedGraph<>(DefaultEdge.class); - modules.modules.forEach((__, project) -> { + modules.modules.values() + .stream() + .sorted() + .forEach(project -> { - graph.addVertex(project); + graph.addVertex(project); - project.getDependencies(modules).stream() // - .map(ApplicationModuleDependency::getTargetModule) // - .forEach(dependency -> { - graph.addVertex(dependency); - graph.addEdge(project, dependency); - }); - }); + project.getDependencies(modules).stream() // + .map(ApplicationModuleDependency::getTargetModule) // + .forEach(dependency -> { + graph.addVertex(dependency); + graph.addEdge(project, dependency); + }); + }); var names = new ArrayList(); var iterator = new TopologicalOrderIterator<>(graph); @@ -751,7 +790,7 @@ public class ApplicationModules implements Iterable { return names; } catch (IllegalArgumentException o_O) { - return modules.modules.values().stream().map(ApplicationModule::getName).toList(); + return null; } } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java index 6e96d6f1..e83e15ea 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java @@ -18,17 +18,21 @@ package org.springframework.modulith.core; import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; import static com.tngtech.archunit.core.domain.properties.CanBeAnnotated.Predicates.*; import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.*; +import static java.util.stream.Collectors.*; import static org.springframework.modulith.core.SyntacticSugar.*; import java.lang.annotation.Annotation; import java.util.Collection; +import java.util.Comparator; import java.util.Iterator; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.modulith.PackageInfo; @@ -45,36 +49,35 @@ import com.tngtech.archunit.core.domain.JavaModifier; * * @author Oliver Drotbohm */ -public class JavaPackage implements DescribedIterable { +public class JavaPackage implements DescribedIterable, Comparable { private static final String PACKAGE_INFO_NAME = "package-info"; private static final String MULTIPLE_TYPES_ANNOTATED_WITH = "Expected maximum of one type in package %s to be annotated with %s, but got %s!"; private static final DescribedPredicate ARE_PACKAGE_INFOS = // has(simpleName(PACKAGE_INFO_NAME)).or(is(metaAnnotatedWith(PackageInfo.class))); - private final String name; - private final Classes classes; - private final Classes packageClasses; + private final PackageName name; + private final Classes classes, packageClasses; private final Supplier> directSubPackages; /** * Creates a new {@link JavaPackage} for the given {@link Classes}, name and whether to include all sub-packages. * * @param classes must not be {@literal null}. - * @param name must not be {@literal null} or empty. + * @param name must not be {@literal null}. * @param includeSubPackages */ - private JavaPackage(Classes classes, String name, boolean includeSubPackages) { + private JavaPackage(Classes classes, PackageName name, boolean includeSubPackages) { Assert.notNull(classes, "Classes must not be null!"); - Assert.hasText(name, "Name must not be null or empty!"); this.classes = classes; - this.packageClasses = classes.that(resideInAPackage(includeSubPackages ? name.concat("..") : name)); + this.packageClasses = classes + .that(resideInAPackage(name.asFilter(includeSubPackages))); this.name = name; this.directSubPackages = SingletonSupplier.of(() -> packageClasses.stream() // .map(it -> it.getPackageName()) // - .filter(it -> !it.equals(name)) // + .filter(Predicate.not(name::hasName)) // .map(it -> extractDirectSubPackage(it)) // .distinct() // .map(it -> of(classes, it)) // @@ -89,7 +92,7 @@ public class JavaPackage implements DescribedIterable { * @return */ public static JavaPackage of(Classes classes, String name) { - return new JavaPackage(classes, name, true); + return new JavaPackage(classes, new PackageName(name), true); } /** @@ -110,7 +113,22 @@ public class JavaPackage implements DescribedIterable { * @return will never be {@literal null}. */ public String getName() { - return name; + return name.getName(); + } + + /** + * Returns the name of the given {@link JavaPackage} with regards to the current one. + * + * @param pkg must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + public String getTrailingName(JavaPackage pkg) { + + Assert.notNull(pkg, "JavaPackage must not be null!"); + Assert.isTrue(pkg.isSubPackageOf(this), "Given package must be a sub-package of the current one!"); + + return pkg.getName().substring(getName().length() + 1); } /** @@ -128,7 +146,7 @@ public class JavaPackage implements DescribedIterable { * @return will never be {@literal null}. */ public String getLocalName() { - return name.substring(name.lastIndexOf('.') + 1); + return name.getLocalName(); } /** @@ -174,6 +192,7 @@ public class JavaPackage implements DescribedIterable { return packageClasses.that(ARE_PACKAGE_INFOS.and(are(metaAnnotatedWith(annotation)))).stream() // .map(JavaClass::getPackageName) // + .filter(Predicate.not(name::hasName)) .distinct() // .map(it -> of(classes, it)); } @@ -182,7 +201,7 @@ public class JavaPackage implements DescribedIterable { * Returns all {@link Classes} that match the given {@link DescribedPredicate}. * * @param predicate must not be {@literal null}. - * @return + * @return will never be {@literal null}. */ public Classes that(DescribedPredicate predicate) { @@ -240,6 +259,105 @@ public class JavaPackage implements DescribedIterable { .map(it -> AnnotatedElementUtils.getMergedAnnotation(it, annotationType)); } + /** + * Returns the name of the package. + * + * @return will never be {@literal null}. + * @since 1.3 + */ + PackageName getPackageName() { + return name; + } + + /** + * Returns a filter expression including all types within that package and any nested package. + * + * @return will never be {@literal null}. + * @since 1.3 + */ + String asFilter() { + return name.asFilter(true); + } + + /** + * Returns a new {@link Stream} of the current package and all its sub-packages annotated with the given annotation + * type. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + Stream andSubPackagesAnnotatedWith(Class type) { + + Assert.notNull(type, "Annotation type must not be null!"); + + return Stream.concat(Stream.of(this), getSubPackagesAnnotatedWith(type)); + } + + /** + * Returns whether the given reference package is a sub-package of the current one. + * + * @param reference must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + boolean isSubPackageOf(JavaPackage reference) { + + Assert.notNull(reference, "Reference package must not be null!"); + + return name.isSubPackageOf(reference.name); + } + + /** + * Returns all Classes residing in the current package but not in any of the given sub-packages. + * + * @param exclusions must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + Classes getClasses(Iterable exclusions) { + + Assert.notNull(exclusions, "Object must not be null!"); + + var excludedPackages = StreamSupport.stream(exclusions.spliterator(), false) + .map(JavaPackage::asFilter) + .toArray(String[]::new); + + return packageClasses.that(resideOutsideOfPackages(excludedPackages)); + } + + /** + * Returns a {@link JavaPackages} instance representing all sub-packages. + * + * @return will never be {@literal null}. + * @since 1.3 + */ + JavaPackages getSubPackages() { + + return packageClasses.stream() // + .map(JavaClass::getPackageName) + .filter(Predicate.not(name::hasName)) + .distinct() + .map(it -> new JavaPackage(classes, new PackageName(it), true)) + .collect(collectingAndThen(toUnmodifiableList(), JavaPackages::new)); + } + + /** + * Returns the sub-package with the given local name. + * + * @param localName must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.3 + */ + Optional getSubPackage(String localName) { + + Assert.hasText(localName, "Local name must not be null or empty!"); + + return getSubPackages().stream() + .filter(it -> it.getLocalName().equals(localName)) + .findFirst(); + } + /** * Finds the annotation of the given type declared on the package itself or any type located the direct package's * types . @@ -290,6 +408,15 @@ public class JavaPackage implements DescribedIterable { return classes.iterator(); } + /* + * (non-Javadoc) + * @see java.lang.Comparable#compareTo(java.lang.Object) + */ + @Override + public int compareTo(JavaPackage o) { + return name.compareTo(o.name); + } + /* * (non-Javadoc) * @see java.lang.Object#toString() @@ -297,9 +424,9 @@ public class JavaPackage implements DescribedIterable { @Override public String toString() { - return new StringBuilder(name) // + return new StringBuilder(name.toString()) // .append("\n") // - .append(getClasses().format(name)) // + .append(getClasses().format(name.toString())) // .append('\n') // .toString(); } @@ -331,7 +458,7 @@ public class JavaPackage implements DescribedIterable { */ @Override public int hashCode() { - return Objects.hash(classes, directSubPackages, name, packageClasses); + return Objects.hash(classes, directSubPackages.get(), name, packageClasses); } /** @@ -351,4 +478,8 @@ public class JavaPackage implements DescribedIterable { return candidate.substring(0, endIndex); } + + static Comparator reverse() { + return (left, right) -> -left.compareTo(right); + } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackages.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackages.java new file mode 100644 index 00000000..4d4f3456 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackages.java @@ -0,0 +1,126 @@ +/* + * Copyright 2023-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 java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.util.Assert; + +/** + * A collection of {@link JavaPackage}s. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class JavaPackages implements Iterable { + + public static JavaPackages NONE = new JavaPackages(Collections.emptyList()); + + private final List packages; + + /** + * Creates a new {@link JavaPackages} instance for the given {@link JavaPackage}s. + * + * @param packages must not be {@literal null}. + */ + JavaPackages(Collection packages) { + + Assert.notNull(packages, "Packages must not be null!"); + + this.packages = packages.stream().sorted().toList(); + } + + /** + * Creates a new {@link JavaPackages} containing all sub-packages of the given reference package contained in the + * given packages. + * + * @param reference must not be {@literal null}. + * @param packages must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static JavaPackages onlySubPackagesOf(JavaPackage reference, Collection 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.