diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java
index a6fcc7f44..77b30a92d 100644
--- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java
+++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java
@@ -74,7 +74,7 @@ class ApplicationModuleDetectionStrategyLookup {
*
Use the prepared strategies if either {@code direct-sub-packages} or {@code explicitly-annotated} is configured
* for the {@code spring.modulith.detection-strategy} configuration property.
* Interpret the configured value as class if it doesn't match the predefined values just described.
- * Use the {@link ApplicationModuleDetectionStrategy} declared in {@code META-INF/spring.properties}
+ * Use the {@link ApplicationModuleDetectionStrategy} declared in {@code META-INF/spring.factories}
* (deprecated)
* A final fallback on the {@code direct-sub-packages}.
*
diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java
index 0fc2195a4..43c7309d8 100644
--- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java
+++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java
@@ -15,36 +15,27 @@
*/
package org.springframework.modulith.docs;
-import static java.util.stream.Collectors.*;
-import static org.springframework.util.ClassUtils.*;
-
-import java.util.Collection;
-import java.util.List;
-import java.util.Optional;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaModifier;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.modulith.core.ApplicationModule;
-import org.springframework.modulith.core.ApplicationModuleDependency;
-import org.springframework.modulith.core.ApplicationModules;
-import org.springframework.modulith.core.ArchitecturallyEvidentType;
+import org.springframework.modulith.core.*;
import org.springframework.modulith.core.ArchitecturallyEvidentType.ReferenceMethod;
-import org.springframework.modulith.core.DependencyType;
-import org.springframework.modulith.core.EventType;
-import org.springframework.modulith.core.FormattableType;
-import org.springframework.modulith.core.Source;
-import org.springframework.modulith.core.SpringBean;
import org.springframework.modulith.docs.ConfigurationProperties.ModuleProperty;
import org.springframework.modulith.docs.Documenter.CanvasOptions;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
-import com.tngtech.archunit.core.domain.JavaClass;
-import com.tngtech.archunit.core.domain.JavaModifier;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.joining;
+import static org.springframework.util.ClassUtils.convertClassNameToResourcePath;
/**
* @author Oliver Drotbohm
@@ -56,11 +47,9 @@ class Asciidoctor {
private static final Pattern LINE_BREAKS = Pattern.compile("\\<\\s*br\\s*\\>");
private static final Logger LOG = LoggerFactory.getLogger(Asciidoctor.class);
- private static final Optional DOC_SOURCE = getSpringModulithDocsSource();
-
private final ApplicationModules modules;
private final String javaDocBase;
- private final Optional docSource;
+ private final DocumentationSource docSource;
private Asciidoctor(ApplicationModules modules, String javaDocBase) {
@@ -69,13 +58,15 @@ private Asciidoctor(ApplicationModules modules, String javaDocBase) {
this.javaDocBase = javaDocBase;
this.modules = modules;
- this.docSource = DOC_SOURCE.map(it -> new CodeReplacingDocumentationSource(it, this));
+
+ var rawSource = DocumentationSourceLookup.getDocumentationSource();
+ this.docSource = new CodeReplacingDocumentationSource(rawSource, this);
}
/**
* Creates a new {@link Asciidoctor} instance for the given {@link ApplicationModules} and Javadoc base URI.
*
- * @param modules must not be {@literal null}.
+ * @param modules must not be {@literal null}.
* @param javadocBase can be {@literal null}.
* @return will never be {@literal null}.
*/
@@ -103,7 +94,7 @@ public String toInlineCode(String source) {
var parts = source.split("#");
var type = parts[0];
- var methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional. empty();
+ var methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional.empty();
if (type.isBlank()) {
return methodSignature.map(Asciidoctor::toCode).orElse(source);
@@ -138,7 +129,7 @@ public String toInlineCode(SpringBean bean) {
private String withDocumentation(String base, JavaClass type) {
- return docSource.flatMap(it -> it.getDocumentation(type))
+ return docSource.getDocumentation(type)
.map(it -> base + " -- " + it)
.orElse(base);
}
@@ -194,7 +185,7 @@ public String renderPublishedEvents(ApplicationModule module) {
continue;
}
- var documentation = docSource.flatMap(it -> it.getDocumentation(eventType.getType()))
+ var documentation = docSource.getDocumentation(eventType.getType())
.map(" -- "::concat);
builder.append("* ")
@@ -333,7 +324,7 @@ private String renderReferenceMethod(ReferenceMethod it, int level) {
var isAsync = it.isAsync() ? "(async) " : "";
var indent = "*".repeat(level + 1);
- return docSource.flatMap(source -> source.getDocumentation(method))
+ return docSource.getDocumentation(method)
.map(doc -> "%s %s %s-- %s".formatted(indent, toInlineCode(exposedReferenceTypes), isAsync, doc))
.orElseGet(() -> "%s %s %s".formatted(indent, toInlineCode(exposedReferenceTypes), isAsync));
}
@@ -411,7 +402,7 @@ public String renderBeanReferences(ApplicationModule module) {
}
public String renderModuleDescription(ApplicationModule module) {
- return docSource.flatMap(it -> it.getDocumentation(module.getBasePackage())).orElse("");
+ return docSource.getDocumentation(module.getBasePackage()).orElse("");
}
public String renderHeadline(int i, String modules) {
@@ -426,16 +417,6 @@ public String renderGeneralInclude(String componentsFilename) {
return "include::" + componentsFilename + "[]" + System.lineSeparator();
}
- private static Optional getSpringModulithDocsSource() {
-
- return SpringModulithDocumentationSource.getInstance()
- .map(it -> {
- LOG.debug("Using Javadoc extracted by Spring Modulith in {}.",
- SpringModulithDocumentationSource.getMetadataLocation());
- return it;
- });
- }
-
private static final String wrap(String source, String chars) {
return chars + source + chars;
}
diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java
index 2b1f799c4..ef61c2843 100644
--- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java
+++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java
@@ -27,7 +27,7 @@
*
* @author Oliver Drotbohm
*/
-interface DocumentationSource {
+public interface DocumentationSource {
/**
* Returns the documentation to be used for the given {@link JavaMethod}.
diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java
new file mode 100644
index 000000000..1c466baa8
--- /dev/null
+++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2024-2025 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.docs;
+
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * A factory for the {@link DocumentationSource} to be used when generating documentation.
+ */
+class DocumentationSourceLookup {
+
+ private static final String DOCUMENTATION_SOURCE_PROPERTY = "spring.modulith.documentation-source";
+ private static final Logger LOG = LoggerFactory.getLogger(DocumentationSourceLookup.class);
+
+ /**
+ * Returns the {@link DocumentationSource} to be used for documentation generation. Will use the following
+ * algorithm:
+ *
+ * - Use the predefined strategy if {@code spring-modulith} is configured for the
+ * {@code spring.modulith.documentation-source} configuration property.
+ * - Interpret the configured value as class if it doesn't match the predefined value.
+ * - Use the {@link DocumentationSource} declared in {@code META-INF/spring.factories} (deprecated)
+ * - A final fallback on {@link SpringModulithDocumentationSource} or {@link NoOpDocumentationSource} if the
+ * metadata file is not available.
+ *
+ *
+ * @return will never be {@literal null}.
+ */
+ static DocumentationSource getDocumentationSource() {
+
+ var environment = new StandardEnvironment();
+ ConfigDataEnvironmentPostProcessor.applyTo(environment,
+ new DefaultResourceLoader(DocumentationSourceLookup.class.getClassLoader()), null);
+
+ var configuredSource = environment.getProperty(DOCUMENTATION_SOURCE_PROPERTY, String.class);
+
+ // Nothing configured? Use SpringFactoriesLoader or fallback
+ if (!StringUtils.hasText(configuredSource)) {
+ return lookupViaSpringFactoriesOrFallback();
+ }
+
+ // Check predefined strategy
+ if ("spring-modulith".equals(configuredSource)) {
+ return getSpringModulithDocumentationSource();
+ }
+
+ // Try to load configured value as class
+ try {
+
+ var sourceClass = ClassUtils.forName(configuredSource, DocumentationSource.class.getClassLoader());
+ return BeanUtils.instantiateClass(sourceClass, DocumentationSource.class);
+
+ } catch (ClassNotFoundException | LinkageError o_O) {
+ throw new IllegalStateException("Unable to load documentation source class: " + configuredSource, o_O);
+ }
+ }
+
+ /**
+ * Attempts to load documentation source via {@link SpringFactoriesLoader} (deprecated), falling back to the default
+ * source if none found.
+ *
+ * @return will never be {@literal null}.
+ */
+ private static DocumentationSource lookupViaSpringFactoriesOrFallback() {
+
+ List loadFactories = SpringFactoriesLoader.loadFactories(DocumentationSource.class,
+ DocumentationSource.class.getClassLoader());
+
+ var size = loadFactories.size();
+
+ if (size == 0) {
+ return getDefaultDocumentationSource();
+ }
+
+ if (size > 1) {
+ throw new IllegalStateException(
+ "Multiple documentation sources configured via spring.factories. Only one supported! %s"
+ .formatted(loadFactories));
+ }
+
+ LOG.warn(
+ "Configuring documentation source via spring.factories is deprecated! Please configure {} instead.",
+ DOCUMENTATION_SOURCE_PROPERTY);
+
+ return loadFactories.get(0);
+ }
+
+ /**
+ * Returns the Spring Modulith documentation source, or a no-op source if metadata is not available.
+ *
+ * @return will never be {@literal null}.
+ */
+ private static DocumentationSource getSpringModulithDocumentationSource() {
+
+ return SpringModulithDocumentationSource.getInstance()
+ .map(it -> {
+ LOG.debug("Using Javadoc extracted by Spring Modulith in {}.",
+ SpringModulithDocumentationSource.getMetadataLocation());
+ return it;
+ })
+ .orElseGet(NoOpDocumentationSource::new);
+ }
+
+ /**
+ * Returns the default documentation source (Spring Modulith or no-op).
+ *
+ * @return will never be {@literal null}.
+ */
+ private static DocumentationSource getDefaultDocumentationSource() {
+ return getSpringModulithDocumentationSource();
+ }
+}
diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java
new file mode 100644
index 000000000..8a290f4d6
--- /dev/null
+++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024-2025 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.docs;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaMethod;
+import org.springframework.modulith.core.JavaPackage;
+
+import java.util.Optional;
+
+/**
+ * A no-op {@link DocumentationSource} that returns empty {@link Optional}s for all documentation lookups. Used as a
+ * fallback when no documentation source is available.
+ */
+class NoOpDocumentationSource implements DocumentationSource {
+
+ @Override
+ public Optional getDocumentation(JavaMethod method) {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional getDocumentation(JavaClass type) {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional getDocumentation(JavaPackage pkg) {
+ return Optional.empty();
+ }
+}
diff --git a/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 000000000..afa34126b
--- /dev/null
+++ b/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,28 @@
+{
+ "properties": [
+ {
+ "name": "spring.modulith.documentation-source",
+ "type": "java.lang.String",
+ "description": "The documentation source to use for extracting Javadoc comments."
+ }
+ ],
+ "hints": [
+ {
+ "name": "spring.modulith.documentation-source",
+ "values": [
+ {
+ "value": "spring-modulith",
+ "description": "Uses Javadoc metadata extracted by Spring Modulith APT processor."
+ }
+ ],
+ "providers": [
+ {
+ "name": "class-reference",
+ "parameters": {
+ "target": "org.springframework.modulith.docs.DocumentationSource"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java
index 26f2272a2..53207fbc2 100644
--- a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java
+++ b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java
@@ -48,8 +48,8 @@ void rendersLinkToMethodReference() {
@Test
void doesNotRenderLinkToMethodReferenceForNonPublicType() {
- assertThat(asciidoctor.toInlineCode("DocumentationSource#getDocumentation(JavaMethod)"))
- .isEqualTo("`o.s.m.d.DocumentationSource#getDocumentation(JavaMethod)`");
+ assertThat(asciidoctor.toInlineCode("ConfigurationProperties#getModuleProperties(ApplicationModule)"))
+ .isEqualTo("`o.s.m.d.ConfigurationProperties#getModuleProperties(ApplicationModule)`");
}
@Test
diff --git a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java
new file mode 100644
index 000000000..d3d44c284
--- /dev/null
+++ b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024-2025 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.docs;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaMethod;
+import org.junit.jupiter.api.Test;
+import org.springframework.modulith.core.JavaPackage;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link DocumentationSourceLookup}.
+ */
+class DocumentationSourceLookupTests {
+
+ @Test
+ void usesSpringModulithSourceIfConfigured() {
+
+ System.setProperty("spring.config.additional-location", "classpath:documentation-source/spring-modulith.properties");
+
+ var source = DocumentationSourceLookup.getDocumentationSource();
+
+ // Should return either SpringModulithDocumentationSource or NoOpDocumentationSource
+ // (depending on whether metadata file exists)
+ assertThat(source).isNotNull();
+ }
+
+ @Test
+ void usesCustomSourceIfConfigured() {
+
+ System.setProperty("spring.config.additional-location", "classpath:documentation-source/custom-type.properties");
+
+ var source = DocumentationSourceLookup.getDocumentationSource();
+
+ assertThat(source).isInstanceOf(TestDocumentationSource.class);
+ }
+
+ @Test
+ void usesDefaultSourceWhenNoConfigurationProvided() {
+
+ // Clear any existing configuration
+ System.clearProperty("spring.config.additional-location");
+
+ var source = DocumentationSourceLookup.getDocumentationSource();
+
+ // Should return either SpringModulithDocumentationSource or NoOpDocumentationSource as default
+ assertThat(source).isNotNull();
+ }
+
+ /**
+ * Test implementation of {@link DocumentationSource} for testing custom type configuration.
+ */
+ public static class TestDocumentationSource implements DocumentationSource {
+
+ @Override
+ public Optional getDocumentation(JavaMethod method) {
+ return Optional.of("Test method documentation");
+ }
+
+ @Override
+ public Optional getDocumentation(JavaClass type) {
+ return Optional.of("Test class documentation");
+ }
+
+ @Override
+ public Optional getDocumentation(JavaPackage pkg) {
+ return Optional.of("Test package documentation");
+ }
+ }
+}
diff --git a/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties b/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties
new file mode 100644
index 000000000..86f22089a
--- /dev/null
+++ b/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties
@@ -0,0 +1 @@
+spring.modulith.documentation-source=org.springframework.modulith.docs.DocumentationSourceLookupTests.TestDocumentationSource
diff --git a/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties b/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties
new file mode 100644
index 000000000..26ca878d9
--- /dev/null
+++ b/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties
@@ -0,0 +1 @@
+spring.modulith.documentation-source=spring-modulith