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 + + + + +