GH-248 - Support to automatically externalize events.

We now allow externalizing application events to a variety of message brokers through the addition of Spring Modulith modules for Kafka, AMQP and JMS to a user project's classpath. Which events shall be externalized and how they're supposed to be routed to the message broker can be configured through either annotations or via a configuration API declared as Spring bean.

In case Jackson is on the classpath, we also add auto-configuration to use a Boot-configured ObjectMapper instance with the corresponding message broker client APIs to properly serialize and deserialize messages to JSON.
This commit is contained in:
Oliver Drotbohm
2023-08-24 12:08:46 -07:00
parent 971a143436
commit 9b0062f354
58 changed files with 3394 additions and 45 deletions

View File

@@ -19,6 +19,7 @@
<module>spring-modulith-example-epr-jdbc</module>
<module>spring-modulith-example-epr-mongodb</module>
<module>spring-modulith-example-full</module>
<module>spring-modulith-example-kafka</module>
</modules>
<properties>

View File

@@ -0,0 +1,52 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-examples</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<name>Spring Modulith - Examples - Kafka Example</name>
<artifactId>spring-modulith-example-kafka</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- jMolecules -->
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-starter-ddd</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,29 @@
= Spring Modulith -- Kafka event externalization example
This examples how domain events can automatically be externalized to Kafka.
The two fundamentally required steps are:
1. Add the `spring-modulith-events-kafka` dependency to the project (`runtime` scope is sufficient).
2. Add the `spring-modulith-events-api` dependency to annotate the event types to be externalized automatically with `@Externalized` (see `OrderCompleted`).
`TestApplication` (in `src/test/java`) declares a `KafkaOperations` instance so that we do not need an actual Kafka instance running for the sample.
The bean declared simply triggers some log output simulating the actual interaction with Kafka.
Running the test application using `./mvnw spring-boot:test-run` should show the following output.
[source]
----
22:20:20.398 D - main : Registering domain event externalization to Kafka… <1>
22:20:21.267 I - main : Triggering order completion… <2>
22:20:21.277 D - main : Registering publication of example.order.OrderCompleted for org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object). <3>
22:20:21.325 D - task-1 : Externalizing event of type class example.order.OrderCompleted to RoutingTarget[value=order.OrderCompleted]. <4>
22:20:21.327 I - task-1 : Sending message {"orderId":{"id":"ef3521e8-d498-4539-8745-3a1c74bbe90d"}} to RoutingTarget[value=order.OrderCompleted]. <5>
22:20:21.376 D - task-1 : Marking publication of event example.order.OrderCompleted to listener org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object) completed. <6>
----
<1> On application bootstrap, the `spring-modulith-events-kafka` module registers an `ApplicationModuleListener` that will listen to domain events to be externalized.
<2> Once started, the application's `main` method invokes a business method on the `OrderManagement` that ultimately results in the publication of an `OrderCompleted` event.
That in turn is annotated with Spring Modulith's `@Externalized` and thus qualifies for externalization.
<3> The event publication infrastructure detects an `@ApplicationModuleListener` interested in the event, it creates an entry in the Event Publication Registry to track the processing of the event.
<4> The externalizing `@ApplicationModuleListener` gets triggered (note how it runs asynchronously, indicated by the `task-1` thread).
<5> Our mock `KafkaOperations` is invoked and triggers the log message simulating the actual sending.
<6> The Event Publication Registry eventually marks the publication completed as the sending has completed successfully.

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package example;
import example.order.Order;
import example.order.OrderManagement;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Oliver Drotbohm
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args)
.getBean(OrderManagement.class)
.complete(new Order());
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package example.order;
import example.order.Order.OrderIdentifier;
import lombok.Getter;
import java.util.UUID;
import org.jmolecules.ddd.types.AggregateRoot;
import org.jmolecules.ddd.types.Identifier;
/**
* @author Oliver Drotbohm
*/
public class Order implements AggregateRoot<Order, OrderIdentifier> {
private @Getter OrderIdentifier id = new OrderIdentifier(UUID.randomUUID());
public static record OrderIdentifier(UUID id) implements Identifier {}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package example.order;
import example.order.Order.OrderIdentifier;
import org.jmolecules.event.types.DomainEvent;
import org.springframework.modulith.events.Externalized;
/**
* @author Oliver Drotbohm
*/
@Externalized
public record OrderCompleted(OrderIdentifier orderId) implements DomainEvent {}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package example.order;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* @author Oliver Drotbohm
*/
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final @NonNull ApplicationEventPublisher events;
@Transactional
public void complete(Order order) {
events.publishEvent(new OrderCompleted(order.getId()));
}
}

View File

@@ -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;

View File

@@ -0,0 +1 @@
spring.jpa.show-sql=true

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="CONSOLE_LOG_PATTERN" value="%d{HH:mm:ss.SSS} %1.-1level - %8.8t : %m%n%wEx" />
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
<logger name="com.tngtech.archunit" level="WARN" />
<logger name="org.springframework.modulith" level="DEBUG" />
</configuration>

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package example;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import example.order.Order;
import example.order.OrderManagement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.kafka.core.KafkaOperations;
/**
* @author Oliver Drotbohm
*/
@SpringBootApplication
public class TestApplication {
private static final Logger logger = LoggerFactory.getLogger(TestApplication.class);
@Bean
@Primary
@SuppressWarnings("unchecked")
KafkaOperations<?, ?> kafkaOperations() {
var mock = mock(KafkaOperations.class);
when(mock.send(any(), any())).then(invocation -> {
logger.info("Sending message {} to {}.", invocation.getArguments()[1], invocation.getArguments()[0]);
return null;
});
return mock;
}
public static void main(String[] args) {
var orders = SpringApplication.run(TestApplication.class, args)
.getBean(OrderManagement.class);
logger.info("Triggering order completion…");
orders.complete(new Order());
}
}