Customer Address JPA Service

Canonical ZenWave example of a DDD Aggregate with JPA persistence and Externalized Domain Events.

This is a canonical example of a ZenWave designed and generated project, around a DDD Aggregate persisted with JPA and externalized Domain Events published to a Kafka topic. You can find the Complete Source Code at GitHub.

What You'll Learn

  • How to model a DDD aggregate using ZDL
  • Generate REST APIs from domain models
  • Implement event-driven architecture with AsyncAPI
  • Create a complete Spring Boot microservice

We assume you have already read the Getting Started section and installed ZenWave SDK CLI and IntelliJ Plugin and are somewhat familiar with the concepts of DDD and Event-Driven Architecture.

What we will be building: A Customer Master Data Service

We will be building a Java/SpringBoot microservice exposing CRUD operations for a Customer aggregate as a REST API and publishing Domain Events to Kafka.

The Customer entity is the root of the aggregate, holding relationships to other entities:

  • an array of Address entities stored in a JSON column in the database and
  • a @OneToMany collection of PaymentMethod entities.

NOTE: In this example, the Customer aggregate represents a cluster of related entities that are persisted and managed as a single unit. However, the business logic resides in the CustomerService class rather than within the entities themselves.

This pattern is sometimes called an "Anemic Domain Model." For this particular example, the domain complexity is relatively simple and doesn't justify implementing a Rich Domain Model with business logic embedded in the aggregate entities.

REST API defined with OpenAPI

With these REST Endpoints:

https://github.com/ZenWave360/zenwave-playground/blob/main/examples/customer-address-jpa/src/main/resources/public/apis/openapi.yml

OpenAPI Endpoints

Externalized Domain Events with AsyncAPI

And these Domain Events published to a Kafka Topic:

https://github.com/ZenWave360/zenwave-playground/blob/main/examples/customer-address-jpa/src/main/resources/public/apis/asyncapi.yml

AsyncAPI Operations

Building with ZenWave Domain Model and SDK

When modeling a microservice with ZenWave SDK we usually do it using two main files:

  • A zenwave-model.zdl file containing the domain model and service definitions, we use this file to iterate and refine the domain model.
  • A zenwave-scripts.zw file containing the plugin configurations and executions, you can run each plugin individually from ZenWave Model Editor for IntelliJ to generate different aspects of the application.

The full application we are building in this example was defined in the following ZDL model:

`zenwave-model.zdl` for Customer Master Data Service (πŸ‘‡ view source)
/**
* Sample ZenWave Model Definition.
* Use zenwave-scripts.zdl to generate your code from this model definition.
*/
config {
title "ZenWave Customer JPA Example"
basePackage "io.zenwave360.example"
persistence jpa
databaseType postgresql
layout CleanHexagonalProjectLayout
// these should match the values of openapi-generator-maven-plugin
// used by the OpenAPIControllersPlugin and SpringWebTestClientPlugin
openApiApiPackage "{{basePackage}}.adapters.web"
openApiModelPackage "{{basePackage}}.adapters.web.model"
openApiModelNameSuffix DTO
}
/**
* Customer entity
*/
@aggregate
@auditing // adds auditing fields to the entity
entity Customer {
name String required maxlength(254) /** Customer name */
email String required maxlength(254)
/** Customer Addresses can be stored in a JSON column in the database. */
@json addresses Address[] minlength(1) maxlength(5) {
street String required maxlength(254)
city String required maxlength(254)
}
}
@auditing
entity PaymentMethod {
type PaymentMethodType required
cardNumber String required
}
enum PaymentMethodType { VISA(1), MASTERCARD(2) }
relationship OneToMany {
Customer{paymentMethods required maxlength(3)} to PaymentMethod{customer required}
}
// you can create 'inputs' as dtos for your service methods, or use entities directly
input CustomerSearchCriteria {
name String
email String
city String
state String
}
@rest("/customers")
service CustomerService for (Customer) {
@post
createCustomer(Customer) Customer withEvents CustomerEvent
@get("/{id}")
getCustomer(id) Customer?
@put("/{id}")
updateCustomer(id, Customer) Customer? withEvents CustomerEvent
@delete("/{id}")
deleteCustomer(id) withEvents CustomerDeletedEvent
@post("/search")
@paginated
searchCustomers(CustomerSearchCriteria) Customer[]
}
@copy(Customer)
@asyncapi({ channel: "CustomersChannel", topic: "customers" })
event CustomerEvent {
id Long required
version Integer
// all fields from Customer are copied here, but not relationships
paymentMethods PaymentMethod[]
}
@asyncapi({ channel: "CustomersChannel", topic: "customers" })
event CustomerDeletedEvent {
id Long required
}
πŸ”—Navigate to source
`zenwave-scripts.zw` for Customer Master Data Service (πŸ‘‡ view source)
config {
zdlFile "zenwave-model.zdl"
plugins {
ZDLToOpenAPIPlugin {
idType integer
idTypeFormat int64
targetFile "src/main/resources/public/apis/openapi.yml"
}
ZDLToAsyncAPIPlugin {
asyncapiVersion v3
schemaFormat avro
avroPackage "io.zenwave360.example.core.outbound.events.dtos"
idType long
targetFile "src/main/resources/public/apis/asyncapi.yml"
includeKafkaCommonHeaders true
}
BackendApplicationDefaultPlugin {
useLombok true
includeEmitEventsImplementation true
// --force // overwite all files
}
OpenAPIControllersPlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
}
SpringWebTestClientPlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
}
SpringWebTestClientPlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
groupBy businessFlow
businessFlowTestName CreateUpdateDeleteCustomerIntegrationTest
operationIds createCustomer,updateCustomer,deleteCustomer,getCustomer
}
OpenAPIKaratePlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
}
OpenAPIKaratePlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
groupBy businessFlow
businessFlowTestName CreateUpdateDeleteCustomerKarateTest
operationIds createCustomer,updateCustomer,deleteCustomer,getCustomer
}
}
}
πŸ”—Navigate to source

NOTE: You can name this files as you wish, just mind the file extension .zdl for the domain model and .zw for the scripts and in .zw pointing to the proper zdlFile file containing the domain model.

So let's dive into the details of how this application was built using ZenWave SDK.

Model Configuration

Let's start with the configuration section of the ZDL model:

