Skip to content

Commit f3c111f

Browse files
committed
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.
1 parent b73b4ca commit f3c111f

File tree

19 files changed

+331
-22
lines changed

19 files changed

+331
-22
lines changed

spring-modulith-api/src/main/java/org/springframework/modulith/ApplicationModule.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,37 @@
5252
* @see NamedInterface
5353
*/
5454
String[] allowedDependencies() default { OPEN_TOKEN };
55+
56+
/**
57+
* Declares the {@link Type} of the {@link ApplicationModule}
58+
*
59+
* @return will never be {@literal null}.
60+
* @since 1.2
61+
*/
62+
Type type() default Type.CLOSED;
63+
64+
/**
65+
* The type of an application module
66+
*
67+
* @author Oliver Drotbohm
68+
* @since 1.2
69+
*/
70+
enum Type {
71+
72+
/**
73+
* A closed application module exposes an API to other modules, but also allows to hide internals. Access to those
74+
* internals from other modules is sanctioned. Also, closed application modules must not be part of dependency
75+
* cycles.
76+
*
77+
* @see NamedInterface
78+
*/
79+
CLOSED,
80+
81+
/**
82+
* An open application module does not hide its internals, which means that access to those from other modules is
83+
* not sanctioned. They are also excluded from the cycle detection algorithm. All types contained in an open module
84+
* are part of the unnamed named interface.
85+
*/
86+
OPEN;
87+
}
5588
}

spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ public class ApplicationModule {
8686

8787
this.basePackage = basePackage;
8888
this.information = ApplicationModuleInformation.of(basePackage);
89-
this.namedInterfaces = NamedInterfaces.discoverNamedInterfaces(basePackage);
89+
this.namedInterfaces = isOpen()
90+
? NamedInterfaces.forOpen(basePackage)
91+
: NamedInterfaces.discoverNamedInterfaces(basePackage);
9092
this.useFullyQualifiedModuleNames = useFullyQualifiedModuleNames;
9193

9294
this.springBeans = SingletonSupplier.of(() -> filterSpringBeans(basePackage));
@@ -320,7 +322,7 @@ public Violations detectDependencies(ApplicationModules modules) {
320322
/**
321323
* Returns whether the module is considered a root one, i.e., it is an artificial one created for each base package
322324
* configured.
323-
*
325+
*
324326
* @return whether the module is considered a root one.
325327
* @since 1.1
326328
*/
@@ -350,7 +352,13 @@ public String toString() {
350352

351353
public String toString(@Nullable ApplicationModules modules) {
352354

353-
var builder = new StringBuilder("# ").append(getDisplayName()).append("\n");
355+
var builder = new StringBuilder("# ").append(getDisplayName());
356+
357+
if (isOpen()) {
358+
builder.append(" (open)");
359+
}
360+
361+
builder.append("\n");
354362

355363
builder.append("> Logical name: ").append(getName()).append('\n');
356364
builder.append("> Base package: ").append(basePackage.getName()).append('\n');
@@ -454,6 +462,16 @@ boolean containsPackage(String packageName) {
454462
|| packageName.startsWith(basePackageName + ".");
455463
}
456464

465+
/**
466+
* Returns whether the module is considered open.
467+
*
468+
* @see org.springframework.modulith.ApplicationModule.Type
469+
* @since 1.2
470+
*/
471+
boolean isOpen() {
472+
return information.isOpen();
473+
}
474+
457475
/*
458476
* (non-Javadoc)
459477
* @see java.lang.Object#equals(java.lang.Object)
@@ -964,8 +982,8 @@ Violations isValidDependencyWithin(ApplicationModules modules) {
964982
var originModule = getExistingModuleOf(source, modules);
965983
var targetModule = getExistingModuleOf(target, modules);
966984

967-
DeclaredDependencies declaredDependencies = originModule.getDeclaredDependencies(modules);
968-
Violations violations = Violations.NONE;
985+
var declaredDependencies = originModule.getDeclaredDependencies(modules);
986+
var violations = Violations.NONE;
969987

970988
// Check explicitly defined allowed targets
971989
if (!declaredDependencies.isAllowedDependency(target)) {
@@ -979,6 +997,10 @@ Violations isValidDependencyWithin(ApplicationModules modules) {
979997

980998
// No explicitly allowed dependencies - check for general access
981999

1000+
if (targetModule.isOpen()) {
1001+
return violations;
1002+
}
1003+
9821004
if (!targetModule.isExposed(target)) {
9831005

9841006
var violationText = "Module '%s' depends on non-exposed type %s within module '%s'!"

spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleInformation.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
import java.util.function.Supplier;
2222
import java.util.stream.Stream;
2323

24-
import org.jmolecules.ddd.annotation.Module;
2524
import org.springframework.modulith.ApplicationModule;
25+
import org.springframework.modulith.ApplicationModule.Type;
2626
import org.springframework.util.Assert;
2727
import org.springframework.util.ClassUtils;
2828
import org.springframework.util.StringUtils;
@@ -68,6 +68,14 @@ default Optional<String> getDisplayName() {
6868
*/
6969
List<String> getDeclaredDependencies();
7070

71+
/**
72+
* Returns whether the module is considered open.
73+
*
74+
* @see org.springframework.modulith.ApplicationModule.Type
75+
* @since 1.2
76+
*/
77+
boolean isOpen();
78+
7179
/**
7280
* An {@link ApplicationModuleInformation} for the jMolecules {@link Module} annotation.
7381
*
@@ -111,6 +119,15 @@ public Optional<String> getDisplayName() {
111119
public List<String> getDeclaredDependencies() {
112120
return List.of(ApplicationModule.OPEN_TOKEN);
113121
}
122+
123+
/*
124+
* (non-Javadoc)
125+
* @see org.springframework.modulith.core.ApplicationModuleInformation#isOpenModule()
126+
*/
127+
@Override
128+
public boolean isOpen() {
129+
return false;
130+
}
114131
}
115132

116133
/**
@@ -167,5 +184,14 @@ public List<String> getDeclaredDependencies() {
167184
.orElse(Stream.of(ApplicationModule.OPEN_TOKEN)) //
168185
.toList();
169186
}
187+
188+
/*
189+
* (non-Javadoc)
190+
* @see org.springframework.modulith.core.ApplicationModuleInformation#isOpenModule()
191+
*/
192+
@Override
193+
public boolean isOpen() {
194+
return annotation.map(it -> it.type().equals(Type.OPEN)).orElse(false);
195+
}
170196
}
171197
}

spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.*;
2525
import java.util.concurrent.ConcurrentHashMap;
2626
import java.util.function.Function;
27+
import java.util.function.Predicate;
2728
import java.util.function.Supplier;
2829
import java.util.stream.Collectors;
2930
import java.util.stream.Stream;
@@ -810,6 +811,7 @@ private class ApplicationModulesSliceAssignment implements SliceAssignment {
810811
public SliceIdentifier getIdentifierOf(JavaClass javaClass) {
811812

812813
return getModuleByType(javaClass)
814+
.filter(Predicate.not(ApplicationModule::isOpen))
813815
.map(ApplicationModule::getName)
814816
.map(SliceIdentifier::of)
815817
.orElse(SliceIdentifier.ignore());

spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterface.java

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public class NamedInterface implements Iterable<JavaClass> {
4444
static final String UNNAMED_NAME = "<<UNNAMED>>";
4545
private static final DescribedPredicate<CanBeAnnotated> ANNOTATED_NAMED_INTERFACE = //
4646
isAnnotatedWith(org.springframework.modulith.NamedInterface.class);
47+
private static final DescribedPredicate<JavaClass> ANNOTATED_NAMED_INTERFACE_PACKAGE = //
48+
residesInPackageAnnotatedWith(org.springframework.modulith.NamedInterface.class);
4749

4850
private final String name;
4951
private final Classes classes;
@@ -98,22 +100,26 @@ static NamedInterface of(String name, Classes classes) {
98100
* @param javaPackage must not be {@literal null}.
99101
* @return will never be {@literal null}.
100102
*/
101-
static NamedInterface unnamed(JavaPackage javaPackage) {
103+
static NamedInterface unnamed(JavaPackage javaPackage, boolean flatten) {
102104

103-
var basePackageClasses = javaPackage.toSingle().getExposedClasses();
105+
var basePackageClasses = (flatten ? javaPackage.toSingle() : javaPackage).getExposedClasses();
104106

105-
// Types that declare the annotation but no explicit name
106-
var withDefaultedNamedInterface = basePackageClasses.stream()
107-
.filter(ANNOTATED_NAMED_INTERFACE)
108-
.filter(NamedInterface::withDefaultedNamedInterface)
109-
.toList();
107+
if (flatten) {
108+
109+
// Types that declare the annotation but no explicit name
110+
var withDefaultedNamedInterface = basePackageClasses.stream()
111+
.filter(ANNOTATED_NAMED_INTERFACE)
112+
.filter(NamedInterface::withDefaultedNamedInterface)
113+
.toList();
110114

111-
// Illegal in the base package
112-
Assert.state(withDefaultedNamedInterface.isEmpty(),
113-
() -> "Cannot use named interface defaulting for type(s) %s located in base package!"
114-
.formatted(FormatableType.format(withDefaultedNamedInterface)));
115+
// Illegal in the base package
116+
Assert.state(withDefaultedNamedInterface.isEmpty(),
117+
() -> "Cannot use named interface defaulting for type(s) %s located in base package!"
118+
.formatted(FormatableType.format(withDefaultedNamedInterface)));
119+
}
115120

116-
return new NamedInterface(UNNAMED_NAME, basePackageClasses.that(not(ANNOTATED_NAMED_INTERFACE)));
121+
return new NamedInterface(UNNAMED_NAME, basePackageClasses
122+
.that(not(ANNOTATED_NAMED_INTERFACE_PACKAGE).and(not(ANNOTATED_NAMED_INTERFACE))));
117123
}
118124

119125
/**

spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ private NamedInterfaces(List<NamedInterface> namedInterfaces) {
6363
*/
6464
static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) {
6565

66-
return NamedInterfaces.of(NamedInterface.unnamed(basePackage))
66+
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, true))
6767
.and(ofAnnotatedPackages(basePackage))
6868
.and(ofAnnotatedTypes(basePackage));
6969
}
@@ -94,6 +94,21 @@ static NamedInterfaces ofAnnotatedPackages(JavaPackage basePackage) {
9494
.collect(Collectors.collectingAndThen(Collectors.toList(), NamedInterfaces::of));
9595
}
9696

97+
/**
98+
* Creates a new {@link NamedInterface} consisting of the unnamed one containing all classes in the given
99+
* {@link JavaPackage}.
100+
*
101+
* @param basePackage must not be {@literal null}.
102+
* @return will never be {@literal null}.
103+
* @since 1.2
104+
*/
105+
static NamedInterfaces forOpen(JavaPackage basePackage) {
106+
107+
return NamedInterfaces.of(NamedInterface.unnamed(basePackage, false))
108+
.and(ofAnnotatedPackages(basePackage))
109+
.and(ofAnnotatedTypes(basePackage));
110+
}
111+
97112
/**
98113
* Returns whether at least one explicit {@link NamedInterface} is declared.
99114
*

spring-modulith-core/src/main/java/org/springframework/modulith/core/Types.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.lang.annotation.Annotation;
2121

2222
import org.springframework.lang.Nullable;
23+
import org.springframework.util.Assert;
2324
import org.springframework.util.ClassUtils;
2425

2526
import com.tngtech.archunit.base.DescribedPredicate;
@@ -145,12 +146,32 @@ static DescribedPredicate<JavaClass> isSpringDataRepository() {
145146
}
146147
}
147148

148-
static DescribedPredicate<CanBeAnnotated> isAnnotatedWith(Class<?> type) {
149+
static DescribedPredicate<CanBeAnnotated> isAnnotatedWith(Class<? extends Annotation> type) {
149150
return isAnnotatedWith(type.getName());
150151
}
151152

152153
static DescribedPredicate<CanBeAnnotated> isAnnotatedWith(String type) {
153154
return Predicates.annotatedWith(type) //
154155
.or(Predicates.metaAnnotatedWith(type));
155156
}
157+
158+
/**
159+
* Creates a new {@link DescribedPredicate} to match classes
160+
*
161+
* @param type must not be {@literal null}.
162+
* @return will never be {@literal null}.
163+
* @since 1.2
164+
*/
165+
static DescribedPredicate<JavaClass> residesInPackageAnnotatedWith(Class<? extends Annotation> type) {
166+
167+
Assert.notNull(type, "Annotation type must not be null!");
168+
169+
return new DescribedPredicate<JavaClass>("resides in a package annotated with", type) {
170+
171+
@Override
172+
public boolean test(JavaClass t) {
173+
return t.getPackage().isMetaAnnotatedWith(type);
174+
}
175+
};
176+
}
156177
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package example.ni.internal;
17+
18+
/**
19+
* @author Oliver Drotbohm
20+
*/
21+
public interface Internal {}

spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import example.ni.api.ApiType;
2323
import example.ni.internal.AdditionalSpiType;
2424
import example.ni.internal.DefaultedNamedInterfaceType;
25+
import example.ni.internal.Internal;
2526
import example.ni.spi.SpiType;
2627
import example.ninvalid.InvalidDefaultNamedInterface;
2728

@@ -64,6 +65,18 @@ void rejectsDefaultingNamedInterfaceTypeInBasePackage() {
6465
.withMessageContaining(InvalidDefaultNamedInterface.class.getSimpleName());
6566
}
6667

68+
@Test // GH-284
69+
void detectsOpenNamedInterface() {
70+
71+
var javaPackage = TestUtils.getPackage(RootType.class);
72+
var interfaces = NamedInterfaces.forOpen(javaPackage);
73+
74+
assertThat(interfaces).map(NamedInterface::getName)
75+
.containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "api", "spi", "kpi", "internal");
76+
77+
assertInterfaceContains(interfaces, NamedInterface.UNNAMED_NAME, RootType.class, Internal.class);
78+
}
79+
6780
private static void assertInterfaceContains(NamedInterfaces interfaces, String name, Class<?>... types) {
6881

6982
var classNames = Arrays.stream(types).map(Class::getName).toArray(String[]::new);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.acme.myproject.open.internal;
17+
18+
import org.springframework.stereotype.Component;
19+
20+
import com.acme.myproject.openclient.ClientToInternal;
21+
22+
/**
23+
* @author Oliver Drotbohm
24+
*/
25+
@Component
26+
public class Internal {
27+
28+
ClientToInternal clientToInternal;
29+
}

0 commit comments

Comments
 (0)