diff --git a/spring-modulith-bom/pom.xml b/spring-modulith-bom/pom.xml
index ce581807..3715b0bb 100644
--- a/spring-modulith-bom/pom.xml
+++ b/spring-modulith-bom/pom.xml
@@ -124,6 +124,11 @@
spring-modulith-starter-mongodb
1.1.0-SNAPSHOT
+
+ org.springframework.modulith
+ spring-modulith-starter-neo4j
+ 1.1.0-SNAPSHOT
+
org.springframework.modulith
spring-modulith-starter-test
diff --git a/spring-modulith-events/pom.xml b/spring-modulith-events/pom.xml
index 26c3375a..c3d3d896 100644
--- a/spring-modulith-events/pom.xml
+++ b/spring-modulith-events/pom.xml
@@ -23,6 +23,7 @@
spring-modulith-events-jpa
spring-modulith-events-kafka
spring-modulith-events-mongodb
+ spring-modulith-events-neo4j
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/pom.xml b/spring-modulith-events/spring-modulith-events-neo4j/pom.xml
new file mode 100644
index 00000000..e3beba04
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/pom.xml
@@ -0,0 +1,58 @@
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-events
+ 1.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ Spring Modulith - Events - Neo4j-based repository
+ spring-modulith-events-neo4j
+
+
+ org.springframework.modulith.events.neo4j
+
+
+
+
+
+ ${project.groupId}
+ spring-modulith-events-core
+ ${project.version}
+
+
+
+ org.springframework.data
+ spring-data-neo4j
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-neo4j
+ test
+
+
+
+ org.testcontainers
+ neo4j
+ 1.19.0
+ test
+
+
+
+ org.testcontainers
+ junit-jupiter
+ 1.19.0
+ test
+
+
+
+
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublication.java b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublication.java
new file mode 100644
index 00000000..745325ae
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublication.java
@@ -0,0 +1,30 @@
+package org.springframework.modulith.events.neo4j;
+
+import java.time.Instant;
+import java.util.UUID;
+
+/**
+ *
+ * The event publication entity definition.
+ *
+ * @author Gerrit Meier
+ */
+public class Neo4jEventPublication {
+
+ public final UUID identifier;
+ public final Instant publicationDate;
+ public final String listenerId;
+ public final Object event;
+ public final String eventHash;
+
+ public Instant completionDate;
+
+ public Neo4jEventPublication(UUID identifier, Instant publicationDate, String listenerId, Object event, String eventHash) {
+ this.identifier = identifier;
+ this.publicationDate = publicationDate;
+ this.listenerId = listenerId;
+ this.event = event;
+ this.eventHash = eventHash;
+ }
+
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationAutoConfiguration.java
new file mode 100644
index 00000000..f29739d2
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationAutoConfiguration.java
@@ -0,0 +1,37 @@
+package org.springframework.modulith.events.neo4j;
+
+import org.neo4j.cypherdsl.core.renderer.Configuration;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.neo4j.core.Neo4jClient;
+import org.springframework.modulith.events.config.EventPublicationAutoConfiguration;
+import org.springframework.modulith.events.config.EventPublicationConfigurationExtension;
+import org.springframework.modulith.events.core.EventSerializer;
+
+/**
+ * @author Gerrit Meier
+ */
+@AutoConfiguration
+@AutoConfigureBefore(EventPublicationAutoConfiguration.class)
+public class Neo4jEventPublicationAutoConfiguration implements EventPublicationConfigurationExtension {
+
+ @Bean
+ Neo4jEventPublicationRepository neo4jEventPublicationRepository(Neo4jClient neo4jClient, Configuration cypherDslConfiguration, EventSerializer eventSerializer) {
+ return new Neo4jEventPublicationRepository(neo4jClient, cypherDslConfiguration, eventSerializer);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(Configuration.class)
+ Configuration cypherDslConfiguration() {
+ return Configuration.defaultConfig();
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = "spring.modulith.events.neo4j.event-index.enabled", havingValue = "true")
+ Neo4jIndexInitializer neo4jIndexInitializer(Neo4jClient neo4jClient) {
+ return new Neo4jIndexInitializer(neo4jClient);
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepository.java
new file mode 100644
index 00000000..787059eb
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepository.java
@@ -0,0 +1,310 @@
+package org.springframework.modulith.events.neo4j;
+
+import org.neo4j.cypherdsl.core.Cypher;
+import org.neo4j.cypherdsl.core.Node;
+import org.neo4j.cypherdsl.core.Statement;
+import org.neo4j.cypherdsl.core.renderer.Configuration;
+import org.neo4j.cypherdsl.core.renderer.Renderer;
+import org.neo4j.driver.Values;
+import org.neo4j.driver.types.TypeSystem;
+import org.springframework.data.neo4j.core.Neo4jClient;
+import org.springframework.modulith.events.core.EventPublicationRepository;
+import org.springframework.modulith.events.core.EventSerializer;
+import org.springframework.modulith.events.core.PublicationTargetIdentifier;
+import org.springframework.modulith.events.core.TargetEventPublication;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.Assert;
+import org.springframework.util.DigestUtils;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+@Transactional
+class Neo4jEventPublicationRepository implements EventPublicationRepository {
+
+ private static final String ID = "identifier";
+ private static final String EVENT_SERIALIZED = "eventSerialized";
+ private static final String EVENT_HASH = "eventHash";
+ private static final String EVENT_TYPE = "eventType";
+ private static final String LISTENER_ID = "listenerId";
+ private static final String PUBLICATION_DATE = "publicationDate";
+ private static final String COMPLETION_DATE = "completionDate";
+
+ private static final Node eventPublicationNode = Cypher.node("Neo4jEventPublication").named("neo4jEventPublication");
+
+ private final Neo4jClient neo4jClient;
+ private final Configuration cypherDslConfiguration;
+ private final EventSerializer eventSerializer;
+
+ Neo4jEventPublicationRepository(Neo4jClient neo4jClient, Configuration cypherDslConfiguration, EventSerializer eventSerializer) {
+
+ Assert.notNull(neo4jClient, "Neo4jClient must not be null!");
+ Assert.notNull(cypherDslConfiguration, "CypherDSL configuration must not be null!");
+ Assert.notNull(eventSerializer, "EventSerializer must not be null!");
+
+ this.neo4jClient = neo4jClient;
+ this.cypherDslConfiguration = cypherDslConfiguration;
+ this.eventSerializer = eventSerializer;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication)
+ */
+ @Override
+ @Transactional
+ public TargetEventPublication create(TargetEventPublication publication) {
+
+ var identifier = publication.getIdentifier();
+ var publicationDate = publication.getPublicationDate();
+ var listenerId = publication.getTargetIdentifier().getValue();
+ var event = publication.getEvent();
+ var eventType = event.getClass().getName();
+
+ var eventSerialized = (String) eventSerializer.serialize(event);
+ var eventHash = DigestUtils.md5DigestAsHex(eventSerialized.getBytes());
+
+ var createStatement = Cypher.create(eventPublicationNode)
+ .set(eventPublicationNode.property(ID).to(Cypher.parameter(ID)))
+ .set(eventPublicationNode.property(EVENT_SERIALIZED).to(Cypher.parameter(EVENT_SERIALIZED)))
+ .set(eventPublicationNode.property(EVENT_HASH).to(Cypher.parameter(EVENT_HASH)))
+ .set(eventPublicationNode.property(EVENT_TYPE).to(Cypher.parameter(EVENT_TYPE)))
+ .set(eventPublicationNode.property(LISTENER_ID).to(Cypher.parameter(LISTENER_ID)))
+ .set(eventPublicationNode.property(PUBLICATION_DATE).to(Cypher.parameter(PUBLICATION_DATE)))
+ .build();
+
+ neo4jClient.query(renderStatement(createStatement))
+ .bindAll(Map.of(
+ ID, Values.value(identifier.toString()),
+ EVENT_SERIALIZED, eventSerialized,
+ EVENT_HASH, eventHash,
+ EVENT_TYPE, eventType,
+ LISTENER_ID, listenerId,
+ PUBLICATION_DATE, Values.value(publicationDate.atOffset(ZoneOffset.UTC))
+ ))
+ .run();
+
+ return publication;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant)
+ */
+ @Override
+ @Transactional
+ public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) {
+ var eventHash = DigestUtils.md5DigestAsHex(((String) eventSerializer.serialize(event)).getBytes());
+
+ var completeStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(EVENT_HASH).eq(Cypher.parameter(EVENT_HASH)))
+ .and(eventPublicationNode.property(LISTENER_ID).eq(Cypher.parameter(LISTENER_ID)))
+ .set(eventPublicationNode.property(COMPLETION_DATE).to(Cypher.parameter(COMPLETION_DATE)))
+ .build();
+
+ neo4jClient.query(renderStatement(completeStatement))
+ .bind(eventHash).to(EVENT_HASH)
+ .bind(identifier.getValue()).to(LISTENER_ID)
+ .bind(Values.value(completionDate.atOffset(ZoneOffset.UTC))).to(COMPLETION_DATE)
+ .run();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier)
+ */
+ @Override
+ @Transactional(readOnly = true)
+ public List findIncompletePublications() {
+
+ var findIncompleteStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(COMPLETION_DATE).isNull())
+ .returning(eventPublicationNode)
+ .orderBy(eventPublicationNode.property(PUBLICATION_DATE))
+ .build();
+
+ return List.copyOf(neo4jClient.query(renderStatement(findIncompleteStatement))
+ .fetchAs(TargetEventPublication.class)
+ .mappedBy(this::mapRecordToPublication)
+ .all());
+
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List findIncompletePublicationsPublishedBefore(Instant instant) {
+ var findIncompleteStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(PUBLICATION_DATE).lt(Cypher.parameter(PUBLICATION_DATE)))
+ .and(eventPublicationNode.property(COMPLETION_DATE).isNull())
+ .returning(eventPublicationNode)
+ .orderBy(eventPublicationNode.property(PUBLICATION_DATE))
+ .build();
+
+ return List.copyOf(neo4jClient.query(renderStatement(findIncompleteStatement))
+ .bind(Values.value(instant.atOffset(ZoneOffset.UTC))).to(PUBLICATION_DATE)
+ .fetchAs(TargetEventPublication.class)
+ .mappedBy(this::mapRecordToPublication)
+ .all());
+
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Optional findIncompletePublicationsByEventAndTargetIdentifier(Object event, PublicationTargetIdentifier targetIdentifier) {
+
+ var eventHash = DigestUtils.md5DigestAsHex(((String) eventSerializer.serialize(event)).getBytes());
+ var listenerId = targetIdentifier.getValue();
+
+ var statement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(EVENT_HASH).eq(Cypher.parameter(EVENT_HASH)))
+ .and(eventPublicationNode.property(LISTENER_ID).eq(Cypher.parameter(LISTENER_ID)))
+ .and(eventPublicationNode.property(COMPLETION_DATE).isNull())
+ .returning(eventPublicationNode)
+ .build();
+
+ return neo4jClient.query(renderStatement(statement))
+ .bindAll(Map.of(EVENT_HASH, eventHash, LISTENER_ID, listenerId))
+ .fetchAs(TargetEventPublication.class)
+ .mappedBy(this::mapRecordToPublication)
+ .one();
+ }
+
+ @Override
+ @Transactional
+ public void deletePublications(List identifiers) {
+ var deleteStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(ID).in(Cypher.parameter(ID)))
+ .delete(eventPublicationNode)
+ .build();
+
+ neo4jClient.query(renderStatement(deleteStatement))
+ .bind(identifiers.stream().map(UUID::toString).toList()).to(ID)
+ .run();
+ }
+
+ @Override
+ @Transactional
+ public void deleteCompletedPublications() {
+ var deleteStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(COMPLETION_DATE).isNotNull())
+ .delete(eventPublicationNode)
+ .build();
+
+ neo4jClient.query(renderStatement(deleteStatement))
+ .run();
+ }
+
+ @Override
+ @Transactional
+ public void deleteCompletedPublicationsBefore(Instant instant) {
+ var deleteStatement = Cypher.match(eventPublicationNode)
+ .where(eventPublicationNode.property(PUBLICATION_DATE).lt(Cypher.parameter(PUBLICATION_DATE)))
+ .and(eventPublicationNode.property(COMPLETION_DATE).isNotNull())
+ .delete(eventPublicationNode)
+ .build();
+
+ neo4jClient.query(renderStatement(deleteStatement))
+ .bind(Values.value(instant.atOffset(ZoneOffset.UTC))).to(PUBLICATION_DATE)
+ .run();
+ }
+
+ private Neo4jEventPublicationAdapter mapRecordToPublication(TypeSystem typeSystem, org.neo4j.driver.Record record) {
+ var publicationNode = record.get(eventPublicationNode.getRequiredSymbolicName().getValue()).asNode();
+ var identifier = UUID.fromString(publicationNode.get(ID).asString());
+ var publicationDate = publicationNode.get(PUBLICATION_DATE).asZonedDateTime().toInstant();
+ var listenerId = publicationNode.get(LISTENER_ID).asString();
+ var eventSerialized = publicationNode.get(EVENT_SERIALIZED).asString();
+ var eventHash = publicationNode.get(EVENT_HASH).asString();
+ var eventType = publicationNode.get(EVENT_TYPE).asString();
+
+ try {
+ Object event = eventSerializer.deserialize(eventSerialized, Class.forName(eventType));
+ Neo4jEventPublication publication = new Neo4jEventPublication(identifier, publicationDate, listenerId, event, eventHash);
+
+ return new Neo4jEventPublicationAdapter(publication);
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private String renderStatement(Statement statement) {
+ return Renderer.getRenderer(cypherDslConfiguration).render(statement);
+ }
+
+
+ public static class Neo4jEventPublicationAdapter implements TargetEventPublication {
+
+ private final Neo4jEventPublication delegate;
+
+ public Neo4jEventPublicationAdapter(Neo4jEventPublication delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public UUID getIdentifier() {
+ return delegate.identifier;
+ }
+
+ @Override
+ public Object getEvent() {
+ return delegate.event;
+ }
+
+ @Override
+ public Instant getPublicationDate() {
+ return delegate.publicationDate;
+ }
+
+ @Override
+ public Optional getCompletionDate() {
+ return Optional.ofNullable(delegate.completionDate);
+ }
+
+ @Override
+ public void markCompleted(Instant instant) {
+ delegate.completionDate = instant;
+ }
+
+ @Override
+ public PublicationTargetIdentifier getTargetIdentifier() {
+ return PublicationTargetIdentifier.of(delegate.listenerId);
+ }
+
+ @Override
+ public boolean isPublicationCompleted() {
+ return delegate.completionDate != null;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#equals(java.lang.Object)
+ */
+ @Override
+ public boolean equals(Object obj) {
+
+ if (this == obj) {
+ return true;
+ }
+
+ if (!(obj instanceof Neo4jEventPublicationAdapter that)) {
+ return false;
+ }
+
+ return Objects.equals(delegate, that.delegate);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Object#hashCode()
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(delegate);
+ }
+ }
+}
\ No newline at end of file
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializer.java b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializer.java
new file mode 100644
index 00000000..6ac78da1
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializer.java
@@ -0,0 +1,23 @@
+package org.springframework.modulith.events.neo4j;
+
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.data.neo4j.core.Neo4jClient;
+
+/**
+ * @author Gerrit Meier
+ */
+public class Neo4jIndexInitializer implements InitializingBean {
+
+ private final Neo4jClient neo4jClient;
+
+ public Neo4jIndexInitializer(Neo4jClient neo4jClient) {
+ this.neo4jClient = neo4jClient;
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ neo4jClient
+ .query("CREATE INDEX eventHashIndex IF NOT EXISTS FOR (n:`Neo4jEventPublication`) ON (n.eventHash)")
+ .run();
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000..166fba22
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,10 @@
+{
+ "properties": [
+ {
+ "name": "spring.modulith.events.neo4j.event-index.enabled",
+ "type": "java.lang.boolean",
+ "description": "Whether to initialize the index on the Neo4j event publication event hash property.",
+ "defaultValue": "false"
+ }
+ ]
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000..75ac47df
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.springframework.modulith.events.neo4j.Neo4jEventPublicationAutoConfiguration
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepositoryTest.java b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepositoryTest.java
new file mode 100644
index 00000000..eb05ba37
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jEventPublicationRepositoryTest.java
@@ -0,0 +1,230 @@
+package org.springframework.modulith.events.neo4j;
+
+import lombok.Value;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.neo4j.cypherdsl.core.renderer.Dialect;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+import org.neo4j.driver.types.Node;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.modulith.events.core.EventSerializer;
+import org.springframework.modulith.events.core.PublicationTargetIdentifier;
+import org.springframework.modulith.events.core.TargetEventPublication;
+import org.springframework.modulith.testapp.TestApplication;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.util.DigestUtils;
+import org.testcontainers.containers.Neo4jContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Gerrit Meier
+ */
+@SpringJUnitConfig(Neo4jEventPublicationRepositoryTest.Config.class)
+@Testcontainers(disabledWithoutDocker = true)
+class Neo4jEventPublicationRepositoryTest {
+
+ @Container
+ private static Neo4jContainer> neo4jContainer = new Neo4jContainer<>(DockerImageName.parse("neo4j:5"))
+ .withRandomPassword();
+
+ static final PublicationTargetIdentifier TARGET_IDENTIFIER = PublicationTargetIdentifier.of("listener");
+
+ @Autowired
+ private Neo4jEventPublicationRepository repository;
+
+ @Autowired
+ private Driver driver;
+
+ @MockBean
+ private EventSerializer eventSerializer;
+
+ @BeforeEach
+ void clearDb() {
+ try (var session = driver.session()) {
+ session.run("MATCH (n) detach delete n").consume();
+ }
+ }
+
+ @Test
+ void createEventPublication() {
+ var testEvent = new TestEvent("id");
+ var eventSerialized = "{\"eventId\":\"id\"}";
+ var eventHash = DigestUtils.md5DigestAsHex(eventSerialized.getBytes());
+
+ when(eventSerializer.serialize(testEvent)).thenReturn(eventSerialized);
+ var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
+
+ try (var session = driver.session()) {
+ var result = session.run("MATCH (p:Neo4jEventPublication) return p")
+ .single();
+
+ Node neo4jEventPublicationNode = result.get("p").asNode();
+ assertThat(UUID.fromString(neo4jEventPublicationNode.get("identifier").asString())).isEqualTo(publication.getIdentifier());
+ assertThat(neo4jEventPublicationNode.get("publicationDate").asZonedDateTime().toInstant()).isEqualTo(publication.getPublicationDate());
+ assertThat(neo4jEventPublicationNode.get("listenerId").asString()).isEqualTo(publication.getTargetIdentifier().getValue());
+ assertThat(neo4jEventPublicationNode.get("completionDate").isNull()).isTrue();
+ assertThat(neo4jEventPublicationNode.get("eventSerialized").asString()).isEqualTo(eventSerialized);
+ assertThat(neo4jEventPublicationNode.get("eventHash").asString()).isEqualTo(eventHash);
+ }
+
+ }
+
+ @Test
+ void updateEventPublication() {
+ var testEvent1 = new TestEvent("id1");
+ var event1Serialized = "{\"eventId\":\"id1\"}";
+ var testEvent2 = new TestEvent("id2");
+ var event2Serialized = "{\"eventId\":\"id2\"}";
+
+ when(eventSerializer.serialize(testEvent1)).thenReturn(event1Serialized);
+ when(eventSerializer.serialize(testEvent2)).thenReturn(event2Serialized);
+ when(eventSerializer.deserialize(event2Serialized, TestEvent.class)).thenReturn(testEvent2);
+
+ var event1 = repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
+ var event2 = repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
+
+ var now = Instant.now();
+ repository.markCompleted(event1, now);
+
+ assertThat(repository.findIncompletePublications()).hasSize(1)
+ .element(0)
+ .extracting(TargetEventPublication::getEvent).isEqualTo(event2.getEvent());
+ }
+
+ @Test
+ void findInCompletePastPublications() {
+ var testEvent = new TestEvent("id");
+ var eventSerialized = "{\"eventId\":\"id\"}";
+
+ when(eventSerializer.serialize(testEvent)).thenReturn(eventSerialized);
+ when(eventSerializer.deserialize(eventSerialized, TestEvent.class)).thenReturn(testEvent);
+
+ var event = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
+
+ var newer = Instant.now().plus(1L, ChronoUnit.MINUTES);
+ var older = Instant.now().minus(1L, ChronoUnit.MINUTES);
+
+ assertThat(repository.findIncompletePublicationsPublishedBefore(newer)).hasSize(1)
+ .element(0)
+ .extracting(TargetEventPublication::getEvent).isEqualTo(event.getEvent());
+
+ assertThat(repository.findIncompletePublicationsPublishedBefore(older)).hasSize(0);
+ }
+
+ @Test
+ void findIncompleteByEventAndTargetIdentifier() {
+ var testEvent = new TestEvent("id");
+ var eventSerialized = "{\"eventId\":\"id\"}";
+
+ when(eventSerializer.serialize(testEvent)).thenReturn(eventSerialized);
+ when(eventSerializer.deserialize(eventSerialized, TestEvent.class)).thenReturn(testEvent);
+
+ var event = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
+
+ assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, event.getTargetIdentifier()))
+ .isPresent();
+ }
+
+ @Test
+ void deletePublicationById() {
+ TestEvent testEvent = new TestEvent("id");
+ var eventSerialized = "{\"eventId\":\"id\"}";
+ when(eventSerializer.serialize(testEvent)).thenReturn(eventSerialized);
+
+ var event = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
+ assertThat(repository.findIncompletePublications()).hasSize(1);
+
+ repository.deletePublications(List.of(event.getIdentifier()));
+ assertThat(repository.findIncompletePublications()).hasSize(0);
+ }
+
+ @Test
+ void deleteCompletedPublications() {
+ TestEvent testEvent1 = new TestEvent("id1");
+ var event1Serialized = "{\"eventId\":\"id1\"}";
+ TestEvent testEvent2 = new TestEvent("id2");
+ var event2Serialized = "{\"eventId\":\"id2\"}";
+
+ when(eventSerializer.serialize(testEvent1)).thenReturn(event1Serialized);
+ when(eventSerializer.serialize(testEvent2)).thenReturn(event2Serialized);
+ when(eventSerializer.deserialize(event1Serialized, TestEvent.class)).thenReturn(testEvent1);
+ when(eventSerializer.deserialize(event2Serialized, TestEvent.class)).thenReturn(testEvent2);
+
+ var event1 = repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
+
+ repository.markCompleted(event1, Instant.now());
+
+ repository.deleteCompletedPublications();
+
+ try (var session = driver.session()) {
+ var count = session.run("MATCH (n) WHERE n.completionDate is not null return count(n)").single().get("count(n)").asLong();
+ assertThat(count).isEqualTo(0);
+ }
+ }
+
+ @Test
+ void deleteCompletedPublicationsBefore() throws Exception {
+ TestEvent testEvent1 = new TestEvent("id1");
+ var event1Serialized = "{\"eventId\":\"id1\"}";
+ TestEvent testEvent2 = new TestEvent("id2");
+ var event2Serialized = "{\"eventId\":\"id2\"}";
+
+ when(eventSerializer.serialize(testEvent1)).thenReturn(event1Serialized);
+ when(eventSerializer.serialize(testEvent2)).thenReturn(event2Serialized);
+ when(eventSerializer.deserialize(event1Serialized, TestEvent.class)).thenReturn(testEvent1);
+ when(eventSerializer.deserialize(event2Serialized, TestEvent.class)).thenReturn(testEvent2);
+
+ var event1 = repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
+
+ Instant old = Instant.now();
+ repository.markCompleted(event1, old);
+ Thread.sleep(100);
+ var event2 = repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
+ repository.markCompleted(event2, Instant.now());
+
+ repository.deleteCompletedPublicationsBefore(old.plus(10, ChronoUnit.MILLIS));
+ // defensive check just to be sure
+ assertThat(repository.findIncompletePublications()).hasSize(0);
+
+ try (var session = driver.session()) {
+ var records = session.run("MATCH (n) WHERE n.completionDate is not null return n").list();
+ assertThat(records.size()).isEqualTo(1);
+ assertThat(records.get(0).get("n").asNode().get("eventSerialized").asString()).contains("id2");
+ }
+ }
+
+ @Value
+ private static final class TestEvent {
+ String eventId;
+ }
+
+ @Import({TestApplication.class})
+ @Configuration
+ static class Config {
+ @Bean
+ public Driver driver() {
+ return GraphDatabase.driver(neo4jContainer.getBoltUrl(), AuthTokens.basic("neo4j", neo4jContainer.getAdminPassword()));
+ }
+
+ @Bean
+ public org.neo4j.cypherdsl.core.renderer.Configuration cypherDslConfiguration() {
+ return org.neo4j.cypherdsl.core.renderer.Configuration.newConfig().withDialect(Dialect.NEO4J_5).build();
+ }
+ }
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializerTest.java b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializerTest.java
new file mode 100644
index 00000000..44b4b89c
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/events/neo4j/Neo4jIndexInitializerTest.java
@@ -0,0 +1,99 @@
+package org.springframework.modulith.events.neo4j;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.test.autoconfigure.data.neo4j.DataNeo4jTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.neo4j.core.Neo4jClient;
+import org.springframework.modulith.events.core.EventSerializer;
+import org.springframework.modulith.testapp.TestApplication;
+import org.springframework.test.context.ContextConfiguration;
+import org.testcontainers.containers.Neo4jContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Gerrit Meier
+ */
+public class Neo4jIndexInitializerTest {
+
+ @ImportAutoConfiguration({Neo4jEventPublicationAutoConfiguration.class, TestBase.Config.class})
+ @Testcontainers(disabledWithoutDocker = true)
+ @ContextConfiguration(classes = TestApplication.class)
+ static class TestBase {
+
+ @Container
+ private static Neo4jContainer> neo4jContainer = new Neo4jContainer<>(DockerImageName.parse("neo4j:5"))
+ .withRandomPassword();
+
+ @MockBean
+ EventSerializer eventSerializer;
+
+
+ @Configuration
+ static class Config {
+
+ @Bean
+ Driver driver() {
+ return GraphDatabase.driver(neo4jContainer.getBoltUrl(), AuthTokens.basic("neo4j", neo4jContainer.getAdminPassword()));
+ }
+ }
+ }
+
+ @Nested
+ @DataNeo4jTest(properties = "spring.modulith.events.neo4j.event-index.enabled=true")
+ class WithIndexEnabled extends TestBase {
+ @Autowired
+ Neo4jClient neo4jClient;
+
+ @Autowired
+ Optional neo4jIndexInitializer;
+
+ @Test
+ void indexInitializerBeanIsPresent() {
+ assertThat(neo4jIndexInitializer).isPresent();
+ }
+
+ @Test
+ void indexWasCreated() {
+ assertThat(neo4jClient.query("SHOW INDEX YIELD name")
+ .fetchAs(String.class)
+ .all()).contains("eventHashIndex");
+ }
+ }
+
+ @Nested
+ @DataNeo4jTest(properties = "spring.modulith.events.neo4j.event-index.enabled=false")
+ class WithoutIndexEnabled extends TestBase {
+ @Autowired
+ Neo4jClient neo4jClient;
+
+ @Autowired
+ Optional neo4jIndexInitializer;
+
+ @Test
+ void indexInitializerBeanIsNotPresent() {
+ assertThat(neo4jIndexInitializer).isEmpty();
+ }
+
+ @Test
+ void indexWasNotCreated() {
+ assertThat(neo4jClient.query("SHOW INDEX YIELD name")
+ .fetchAs(String.class)
+ .all()).doesNotContain("eventHashIndex");
+ }
+ }
+
+}
diff --git a/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/testapp/TestApplication.java b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/testapp/TestApplication.java
new file mode 100644
index 00000000..ba5fbad8
--- /dev/null
+++ b/spring-modulith-events/spring-modulith-events-neo4j/src/test/java/org/springframework/modulith/testapp/TestApplication.java
@@ -0,0 +1,10 @@
+package org.springframework.modulith.testapp;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Gerrit Meier
+ */
+@SpringBootApplication
+public class TestApplication {
+}
diff --git a/spring-modulith-examples/pom.xml b/spring-modulith-examples/pom.xml
index e9b9eded..bbc693f7 100644
--- a/spring-modulith-examples/pom.xml
+++ b/spring-modulith-examples/pom.xml
@@ -18,6 +18,7 @@
spring-modulith-example-epr-jdbc
spring-modulith-example-epr-mongodb
+ spring-modulith-example-epr-neo4j
spring-modulith-example-full
spring-modulith-example-kafka
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.jar b/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.jar
new file mode 100644
index 00000000..bf82ff01
Binary files /dev/null and b/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.jar differ
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.properties b/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 00000000..6caf4740
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw b/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw
new file mode 100755
index 00000000..b7f06462
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw
@@ -0,0 +1,287 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# http://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.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.1.1
+#
+# Required ENV vars:
+# ------------------
+# JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+# MAVEN_OPTS - parameters passed to the Java VM when running Maven
+# e.g. to debug Maven itself, use
+# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+ if [ -f /usr/local/etc/mavenrc ] ; then
+ . /usr/local/etc/mavenrc
+ fi
+
+ if [ -f /etc/mavenrc ] ; then
+ . /etc/mavenrc
+ fi
+
+ if [ -f "$HOME/.mavenrc" ] ; then
+ . "$HOME/.mavenrc"
+ fi
+
+fi
+
+# OS specific support. $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "`uname`" in
+ CYGWIN*) cygwin=true ;;
+ MINGW*) mingw=true;;
+ Darwin*) darwin=true
+ # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+ # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+ if [ -z "$JAVA_HOME" ]; then
+ if [ -x "/usr/libexec/java_home" ]; then
+ JAVA_HOME="`/usr/libexec/java_home`"; export JAVA_HOME
+ else
+ JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
+ fi
+ fi
+ ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+ if [ -r /etc/gentoo-release ] ; then
+ JAVA_HOME=`java-config --jre-home`
+ fi
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+ javaExecutable="`which javac`"
+ if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
+ # readlink(1) is not available as standard on Solaris 10.
+ readLink=`which readlink`
+ if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
+ if $darwin ; then
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
+ else
+ javaExecutable="`readlink -f \"$javaExecutable\"`"
+ fi
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaHome=`expr "$javaHome" : '\(.*\)/bin'`
+ JAVA_HOME="$javaHome"
+ export JAVA_HOME
+ fi
+ fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+ if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ else
+ JAVACMD="`\\unset -f command; \\command -v java`"
+ fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+ echo "Error: JAVA_HOME is not defined correctly." >&2
+ echo " We cannot execute $JAVACMD" >&2
+ exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+ echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+ if [ -z "$1" ]
+ then
+ echo "Path not specified to find_maven_basedir"
+ return 1
+ fi
+
+ basedir="$1"
+ wdir="$1"
+ while [ "$wdir" != '/' ] ; do
+ if [ -d "$wdir"/.mvn ] ; then
+ basedir=$wdir
+ break
+ fi
+ # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+ if [ -d "${wdir}" ]; then
+ wdir=`cd "$wdir/.."; pwd`
+ fi
+ # end of workaround
+ done
+ printf '%s' "$(cd "$basedir"; pwd)"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+ if [ -f "$1" ]; then
+ echo "$(tr -s '\n' ' ' < "$1")"
+ fi
+}
+
+BASE_DIR=$(find_maven_basedir "$(dirname $0)")
+if [ -z "$BASE_DIR" ]; then
+ exit 1;
+fi
+
+MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
+if [ "$MVNW_VERBOSE" = true ]; then
+ echo $MAVEN_PROJECTBASEDIR
+fi
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found .mvn/wrapper/maven-wrapper.jar"
+ fi
+else
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
+ fi
+ if [ -n "$MVNW_REPOURL" ]; then
+ wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
+ else
+ wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
+ fi
+ while IFS="=" read key value; do
+ case "$key" in (wrapperUrl) wrapperUrl="$value"; break ;;
+ esac
+ done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Downloading from: $wrapperUrl"
+ fi
+ wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
+ if $cygwin; then
+ wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
+ fi
+
+ if command -v wget > /dev/null; then
+ QUIET="--quiet"
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found wget ... using wget"
+ QUIET=""
+ fi
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ wget $QUIET "$wrapperUrl" -O "$wrapperJarPath"
+ else
+ wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath"
+ fi
+ [ $? -eq 0 ] || rm -f "$wrapperJarPath"
+ elif command -v curl > /dev/null; then
+ QUIET="--silent"
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found curl ... using curl"
+ QUIET=""
+ fi
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L
+ else
+ curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L
+ fi
+ [ $? -eq 0 ] || rm -f "$wrapperJarPath"
+ else
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Falling back to using Java to download"
+ fi
+ javaSource="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
+ javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class"
+ # For Cygwin, switch paths to Windows format before running javac
+ if $cygwin; then
+ javaSource=`cygpath --path --windows "$javaSource"`
+ javaClass=`cygpath --path --windows "$javaClass"`
+ fi
+ if [ -e "$javaSource" ]; then
+ if [ ! -e "$javaClass" ]; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo " - Compiling MavenWrapperDownloader.java ..."
+ fi
+ # Compiling the Java class
+ ("$JAVA_HOME/bin/javac" "$javaSource")
+ fi
+ if [ -e "$javaClass" ]; then
+ # Running the downloader
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo " - Running MavenWrapperDownloader.java ..."
+ fi
+ ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
+ fi
+ fi
+ fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
+ [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+ MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+ $MAVEN_OPTS \
+ $MAVEN_DEBUG_OPTS \
+ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+ "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw.cmd b/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw.cmd
new file mode 100644
index 00000000..474c9d6b
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/mvnw.cmd
@@ -0,0 +1,187 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.1.1
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
+if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
+
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+ IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Found %WRAPPER_JAR%
+ )
+) else (
+ if not "%MVNW_REPOURL%" == "" (
+ SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
+ )
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Couldn't find %WRAPPER_JAR%, downloading it ...
+ echo Downloading from: %WRAPPER_URL%
+ )
+
+ powershell -Command "&{"^
+ "$webclient = new-object System.Net.WebClient;"^
+ "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+ "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+ "}"^
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
+ "}"
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Finished downloading %WRAPPER_JAR%
+ )
+)
+@REM End of extension
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% ^
+ %JVM_CONFIG_MAVEN_PROPS% ^
+ %MAVEN_OPTS% ^
+ %MAVEN_DEBUG_OPTS% ^
+ -classpath %WRAPPER_JAR% ^
+ "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
+ %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
+if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%"=="on" pause
+
+if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
+
+cmd /C exit /B %ERROR_CODE%
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/pom.xml b/spring-modulith-examples/spring-modulith-example-epr-neo4j/pom.xml
new file mode 100644
index 00000000..ef8f3d68
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/pom.xml
@@ -0,0 +1,56 @@
+
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-examples
+ 1.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ spring-modulith-example-epr-neo4j
+ Spring Modulith - Examples - EPR Neo4j Example
+
+
+
+
+ org.springframework.modulith
+ spring-modulith-starter-neo4j
+
+
+
+
+
+ org.jmolecules
+ jmolecules-events
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-neo4j
+
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+
+ org.testcontainers
+ neo4j
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+
+
+
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/asciidoc/index.adoc b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/asciidoc/index.adoc
new file mode 100644
index 00000000..bab583d6
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/asciidoc/index.adoc
@@ -0,0 +1,16 @@
+= Spring Modulith Example Documentation
+:modulith-docs: ../../../target/spring-modulith-docs
+
+== Overview
+
+plantuml::{modulith-docs}/components.puml[format="svg"]
+
+== Inventory
+
+plantuml::{modulith-docs}/module-inventory.puml[format="svg"]
+include::{modulith-docs}/module-inventory.adoc[]
+
+== Orders
+
+plantuml::{modulith-docs}/module-order.puml[format="svg"]
+include::{modulith-docs}/module-order.adoc[]
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/Application.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/Application.java
new file mode 100644
index 00000000..648c04dc
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/Application.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Spring Modulith example application
+ *
+ * @author Oliver Drotbohm
+ */
+@SpringBootApplication
+public class Application {
+
+ public static void main(String... args) {}
+}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryManagement.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryManagement.java
new file mode 100644
index 00000000..52d96746
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryManagement.java
@@ -0,0 +1,53 @@
+/*
+ * 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.inventory;
+
+import example.order.OrderCompleted;
+import lombok.RequiredArgsConstructor;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.modulith.ApplicationModuleListener;
+import org.springframework.stereotype.Service;
+
+/**
+ * A Spring {@link Service} exposed by the inventory module.
+ *
+ * @author Oliver Drotbohm
+ */
+@Service
+@RequiredArgsConstructor
+class InventoryManagement {
+
+ private static final Logger LOG = LoggerFactory.getLogger(InventoryManagement.class);
+
+ private final ApplicationEventPublisher events;
+
+ @ApplicationModuleListener
+ void on(OrderCompleted event) throws InterruptedException {
+
+ var orderId = event.orderId();
+
+ LOG.info("Received order completion for {}.", orderId);
+
+ // Simulate busy work
+ Thread.sleep(1000);
+ events.publishEvent(new InventoryUpdated(orderId));
+
+ LOG.info("Finished order completion for {}.", orderId);
+ }
+}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryUpdated.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryUpdated.java
new file mode 100644
index 00000000..b4c7c29c
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/InventoryUpdated.java
@@ -0,0 +1,23 @@
+/*
+ * 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.inventory;
+
+import java.util.UUID;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public record InventoryUpdated(UUID orderId) {}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/package-info.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/package-info.java
new file mode 100644
index 00000000..bf3e64df
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/inventory/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * The logical application module inventory implemented as a single-package module. Allows to hide application
+ * components inside the module by using package scoped types.
+ *
+ * @see example.inventory.InventoryInternal
+ */
+@org.springframework.lang.NonNullApi
+package example.inventory;
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderCompleted.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderCompleted.java
new file mode 100644
index 00000000..5aa24d46
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderCompleted.java
@@ -0,0 +1,25 @@
+/*
+ * 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 java.util.UUID;
+
+import org.jmolecules.event.types.DomainEvent;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public record OrderCompleted(UUID orderId) implements DomainEvent {}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderManagement.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderManagement.java
new file mode 100644
index 00000000..a20bd0aa
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/OrderManagement.java
@@ -0,0 +1,40 @@
+/*
+ * 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 java.util.UUID;
+
+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() {
+ events.publishEvent(new OrderCompleted(UUID.randomUUID()));
+ }
+}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/package-info.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/main/java/example/order/package-info.java
new file mode 100644
index 00000000..376d865b
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/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-epr-neo4j/src/test/java/example/ApplicationIntegrationTests.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/test/java/example/ApplicationIntegrationTests.java
new file mode 100644
index 00000000..d87d775b
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/test/java/example/ApplicationIntegrationTests.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+import example.inventory.InventoryUpdated;
+import example.order.OrderManagement;
+import org.junit.jupiter.api.Test;
+import org.neo4j.cypherdsl.core.renderer.Configuration;
+import org.neo4j.cypherdsl.core.renderer.Dialect;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+import org.springframework.modulith.events.core.EventPublicationRegistry;
+import org.springframework.modulith.test.EnableScenarios;
+import org.springframework.modulith.test.Scenario;
+import org.testcontainers.containers.Neo4jContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.Collection;
+
+/**
+ * @author Oliver Drotbohm
+ * @author Gerrit Meier
+ */
+@SpringBootTest
+@EnableScenarios
+@Testcontainers(disabledWithoutDocker = true)
+class ApplicationIntegrationTests {
+
+ @TestConfiguration
+ static class MongoDbInfrastructureConfiguration {
+
+ @Bean
+ @ServiceConnection
+ Neo4jContainer> neo4jContainer() {
+ return new Neo4jContainer<>("neo4j:5").withRandomPassword();
+ }
+
+ @Bean
+ public Driver driver(Neo4jContainer> container) {
+ return GraphDatabase.driver(container.getBoltUrl(), AuthTokens.basic("neo4j", container.getAdminPassword()));
+ }
+
+ @Bean
+ public Configuration cypherDslConfiguration() {
+ return Configuration.newConfig().withDialect(Dialect.NEO4J_5).build();
+ }
+ }
+
+ @Autowired OrderManagement orders;
+ @Autowired EventPublicationRegistry registry;
+
+ @Test
+ void bootstrapsApplication(Scenario scenario) throws Exception {
+
+ scenario.stimulate(() -> orders.complete())
+ .andWaitForStateChange(() -> registry.findIncompletePublications(), Collection::isEmpty)
+ .andExpect(InventoryUpdated.class)
+ .toArrive();
+ }
+}
diff --git a/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/test/java/example/ModularityTests.java b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/test/java/example/ModularityTests.java
new file mode 100644
index 00000000..e8d261f9
--- /dev/null
+++ b/spring-modulith-examples/spring-modulith-example-epr-neo4j/src/test/java/example/ModularityTests.java
@@ -0,0 +1,40 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.modulith.core.ApplicationModules;
+import org.springframework.modulith.docs.Documenter;
+
+/**
+ * Tests to verify the modular structure and generate documentation for the modules.
+ *
+ * @author Oliver Drotbohm
+ */
+class ModularityTests {
+
+ ApplicationModules modules = ApplicationModules.of(Application.class);
+
+ @Test
+ void verifiesModularStructure() {
+ modules.verify();
+ }
+
+ @Test
+ void createModuleDocumentation() {
+ new Documenter(modules).writeDocumentation();
+ }
+}
diff --git a/spring-modulith-starters/pom.xml b/spring-modulith-starters/pom.xml
index 9cb8db6c..ccba9dbe 100644
--- a/spring-modulith-starters/pom.xml
+++ b/spring-modulith-starters/pom.xml
@@ -19,6 +19,7 @@
spring-modulith-starter-jdbc
spring-modulith-starter-jpa
spring-modulith-starter-mongodb
+ spring-modulith-starter-neo4j
spring-modulith-starter-test
diff --git a/spring-modulith-starters/spring-modulith-starter-neo4j/pom.xml b/spring-modulith-starters/spring-modulith-starter-neo4j/pom.xml
new file mode 100644
index 00000000..a51f17b1
--- /dev/null
+++ b/spring-modulith-starters/spring-modulith-starter-neo4j/pom.xml
@@ -0,0 +1,54 @@
+
+
+ 4.0.0
+
+
+ org.springframework.modulith
+ spring-modulith-starters
+ 1.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ spring-modulith-starter-neo4j
+ Spring Modulith - Starters - Starter Neo4j
+
+
+ org.springframework.modulith.starter.neo4j
+
+
+
+
+
+ org.springframework.modulith
+ spring-modulith-starter-core
+ 1.1.0-SNAPSHOT
+
+
+
+
+ org.springframework.modulith
+ spring-modulith-events-api
+ 1.1.0-SNAPSHOT
+
+
+ org.springframework.modulith
+ spring-modulith-events-core
+ 1.1.0-SNAPSHOT
+ runtime
+
+
+ org.springframework.modulith
+ spring-modulith-events-jackson
+ 1.1.0-SNAPSHOT
+ runtime
+
+
+ org.springframework.modulith
+ spring-modulith-events-neo4j
+ 1.1.0-SNAPSHOT
+ runtime
+
+
+
+
+