GH-284 - Support for open application modules.

Application modules can now be declared as open, which causes internal components being exposed for access by other modules.
This commit is contained in:
Oliver Drotbohm
2024-02-29 12:47:10 +01:00
parent b73b4ca70a
commit f3c111f769
19 changed files with 331 additions and 22 deletions

View File

@@ -52,4 +52,37 @@ public @interface ApplicationModule {
* @see NamedInterface
*/
String[] allowedDependencies() default { OPEN_TOKEN };
/**
* Declares the {@link Type} of the {@link ApplicationModule}
*
* @return will never be {@literal null}.
* @since 1.2
*/
Type type() default Type.CLOSED;
/**
* The type of an application module
*
* @author Oliver Drotbohm
* @since 1.2
*/
enum Type {
/**
* A closed application module exposes an API to other modules, but also allows to hide internals. Access to those
* internals from other modules is sanctioned. Also, closed application modules must not be part of dependency
* cycles.
*
* @see NamedInterface
*/
CLOSED,
/**
* An open application module does not hide its internals, which means that access to those from other modules is
* not sanctioned. They are also excluded from the cycle detection algorithm. All types contained in an open module
* are part of the unnamed named interface.
*/
OPEN;
}
}

View File

@@ -86,7 +86,9 @@ public class ApplicationModule {
this.basePackage = basePackage;
this.information = ApplicationModuleInformation.of(basePackage);
this.namedInterfaces = NamedInterfaces.discoverNamedInterfaces(basePackage);
this.namedInterfaces = isOpen()
? NamedInterfaces.forOpen(basePackage)
: NamedInterfaces.discoverNamedInterfaces(basePackage);
this.useFullyQualifiedModuleNames = useFullyQualifiedModuleNames;
this.springBeans = SingletonSupplier.of(() -> filterSpringBeans(basePackage));
@@ -320,7 +322,7 @@ public class ApplicationModule {
/**
* Returns whether the module is considered a root one, i.e., it is an artificial one created for each base package
* configured.
*
*
* @return whether the module is considered a root one.
* @since 1.1
*/
@@ -350,7 +352,13 @@ public class ApplicationModule {
public String toString(@Nullable ApplicationModules modules) {
var builder = new StringBuilder("# ").append(getDisplayName()).append("\n");
var builder = new StringBuilder("# ").append(getDisplayName());
if (isOpen()) {
builder.append(" (open)");
}
builder.append("\n");
builder.append("> Logical name: ").append(getName()).append('\n');
builder.append("> Base package: ").append(basePackage.getName()).append('\n');
@@ -454,6 +462,16 @@ public class ApplicationModule {
|| packageName.startsWith(basePackageName + ".");
}
/**
* Returns whether the module is considered open.
*
* @see org.springframework.modulith.ApplicationModule.Type
* @since 1.2
*/
boolean isOpen() {
return information.isOpen();
}
/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
@@ -964,8 +982,8 @@ public class ApplicationModule {
var originModule = getExistingModuleOf(source, modules);
var targetModule = getExistingModuleOf(target, modules);
DeclaredDependencies declaredDependencies = originModule.getDeclaredDependencies(modules);
Violations violations = Violations.NONE;
var declaredDependencies = originModule.getDeclaredDependencies(modules);
var violations = Violations.NONE;
// Check explicitly defined allowed targets
if (!declaredDependencies.isAllowedDependency(target)) {
@@ -979,6 +997,10 @@ public class ApplicationModule {
// No explicitly allowed dependencies - check for general access
if (targetModule.isOpen()) {
return violations;
}
if (!targetModule.isExposed(target)) {
var violationText = "Module '%s' depends on non-exposed type %s within module '%s'!"

View File

@@ -21,8 +21,8 @@ import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.jmolecules.ddd.annotation.Module;
import org.springframework.modulith.ApplicationModule;
import org.springframework.modulith.ApplicationModule.Type;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
@@ -68,6 +68,14 @@ interface ApplicationModuleInformation {
*/
List<String> getDeclaredDependencies();
/**
* Returns whether the module is considered open.
*
* @see org.springframework.modulith.ApplicationModule.Type
* @since 1.2
*/
boolean isOpen();
/**
* An {@link ApplicationModuleInformation} for the jMolecules {@link Module} annotation.
*
@@ -111,6 +119,15 @@ interface ApplicationModuleInformation {
public List<String> getDeclaredDependencies() {
return List.of(ApplicationModule.OPEN_TOKEN);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.core.ApplicationModuleInformation#isOpenModule()
*/
@Override
public boolean isOpen() {
return false;
}
}
/**
@@ -167,5 +184,14 @@ interface ApplicationModuleInformation {
.orElse(Stream.of(ApplicationModule.OPEN_TOKEN)) //
.toList();
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.core.ApplicationModuleInformation#isOpenModule()
*/
@Override
public boolean isOpen() {
return annotation.map(it -> it.type().equals(Type.OPEN)).orElse(false);
}
}
}

View File

@@ -24,6 +24,7 @@ import static java.util.stream.Collectors.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -810,6 +811,7 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
public SliceIdentifier getIdentifierOf(JavaClass javaClass) {
return getModuleByType(javaClass)
.filter(Predicate.not(ApplicationModule::isOpen))
.map(ApplicationModule::getName)
.map(SliceIdentifier::of)
.orElse(SliceIdentifier.ignore());

View File

@@ -44,6 +44,8 @@ public class NamedInterface implements Iterable<JavaClass> {
static final String UNNAMED_NAME = "<<UNNAMED>>";
private static final DescribedPredicate<CanBeAnnotated> ANNOTATED_NAMED_INTERFACE = //
isAnnotatedWith(org.springframework.modulith.NamedInterface.class);
private static final DescribedPredicate<JavaClass> ANNOTATED_NAMED_INTERFACE_PACKAGE = //
residesInPackageAnnotatedWith(org.springframework.modulith.NamedInterface.class);
private final String name;
private final Classes classes;
@@ -98,22 +100,26 @@ public class NamedInterface implements Iterable<JavaClass> {
* @param javaPackage must not be {@literal null}.
* @return will never be {@literal null}.
*/
static NamedInterface unnamed(JavaPackage javaPackage) {
static NamedInterface unnamed(JavaPackage javaPackage, boolean flatten) {
var basePackageClasses = javaPackage.toSingle().getExposedClasses();
var basePackageClasses = (flatten ? javaPackage.toSingle() : javaPackage).getExposedClasses();
// Types that declare the annotation but no explicit name
var withDefaultedNamedInterface = basePackageClasses.stream()
.filter(ANNOTATED_NAMED_INTERFACE)
.filter(NamedInterface::withDefaultedNamedInterface)
.toList();
if (flatten) {
// Illegal in the base package
Assert.state(withDefaultedNamedInterface.isEmpty(),
() -> "Cannot use named interface defaulting for type(s) %s located in base package!"
.formatted(FormatableType.format(withDefaultedNamedInterface)));
// Types that declare the annotation but no explicit name
var withDefaultedNamedInterface = basePackageClasses.stream()
.filter(ANNOTATED_NAMED_INTERFACE)
.filter(NamedInterface::withDefaultedNamedInterface)
.toList();
return new NamedInterface(UNNAMED_NAME, basePackageClasses.that(not(ANNOTATED_NAMED_INTERFACE)));
// Illegal in the base package
Assert.state(withDefaultedNamedInterface.isEmpty(),
() -> "Cannot use named interface defaulting for type(s) %s located in base package!"
.formatted(FormatableType.format(withDefaultedNamedInterface)));
}
return new NamedInterface(UNNAMED_NAME, basePackageClasses
.that(not(ANNOTATED_NAMED_INTERFACE_PACKAGE).and(not(ANNOTATED_NAMED_INTERFACE))));
}
/**

View File

@@ -63,7 +63,7 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
*/
static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) {
return NamedInterfaces.of(NamedInterface.unnamed(basePackage))
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, true))
.and(ofAnnotatedPackages(basePackage))
.and(ofAnnotatedTypes(basePackage));
}
@@ -94,6 +94,21 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
.collect(Collectors.collectingAndThen(Collectors.toList(), NamedInterfaces::of));
}
/**
* Creates a new {@link NamedInterface} consisting of the unnamed one containing all classes in the given
* {@link JavaPackage}.
*
* @param basePackage must not be {@literal null}.
* @return will never be {@literal null}.
* @since 1.2
*/
static NamedInterfaces forOpen(JavaPackage basePackage) {
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, false))
.and(ofAnnotatedPackages(basePackage))
.and(ofAnnotatedTypes(basePackage));
}
/**
* Returns whether at least one explicit {@link NamedInterface} is declared.
*

View File

@@ -20,6 +20,7 @@ import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*;
import java.lang.annotation.Annotation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import com.tngtech.archunit.base.DescribedPredicate;
@@ -145,7 +146,7 @@ class Types {
}
}
static DescribedPredicate<CanBeAnnotated> isAnnotatedWith(Class<?> type) {
static DescribedPredicate<CanBeAnnotated> isAnnotatedWith(Class<? extends Annotation> type) {
return isAnnotatedWith(type.getName());
}
@@ -153,4 +154,24 @@ class Types {
return Predicates.annotatedWith(type) //
.or(Predicates.metaAnnotatedWith(type));
}
/**
* Creates a new {@link DescribedPredicate} to match classes
*
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @since 1.2
*/
static DescribedPredicate<JavaClass> residesInPackageAnnotatedWith(Class<? extends Annotation> type) {
Assert.notNull(type, "Annotation type must not be null!");
return new DescribedPredicate<JavaClass>("resides in a package annotated with", type) {
@Override
public boolean test(JavaClass t) {
return t.getPackage().isMetaAnnotatedWith(type);
}
};
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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 example.ni.internal;
/**
* @author Oliver Drotbohm
*/
public interface Internal {}

View File

@@ -22,6 +22,7 @@ import example.ni.RootType;
import example.ni.api.ApiType;
import example.ni.internal.AdditionalSpiType;
import example.ni.internal.DefaultedNamedInterfaceType;
import example.ni.internal.Internal;
import example.ni.spi.SpiType;
import example.ninvalid.InvalidDefaultNamedInterface;
@@ -64,6 +65,18 @@ class NamedInterfacesUnitTests {
.withMessageContaining(InvalidDefaultNamedInterface.class.getSimpleName());
}
@Test // GH-284
void detectsOpenNamedInterface() {
var javaPackage = TestUtils.getPackage(RootType.class);
var interfaces = NamedInterfaces.forOpen(javaPackage);
assertThat(interfaces).map(NamedInterface::getName)
.containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "api", "spi", "kpi", "internal");
assertInterfaceContains(interfaces, NamedInterface.UNNAMED_NAME, RootType.class, Internal.class);
}
private static void assertInterfaceContains(NamedInterfaces interfaces, String name, Class<?>... types) {
var classNames = Arrays.stream(types).map(Class::getName).toArray(String[]::new);

View File

@@ -0,0 +1,29 @@
/*
* 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 com.acme.myproject.open.internal;
import org.springframework.stereotype.Component;
import com.acme.myproject.openclient.ClientToInternal;
/**
* @author Oliver Drotbohm
*/
@Component
public class Internal {
ClientToInternal clientToInternal;
}

View File

@@ -0,0 +1,5 @@
@ApplicationModule(type = Type.OPEN)
package com.acme.myproject.open;
import org.springframework.modulith.ApplicationModule;
import org.springframework.modulith.ApplicationModule.Type;

View File

@@ -0,0 +1,26 @@
/*
* 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 com.acme.myproject.openclient;
import com.acme.myproject.open.internal.Internal;
/**
* @author Oliver Drotbohm
*/
public class ClientToInternal {
Internal internal;
}

View File

@@ -0,0 +1,25 @@
/*
* 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 com.acme.myproject.opendisallowedclient;
import com.acme.myproject.open.internal.Internal;
/**
* @author Oliver Drotbohm
*/
public class DisallowedClientOfInternal {
Internal internal;
}

View File

@@ -0,0 +1,2 @@
@org.springframework.modulith.ApplicationModule(allowedDependencies = "moduleA")
package com.acme.myproject.opendisallowedclient;

View File

@@ -61,7 +61,7 @@ class ModulithTest {
assertThatExceptionOfType(Violations.class).isThrownBy(() -> {
ApplicationModules
.of(Application.class, DEFAULT_EXCLUSIONS.or(Filters.withoutModule("invalid")))
.of(Application.class, DEFAULT_EXCLUSIONS.or(Filters.withoutModules("invalid", "opendisallowedclient")))
.verify();
}).satisfies(it -> {

View File

@@ -211,6 +211,29 @@ class ApplicationModulesIntegrationTest {
});
}
@Test // GH-284
void detectsOpenModule() {
assertThat(modules.getModuleByName("open")).hasValueSatisfying(it -> {
assertThat(it.isOpen()).isTrue();
});
var detectViolations = modules.detectViolations().getMessages();
assertThat(detectViolations)
.isNotEmpty()
// No invalid references to internals from unrestricted module
.noneMatch(it -> it.matches("Module 'openclient' depends on non-exposed type .* within module 'open'"))
// Invalid reference to internals from restricted module
.anyMatch(it -> it.contains("Module 'opendisallowedclient' depends on module 'open'"))
// No cycle detection
.anyMatch(it -> it.contains("Cycle detected: Slice cycleA"))
.noneMatch(it -> it.contains("Cycle detected: Slice open"));
}
private static void verifyNamedInterfaces(NamedInterfaces interfaces, String name, Class<?>... types) {
Stream.of(types).forEach(type -> {

View File

@@ -0,0 +1 @@
spring.main.banner-mode=off

View File

@@ -9,7 +9,7 @@
<logger name="org.springframework.modulith" level="error" />
<root level="error">
<root level="off">
<appender-ref ref="console" />
</root>

View File

@@ -73,6 +73,45 @@ Note, how `SomethingOrderInternal` is a public type, likely because `OrderManage
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
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.
To turn an application module into an open one, use the `@ApplicationModule` annotation on the `package-info.java` type.
.Declaring an Application Modules as Open
[tabs]
======
Java::
+
[source, java, role="primary", chomp="none"]
----
@org.springframework.modulith.ApplicationModule(
type = Type.OPEN
)
package example.inventory;
----
Kotlin::
+
[source, kotlin, role="secondary", chomp="none"]
----
@org.springframework.modulith.ApplicationModule(
type = Type.OPEN
)
package example.inventory
----
======
Declaring an application module as open will cause the following changes to the verification:
* Access to application module internal types from other modules is generally allowed.
* All types, also ones residing in sub-packages of the application module base package are added to the xref:fundamentals.adoc#modules.named-interfaces[unnamed named interface], unless explicitly assigned to a named interface.
NOTE: This feature is intended to be primarily used with code bases of existing projects gradually moving to the Spring Modulith recommended packaging structure.
In a fully-modularized application, using open application modules usually hints at sub-optimal modularization and packaging structures.
[[modules.explicit-dependencies]]
=== Explicit Application Module Dependencies
A module can opt into declaring its allowed dependencies by using the `@ApplicationModule` annotation on the `package-info.java` type.