Customer Address JPA Service
Section titled “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
Section titled “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
Section titled “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
Addressentities stored in a JSON column in the database and - a
@OneToManycollection ofPaymentMethodentities.
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
Section titled “REST API defined with OpenAPI”With these REST Endpoints:

Externalized Domain Events with AsyncAPI
Section titled “Externalized Domain Events with AsyncAPI”And these Domain Events published to a Kafka Topic:

Building with ZenWave Domain Model and SDK
Section titled “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.zdlfile containing the domain model and service definitions, we use this file to iterate and refine the domain model. - A
zenwave-scripts.zwfile 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
/** * 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 entityentity Customer { name String required maxlength(254) /** Customer name */ email String required maxlength(254) pattern(/^[^@]+@[^\\s@]+\\.[^\\s@]+$/) /** 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) }}
@auditingentity 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 directlyinput 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(path: "/search", status: 200) @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}`zenwave-scripts.zw` for Customer Master Data Service
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 } }}NOTE: You can name this files as you wish, just mind the file extension
.zdlfor the domain model and.zwfor the scripts and in.zwpointing to the properzdlFilefile containing the domain model.
So let’s dive into the details of how this application was built using ZenWave SDK.
Model Configuration
Section titled “Model Configuration”Let’s start with the configuration section of the ZDL model:
ZenWave Model Configuration for Customer Master Data Service
/** * 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}Basic settings:
titleandbasePackageare self-explanatory and used throughout ZenWave SDK plugins for code and documentation generation.persistencetells theBackendApplicationDefaultPluginwhich data store to target (jpaormongodb), generating the appropriate Spring Data interfaces and controlling entity ID data types.databaseTypeworks withjpapersistence to generate proper Hibernate configuration for ID generation strategies.layoutdetermines the architectural structure generated byBackendApplicationDefaultPlugin. TheCleanHexagonalProjectLayoutcreates 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, andopenApiModelNameSuffixare used by theOpenAPIControllersPluginand should match the values configured in theopenapi-generator-maven-pluginin thepom.xmlfile.
OpenAPI Generator Maven Plugin Configuration in pom.xml
<plugin> <groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId> <version>${openapi-generator-maven-plugin.version}</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>Domain Modeling
Section titled “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
mongodbthey are stored as nested documents, and they don’t have an id or version fields. - In
jpathey are stored either as@Embeddableor as a JSON column in the database. In this particular example, we are using a JSON column to store theaddressesarray in theCustomerentity. (Arrays are not supported as@Embeddablein 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
/*** Customer entity*/@aggregate@auditing // adds auditing fields to the entityentity Customer { name String required maxlength(254) /** Customer name */ email String required maxlength(254) pattern(/^[^@]+@[^\\s@]+\\.[^\\s@]+$/) /** 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) }}
@auditingentity PaymentMethod { type PaymentMethodType required cardNumber String required}
enum PaymentMethodType { VISA(1), MASTERCARD(2) }
relationship OneToMany { Customer{paymentMethods required maxlength(3)} to PaymentMethod{customer required}}Customer Entity Generated by ZenWave SDK
@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(); }}Address Nested Entity Generated by ZenWave SDK
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 embeddedpublic 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;}PaymentMethod Nested Entity Generated by ZenWave SDK
package io.zenwave360.example.core.domain;
import jakarta.persistence.*;import jakarta.validation.constraints.NotNull;import java.io.Serializable;import java.time.LocalDateTime;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;
/** */@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) 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(); }}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
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); } }}Aggregates will also get generated a Spring Data Repository interface and an InMemory implementation for testing purposes.
CustomerRepository.java Generated by ZenWave SDK
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")@Repositorypublic interface CustomerRepository extends JpaRepository<Customer, Long> {}InMemoryCustomerRepository.java Generated by ZenWave SDK
package io.zenwave360.example.infrastructure.jpa.inmemory;
import static org.apache.commons.lang3.ObjectUtils.firstNonNull;
import io.zenwave360.example.core.domain.Customer;import io.zenwave360.example.core.outbound.jpa.CustomerRepository;
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; }}Services
Section titled “Services”Services are the entry point to the core domain, and are generated as Spring @Service classes.
Customer Service for (Customer)
@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(path: "/search", status: 200) @paginated searchCustomers(CustomerSearchCriteria) Customer[]}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
package io.zenwave360.example.core.inbound;
import io.zenwave360.example.core.domain.Customer;import io.zenwave360.example.core.inbound.dtos.CustomerSearchCriteria;import java.util.Optional;import org.springframework.data.domain.Page;import org.springframework.data.domain.Pageable;
/** 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);}CustomerServiceImpl.java Generated by ZenWave SDK
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 java.util.Optional;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;
/** Service Implementation for managing [Customer]. */@Service@Transactional(readOnly = true)@lombok.AllArgsConstructorpublic 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; }}CustomerServiceTest.java Generated by ZenWave SDK
package io.zenwave360.example.core.implementation;
import static org.junit.jupiter.api.Assertions.*;
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 java.util.List;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;
/** 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()); }}This CustomerServiceTest is just an skeleton, you will need to provide testing data and assertions.
Unit and Integration Tests
Section titled “Unit and Integration Tests”ZenWave SDK generates a complete suit of both Unit and Integration Tests:
Core Integration Tests
Section titled “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
@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)); }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
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"));DockerComposeInitializer.java contains the annotation required to start TestContainers in your @SpringBootTest tests:
BaseRepositoryIntegrationTest.java
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.Transactionalpublic abstract class BaseRepositoryIntegrationTest {}Core Unit Tests
Section titled “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
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(); }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
{ "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"}Exposing a REST API for your Service
Section titled “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
Section titled “Create Customer Endpoint”When using the `ZDLToOpenAPIPlugin`, the following
would produce this:
src/main/resources/public/apis/openapi.yml
/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"When using the `OpenAPIControllersPlugin`, the following
src/main/resources/public/apis/openapi.yml
/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"would produce this java code:
CustomerApiController.java
@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); }And its corresponding:
CustomerDTOsMapper.java Generated by ZenWave SDK
@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);}CustomerApiControllerTest.java Generated by ZenWave SDK
@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()); }Remember that generated
MappersandTestsare provided as a starting point, you will need to adapt them to your needs.
Search Customer Endpoint
Section titled “Search Customer Endpoint”When using the `ZDLToOpenAPIPlugin`, the following
would produce this:
src/main/resources/public/apis/openapi.yml
/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"When using the `OpenAPIControllersPlugin`, the previous OpenAPI definition:
src/main/resources/public/apis/openapi.yml
/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"would produce this java code:
CustomerApiController.java
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); }
protected Pageable pageOf(Integer page, Integer limit, List<String> sort) { Sort sortOrder = sort != null ? Sort.by(sort.stream() .map(sortParam -> { String[] parts = sortParam.split(":"); String property = parts[0]; Sort.Direction direction = parts.length > 1 ? Sort.Direction.fromString(parts[1]) : Sort.Direction.ASC; return new Sort.Order(direction, property); }) .toList()) : Sort.unsorted(); return PageRequest.of(page != null ? page : 0, limit != null ? limit : 10, sortOrder); }And its corresponding:
CustomerApiControllerTest.java Generated by ZenWave SDK
@Test void searchCustomersTest() { Integer page = 0; Integer limit = 10; List<String> sort = List.of("name"); CustomerSearchCriteriaDTO reqBody = new CustomerSearchCriteriaDTO(); var response = controller.searchCustomers(page, limit, sort, reqBody); Assertions.assertEquals(200, response.getStatusCode().value()); }Web Adapters Unit Tests
Section titled “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
/** 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()); }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
Section titled “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
would produce this API test:
CustomerApiIntegrationTest
package io.zenwave360.example.adapters.web;
import io.zenwave360.example.adapters.web.model.*;import io.zenwave360.example.config.ServicesInMemoryConfig;import java.util.List;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;
/** 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() { Integer page = 0; Integer limit = 10; List<String> sort = List.of("name"); CustomerSearchCriteriaDTO reqBody = new CustomerSearchCriteriaDTO(); var response = controller.searchCustomers(page, limit, sort, reqBody); Assertions.assertEquals(200, response.getStatusCode().value()); }}When using the `SpringWebTestClientPlugin`, the following
would produce this Business Flow API test:
CustomerApiIntegrationTest
package io.zenwave360.example.adapters.web;
import static org.springframework.http.HttpMethod.*;
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;
/** * 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); }}You can control whether these are Unit or Integration Tests with @SpringBootTest in
BaseWebTestClientTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)@ActiveProfiles("test")@DockerComposeInitializer.EnableDockerCompose@org.springframework.transaction.annotation.Transactionalpublic abstract class BaseWebTestClientTest {
@Autowired protected WebApplicationContext context;
protected WebTestClient webTestClient;
@BeforeEach void setup() { this.webTestClient = MockMvcWebTestClient.bindToApplicationContext(this.context).build(); }}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
Section titled “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
would produce generate and use an EventsProvider in your service implementation:
CustomerServiceImpl.java
@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; }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
@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}Events decorated with `@asyncapi` when running `ZDLToAsyncAPIPlugin`
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
asyncapi: 3.0.0info: 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: "integer" description: This header value will be populated automatically at runtime x-runtime-expression: $message.payload#/idThen 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 (AsyncAPIGenerator) configured to genreate SpringCloudStream and Avro code from asyncapi.yml
<plugin> <groupId>io.zenwave360.sdk</groupId> <artifactId>zenwave-sdk-maven-plugin</artifactId> <version>${zenwave.version}</version> <configuration> <inputSpec>${asyncapi.inputSpec}</inputSpec> <skip>false</skip> <addCompileSourceRoot>true</addCompileSourceRoot> <addTestCompileSourceRoot>true</addTestCompileSourceRoot> <!-- <authentication>--> <!-- <authentication><key>API_KEY</key><value>XXX</value></authentication> --> <!-- </authentication>--> </configuration> <executions> <execution> <id>generate-asyncapi</id> <phase>generate-sources</phase> <goals> <goal>generate</goal> </goals> <configuration> <generatorName>AsyncAPIGenerator</generatorName> <configOptions> <role>provider</role> <transactionalOutbox>modulith</transactionalOutbox> <includeApplicationEventListener>true</includeApplicationEventListener> <!-- <modelPackage>${asyncApiModelPackage}</modelPackage> required for json-schema, here it uses avro package --> <producerApiPackage>${asyncApiProducerApiPackage}</producerApiPackage> <consumerApiPackage>${asyncApiConsumerApiPackage}</consumerApiPackage> <avroCompilerProperties.imports>${asyncapi.avro.imports}</avroCompilerProperties.imports> </configOptions> </configuration> </execution> </executions> <dependencies> <dependency> <groupId>io.zenwave360.sdk.plugins</groupId> <artifactId>asyncapi-generator</artifactId> <version>${zenwave.version}</version> </dependency> <dependency> <groupId>org.apache.avro</groupId> <artifactId>avro-compiler</artifactId> <version>${avro.version}</version> <exclusions> <exclusion> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> </exclusion> <exclusion> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </exclusion> </exclusions> </dependency> </dependencies> </plugin>Please refer to /zenwave-sdk/plugins/asyncapi-generator/ for more details about how to configure ZenWave SDK Maven Plugin.
Running the Customer Service
Section titled “Running the Customer Service”Prerequisites
Section titled “Prerequisites”- JDK 21+
- Maven 3.8+
- Docker & Docker Compose - If you don’t have Docker Compose installed, we recommend Rancher Desktop configured with
dockerdengine (notcontainerd), which includes bothdockeranddocker-composecommands. - Your favorite IDE
Quick Start
Section titled “Quick Start”Follow these steps to run the complete application:
-
Start infrastructure services:
docker-compose up -d -
Run the Spring Boot application:
mvn spring-boot:run -
Access the API:
- Open Swagger UI in your browser
- Use Basic Authentication: username
admin, passwordpassword
-
Test the endpoints:
- Try creating a customer via the POST
/customersendpoint - Retrieve customers using GET
/customers/search
- Try creating a customer via the POST
What’s Running
Section titled “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! 🚀