ZenWave Model Configuration for Customer Master Data Service (πŸ‘‡ view source)
/**
* Sample ZenWave Model Definition.
* Use zenwave-scripts.zdl to generate your code from this model definition.
*/
config {
title "ZenWave Customer JPA Example"
basePackage "io.zenwave360.example"
persistence jpa
databaseType postgresql
layout CleanHexagonalProjectLayout
// these should match the values of openapi-generator-maven-plugin
// used by the OpenAPIControllersPlugin and SpringWebTestClientPlugin
openApiApiPackage "{{basePackage}}.adapters.web"
openApiModelPackage "{{basePackage}}.adapters.web.model"
openApiModelNameSuffix DTO
}
πŸ”—Navigate to source

Basic settings:

  • title and basePackage are self-explanatory and used throughout ZenWave SDK plugins for code and documentation generation.
  • persistence tells the BackendApplicationDefaultPlugin which data store to target (jpa or mongodb), generating the appropriate Spring Data interfaces and controlling entity ID data types.
  • databaseType works with jpa persistence to generate proper Hibernate configuration for ID generation strategies.
  • layout determines the architectural structure generated by BackendApplicationDefaultPlugin. The CleanHexagonalProjectLayout creates a clean/hexagonal architecture with distinct layers: core domain, web adapters, and infrastructure. Note that JPA repository implementations are auto-generated by Spring Data, so no manual repository code is needed.

Additional configurations customizing the project layout:

  • openApiApiPackage, openApiModelPackage, and openApiModelNameSuffix are used by the OpenAPIControllersPlugin and should match the values configured in the openapi-generator-maven-plugin in the pom.xml file.
OpenAPI Generator Maven Plugin Configuration in pom.xml (πŸ‘‡ view source)
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.10.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/public/apis/openapi.yml</inputSpec>
<skipIfSpecIsUnchanged>true</skipIfSpecIsUnchanged>
<generatorName>spring</generatorName>
<apiPackage>${openApiApiPackage}</apiPackage>
<modelPackage>${openApiModelPackage}</modelPackage>
<modelNameSuffix>DTO</modelNameSuffix>
<addCompileSourceRoot>true</addCompileSourceRoot>
<generateSupportingFiles>false</generateSupportingFiles>
<typeMappings>
<typeMapping>Double=java.math.BigDecimal</typeMapping>
</typeMappings>
<configOptions>
<useSpringBoot3>true</useSpringBoot3>
<documentationProvider>none</documentationProvider>
<openApiNullable>false</openApiNullable>
<useOptional>false</useOptional>
<useTags>true</useTags>
<interfaceOnly>true</interfaceOnly>
<skipDefaultInterface>true</skipDefaultInterface>
<delegatePattern>false</delegatePattern>
<sortParamsByRequiredFlag>false</sortParamsByRequiredFlag>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
πŸ”—Navigate to source

Domain Modeling

Domain modeling starts with the entity declarations. Entities body contains fields, nested entities. id and version fields are added automatically to entities. Entities decorated with @auditing will also have createdDate, createdBy, lastModifiedDate, and lastModifiedBy fields added automatically.

Entities decorated with @aggregate are considered the root of an aggregate and a Spring Data repository is generated for them and can be used on services.

Nested entities are equivalente to entities decorated with @embedded which are not persisted separately, they are part of the aggregate root entity:

  • In mongodb they are stored as nested documents, and they don't have an id or version fields.
  • In jpa they are stored either as @Embeddable or as a JSON column in the database. In this particular example, we are using a JSON column to store the addresses array in the Customer entity. (Arrays are not supported as @Embeddable in JPA)

Relationships are modeled following JHipster JDL syntax and the corresponding fields are added to entities. In DDD, relationships between aggregates should typically be mapped only by their id. ZenWave SDK allows you to use @OneToXXX relationships between aggregates, which are mapped by their id and include a read-only reference to the related entity, enabling a richer domain model in the ZDL while maintaining DDD principles. This pattern is not used in this example, but it's good to know this capability exists.

