diff --git a/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java b/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java
new file mode 100644
index 000000000..fb48b2291
--- /dev/null
+++ b/spring-modulith-api/src/main/java/org/springframework/modulith/Externalized.java
@@ -0,0 +1,51 @@
+/*
+ * 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;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.core.annotation.AliasFor;
+
+/**
+ * Marks domain events as to be externalized.
+ *
+ * @author Oliver Drotbohm
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE })
+public @interface Externalized {
+
+ /**
+ * The logical target name. Will default to a strategy defined by configuration if empty.
+ *
+ * @return
+ * @see #target()
+ */
+ @AliasFor("target")
+ String value() default "";
+
+ /**
+ * The logical target name. Will default to a strategy defined by configuration if empty.
+ *
+ * @return
+ * @see #value()
+ */
+ @AliasFor("value")
+ String target() default "";
+}
diff --git a/spring-modulith-events/pom.xml b/spring-modulith-events/pom.xml
index 9c5162fce..c6b40105b 100644
--- a/spring-modulith-events/pom.xml
+++ b/spring-modulith-events/pom.xml
@@ -14,11 +14,13 @@
Spring Modulith - Events
+ spring-modulith-events-api
spring-modulith-events-core
spring-modulith-events-jpa
spring-modulith-events-jdbc
spring-modulith-events-mongodb
spring-modulith-events-jackson
+ spring-modulith-events-kafka
diff --git a/spring-modulith-events/spring-modulith-events-api/pom.xml b/spring-modulith-events/spring-modulith-events-api/pom.xml
new file mode 100644
index 000000000..f3a2e6b3f
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-events
+ 1.1.0-SNAPSHOT
+
+
+ Spring Modulith - Events - API
+ spring-modulith-events-api
+
+
+ org.springframework.modulith.events.api
+
+
+
+
+
+ org.springframework.modulith
+ spring-modulith-api
+ ${project.version}
+
+
+
+ org.springframework
+ spring-core
+
+
+
+ org.jmolecules
+ jmolecules-events
+ true
+
+
+
+ org.springframework
+ spring-test
+ test
+
+
+
+
+
diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/AnnotationTargetLookup.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/AnnotationTargetLookup.java
new file mode 100644
index 000000000..918f03f5d
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/AnnotationTargetLookup.java
@@ -0,0 +1,172 @@
+/*
+ * 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.events.externalize;
+
+import static org.springframework.core.annotation.AnnotatedElementUtils.*;
+
+import java.lang.annotation.Annotation;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import org.springframework.modulith.Externalized;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ConcurrentReferenceHashMap;
+
+/**
+ * An annotation based target lookup strategy to enable caching of the function lookups that involve classpath checks.
+ * The currently supported annotations are:
+ *
+ * Spring Modulith's {@link Externalized}
+ * jMolecules {@link org.jmolecules.event.annotation.Externalized} (if present on the classpath)
+ *
+ *
+ * @author Oliver Drotbohm
+ * @since 1.1
+ */
+class AnnotationTargetLookup implements Supplier> {
+
+ private static Map, AnnotationTargetLookup> LOOKUPS = new ConcurrentReferenceHashMap<>(25);
+ private static final String JMOLECULES_EXTERNALIZED = "org.jmolecules.event.annotation.Externalized";
+ private static final Class extends Annotation> JMOLECULES_ANNOTATION = loadJMoleculesExternalizedIfPresent();
+
+ static {
+
+ }
+
+ private final Class> type;
+ private final Supplier> lookup;
+
+ /**
+ * Creates a new {@link AnnotationTargetLookup} for the given type.
+ *
+ * @param type must not be {@literal null}.
+ */
+ private AnnotationTargetLookup(Class> type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ this.type = type;
+ this.lookup = firstMatching(fromJMoleculesExternalized(), fromModulithExternalized());
+ }
+
+ /**
+ * Returns the {@link AnnotationTargetLookup} for the given type.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ static AnnotationTargetLookup of(Class> type) {
+ return LOOKUPS.computeIfAbsent(type, AnnotationTargetLookup::new);
+ }
+
+ static boolean hasExternalizedAnnotation(Object event) {
+
+ var type = event.getClass();
+
+ return hasAnnotation(type, Externalized.class)
+ || JMOLECULES_ANNOTATION != null && hasAnnotation(type, JMOLECULES_ANNOTATION);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.util.function.Supplier#get()
+ */
+ @Override
+ public Optional get() {
+ return lookup.get();
+ }
+
+ /**
+ * Creates a {@link Supplier} to lookup the target from Spring Modulith's {@link Externalized} annotation.
+ *
+ * @return will never be {@literal null}.
+ */
+ private Supplier> fromModulithExternalized() {
+ return () -> lookupTarget(Externalized.class, Externalized::target);
+ }
+
+ /**
+ * Creates a {@link Supplier} to lookup the target from jMolecules
+ * {@link org.jmolecules.event.annotation.Externalized} annotation if present on the classpath.
+ *
+ * @return will never be {@literal null}.
+ */
+ private Supplier> fromJMoleculesExternalized() {
+
+ return JMOLECULES_ANNOTATION == null
+ ? () -> Optional.empty()
+ : () -> lookupTarget(org.jmolecules.event.annotation.Externalized.class,
+ org.jmolecules.event.annotation.Externalized::target,
+ org.jmolecules.event.annotation.Externalized::value);
+ }
+
+ /**
+ * Returns a new {@link Function} that chains the given lookup functions until one returns a non-empty
+ * {@link Optional}.
+ *
+ * @param functions must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ @SafeVarargs
+ private Supplier> firstMatching(
+ Supplier>... functions) {
+
+ return () -> Arrays.stream(functions)
+ .reduce(Optional.empty(), (current, function) -> current.or(() -> function.get()), (l, r) -> r);
+ }
+
+ /**
+ * Looks up the target from the given annotation applying the given extractors aborting if a non-empty {@link String}
+ * is found.
+ *
+ * @param the annotation type
+ * @param annotation must not be {@literal null}.
+ * @param extractors must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ @SafeVarargs
+ private Optional lookupTarget(Class annotation,
+ Function... extractors) {
+
+ return Optional.ofNullable(findMergedAnnotation(type, annotation))
+ .stream()
+ .flatMap(it -> Arrays.stream(extractors)
+ .map(function -> function.apply(it))
+ .filter(Predicate.not(String::isBlank)))
+ .findFirst();
+ }
+
+ private static Class extends Annotation> loadJMoleculesExternalizedIfPresent() {
+
+ var classLoader = DefaultEventExternalizationConfiguration.class.getClassLoader();
+
+ if (ClassUtils.isPresent(JMOLECULES_EXTERNALIZED, classLoader)) {
+
+ try {
+ return (Class extends Annotation>) ClassUtils.forName(JMOLECULES_EXTERNALIZED, classLoader);
+ } catch (Exception o_O) {
+ return null;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfiguration.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfiguration.java
new file mode 100644
index 000000000..a507ed143
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfiguration.java
@@ -0,0 +1,363 @@
+/*
+ * 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.events.externalize;
+
+import static org.springframework.core.annotation.AnnotatedElementUtils.*;
+
+import java.lang.annotation.Annotation;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.BiPredicate;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.lang.Nullable;
+import org.springframework.modulith.Externalized;
+import org.springframework.util.Assert;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class DefaultEventExternalizationConfiguration implements EventExternalizationConfiguration {
+
+ private static final Predicate DEFAULT_FILTER = it -> true;
+ private static final Function DEFAULT_ROUTER = it -> Optional.of(it)
+ .flatMap(byExternalizedAnnotations())
+ .orElseGet(() -> byFullyQualifiedTypeName().apply(it));
+
+ private final Predicate filter;
+ private final Function mapper;
+ private final Function router;
+
+ static {
+
+ }
+
+ /**
+ * Creates a new {@link DefaultEventExternalizationConfiguration}
+ *
+ * @param filter must not be {@literal null}.
+ * @param mapper must not be {@literal null}.
+ * @param router must not be {@literal null}.
+ */
+ DefaultEventExternalizationConfiguration(Predicate filter, Function mapper,
+ Function router) {
+
+ this.filter = filter;
+ this.mapper = mapper;
+ this.router = router;
+ }
+
+ /**
+ * Creates a default {@link DefaultEventExternalizationConfiguration} with the following characteristics:
+ *
+ * Only events that reside in any application auto-configuration package and are annotated with
+ * {@link Externalized} will be selected for externalization.
+ * Routing information is discovered from the {@link Externalized} annotation and, if missing, will default to the
+ * application-local name of the event type. In other words, an event type {@code com.acme.myapp.mymodule.MyEvent}
+ * will result in a route {@code mymodule.MyEvent}.
+ *
+ *
+ * @param packages must not be {@literal null} or empty.
+ * @return will never be {@literal null}.
+ * @see Externalized
+ */
+ public static DefaultEventExternalizationConfiguration defaults(Collection packages) {
+
+ Assert.notEmpty(packages, "Packages must not be null or empty!");
+
+ Function router = it -> Optional.of(it)
+ .flatMap(byExternalizedAnnotations())
+ .or(() -> byApplicationLocalName(packages).apply(it))
+ .orElseGet(() -> byFullyQualifiedTypeName().apply(it));
+
+ return DefaultEventExternalizationConfiguration.builder()
+ .selectByPackagesAndAnnotation(packages, AnnotationTargetLookup::hasExternalizedAnnotation)
+ .route(router);
+ }
+
+ public static Selector builder() {
+ return new Selector();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.foo.EventExternalizerFilter#supports(java.lang.Object)
+ */
+ @Override
+ public boolean supports(Object event) {
+ return filter.test(event);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.foo.EventExternalizationConfiguration#map(java.lang.Object)
+ */
+ @Override
+ public Object map(Object event) {
+ return mapper.apply(event);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.foo.EventExternalizerFilter#determineTarget(java.lang.Object)
+ */
+ @Override
+ public RoutingTarget determineTarget(Object event) {
+ return new RoutingTarget(router.apply(event));
+ }
+
+ public static Function> byApplicationLocalName(Collection packages) {
+
+ return toEventType().andThen(type -> packages.stream()
+ .filter(it -> type.getPackageName().startsWith(it))
+ .map(it -> type.getName().substring(it.length() + 1))
+ .findFirst());
+ }
+
+ /**
+ * Returns a {@link Function} that looks up the target from the supported externalization annotations. The currently
+ * supported annotations are:
+ *
+ * Spring Modulith's {@link Externalized}
+ * jMolecules {@link org.jmolecules.event.annotation.Externalized} (if present on the classpath)
+ *
+ *
+ * @return will never be {@literal null}.
+ */
+ public static Function> byExternalizedAnnotations() {
+ return event -> AnnotationTargetLookup.of(event.getClass()).get();
+ }
+
+ /**
+ * Returns a {@link Function} that looks up the target from the fully-qualified type name of the event's type.
+ *
+ * @return will never be {@literal null}.
+ */
+ public static Function byFullyQualifiedTypeName() {
+ return toEventType().andThen(Class::getName);
+ }
+
+ private static Function> toEventType() {
+ return event -> event.getClass();
+ }
+
+ public static class Selector {
+
+ private final @Nullable Predicate predicate;
+
+ Selector() {
+ this.predicate = DEFAULT_FILTER;
+ }
+
+ /**
+ * Selects events to externalize by applying the given {@link Predicate}.
+ *
+ * @param predicate will never be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public Router select(Predicate predicate) {
+ return new Router(predicate);
+ }
+
+ /**
+ * Selects events to externalize by the given base package and all sub-packages.
+ *
+ * @param basePackage must not be {@literal null} or empty.
+ * @return will never be {@literal null}.
+ */
+ public Router selectByPackage(String basePackage) {
+
+ Assert.hasText(basePackage, "Base package must not be null or empty!");
+
+ return select(it -> it.getClass().getPackageName().startsWith(basePackage));
+ }
+
+ /**
+ * Selects events to externalize by the package of the given type and all sub-packages.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public Router selectByPackage(Class> type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ return selectByPackage(type.getPackageName());
+ }
+
+ /**
+ * Selects events to externalize by the given base packages (and their sub-packages) that
+ *
+ * @param basePackages must not be {@literal null} or empty.
+ * @param filter must not be {@literal null}.
+ * @return
+ */
+ public final Router selectByPackagesAndAnnotation(Collection basePackages,
+ Predicate filter) {
+
+ Assert.notEmpty(basePackages, "Base packages must not be null or empty!");
+ Assert.notNull(filter, "Filter must not be null!");
+
+ BiPredicate matcher = (event, reference) -> event.getClass().getPackageName()
+ .startsWith(reference);
+ Predicate residesInPackage = it -> basePackages.stream().anyMatch(inner -> matcher.test(it, inner));
+
+ return select(residesInPackage.and(filter));
+ }
+
+ /**
+ * Selects events to be externalized by inspecting the event type for the given annotation.
+ *
+ * @param type the annotation type to find on the event type, must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public Router selectByAnnotation(Class extends Annotation> type) {
+
+ Assert.notNull(type, "Annotation type must not be null!");
+
+ return select(it -> AnnotatedElementUtils.hasAnnotation(it.getClass(), type));
+ }
+
+ /**
+ * Selects events to be externalized by type.
+ *
+ * @param type the type that events to be externalized need to implement, must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public Router selectByType(Class> type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ return select(type::isInstance);
+ }
+
+ /**
+ * Selects events to be externalized by the given {@link Predicate}.
+ *
+ * @param predicate must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public Router selectByType(Predicate> predicate) {
+
+ Assert.notNull(predicate, "Predicate must not be null!");
+
+ return select(it -> predicate.test(it.getClass()));
+ }
+
+ public EventExternalizationConfiguration selectAndRoute(Class annotationType,
+ Function router) {
+
+ Function extractor = it -> findAnnotation(it, annotationType);
+
+ return selectByAnnotation(annotationType).route(it -> extractor.andThen(router).apply(it));
+ }
+
+ public EventExternalizationConfiguration selectAndRoute(Class annotationType,
+ BiFunction router) {
+
+ return selectByAnnotation(annotationType)
+ .route(it -> router.apply(it, findAnnotation(it, annotationType)));
+ }
+
+ private static T findAnnotation(Object event, Class annotationType) {
+ return findMergedAnnotation(event.getClass(), annotationType);
+ }
+ }
+
+ public static class Router {
+
+ private final Predicate filter;
+ private final Function mapper;
+ private final @Nullable Function router;
+
+ Router(Predicate filter, Function mapper, Function router) {
+
+ this.filter = filter;
+ this.mapper = mapper;
+ this.router = router;
+ }
+
+ Router(Predicate filter) {
+ this(filter, Function.identity(), DEFAULT_ROUTER);
+ }
+
+ public Router mapping(Function mapper) {
+ return new Router(filter, mapper, router);
+ }
+
+ public Router mapping(Class type, Function mapper) {
+
+ Function combined = it -> {
+ return type.isInstance(it)
+ ? mapper.apply(type.cast(it))
+ : it;
+ };
+
+ return new Router(filter, this.mapper.compose(combined), router);
+ }
+
+ public Router routeMapped() {
+ return new Router(filter, mapper, router.compose(mapper));
+ }
+
+ public DefaultEventExternalizationConfiguration route(Function router) {
+ return new Router(filter, mapper, router).build();
+ }
+
+ /**
+ * Routes by extracting an {@link Optional} route from the event. If {@link Optional#empty()} is returned by the
+ * function, we will fall back to the currently configured routing.
+ *
+ * @param router must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public DefaultEventExternalizationConfiguration routeOptional(Function> router) {
+
+ Assert.notNull(router, "Router must not be null!");
+
+ Function foo = it -> router.apply(it).orElseGet(() -> this.router.apply(it));
+
+ return new Router(filter, mapper, foo).build();
+ }
+
+ /**
+ * Routes by extracting an {@link Optional} route from the event type. If {@link Optional#empty()} is returned by
+ * the function, we will fall back to the currently configured routing.
+ *
+ * @param router must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public DefaultEventExternalizationConfiguration routeOptionalByType(
+ Function, Optional> router) {
+ return routeOptional(it -> router.apply(it.getClass()));
+ }
+
+ public Router routeByType(Function, String> router) {
+ return new Router(filter, mapper, it -> router.apply(it.getClass()));
+ }
+
+ public DefaultEventExternalizationConfiguration routeByTypeName() {
+ return new Router(filter, mapper, DEFAULT_ROUTER).build();
+ }
+
+ public DefaultEventExternalizationConfiguration build() {
+ return new DefaultEventExternalizationConfiguration(filter, mapper, router);
+ }
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/EventExternalizationConfiguration.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/EventExternalizationConfiguration.java
new file mode 100644
index 000000000..3c79f8ca2
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/externalize/EventExternalizationConfiguration.java
@@ -0,0 +1,42 @@
+/*
+ * 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.events.externalize;
+
+import org.springframework.modulith.events.externalize.DefaultEventExternalizationConfiguration.Selector;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public interface EventExternalizationConfiguration {
+
+ boolean supports(Object event);
+
+ /**
+ * Map the event to be externalized before
+ *
+ * @param event
+ * @return
+ */
+ Object map(Object event);
+
+ RoutingTarget determineTarget(Object event);
+
+ public static Selector externalizing() {
+ return new Selector();
+ }
+
+ record RoutingTarget(String value) {}
+}
diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/AnnotationTargetLookupUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/AnnotationTargetLookupUnitTests.java
new file mode 100644
index 000000000..d8592e94b
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/AnnotationTargetLookupUnitTests.java
@@ -0,0 +1,125 @@
+/*
+ * 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.events.externalize;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.jmolecules.event.annotation.Externalized;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * Unit tests for {@link AnnotationTargetLookup}.
+ *
+ * @author Oliver Drotbohm
+ */
+class AnnotationTargetLookupUnitTests {
+
+ @TestFactory // GH-248
+ Stream detectesModulithExternalizedTarget() {
+
+ var tests = Stream.of(
+ new $(Unannotated.class, null),
+ new $(WithJMoleculesExternalizedValue.class, "jMoleculesTarget"),
+ new $(WithJMoleculesExternalizedTarget.class, "jMoleculesTarget"),
+ new $(WithModulithExternalizedValue.class, "modulithTarget"),
+ new $(WithModulithExternalizedTarget.class, "modulithTarget"));
+
+ return DynamicTest.stream(tests, $::verify);
+ }
+
+ @Test // GH-248
+ void cachesLookups() {
+
+ wipeCache();
+
+ AnnotationTargetLookup.of(Unannotated.class);
+ assertCacheEntries(1);
+
+ AnnotationTargetLookup.of(WithJMoleculesExternalizedValue.class);
+ assertCacheEntries(2);
+
+ AnnotationTargetLookup.of(Unannotated.class);
+ assertCacheEntries(2);
+ }
+
+ private static void wipeCache() {
+ ReflectionTestUtils.setField(AnnotationTargetLookup.class, "LOOKUPS", new HashMap<>());
+ }
+
+ private static void assertCacheEntries(int size) {
+
+ Map, ?> lookups = (Map, ?>) ReflectionTestUtils.getField(AnnotationTargetLookup.class, "LOOKUPS");
+
+ assertThat(lookups).hasSize(size);
+ }
+
+ class Unannotated {}
+
+ @Externalized("jMoleculesTarget")
+ class WithJMoleculesExternalizedValue {}
+
+ @Externalized(target = "jMoleculesTarget")
+ class WithJMoleculesExternalizedTarget {}
+
+ @org.springframework.modulith.Externalized("modulithTarget")
+ class WithModulithExternalizedValue {}
+
+ @org.springframework.modulith.Externalized(target = "modulithTarget")
+ class WithModulithExternalizedTarget {}
+
+ record $(Class> type, String target) implements Named<$> {
+
+ /*
+ * (non-Javadoc)
+ * @see org.junit.jupiter.api.Named#getName()
+ */
+ @Override
+ public String getName() {
+ return target == null
+ ? "%s does not carry target".formatted(type.getSimpleName())
+ : "%s targets %s".formatted(type.getSimpleName(), target);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.junit.jupiter.api.Named#getPayload()
+ */
+ @Override
+ public $ getPayload() {
+ return this;
+ }
+
+ public void verify() {
+
+ var lookup = AnnotationTargetLookup.of(type).get();
+
+ if (target == null) {
+ assertThat(lookup).isEmpty();
+ } else {
+ assertThat(lookup).hasValue(target);
+ }
+ }
+
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfigurationUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfigurationUnitTests.java
new file mode 100644
index 000000000..9b232aded
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/externalize/DefaultEventExternalizationConfigurationUnitTests.java
@@ -0,0 +1,96 @@
+/*
+ * 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.events.externalize;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration.RoutingTarget;
+
+/**
+ * Unit tests for {@link DefaultEventExternalizationConfiguration}.
+ *
+ * @author Oliver Drotbohm
+ */
+class DefaultEventExternalizationConfigurationUnitTests {
+
+ @Test // GH-248
+ void filtersEventByAnnotation() {
+
+ var filter = EventExternalizationConfiguration.externalizing()
+ .selectByAnnotation(Externalized.class)
+ .build();
+
+ var event = new SampleEvent();
+
+ assertThat(filter.supports(event)).isTrue();
+ assertThat(filter.supports(new Object())).isFalse();
+ assertThat(filter.determineTarget(event))
+ .isEqualTo(new RoutingTarget(SampleEvent.class.getName()));
+ }
+
+ @Test // GH-248
+ void routesByAnnotationAttribute() {
+
+ var filter = EventExternalizationConfiguration.externalizing()
+ .selectAndRoute(Externalized.class, Externalized::value);
+
+ var event = new SampleEvent();
+
+ assertThat(filter.supports(event)).isTrue();
+ assertThat(filter.determineTarget(event)).isEqualTo(new RoutingTarget("target"));
+ }
+
+ @Test // GH-248
+ void mapsSourceEventBeforeSerializing() {
+
+ var configuration = EventExternalizationConfiguration.externalizing()
+ .select(__ -> true)
+ .mapping(SampleEvent.class, it -> "foo")
+ .mapping(AnotherSampleEvent.class, it -> "bar")
+ .build();
+
+ assertThat(configuration.map(new SampleEvent())).isEqualTo("foo");
+ assertThat(configuration.map(new AnotherSampleEvent())).isEqualTo("bar");
+ assertThat(configuration.map(4711L)).isEqualTo(4711L);
+ }
+
+ @Test // GH-248
+ void setsUpMappedRouting() {
+
+ var configuration = EventExternalizationConfiguration.externalizing()
+ .select(__ -> true)
+ .mapping(SampleEvent.class, it -> "foo")
+ .routeMapped()
+ .build();
+
+ assertThat(configuration.determineTarget(new SampleEvent()))
+ .isEqualTo(new RoutingTarget(String.class.getName()));
+ }
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Externalized {
+ String value() default "";
+ }
+
+ @Externalized("target")
+ static class SampleEvent {}
+
+ static class AnotherSampleEvent {}
+}
diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml b/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml
new file mode 100644
index 000000000..2646298a2
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-api/src/test/resources/logback.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ %d %5p %40.40c:%4L - %m%n
+
+
+
+
+
+
+
+
diff --git a/spring-modulith-events/spring-modulith-events-core/pom.xml b/spring-modulith-events/spring-modulith-events-core/pom.xml
index 3b651a982..55417670e 100644
--- a/spring-modulith-events/spring-modulith-events-core/pom.xml
+++ b/spring-modulith-events/spring-modulith-events-core/pom.xml
@@ -18,6 +18,18 @@
+
+ org.springframework.modulith
+ spring-modulith-api
+ ${project.version}
+
+
+
+ org.springframework.modulith
+ spring-modulith-events-api
+ ${project.version}
+
+
org.springframework
spring-context
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/ConditionalEventListener.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/ConditionalEventListener.java
new file mode 100644
index 000000000..61ec79fac
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/ConditionalEventListener.java
@@ -0,0 +1,24 @@
+/*
+ * 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.events;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public interface ConditionalEventListener {
+
+ boolean supports(Object event);
+}
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/EventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/EventExternalizer.java
new file mode 100644
index 000000000..aa3ff4f23
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/EventExternalizer.java
@@ -0,0 +1,24 @@
+/*
+ * 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.events;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public interface EventExternalizer {
+
+ void externalize(Object event);
+}
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java
new file mode 100644
index 000000000..7ca9809ba
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java
@@ -0,0 +1,146 @@
+/*
+ * 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.events.config;
+
+import java.lang.reflect.Method;
+
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Role;
+import org.springframework.context.event.EventListenerFactory;
+import org.springframework.core.Ordered;
+import org.springframework.modulith.events.ConditionalEventListener;
+import org.springframework.modulith.events.externalize.DefaultEventExternalizationConfiguration;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration;
+import org.springframework.modulith.events.support.PersistentApplicationEventMulticaster;
+import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter;
+import org.springframework.transaction.event.TransactionalEventListenerFactory;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@AutoConfiguration
+@AutoConfigureAfter(EventPublicationConfiguration.class)
+public class EventExternalizationAutoConfiguration {
+
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ static EventListenerFactory filteringEventListenerFactory(EventExternalizationConfiguration config) {
+ return new ConditionalTransactionalEventListenerFactory(config);
+ }
+
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ @ConditionalOnMissingBean
+ static DefaultEventExternalizationConfiguration eventExternalizationConfiguration(BeanFactory factory) {
+
+ var packages = AutoConfigurationPackages.get(factory);
+
+ return DefaultEventExternalizationConfiguration.defaults(packages);
+ }
+
+ /**
+ * A custom {@link EventListenerFactory} to create {@link ConditionalTransactionalApplicationListenerMethodAdapter}
+ * instances.
+ *
+ * @author Oliver Drotbohm
+ */
+ private static final class ConditionalTransactionalEventListenerFactory
+ extends TransactionalEventListenerFactory implements Ordered {
+
+ private final EventExternalizationConfiguration config;
+
+ /**
+ * Creates a new {@link ConditionalTransactionalEventListenerFactory} for thegiven
+ * {@link EventExternalizationConfiguration}.
+ *
+ * @param config must not be {@literal null}.
+ */
+ private ConditionalTransactionalEventListenerFactory(EventExternalizationConfiguration config) {
+ this.config = config;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.transaction.event.TransactionalEventListenerFactory#supportsMethod(java.lang.reflect.Method)
+ */
+ @Override
+ public boolean supportsMethod(Method method) {
+ return super.supportsMethod(method)
+ && ConditionalEventListener.class.isAssignableFrom(method.getDeclaringClass());
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.transaction.event.TransactionalEventListenerFactory#createApplicationListener(java.lang.String, java.lang.Class, java.lang.reflect.Method)
+ */
+ @Override
+ public ApplicationListener> createApplicationListener(String beanName, Class> type, Method method) {
+ return new ConditionalTransactionalApplicationListenerMethodAdapter(beanName, type, method, config);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.transaction.event.TransactionalEventListenerFactory#getOrder()
+ */
+ @Override
+ public int getOrder() {
+ return 25;
+ }
+ }
+
+ /**
+ * A custom {@link TransactionalApplicationListenerMethodAdapter} that also implements
+ * {@link ConditionalEventListener} so that the adapter can be filtered out based on the event to be published.
+ *
+ * @author Oliver Drotbohm
+ * @see ConditionalEventListener
+ * @see PersistentApplicationEventMulticaster
+ */
+ private static class ConditionalTransactionalApplicationListenerMethodAdapter
+ extends TransactionalApplicationListenerMethodAdapter
+ implements ConditionalEventListener {
+
+ private final EventExternalizationConfiguration configuration;
+
+ /**
+ * @param beanName
+ * @param targetClass
+ * @param method
+ * @param configuration
+ */
+ ConditionalTransactionalApplicationListenerMethodAdapter(String beanName, Class> targetClass, Method method,
+ EventExternalizationConfiguration configuration) {
+ super(beanName, targetClass, method);
+ this.configuration = configuration;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.ConditionalEventListener#supports(java.lang.Object)
+ */
+ @Override
+ public boolean supports(Object event) {
+ return configuration.supports(event);
+ }
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java
index 80bea6a2a..31ff54347 100644
--- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java
@@ -53,7 +53,7 @@
*/
@Configuration(proxyBeanMethods = false)
@Import(AsyncEnablingConfiguration.class)
-class EventPublicationConfiguration {
+public class EventPublicationConfiguration {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java
new file mode 100644
index 000000000..62ef4f148
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/AbstractEventExternalizer.java
@@ -0,0 +1,82 @@
+/*
+ * 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.events.support;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.modulith.ApplicationModuleListener;
+import org.springframework.modulith.events.ConditionalEventListener;
+import org.springframework.modulith.events.EventExternalizer;
+import org.springframework.modulith.events.EventSerializer;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration.RoutingTarget;
+
+/**
+ * @author Oliver Drotbohm
+ */
+abstract class AbstractEventExternalizer implements EventExternalizer, ConditionalEventListener {
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final EventSerializer serializer;
+ private final EventExternalizationConfiguration configuration;
+
+ /**
+ * @param serializer
+ * @param configuration
+ */
+ protected AbstractEventExternalizer(EventSerializer serializer, EventExternalizationConfiguration configuration) {
+ this.serializer = serializer;
+ this.configuration = configuration;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.ConditionalEventListener#supports(java.lang.Object)
+ */
+ @Override
+ public boolean supports(Object event) {
+ return configuration.supports(event);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.foo.EventExternalizer#externalize(java.lang.Object)
+ */
+ @Override
+ @ApplicationModuleListener
+ public void externalize(Object event) {
+
+ if (!configuration.supports(event)) {
+ return;
+ }
+
+ var target = configuration.determineTarget(event);
+ var mapped = configuration.map(event);
+ var serialized = serializer.serialize(mapped);
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("Externalizing event of type {} to {}, payload: {}).", event.getClass(), target,
+ serialized);
+ } else if (logger.isDebugEnabled()) {
+ logger.debug("Externalizing event of type {} to {}.", event.getClass(), target);
+ }
+
+ externalize(target, serialized);
+ }
+
+ protected abstract void externalize(RoutingTarget target, Object payload);
+}
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java
new file mode 100644
index 000000000..1b1d7acc7
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/DelegatingEventExternalizer.java
@@ -0,0 +1,63 @@
+/*
+ * 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.events.support;
+
+import java.util.function.BiConsumer;
+
+import org.springframework.modulith.ApplicationModuleListener;
+import org.springframework.modulith.events.EventSerializer;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration.RoutingTarget;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@Component
+public class DelegatingEventExternalizer extends AbstractEventExternalizer {
+
+ private final BiConsumer delegate;
+
+ /**
+ * @param serializer
+ * @param configuration
+ * @param delegate
+ */
+ public DelegatingEventExternalizer(EventSerializer serializer, EventExternalizationConfiguration configuration,
+ BiConsumer delegate) {
+ super(serializer, configuration);
+ this.delegate = delegate;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.foo.AbstractEventExternalizer#externalize(java.lang.Object)
+ */
+ @Override
+ @ApplicationModuleListener
+ public void externalize(Object event) {
+ super.externalize(event);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.externalize.AbstractEventExternalizer#externalize(org.springframework.modulith.events.externalize.EventExternalizationConfiguration.RoutingTarget, java.lang.Object)
+ */
+ @Override
+ protected void externalize(RoutingTarget target, Object payload) {
+ delegate.accept(target, payload);
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java
index 11fd8a09e..c52f0d895 100644
--- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/PersistentApplicationEventMulticaster.java
@@ -15,7 +15,6 @@
*/
package org.springframework.modulith.events.support;
-import java.lang.reflect.Field;
import java.util.Collection;
import java.util.List;
import java.util.function.Consumer;
@@ -30,11 +29,11 @@
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.context.event.AbstractApplicationEventMulticaster;
import org.springframework.context.event.ApplicationEventMulticaster;
-import org.springframework.context.event.ApplicationListenerMethodAdapter;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.env.Environment;
import org.springframework.lang.NonNull;
+import org.springframework.modulith.events.ConditionalEventListener;
import org.springframework.modulith.events.EventPublication;
import org.springframework.modulith.events.EventPublicationRegistry;
import org.springframework.modulith.events.PublicationTargetIdentifier;
@@ -111,6 +110,22 @@ public void multicastEvent(ApplicationEvent event, ResolvableType eventType) {
}
}
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.event.AbstractApplicationEventMulticaster#getApplicationListeners(org.springframework.context.ApplicationEvent, org.springframework.core.ResolvableType)
+ */
+ @Override
+ protected Collection> getApplicationListeners(ApplicationEvent event,
+ ResolvableType eventType) {
+
+ Object eventToPersist = getEventToPersist(event);
+
+ return super.getApplicationListeners(event, eventType)
+ .stream()
+ .filter(it -> matches(eventToPersist, it))
+ .toList();
+ }
+
/*
* (non-Javadoc)
* @see org.springframework.beans.factory.SmartInitializingSingleton#afterSingletonsInstantiated()
@@ -171,6 +186,13 @@ private static Object getEventToPersist(ApplicationEvent event) {
: event;
}
+ private static boolean matches(Object event, ApplicationListener> listener) {
+
+ return ConditionalEventListener.class.isInstance(listener)
+ ? ConditionalEventListener.class.cast(listener).supports(event)
+ : true;
+ }
+
/**
* First-class collection to work with transactional event listeners, i.e. {@link ApplicationListener} instances that
* implement {@link TransactionalApplicationListener}.
diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 11cddd872..5b89cf18b 100644
--- a/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/spring-modulith-events/spring-modulith-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1,2 @@
org.springframework.modulith.events.config.EventPublicationConfiguration
+org.springframework.modulith.events.config.EventExternalizationAutoConfiguration
diff --git a/spring-modulith-events/spring-modulith-events-jpa/src/main/java/org/springframework/modulith/events/jpa/JpaEventPublication.java b/spring-modulith-events/spring-modulith-events-jpa/src/main/java/org/springframework/modulith/events/jpa/JpaEventPublication.java
index dc196b1cb..0162b4f77 100644
--- a/spring-modulith-events/spring-modulith-events-jpa/src/main/java/org/springframework/modulith/events/jpa/JpaEventPublication.java
+++ b/spring-modulith-events/spring-modulith-events-jpa/src/main/java/org/springframework/modulith/events/jpa/JpaEventPublication.java
@@ -18,6 +18,7 @@
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
+import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@@ -32,6 +33,7 @@
* @author Björn Kieling
*/
@Entity
+@Table(name = "EVENT_PUBLICATION")
class JpaEventPublication {
final @Id @Column(length = 16) UUID id;
diff --git a/spring-modulith-events/spring-modulith-events-kafka/pom.xml b/spring-modulith-events/spring-modulith-events-kafka/pom.xml
new file mode 100644
index 000000000..f3e73b123
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-kafka/pom.xml
@@ -0,0 +1,37 @@
+
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-events
+ 1.1.0-SNAPSHOT
+
+
+ Spring Modulith - Events - Kafka support
+ spring-modulith-events-kafka
+
+
+ org.springframework.modulith.events.kafka
+
+
+
+
+ org.springframework.modulith
+ spring-modulith-api
+ ${project.version}
+
+
+ org.springframework.modulith
+ spring-modulith-events-core
+ ${project.version}
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+
\ No newline at end of file
diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java
new file mode 100644
index 000000000..60282e31b
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * 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.events.kafka;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.context.annotation.Bean;
+import org.springframework.kafka.core.KafkaOperations;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.modulith.events.EventSerializer;
+import org.springframework.modulith.events.config.EventPublicationConfiguration;
+import org.springframework.modulith.events.externalize.EventExternalizationConfiguration;
+import org.springframework.modulith.events.support.DelegatingEventExternalizer;
+
+/**
+ * Auto-configuration to set up a {@link DelegatingEventExternalizer} to externalize events to Kafka.
+ *
+ * @author Oliver Drotbohm
+ */
+@AutoConfiguration
+@AutoConfigureAfter(EventPublicationConfiguration.class)
+@ConditionalOnClass(KafkaTemplate.class)
+class KafkaEventExternalizerConfiguration {
+
+ private static final Logger logger = LoggerFactory.getLogger(KafkaEventExternalizerConfiguration.class);
+
+ @Bean
+ DelegatingEventExternalizer kafkaEventExternalizer(EventSerializer serializer,
+ EventExternalizationConfiguration configuration, KafkaOperations, Object> operations) {
+
+ logger.debug("Registering domain event externalization to Kafka…");
+
+ return new DelegatingEventExternalizer(serializer, configuration, (target, payload) -> {
+ operations.send(target.toString(), payload.toString());
+ });
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..4f927dd69
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.springframework.modulith.events.kafka.KafkaEventExternalizerConfiguration
diff --git a/spring-modulith-examples/pom.xml b/spring-modulith-examples/pom.xml
index 0b072cf1f..5fa0dbc59 100644
--- a/spring-modulith-examples/pom.xml
+++ b/spring-modulith-examples/pom.xml
@@ -19,6 +19,7 @@
spring-modulith-example-epr-jdbc
spring-modulith-example-epr-mongodb
spring-modulith-example-full
+ spring-modulith-example-kafka
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/pom.xml b/spring-modulith-examples/spring-modulith-example-kafka/pom.xml
new file mode 100644
index 000000000..899bbcfbc
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/pom.xml
@@ -0,0 +1,53 @@
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-examples
+ 1.1.0-SNAPSHOT
+
+
+ Spring Modulith - Examples - Kafka Example
+ spring-modulith-example-kafka
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ com.h2database
+ h2
+ runtime
+
+
+
+ org.springframework.modulith
+ spring-modulith-starter-jpa
+
+
+
+ org.springframework.modulith
+ spring-modulith-events-kafka
+ ${project.version}
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+
+
+ org.jmolecules.integrations
+ jmolecules-starter-ddd
+
+
+
+
+
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc b/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc
new file mode 100644
index 000000000..c163a7976
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/readme.adoc
@@ -0,0 +1,29 @@
+= Spring Modulith -- Kafka event externalization example
+
+This examples how domain events can automatically be externalized to Kafka.
+The two fundamentally required steps are:
+
+1. Add the `spring-modulith-events-kafka` dependency to the project (`runtime` scope is sufficient).
+2. Add the `spring-modulith-events-api` dependency to annotate the event types to be externalized automatically with `@Externalized` (see `OrderCompleted`).
+
+`TestApplication` (in `src/test/java`) declares a `KafkaOperations` instance so that we do not need an actual Kafka instance running for the sample.
+The bean declared simply triggers some log output simulating the actual interaction with Kafka.
+Running the test application using `./mvnw spring-boot:test-run` should show the following output.
+
+[source]
+----
+22:20:20.398 D - main : Registering domain event externalization to Kafka… <1>
+…
+22:20:21.267 I - main : Triggering order completion… <2>
+22:20:21.277 D - main : Registering publication of example.order.OrderCompleted for org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object). <3>
+22:20:21.325 D - task-1 : Externalizing event of type class example.order.OrderCompleted to RoutingTarget[value=order.OrderCompleted]. <4>
+22:20:21.327 I - task-1 : Sending message {"orderId":{"id":"ef3521e8-d498-4539-8745-3a1c74bbe90d"}} to RoutingTarget[value=order.OrderCompleted]. <5>
+22:20:21.376 D - task-1 : Marking publication of event example.order.OrderCompleted to listener org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object) completed. <6>
+----
+<1> On application bootstrap, the `spring-modulith-events-kafka` module registers an `ApplicationModuleListener` that will listen to domain events to be externalized.
+<2> Once started, the application's `main` method invokes a business method on the `OrderManagement` that ultimately results in the publication of an `OrderCompleted` event.
+That in turn is annotated with Spring Modulith's `@Externalized` and thus qualifies for externalization.
+<3> The event publication infrastructure detects an `@ApplicationModuleListener` interested in the event, it creates an entry in the Event Publication Registry to track the processing of the event.
+<4> The externalizing `@ApplicationModuleListener` gets triggered (note how it runs asynchronously, indicated by the `task-1` thread).
+<5> Our mock `KafkaOperations` is invoked and triggers the log message simulating the actual sending.
+<6> The Event Publication Registry eventually marks the publication completed as the sending has completed successfully.
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java
new file mode 100644
index 000000000..17dba184a
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/Application.java
@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+import example.order.Order;
+import example.order.OrderManagement;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args)
+ .getBean(OrderManagement.class)
+ .complete(new Order());
+ }
+}
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java
new file mode 100644
index 000000000..d0bc7088c
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/Order.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022-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.order;
+
+import example.order.Order.OrderIdentifier;
+import lombok.Getter;
+
+import java.util.UUID;
+
+import org.jmolecules.ddd.types.AggregateRoot;
+import org.jmolecules.ddd.types.Identifier;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class Order implements AggregateRoot {
+
+ private @Getter OrderIdentifier id = new OrderIdentifier(UUID.randomUUID());
+
+ public static record OrderIdentifier(UUID id) implements Identifier {}
+}
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java
new file mode 100644
index 000000000..7cc2ed310
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderCompleted.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022-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.order;
+
+import example.order.Order.OrderIdentifier;
+
+import org.jmolecules.event.types.DomainEvent;
+import org.springframework.modulith.Externalized;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@Externalized
+public record OrderCompleted(OrderIdentifier orderId) implements DomainEvent {}
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java
new file mode 100644
index 000000000..05d84c52c
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/OrderManagement.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022-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.order;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@Service
+@RequiredArgsConstructor
+public class OrderManagement {
+
+ private final @NonNull ApplicationEventPublisher events;
+
+ @Transactional
+ public void complete(Order order) {
+ events.publishEvent(new OrderCompleted(order.getId()));
+ }
+}
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java
new file mode 100644
index 000000000..376d865b3
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/java/example/order/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * The logical application module order implemented as a multi-package module. Internal components located in nested
+ * packages are prevented from being accessed by the {@link org.springframework.modulith.core.ApplicationModules} type.
+ *
+ * @see example.ModularityTests
+ */
+@org.springframework.lang.NonNullApi
+package example.order;
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties
new file mode 100644
index 000000000..0b9ca8d52
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/application.properties
@@ -0,0 +1 @@
+spring.jpa.show-sql=true
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml
new file mode 100644
index 000000000..029959030
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/main/resources/logback.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java b/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java
new file mode 100644
index 000000000..813c3780b
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-kafka/src/test/java/example/TestApplication.java
@@ -0,0 +1,66 @@
+/*
+ * 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;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import example.order.Order;
+import example.order.OrderManagement;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.kafka.core.KafkaOperations;
+
+/**
+ * @author Oliver Drotbohm
+ */
+@SpringBootApplication
+public class TestApplication {
+
+ private static final Logger logger = LoggerFactory.getLogger(TestApplication.class);
+
+ @Bean
+ @Primary
+ @SuppressWarnings("unchecked")
+ KafkaOperations, ?> kafkaOperations() {
+
+ var mock = mock(KafkaOperations.class);
+
+ when(mock.send(any(), any())).then(invocation -> {
+
+ logger.info("Sending message {} to {}.", invocation.getArguments()[1], invocation.getArguments()[0]);
+
+ return null;
+ });
+
+ return mock;
+ }
+
+ public static void main(String[] args) {
+
+ var orders = SpringApplication.run(TestApplication.class, args)
+ .getBean(OrderManagement.class);
+
+ logger.info("Triggering order completion…");
+
+ orders.complete(new Order());
+ }
+}