ZenWave Domain Model for Customer Master Data Service (πŸ‘‡ view source)
/**
* Customer entity
*/
@aggregate
@auditing // adds auditing fields to the entity
entity Customer {
name String required maxlength(254) /** Customer name */
email String required maxlength(254)
/** Customer Addresses can be stored in a JSON column in the database. */
@json addresses Address[] minlength(1) maxlength(5) {
street String required maxlength(254)
city String required maxlength(254)
}
}
@auditing
entity PaymentMethod {
type PaymentMethodType required
cardNumber String required
}
enum PaymentMethodType { VISA(1), MASTERCARD(2) }
relationship OneToMany {
Customer{paymentMethods required maxlength(3)} to PaymentMethod{customer required}
}
πŸ”—Navigate to source
Customer Entity Generated by ZenWave SDK (πŸ‘‡ view source)
@lombok.Getter
@lombok.Setter
@Entity
@Table(name = "customer")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@EntityListeners(AuditingEntityListener.class)
public class Customer implements Serializable {
@java.io.Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Version
private Integer version;
/** Customer name */
@NotNull
@Size(max = 254)
@Column(name = "name", nullable = false, length = 254)
private String name;
@NotNull
@Size(max = 254)
@Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,4}")
@Column(name = "email", nullable = false, length = 254)
private String email;
/** Customer Addresses can be stored in a JSON column in the database. */
@Size(min = 1, max = 5)
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
@Column(name = "addresses")
private List<Address> addresses = new ArrayList<>();
@NotNull
@Size(max = 3)
@OneToMany(mappedBy = "customer", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private Set<PaymentMethod> paymentMethods = new HashSet<>();
@CreatedBy
@Column(name = "created_by", updatable = false)
protected String createdBy;
@CreatedDate
@Column(name = "created_date", columnDefinition = "TIMESTAMP", updatable = false)
protected LocalDateTime createdDate;
@LastModifiedBy
@Column(name = "last_modified_by")
protected String lastModifiedBy;
@LastModifiedDate
@Column(name = "last_modified_date", columnDefinition = "TIMESTAMP")
protected LocalDateTime lastModifiedDate;
// manage relationships
public Customer addPaymentMethods(PaymentMethod paymentMethods) {
this.paymentMethods.add(paymentMethods);
paymentMethods.setCustomer(this);
return this;
}
public Customer removePaymentMethods(PaymentMethod paymentMethods) {
this.paymentMethods.remove(paymentMethods);
paymentMethods.setCustomer(null);
return this;
}
/*
* https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-
* with-jpa-and-hibernate/
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Customer)) {
return false;
}
Customer other = (Customer) o;
return getId() != null && getId().equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
πŸ”—Navigate to source
Address Nested Entity Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.domain;
import jakarta.persistence.Column;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serializable;
/** */
@lombok.Getter
@lombok.Setter
// @Embeddable // json embedded
public class Address implements Serializable {
@java.io.Serial
private static final long serialVersionUID = 1L;
@NotNull
@Size(max = 254)
@Column(name = "street", nullable = false, length = 254)
private String street;
@NotNull
@Size(max = 254)
@Column(name = "city", nullable = false, length = 254)
private String city;
}
πŸ”—Navigate to source
PaymentMethod Nested Entity Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.io.Serializable;
import java.time.LocalDateTime;
/** */
@lombok.Getter
@lombok.Setter
@Entity
@Table(name = "payment_method")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@EntityListeners(AuditingEntityListener.class)
public class PaymentMethod implements Serializable {
@java.io.Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@Version
private Integer version;
@NotNull
@Column(name = "type", nullable = false)
@Convert(converter = PaymentMethodType.PaymentMethodTypeConverter.class)
private PaymentMethodType type;
@NotNull
@Column(name = "card_number", nullable = false)
private String cardNumber;
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@CreatedBy
@Column(name = "created_by", updatable = false)
protected String createdBy;
@CreatedDate
@Column(name = "created_date", columnDefinition = "TIMESTAMP", updatable = false)
protected LocalDateTime createdDate;
@LastModifiedBy
@Column(name = "last_modified_by")
protected String lastModifiedBy;
@LastModifiedDate
@Column(name = "last_modified_date", columnDefinition = "TIMESTAMP")
protected LocalDateTime lastModifiedDate;
/*
* https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-
* with-jpa-and-hibernate/
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof PaymentMethod)) {
return false;
}
PaymentMethod other = (PaymentMethod) o;
return getId() != null && getId().equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
πŸ”—Navigate to source

Enums are also generated as Java enums, with an autogenerated JPA AttributeConverter which persists the enum the integer configured as enumValue in the ZDL model.

PaymentMethodType Enum Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.domain;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Arrays;
/** Enum for PaymentMethodType. */
public enum PaymentMethodType {
VISA(1), MASTERCARD(2),;
private final Integer value;
private PaymentMethodType(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
public static PaymentMethodType fromValue(Integer value) {
return Arrays.stream(PaymentMethodType.values()).filter(e -> e.value.equals(value)).findFirst().orElse(null);
}
@Converter
static class PaymentMethodTypeConverter implements AttributeConverter<PaymentMethodType, Integer> {
@Override
public Integer convertToDatabaseColumn(PaymentMethodType attribute) {
if (attribute == null) {
return null;
}
return attribute.value;
}
@Override
public PaymentMethodType convertToEntityAttribute(Integer dbData) {
return PaymentMethodType.fromValue(dbData);
}
}
}
πŸ”—Navigate to source

Aggregates will also get generated a Spring Data Repository interface and an InMemory implementation for testing purposes.

CustomerRepository.java Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.outbound.jpa;
import io.zenwave360.example.core.domain.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/** Spring Data JPA repository for the Customer entity. */
@SuppressWarnings("unused")
@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
πŸ”—Navigate to source
InMemoryCustomerRepository.java Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.infrastructure.jpa.inmemory;
import io.zenwave360.example.core.domain.Customer;
import io.zenwave360.example.core.outbound.jpa.CustomerRepository;
import static org.apache.commons.lang3.ObjectUtils.firstNonNull;
public class CustomerRepositoryInMemory extends InMemoryJpaRepository<Customer> implements CustomerRepository {
private long nextId = 0;
private final PrimaryKeyGenerator<Long> primaryKeyGenerator = () -> nextId++;
public Customer save(Customer entity) {
entity = super.save(entity);
entity.getPaymentMethods().forEach(paymentMethod -> {
paymentMethod.setId(firstNonNull(paymentMethod.getId(), primaryKeyGenerator.next()));
});
return entity;
}
}
πŸ”—Navigate to source

Services

Services are the entry point to the core domain, and are generated as Spring @Service classes.

Customer Service for (Customer) (πŸ‘‡ view source)
@rest("/customers")
service CustomerService for (Customer) {
@post
createCustomer(Customer) Customer withEvents CustomerEvent
@get("/{id}")
getCustomer(id) Customer?
@put("/{id}")
updateCustomer(id, Customer) Customer? withEvents CustomerEvent
@delete("/{id}")
deleteCustomer(id) withEvents CustomerDeletedEvent
@post("/search")
@paginated
searchCustomers(CustomerSearchCriteria) Customer[]
}
πŸ”—Navigate to source

This will generate a CustomerService, a CustomerServiceImpl referencing CustomerRepository (because it's this service@aggregate entity) and CustomerServiceTest that uses an in-memory implementation of the repository for testing.

CustomerService.java Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.inbound;
import io.zenwave360.example.core.domain.Customer;
import io.zenwave360.example.core.inbound.dtos.CustomerSearchCriteria;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
/** Inbound Service Port for managing [Customer]. */
public interface CustomerService {
/** With Events: [CustomerEvent]. */
public Customer createCustomer(Customer input);
/** */
public Optional<Customer> getCustomer(Long id);
/** With Events: [CustomerEvent]. */
public Optional<Customer> updateCustomer(Long id, Customer input);
/** With Events: [CustomerEvent]. */
public void deleteCustomer(Long id);
/** */
public Page<Customer> searchCustomers(CustomerSearchCriteria input, Pageable pageable);
}
πŸ”—Navigate to source
CustomerServiceImpl.java Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.implementation;
import io.zenwave360.example.core.domain.Customer;
import io.zenwave360.example.core.implementation.mappers.CustomerServiceMapper;
import io.zenwave360.example.core.implementation.mappers.EventsMapper;
import io.zenwave360.example.core.inbound.CustomerService;
import io.zenwave360.example.core.inbound.dtos.CustomerSearchCriteria;
import io.zenwave360.example.core.outbound.events.CustomerEventsProducer;
import io.zenwave360.example.core.outbound.jpa.CustomerRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
/** Service Implementation for managing [Customer]. */
@Service
@Transactional(readOnly = true)
@lombok.AllArgsConstructor
public class CustomerServiceImpl implements CustomerService {
private final Logger log = LoggerFactory.getLogger(getClass());
private final CustomerServiceMapper customerServiceMapper = CustomerServiceMapper.INSTANCE;
private final CustomerRepository customerRepository;
private final EventsMapper eventsMapper = EventsMapper.INSTANCE;
private final CustomerEventsProducer eventsProducer;
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public Customer createCustomer(Customer input) {
log.debug("[CRUD] Request to save Customer: {}", input);
var customer = customerServiceMapper.update(new Customer(), input);
customer = customerRepository.save(customer);
// TODO: may need to reload the entity to fetch relationships 'mapped by id'
// emit events
var customerEvent = eventsMapper.asCustomerEvent(customer);
eventsProducer.onCustomerEvent(customerEvent);
return customer;
}
public Optional<Customer> getCustomer(Long id) {
log.debug("[CRUD] Request to get Customer : {}", id);
var customer = customerRepository.findById(id);
return customer;
}
@Transactional
public Optional<Customer> updateCustomer(Long id, Customer input) {
log.debug("Request updateCustomer: {} {}", id, input);
var customer = customerRepository.findById(id).map(existingCustomer -> {
return customerServiceMapper.update(existingCustomer, input);
}).map(customerRepository::save);
if (customer.isPresent()) {
// emit events
var customerEvent = eventsMapper.asCustomerEvent(customer.get());
eventsProducer.onCustomerEvent(customerEvent);
}
return customer;
}
@Transactional
public void deleteCustomer(Long id) {
log.debug("[CRUD] Request to delete Customer : {}", id);
customerRepository.deleteById(id);
// emit events
var customerEvent = eventsMapper.asCustomerEvent(id);
eventsProducer.onCustomerDeletedEvent(customerEvent);
}
public Page<Customer> searchCustomers(CustomerSearchCriteria input, Pageable pageable) {
log.debug("Request searchCustomers: {} {}", input, pageable);
var customers = customerRepository.findAll(pageable);
return customers;
}
}
πŸ”—Navigate to source
CustomerServiceTest.java Generated by ZenWave SDK (πŸ‘‡ view source)
package io.zenwave360.example.core.implementation;
import io.zenwave360.example.config.ServicesInMemoryConfig;
import io.zenwave360.example.core.domain.Address;
import io.zenwave360.example.core.domain.Customer;
import io.zenwave360.example.core.inbound.dtos.CustomerSearchCriteria;
import io.zenwave360.example.infrastructure.jpa.inmemory.CustomerRepositoryInMemory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/** Acceptance Test for CustomerService. */
class CustomerServiceTest {
private final Logger log = LoggerFactory.getLogger(getClass());
ServicesInMemoryConfig context = new ServicesInMemoryConfig();
CustomerServiceImpl customerService = context.customerService();
CustomerRepositoryInMemory customerRepository = context.customerRepository();
@BeforeEach
void setUp() {
context.reloadTestData();
}
@Test
void createCustomerTest() {
var input = new Customer();
input.setName("name");
input.setEmail("me@email.com");
input.setAddresses(List.of(new Address() //
.setStreet("street")
.setCity("city")
));
var customer = customerService.createCustomer(input);
assertNotNull(customer.getId());
assertTrue(customerRepository.containsEntity(customer));
}
@Test
void getCustomerTest() {
var id = 1L; // TODO fill id
var customer = customerService.getCustomer(id);
assertTrue(customer.isPresent());
}
@Test
void updateCustomerTest() {
var id = 1L; // TODO fill id
var input = new Customer();
input.setName("name");
input.setEmail("me@email.com");
input.setAddresses(List.of(new Address() //
.setStreet("street")
.setCity("city")
));
assertTrue(customerRepository.containsKey(id));
var customer = customerService.updateCustomer(id, input);
assertTrue(customer.isPresent());
assertTrue(customerRepository.containsEntity(customer.get()));
}
@Test
void deleteCustomerTest() {
var id = 1L; // TODO fill id
assertTrue(customerRepository.containsKey(id));
customerService.deleteCustomer(id);
assertFalse(customerRepository.containsKey(id));
}
@Test
void searchCustomersTest() {
var searchCriteria = new CustomerSearchCriteria();
var results = customerService.searchCustomers(searchCriteria, PageRequest.of(0, 10));
Assertions.assertNotNull(results);
Assertions.assertFalse(results.isEmpty());
}
}
πŸ”—Navigate to source

This CustomerServiceTest is just an skeleton, you will need to provide testing data and assertions.

Unit and Integration Tests

ZenWave SDK generates a complete suit of both Unit and Integration Tests:

Core Integration Tests

BackendApplicationDefaultPlugin generates Integration Tests for infrastructure or outbound adapter classes (repositories in this case) that run as @SpringBootTest against TestContainers using docker-compose.yml configuration.

These tests verify that JPA configuration and entity relationships are correctly mapped. As the developer, you need to populate tests with input data and assertions, and provide initial database state in the standard src/test/resources/data.sql file. These integration tests are transactional, so the database state is rolled back to its original state after each test.

Testing JPA and relationshipst in CustomerRepositoryIntegrationTest.java (πŸ‘‡ view source)
@Test
void saveTest() {
Customer customer = new Customer();
customer.setName("Jane Smith");
customer.setEmail("jane.smith@example.com");
customer.setAddresses(List.of(
new Address()
.setStreet("456 Elm St")
.setCity("Othertown")
)
);
// OneToMany paymentMethods owner: true
var paymentMethods = new PaymentMethod();
paymentMethods.setType(PaymentMethodType.VISA);
paymentMethods.setCardNumber("6543210987654321");
customer.addPaymentMethods(paymentMethods);
// Persist aggregate root
var created = customerRepository.save(customer);
// reloading to get relationships persisted by id
entityManager.flush();
entityManager.refresh(created);
Assertions.assertNotNull(created.getId());
Assertions.assertNotNull(created.getVersion());
Assertions.assertNotNull(created.getCreatedBy());
Assertions.assertNotNull(created.getCreatedDate());
Assertions.assertTrue(customer.getPaymentMethods().stream().allMatch(item -> item.getId() != null));
}
πŸ”—Navigate to source

Remember to verify that Services list in DockerComposeInitializer.java match the services declared in docker-compose.yml, pay particular attention to the database service name, which is app in this case.

Services List in DockerComposeInitializer.java (πŸ‘‡ view source)
private static final String DOCKER_COMPOSE_FILE = "./docker-compose.yml";
private static final List<Service> SERVICES = List.of(
new Service("postgresql", 5432, "DATASOURCE_URL", "jdbc:postgresql://%s:%s/app"),
new Service("kafka", 9092, "KAFKA_BOOTSTRAP_SERVERS", "%s:%s"),
new Service("schema-registry", 8081, "SCHEMA_REGISTRY_URL", "http://%s:%s")
);
πŸ”—Navigate to source

DockerComposeInitializer.java contains the annotation required to start TestContainers in your @SpringBootTest tests:

BaseRepositoryIntegrationTest.java (πŸ‘‡ view source)
package io.zenwave360.example.infrastructure.jpa;
import io.zenwave360.example.config.DockerComposeInitializer;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@ActiveProfiles("test")
@DockerComposeInitializer.EnableDockerCompose
@org.springframework.transaction.annotation.Transactional
public abstract class BaseRepositoryIntegrationTest {
}
πŸ”—Navigate to source

Core Unit Tests

BackendApplicationDefaultPlugin also generates Unit Tests for your core services that use InMemory Repository implementations (avoiding Mockito). It also generates a ServicesInMemoryConfig.java to provide service implementations wired with InMemory dependencies:

Using ServicesInMemoryConfig.java in Service Tests (πŸ‘‡ view source)
/** Acceptance Test for CustomerService. */
class CustomerServiceTest {
private final Logger log = LoggerFactory.getLogger(getClass());
ServicesInMemoryConfig context = new ServicesInMemoryConfig();
CustomerServiceImpl customerService = context.customerService();
CustomerRepositoryInMemory customerRepository = context.customerRepository();
@BeforeEach
void setUp() {
context.reloadTestData();
}
πŸ”—Navigate to source

You can provide in-memory test data by creating JSON files in src/test/resources/{{persistence}}/data/{{aggregateName}}/{{id}}.json - these will populate the in-memory repository before each test runs.

src/test/resources/data/jpa/customer/1.json (πŸ‘‡ view source)
{
"id": 1,
"version": 1,
"email": "john.doe@example.com",
"name": "John Doe",
"addresses": [
{
"city": "Anytown",
"street": "123 Main St"
}
],
"paymentMethods": [
{
"type": 1,
"cardNumber": "1234567890123456",
"createdBy": "system",
"createdDate": "2023-01-01T00:00:00",
"lastModifiedBy": "system",
"lastModifiedDate": "2023-01-01T00:00:00"
}
],
"createdBy": "system",
"createdDate": "2023-01-01T00:00:00",
"lastModifiedBy": "system",
"lastModifiedDate": "2023-01-01T00:00:00"
}
πŸ”—Navigate to source

Exposing a REST API for your Service

Services can be decorated with @rest, @get, @post, @put, @delete, @patch annotations to document how they will be exposed as REST endpoints. The ZdlToOpenAPIPlugin can generate a complete OpenAPI definition from these annotations, which you can then customize manually or by applying OpenAPI overlays during code generation.

The ZdlToOpenAPIPlugin will generate #/components/schemas/** for all entities, inputs and outputs used in your service commands. Additional annotations like @fileupload and @filedownload are available for file handling endpoints (see the Clinical Tool - Modulith example for details).

Create Customer Endpoint

βš™οΈ

When using the ZDLToOpenAPIPlugin, the following

this service command: (πŸ‘‡ view source)
@post
createCustomer(Customer) Customer withEvents CustomerEvent
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/customers:
post:
operationId: createCustomer
description: "createCustomer"
tags: [Customer]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
responses:
"201":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
πŸ”—Navigate to source

βš™οΈ

When using the OpenAPIControllersPlugin, the following

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/customers:
post:
operationId: createCustomer
description: "createCustomer"
tags: [Customer]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
responses:
"201":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
πŸ”—Navigate to source

would produce this java code:

CustomerApiController.java (πŸ‘‡ view source)
@Override
public ResponseEntity<CustomerDTO> createCustomer(CustomerDTO reqBody) {
log.debug("REST request to createCustomer: {}", reqBody);
var input = mapper.asCustomer(reqBody);
var customer = customerService.createCustomer(input);
CustomerDTO responseDTO = mapper.asCustomerDTO(customer);
return ResponseEntity.status(201).body(responseDTO);
}
πŸ”—Navigate to source

And its corresponding:

CustomerDTOsMapper.java Generated by ZenWave SDK (πŸ‘‡ view source)
@Mapper(uses = BaseMapper.class)
public interface CustomerDTOsMapper {
CustomerDTOsMapper INSTANCE = Mappers.getMapper(CustomerDTOsMapper.class);
// request mappings
CustomerSearchCriteria asCustomerSearchCriteria(CustomerSearchCriteriaDTO dto);
Customer asCustomer(CustomerDTO dto);
// response mappings
List<CustomerDTO> asCustomerDTOList(List<Customer> entityList);
CustomerPaginatedDTO asCustomerPaginatedDTO(Page<Customer> page);
default Page<CustomerDTO> asCustomerDTOPage(Page<Customer> page) {
return page.map(this::asCustomerDTO);
}
CustomerDTO asCustomerDTO(Customer entity);
}
πŸ”—Navigate to source
CustomerApiControllerTest.java Generated by ZenWave SDK (πŸ‘‡ view source)
@Test
void createCustomerTest() {
CustomerDTO reqBody = new CustomerDTO();
reqBody.setName("John Doe");
reqBody.setEmail("john.doe@example.com");
reqBody.setAddresses(List.of(new AddressDTO("Anytown", "123 Main St")));
reqBody.setPaymentMethods(List.of(new PaymentMethodDTO(PaymentMethodTypeDTO.VISA, "1234567890123456")));
var response = controller.createCustomer(reqBody);
Assertions.assertEquals(201, response.getStatusCode().value());
}
πŸ”—Navigate to source

Remember that generated Mappers and Tests are provided as a starting point, you will need to adapt them to your needs.

Search Customer Endpoint

βš™οΈ

When using the ZDLToOpenAPIPlugin, the following

sevice command `@paginated` and returning an aray: (πŸ‘‡ view source)
@post("/search")
@paginated
searchCustomers(CustomerSearchCriteria) Customer[]
πŸ”—Navigate to source

would produce this:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/customers/search:
post:
operationId: searchCustomers
description: "searchCustomers"
tags: [Customer]
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/sort"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CustomerSearchCriteria"
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/CustomerPaginated"
πŸ”—Navigate to source

βš™οΈ

When using the OpenAPIControllersPlugin, the previous OpenAPI definition:

src/main/resources/public/apis/openapi.yml (πŸ‘‡ view source)
/customers/search:
post:
operationId: searchCustomers
description: "searchCustomers"
tags: [Customer]
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/sort"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CustomerSearchCriteria"
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/CustomerPaginated"
πŸ”—Navigate to source

would produce this java code:

CustomerApiController.java (πŸ‘‡ view source)
public ResponseEntity<Void> deleteCustomer(Long id) {
log.debug("REST request to deleteCustomer: {}", id);
customerService.deleteCustomer(id);
return ResponseEntity.status(204).build();
}
@Override
public ResponseEntity<CustomerPaginatedDTO> searchCustomers(Integer page, Integer limit, List<String> sort, CustomerSearchCriteriaDTO reqBody) {
log.debug("REST request to searchCustomers: {}, {}, {}, {}", page, limit, sort, reqBody);
var input = mapper.asCustomerSearchCriteria(reqBody);
var customerPage = customerService.searchCustomers(input, pageOf(page, limit, sort));
var responseDTO = mapper.asCustomerPaginatedDTO(customerPage);
return ResponseEntity.status(200).body(responseDTO);
}
πŸ”—Navigate to source

And its corresponding:

CustomerApiControllerTest.java Generated by ZenWave SDK (πŸ‘‡ view source)
@Test
void searchCustomersTest() {
Optional<Integer> page = Optional.of(0);
Optional<Integer> limit = Optional.of(10);
Optional<List<String>> sort = Optional.of(List.of("name"));
CustomerSearchCriteriaDTO reqBody = new CustomerSearchCriteriaDTO();
var response = controller.searchCustomers(reqBody, page, limit, sort);
Assertions.assertEquals(200, response.getStatusCode().value());
}
πŸ”—Navigate to source

Web Adapters Unit Tests

OpenAPIControllersPlugin generates Spring MVC controllers that implement your OpenAPI specification, along with corresponding Unit Tests that use ServicesInMemoryConfig.java for dependency injection.

Unit Testing Spring MVC Controllers (πŸ‘‡ view source)
/** Test controller for CustomerApiController. */
class CustomerApiControllerTest {
private final Logger log = LoggerFactory.getLogger(getClass());
ServicesInMemoryConfig context = new ServicesInMemoryConfig();
CustomerApiController controller = new CustomerApiController(context.customerService());
@BeforeEach
void setUp() {
context.reloadTestData();
}
@Test
void createCustomerTest() {
CustomerDTO reqBody = new CustomerDTO();
reqBody.setName("John Doe");
reqBody.setEmail("john.doe@example.com");
reqBody.setAddresses(List.of(new AddressDTO("Anytown", "123 Main St")));
reqBody.setPaymentMethods(List.of(new PaymentMethodDTO(PaymentMethodTypeDTO.VISA, "1234567890123456")));
var response = controller.createCustomer(reqBody);
Assertions.assertEquals(201, response.getStatusCode().value());
}
πŸ”—Navigate to source

You can disable controller Unit Test generation if you prefer to test your REST API through HTTP calls instead of testing the Java controllers directly.

Web Adapters Integration Tests with Spring WebTestClient

If you prefer to test your REST API via HTTP semantics you can use SpringWebTestClientPlugin to generate both single endpoint tests as well as business flows spaning multiple endpoints:

βš™οΈ

When using the SpringWebTestClientPlugin, the following

zenwave-scripts.zw (πŸ‘‡ view source)
SpringWebTestClientPlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
}
πŸ”—Navigate to source

would produce this API test:

CustomerApiIntegrationTest (πŸ‘‡ view source)
package io.zenwave360.example.adapters.web;
import io.zenwave360.example.adapters.web.model.*;
import io.zenwave360.example.config.ServicesInMemoryConfig;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Optional;
/** Test controller for CustomerApiController. */
class CustomerApiControllerTest {
private final Logger log = LoggerFactory.getLogger(getClass());
ServicesInMemoryConfig context = new ServicesInMemoryConfig();
CustomerApiController controller = new CustomerApiController(context.customerService());
@BeforeEach
void setUp() {
context.reloadTestData();
}
@Test
void createCustomerTest() {
CustomerDTO reqBody = new CustomerDTO();
reqBody.setName("John Doe");
reqBody.setEmail("john.doe@example.com");
reqBody.setAddresses(List.of(new AddressDTO("Anytown", "123 Main St")));
reqBody.setPaymentMethods(List.of(new PaymentMethodDTO(PaymentMethodTypeDTO.VISA, "1234567890123456")));
var response = controller.createCustomer(reqBody);
Assertions.assertEquals(201, response.getStatusCode().value());
}
@Test
void getCustomerTest() {
Long id = 1L;
var response = controller.getCustomer(id);
Assertions.assertEquals(200, response.getStatusCode().value());
}
@Test
void updateCustomerTest() {
Long id = 1L;
CustomerDTO reqBody = new CustomerDTO();
reqBody.setName("John Doe");
reqBody.setEmail("john.doe@example.com");
reqBody.setAddresses(List.of(new AddressDTO("Anytown", "123 Main St")));
reqBody.setPaymentMethods(List.of(new PaymentMethodDTO(PaymentMethodTypeDTO.VISA, "1234567890123456")));
var response = controller.updateCustomer(id, reqBody);
Assertions.assertEquals(200, response.getStatusCode().value());
}
@Test
void deleteCustomerTest() {
Long id = 1L;
var response = controller.deleteCustomer(id);
Assertions.assertEquals(204, response.getStatusCode().value());
}
@Test
void searchCustomersTest() {
Optional<Integer> page = Optional.of(0);
Optional<Integer> limit = Optional.of(10);
Optional<List<String>> sort = Optional.of(List.of("name"));
CustomerSearchCriteriaDTO reqBody = new CustomerSearchCriteriaDTO();
var response = controller.searchCustomers(reqBody, page, limit, sort);
Assertions.assertEquals(200, response.getStatusCode().value());
}
}
πŸ”—Navigate to source

βš™οΈ

When using the SpringWebTestClientPlugin, the following

zenwave-scripts.zw (πŸ‘‡ view source)
SpringWebTestClientPlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
groupBy businessFlow
businessFlowTestName CreateUpdateDeleteCustomerIntegrationTest
operationIds createCustomer,updateCustomer,deleteCustomer,getCustomer
}
OpenAPIKaratePlugin {
openapiFile "src/main/resources/public/apis/openapi.yml"
}
πŸ”—Navigate to source

would produce this Business Flow API test:

CustomerApiIntegrationTest (πŸ‘‡ view source)
package io.zenwave360.example.adapters.web;
import io.zenwave360.example.adapters.web.model.CustomerDTO;
import io.zenwave360.example.adapters.web.model.PaymentMethodTypeDTO;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.http.HttpMethod.*;
/**
* Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer.
*/
class CreateUpdateDeleteCustomerIntegrationTest extends BaseWebTestClientTest {
/**
* Business Flow Test for: createCustomer, getCustomer, updateCustomer, deleteCustomer.
*/
@Test
void testCreateUpdateDeleteCustomerIntegrationTest() {
// createCustomer: createCustomer
var customerRequestBody0 = """
{
"email": "jane.doe@example.com",
"name": "Jane Doe",
"addresses": [
{
"city": "Othertown",
"street": "456 Elm St"
}
],
"paymentMethods": [
{
"type": "VISA",
"cardNumber": "6543210987654321"
}
]
}
""";
var createCustomerResponse0 = webTestClient.method(POST).uri("/api/customers")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(customerRequestBody0)
.exchange()
.expectStatus().isEqualTo(201)
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.returnResult(CustomerDTO.class);
// getCustomer: getCustomer
var id = createCustomerResponse0.getResponseBody().blockFirst().getId();
var getCustomerResponse1 = webTestClient.method(GET).uri("/api/customers/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isEqualTo(200)
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.returnResult(CustomerDTO.class);
// updateCustomer: updateCustomer
CustomerDTO customerRequestBody2 = getCustomerResponse1.getResponseBody().blockFirst();
customerRequestBody2.setName("updated");
customerRequestBody2.setEmail("updated@email.com");
customerRequestBody2.getPaymentMethods().get(0).setType(PaymentMethodTypeDTO.VISA);
var updateCustomerResponse2 = webTestClient.method(PUT).uri("/api/customers/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(customerRequestBody2)
.exchange()
.expectStatus().isEqualTo(200)
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.returnResult(CustomerDTO.class);
// deleteCustomer: deleteCustomer
webTestClient.method(DELETE).uri("/api/customers/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isEqualTo(204);
// getCustomer: getCustomer (not found)
webTestClient.method(GET).uri("/api/customers/{id}", id)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isEqualTo(404);
}
}
πŸ”—Navigate to source

You can control whether these are Unit or Integration Tests with @SpringBootTest in

BaseWebTestClientTest.java (πŸ‘‡ view source)
@ActiveProfiles("test")
@DockerComposeInitializer.EnableDockerCompose
@org.springframework.transaction.annotation.Transactional
public abstract class BaseWebTestClientTest {
@Autowired
protected WebApplicationContext context;
protected WebTestClient webTestClient;
@BeforeEach
void setup() {
this.webTestClient = MockMvcWebTestClient.bindToApplicationContext(this.context).build();
}
}
πŸ”—Navigate to source

As always, it's your responsibility as the developer to provide test data and parameters that match the initial state, whether you're using a containerized database or an in-memory implementation.

Publishing Domain Events with AsyncAPI

You service commands can publish Domain Events as part of their operative using withEvent keyword:

βš™οΈ

When using withEvents in service commands

zenwave-model.zdl (πŸ‘‡ view source)
updateCustomer(id, Customer) Customer? withEvents CustomerEvent
πŸ”—Navigate to source

would produce generate and use an EventsProvider in your service implementation:

CustomerServiceImpl.java (πŸ‘‡ view source)
@Transactional
public Optional<Customer> updateCustomer(Long id, Customer input) {
log.debug("Request updateCustomer: {} {}", id, input);
var customer = customerRepository.findById(id).map(existingCustomer -> {
return customerServiceMapper.update(existingCustomer, input);
}).map(customerRepository::save);
if (customer.isPresent()) {
// emit events
var customerEvent = eventsMapper.asCustomerEvent(customer.get());
eventsProducer.onCustomerEvent(customerEvent);
}
return customer;
}
πŸ”—Navigate to source

You can control whether you want to keep you Domain Events internal to your application or publish them to an external broker and documented using AsyncAPI:

Domain Events decorated with @asyncapi in ZDL (πŸ‘‡ view source)
@copy(Customer)
@asyncapi({ channel: "CustomersChannel", topic: "customers" })
event CustomerEvent {
id Long required
version Integer
// all fields from Customer are copied here, but not relationships
paymentMethods PaymentMethod[]
}
@asyncapi({ channel: "CustomersChannel", topic: "customers" })
event CustomerDeletedEvent {
id Long required
}
πŸ”—Navigate to source
βš™οΈ

Events decorated with @asyncapi when running ZDLToAsyncAPIPlugin

zenwave-scripts.zw (πŸ‘‡ view source)
ZDLToAsyncAPIPlugin {
asyncapiVersion v3
schemaFormat avro
avroPackage "io.zenwave360.example.core.outbound.events.dtos"
idType long
targetFile "src/main/resources/public/apis/asyncapi.yml"
includeKafkaCommonHeaders true
}
πŸ”—Navigate to source

would generate a complete asyncapi.yml file (in this case, based in plugin configuration, including avro schemas avro/*.avsc)

src/main/resources/public/apis/asyncapi.yml (πŸ‘‡ view source)
asyncapi: 3.0.0
info:
title: "ZenWave Customer JPA Example"
version: 0.0.1
tags:
- name: "Default"
- name: "Customer"
defaultContentType: application/json
channels:
CustomersChannel:
address: "customers"
messages:
CustomerDeletedEventMessage:
$ref: '#/components/messages/CustomerDeletedEventMessage'
CustomerEventMessage:
$ref: '#/components/messages/CustomerEventMessage'
operations:
onCustomerEvent:
action: send
tags:
- name: Customer
channel:
$ref: '#/channels/CustomersChannel'
onCustomerDeletedEvent:
action: send
tags:
- name: Customer
channel:
$ref: '#/channels/CustomersChannel'
components:
messages:
CustomerEventMessage:
name: CustomerEventMessage
title: ""
summary: ""
traits:
- $ref: '#/components/messageTraits/CommonHeaders'
payload:
schemaFormat: application/vnd.apache.avro+json;version=1.9.0
schema:
$ref: "avro/CustomerEvent.avsc"
CustomerDeletedEventMessage:
name: CustomerDeletedEventMessage
title: ""
summary: ""
traits:
- $ref: '#/components/messageTraits/CommonHeaders'
payload:
schemaFormat: application/vnd.apache.avro+json;version=1.9.0
schema:
$ref: "avro/CustomerDeletedEvent.avsc"
messageTraits:
CommonHeaders:
headers:
type: object
properties:
kafka_messageKey:
type: "long"
description: This header value will be populated automatically at runtime
x-runtime-expression: $message.payload#/id
πŸ”—Navigate to source

Then you can configure ZenWave SDK Maven Plugin to generate all required DTOs and the EventProducer we see before referenced in you CustomerService.java

ZenWave SDK Maven Plugin configured to genreate Spring Cloud Stream code from asyncapi.yml (πŸ‘‡ view source)
<plugin>
<groupId>io.zenwave360.sdk</groupId>
<artifactId>zenwave-sdk-maven-plugin</artifactId>
<version>${zenwave.version}</version>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/public/apis/asyncapi.yml</inputSpec>
<skip>false</skip>
<addCompileSourceRoot>true</addCompileSourceRoot>
<addTestCompileSourceRoot>true</addTestCompileSourceRoot>
</configuration>
<executions>
<!-- DTOs -->
<!-- <execution>-->
<!-- <id>generate-asyncapi-dtos</id>-->
<!-- <phase>generate-sources</phase>-->
<!-- <goals>-->
<!-- <goal>generate</goal>-->
<!-- </goals>-->
<!-- <configuration>-->
<!-- <generatorName>jsonschema2pojo</generatorName>-->
<!-- <configOptions>-->
<!-- <modelPackage>${asyncApiModelPackage}</modelPackage>-->
<!-- <jsonschema2pojo.isUseJakartaValidation>true</jsonschema2pojo.isUseJakartaValidation>-->
<!-- <jsonschema2pojo.useLongIntegers>true</jsonschema2pojo.useLongIntegers>-->
<!-- <jsonschema2pojo.includeAdditionalProperties>true</jsonschema2pojo.includeAdditionalProperties>-->
<!-- </configOptions>-->
<!-- </configuration>-->
<!-- </execution>-->
<!-- Generate PROVIDER -->
<execution>
<id>generate-asyncapi</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<generatorName>spring-cloud-streams3</generatorName>
<configOptions>
<role>provider</role>
<style>imperative</style>
<transactionalOutbox>modulith</transactionalOutbox>
<includeApplicationEventListener>true</includeApplicationEventListener>
<modelPackage>${asyncApiModelPackage}</modelPackage>
<producerApiPackage>${asyncApiProducerApiPackage}</producerApiPackage>
<consumerApiPackage>${asyncApiConsumerApiPackage}</consumerApiPackage>
</configOptions>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>io.zenwave360.sdk.plugins</groupId>
<artifactId>asyncapi-spring-cloud-streams3</artifactId>
<version>${zenwave.version}</version>
</dependency>
<dependency>
<groupId>io.zenwave360.sdk.plugins</groupId>
<artifactId>asyncapi-jsonschema2pojo</artifactId>
<version>${zenwave.version}</version>
</dependency>
</dependencies>
</plugin>
πŸ”—Navigate to source

Because we are using avro as payload format, we also need to configure avro-maven-plugin to generate java classes from our avro schemas:

avro-maven-plugin configured to generate java classes from avro schemas (πŸ‘‡ view source)
<plugin>
<groupId>org.apache.avro</groupId>
<artifactId>avro-maven-plugin</artifactId>
<version>1.11.1</version>
<executions>
<execution>
<goals>
<goal>schema</goal>
</goals>
<phase>generate-sources</phase>
</execution>
</executions>
<configuration>
<sourceDirectory>${project.basedir}/src/main/resources/public/apis/avro</sourceDirectory>
<outputDirectory>${project.basedir}/target/generated-sources/avro</outputDirectory>
<imports>
<import>${project.basedir}/src/main/resources/public/apis/avro/PaymentMethodType.avsc</import>
<import>${project.basedir}/src/main/resources/public/apis/avro/PaymentMethod.avsc</import>
<import>${project.basedir}/src/main/resources/public/apis/avro/Address.avsc</import>
</imports>
</configuration>
</plugin>
πŸ”—Navigate to source

Please refer to https://www.zenwave360.io/zenwave-sdk/plugins/asyncapi-spring-cloud-streams3/ for more details about how to configure ZenWave SDK Maven Plugin.

Running the Customer Service

Prerequisites

  • JDK 21+
  • Maven 3.8+
  • Docker & Docker Compose - If you don't have Docker Compose installed, we recommend Rancher Desktop configured with dockerd engine (not containerd), which includes both docker and docker-compose commands.
  • Your favorite IDE

Quick Start

Follow these steps to run the complete application:

  1. Start infrastructure services:

    docker-compose up -d
  2. Run the Spring Boot application:

    mvn spring-boot:run
  3. Access the API:

    • Open Swagger UI in your browser
    • Use Basic Authentication: username admin, password password
  4. Test the endpoints:

    • Try creating a customer via the POST /customers endpoint
    • Retrieve customers using GET /customers/search

What's Running

  • PostgreSQL (port 5432) - Customer data persistence
  • Kafka (port 9092) - Domain events messaging
  • Spring Boot App (port 8080) - REST API and business logic

Happy Coding! πŸš€