Order Fulfillment (Kotlin)
Section titled “Order Fulfillment (Kotlin)”Complete order fulfillment microservice built with Kotlin, demonstrating DDD patterns and event-driven architecture with ZenWave SDK.
This is a complete example of a ZenWave designed and generated project built with Kotlin, implementing an Order Fulfillment domain with JPA persistence and Domain Events published to Kafka. You can find the Complete Source Code at GitHub.
What You’ll Learn
Section titled “What You’ll Learn”- Model a DDD aggregate with state transitions using ZDL
- Generate Kotlin code from domain models
- Implement REST APIs with OpenAPI and Kotlin
- Publish domain events with AsyncAPI and Kafka
- Build a complete Spring Boot microservice in Kotlin
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: An Order Fulfillment Service
Section titled “What we will be building: An Order Fulfillment Service”We will be building a Kotlin/Spring Boot microservice that manages the complete order fulfillment lifecycle, from order placement through payment and shipping, exposing REST APIs and publishing Domain Events to Kafka.
The Order entity is the root of the aggregate, managing the order lifecycle through distinct states:
- PLACED → PAID → SHIPPED → CANCELLED
The Order aggregate contains:
- Order metadata (orderNumber, status, totalAmount, currency)
- Payment information (paymentReference)
- Shipping information (trackingNumber)
- An array of
OrderItementities stored in a JSON column
Business Rules:
- Order numbers must be unique
- Shipped orders cannot be cancelled
Domain Model Diagram
Section titled “Domain Model Diagram”State Machine transitions
Section titled “State Machine transitions”State transitions are defined in OrdersService service interface using @transition decorator, see Domain Modeling below.
REST API defined with OpenAPI
Section titled “REST API defined with OpenAPI”The application exposes REST endpoints for managing the order lifecycle:
POST /api/orders- Place a new orderPOST /api/orders/{orderNumber}/pay- Pay for an orderPOST /api/orders/{orderNumber}/ship- Ship an orderPOST /api/orders/{orderNumber}/cancel- Cancel an orderGET /api/orders/{orderNumber}- Get order details
OpenAPI Definition for Order Fulfillment Service
openapi: 3.0.1info: title: "Order Fulfillment DDD Example" version: 0.0.1 description: "Order Fulfillment DDD Example" contact: email: email@domain.comservers: - description: localhost url: http://localhost:8080/api - description: custom url: "{protocol}://{server}/{path}" variables: protocol: enum: ['http', 'https'] default: 'http' server: default: 'localhost:8080' path: default: 'api'tags: - name: "Default" - name: "Order"
paths: /orders: post: operationId: placeOrder description: "Customer places an order" tags: [Order] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PlaceOrderInput" responses: "201": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{orderNumber}/pay: post: operationId: payOrder description: "Set Order as paid" tags: [Order] parameters: - name: "orderNumber" in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PayOrderInput" responses: "201": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{orderNumber}/ship: post: operationId: shipOrder description: "Set Order as shipped" tags: [Order] parameters: - name: "orderNumber" in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ShipOrderInput" responses: "201": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{orderNumber}/cancel: post: operationId: cancelOrder description: "Set Order as cancelled" tags: [Order] parameters: - name: "orderNumber" in: path required: true schema: type: string responses: "201": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{orderNumber}: get: operationId: getOrder description: "Query order" tags: [Order] parameters: - name: "orderNumber" in: path required: true schema: type: string responses: "200": description: "OK" content: application/json: schema: $ref: "#/components/schemas/Order"
components: schemas: PlaceOrderInput: type: "object" x-business-entity: "PlaceOrderInput" required: - "currency" properties: items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 currency: type: "string" maxLength: 3 PayOrderInput: type: "object" x-business-entity: "PayOrderInput" required: - "paymentReference" properties: paymentReference: type: "string" ShipOrderInput: type: "object" x-business-entity: "ShipOrderInput" required: - "trackingNumber" properties: trackingNumber: type: "string" Order: type: "object" x-business-entity: "Order" required: - "status" - "totalAmount" - "currency" properties: id: type: "integer" format: "int64" readOnly: true version: type: "integer" default: "null" description: "Version of the document (required in PUT for concurrency control,\ \ should be null in POSTs)." orderNumber: type: "string" maxLength: 36 description: "prefix doc comment" status: $ref: "#/components/schemas/OrderStatus" totalAmount: type: "number" format: "double" currency: type: "string" maxLength: 3 paymentReference: type: "string" trackingNumber: type: "string" description: "" items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 OrderStatus: type: "string" x-business-entity: "OrderStatus" enum: - "PLACED" - "PAID" - "SHIPPED" - "CANCELLED" OrderItem: type: "object" x-business-entity: "OrderItem" required: - "productId" - "productName" - "quantity" - "unitPrice" properties: productId: type: "string" productName: type: "string" maxLength: 254 quantity: type: "integer" format: "int32" unitPrice: type: "number" format: "double"
Page: type: object required: - "content" - "totalElements" - "totalPages" - "size" - "number" properties: number: type: integer minimum: 0 numberOfElements: type: integer minimum: 0 size: type: integer minimum: 0 maximum: 200 multipleOf: 25 totalElements: type: integer totalPages: type: integer
parameters: page: name: page in: query description: The number of results page schema: type: integer format: int32 default: 0 limit: name: limit in: query description: The number of results in a single page schema: type: integer format: int32 default: 20 sort: name: sort in: query description: Sort results by field name and direction (asc or desc) schema: type: array items: type: string
securitySchemes: basicAuth: # <-- arbitrary name for the security scheme type: http scheme: basic bearerAuth: # <-- arbitrary name for the security scheme type: http scheme: bearer bearerFormat: JWT # optional, arbitrary value for documentation purposessecurity: - basicAuth: [] # <-- use the same name here - bearerAuth: [] # <-- use the same name hereDomain Events with AsyncAPI
Section titled “Domain Events with AsyncAPI”The application publishes domain events to Kafka topics for each state transition:
orders.placed- OrderPlaced eventsorders.paid- OrderPaid eventsorders.shipped- OrderShipped eventsorders.cancelled- OrderCancelled events
Events include CloudEvents headers and Kafka message keys for proper routing and traceability.
AsyncAPI Definition for Order Events
asyncapi: 3.0.0info: title: "Order Fulfillment DDD Example" version: 0.0.1 tags: - name: "Default" - name: "Order"
defaultContentType: application/json
channels: OrdersPlacedChannel: address: "orders.placed" messages: OrderPlacedMessage: $ref: '#/components/messages/OrderPlacedMessage' OrdersPaidChannel: address: "orders.paid" messages: OrderPaidMessage: $ref: '#/components/messages/OrderPaidMessage' OrdersShippedChannel: address: "orders.shipped" messages: OrderShippedMessage: $ref: '#/components/messages/OrderShippedMessage' OrdersCancelledChannel: address: "orders.cancelled" messages: OrderCancelledMessage: $ref: '#/components/messages/OrderCancelledMessage'
operations: onOrderPlaced: action: send tags: - name: Order channel: $ref: '#/channels/OrdersPlacedChannel' onOrderPaid: action: send tags: - name: Order channel: $ref: '#/channels/OrdersPaidChannel' onOrderShipped: action: send tags: - name: Order channel: $ref: '#/channels/OrdersShippedChannel' onOrderCancelled: action: send tags: - name: Order channel: $ref: '#/channels/OrdersCancelledChannel'
components: messages: OrderPlacedMessage: name: OrderPlacedMessage title: "Domain Events" summary: "Domain Events" traits: - $ref: '#/components/messageTraits/CommonHeaders' payload: $ref: "#/components/schemas/OrderPlaced" OrderPaidMessage: name: OrderPaidMessage title: "" summary: "" traits: - $ref: '#/components/messageTraits/CommonHeaders' payload: $ref: "#/components/schemas/OrderPaid" OrderShippedMessage: name: OrderShippedMessage title: "" summary: "" traits: - $ref: '#/components/messageTraits/CommonHeaders' payload: $ref: "#/components/schemas/OrderShipped" OrderCancelledMessage: name: OrderCancelledMessage title: "" summary: "" traits: - $ref: '#/components/messageTraits/CommonHeaders' payload: $ref: "#/components/schemas/OrderCancelled"
messageTraits: CommonHeaders: headers: type: object properties: kafka_messageKey: type: "integer" format: "int64" description: This header value will be populated automatically at runtime x-runtime-expression: $message.payload#/id # CloudEvents Attributes ce-id: type: string description: Unique identifier for the event x-runtime-expression: $message.payload#{#this.id} ce-source: type: string description: URI identifying the context where event happened x-runtime-expression: $message.payload#{"Order"} ce-specversion: type: string description: CloudEvents specification version x-runtime-expression: $message.payload#{"1.0"} ce-type: type: string description: Event type x-runtime-expression: $message.payload#{#this.getClass().getSimpleName()} ce-time: type: string description: Timestamp of when the event happened x-runtime-expression: $message.payload#{T(java.time.Instant).now().toString()}
schemas: OrderPlaced: type: "object" x-business-entity: "OrderPlaced" required: - "orderNumber" - "status" - "totalAmount" - "currency" properties: orderNumber: type: "string" maxLength: 36 description: "prefix doc comment" status: $ref: "#/components/schemas/OrderStatus" totalAmount: type: "number" format: "double" currency: type: "string" maxLength: 3 paymentReference: type: "string" trackingNumber: type: "string" description: "" items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 id: type: "integer" format: "int64" version: type: "integer" format: "int32" OrderPaid: type: "object" x-business-entity: "OrderPaid" required: - "orderNumber" - "status" - "totalAmount" - "currency" properties: orderNumber: type: "string" maxLength: 36 description: "prefix doc comment" status: $ref: "#/components/schemas/OrderStatus" totalAmount: type: "number" format: "double" currency: type: "string" maxLength: 3 paymentReference: type: "string" trackingNumber: type: "string" description: "" items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 id: type: "integer" format: "int64" version: type: "integer" format: "int32" OrderShipped: type: "object" x-business-entity: "OrderShipped" required: - "orderNumber" - "status" - "totalAmount" - "currency" properties: orderNumber: type: "string" maxLength: 36 description: "prefix doc comment" status: $ref: "#/components/schemas/OrderStatus" totalAmount: type: "number" format: "double" currency: type: "string" maxLength: 3 paymentReference: type: "string" trackingNumber: type: "string" items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 id: type: "integer" format: "int64" version: type: "integer" format: "int32" OrderCancelled: type: "object" x-business-entity: "OrderCancelled" required: - "orderNumber" - "status" - "totalAmount" - "currency" properties: orderNumber: type: "string" maxLength: 36 description: "prefix doc comment" status: $ref: "#/components/schemas/OrderStatus" totalAmount: type: "number" format: "double" currency: type: "string" maxLength: 3 paymentReference: type: "string" trackingNumber: type: "string" description: "" items: type: "array" items: $ref: "#/components/schemas/OrderItem" minLength: 1 id: type: "integer" format: "int64" version: type: "integer" format: "int32" OrderStatus: type: "string" x-business-entity: "OrderStatus" enum: - "PLACED" - "PAID" - "SHIPPED" - "CANCELLED" OrderItem: type: "object" x-business-entity: "OrderItem" required: - "productId" - "productName" - "quantity" - "unitPrice" properties: productId: type: "string" productName: type: "string" maxLength: 254 quantity: type: "integer" format: "int32" unitPrice: type: "number" format: "double"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 Order Fulfillment Service
config { title "Order Fulfillment DDD Example" basePackage "io.zenwave360.example.orderfulfillment" persistence jpa databaseType postgresql
// you can choose: DefaultProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout // CleanHexagonalProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayout layout SimpleDomainProjectLayout
// these should match what you have configured in your pom.xml for asyncapi-generator-maven-plugin layout.asyncApiModelPackage "{{basePackage}}.events.dtos" layout.asyncApiProducerApiPackage "{{basePackage}}.events" layout.asyncApiConsumerApiPackage "{{basePackage}}.commands"}
/** * Order aggregate root */@aggregate@auditing@lifecycle(field: status, initial: PLACED)entity Order(order_table) { /** prefix doc comment */ @naturalId orderNumber String required unique maxlength(36) status OrderStatus required /** suffix inline doc comment */ totalAmount BigDecimal required currency String required maxlength(3)
paymentReference String trackingNumber String
/** * Order lines stored as JSON for simplicity in the demo */ @json items OrderItem[] minlength(1) { productId String required productName String required maxlength(254) quantity Integer required min(1) unitPrice BigDecimal required }}
policies (Order) { orderNumberUnique "orderNumber must be unique"}
enum OrderStatus { PLACED(1), PAID(2), SHIPPED(3), CANCELLED(4)}
/** * Commands / Inputs */input PlaceOrderInput { items OrderItem[] minlength(1) currency String required maxlength(3)}
input PayOrderInput { paymentReference String required}
input ShipOrderInput { trackingNumber String required}
/** * Order Application Service */@rest("/orders")service OrderService for (Order) {
/** Customer places an order */ @post @transition(to: PLACED) placeOrder(PlaceOrderInput) Order withEvents OrderPlaced
/** Set Order as paid */ @post("/{orderNumber}/pay") @transition(from: PLACED, to: PAID) payOrder(@natural id, PayOrderInput) Order withEvents OrderPaid
/** Set Order as shipped */ @post("/{orderNumber}/ship") @transition(from: PAID, to: SHIPPED) shipOrder(@natural id, ShipOrderInput) Order withEvents OrderShipped
/** Set Order as cancelled */ @post("/{orderNumber}/cancel") @transition(from: [PLACED, PAID], to: CANCELLED) cancelOrder(@natural id) Order withEvents OrderCancelled
/** Query order */ @get("/{orderNumber}") getOrder(@natural id) Order?}
/** * Domain Events */@copy(Order)@asyncapi({ channel: "OrdersPlacedChannel", topic: "orders.placed" })event OrderPlaced { id Long version Integer}
@copy(Order)@asyncapi({ channel: "OrdersPaidChannel", topic: "orders.paid" })event OrderPaid { id Long version Integer}
@copy(Order)@asyncapi({ channel: "OrdersShippedChannel", topic: "orders.shipped" })event OrderShipped { id Long version Integer trackingNumber String}
@copy(Order)@asyncapi({ channel: "OrdersCancelledChannel", topic: "orders.cancelled" })event OrderCancelled { id Long version Integer}`zenwave-scripts.zw` for Order Fulfillment Service
@import("io.zenwave360.sdk.plugins.customizations:kotlin-backend-application:2.4.0")config {
// This is a global configuration that applies to all plugins. zdlFile "zenwave-model.zdl"
// these should match the values of openapi-generator-maven-plugin // used by the OpenAPIControllersPlugin and SpringWebTestClientPlugin openApiApiPackage "{{basePackage}}.web" openApiModelPackage "{{basePackage}}.web.model" openApiModelNameSuffix DTO
plugins { /** Generates an OpenAPI 3.0 specification from the ZDL model. */ ZDLToOpenAPIPlugin { idType integer idTypeFormat int64 targetFile "src/main/resources/public/apis/openapi.yml" } /** Generates an AsyncAPI 3.0 specification from the ZDL model. */ ZDLToAsyncAPIPlugin { asyncapiVersion v3 idType integer idTypeFormat int64 targetFile "src/main/resources/public/apis/asyncapi.yml" includeCloudEventsHeaders true includeKafkaCommonHeaders true } /** Generates a Backend Application from the ZDL model. (Headless Core) */ BackendApplicationDefaultPlugin { templates "new io.zenwave360.sdk.plugins.kotlin.BackendApplicationKotlinTemplates()" useJSpecify true includeEmitEventsImplementation true// --force // overwite all files } /** Generates Spring MVC controllers from the OpenAPI specification (Web Adapters). */ OpenAPIControllersPlugin { templates "new io.zenwave360.sdk.plugins.kotlin.OpenAPIControllersKotlinTemplates()" openapiFile "src/main/resources/public/apis/openapi.yml" }
SpringWebTestClientPlugin { templates "new io.zenwave360.sdk.plugins.kotlin.SpringWebTestClientKotlinTemplates()" openapiFile "src/main/resources/public/apis/openapi.yml" }
SpringWebTestClientPlugin { templates "new io.zenwave360.sdk.plugins.kotlin.SpringWebTestClientKotlinTemplates()" openapiFile "src/main/resources/public/apis/openapi.yml" groupBy businessFlow businessFlowTestName FromPlaceOrderToCancelIntegrationTest operationIds placeOrder,getOrder,payOrder,shipOrder,cancelOrder } }}NOTE: You can name these 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.
Domain Modeling
Section titled “Domain Modeling”The Order aggregate is modeled with a natural ID (orderNumber) and includes order items stored as a JSON array. The domain model defines the complete order lifecycle with state transitions and business rules.
Order Entity in ZDL
/** * Order aggregate root */@aggregate@auditing@lifecycle(field: status, initial: PLACED)entity Order(order_table) { /** prefix doc comment */ @naturalId orderNumber String required unique maxlength(36) status OrderStatus required /** suffix inline doc comment */ totalAmount BigDecimal required currency String required maxlength(3)
paymentReference String trackingNumber String
/** * Order lines stored as JSON for simplicity in the demo */ @json items OrderItem[] minlength(1) { productId String required productName String required maxlength(254) quantity Integer required min(1) unitPrice BigDecimal required }}
policies (Order) { orderNumberUnique "orderNumber must be unique"}
enum OrderStatus { PLACED(1), PAID(2), SHIPPED(3), CANCELLED(4)}This generates the following Kotlin entity:
Order.kt - Generated Kotlin Entity
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.*import jakarta.validation.constraints.*import java.io.Serializableimport java.math.*import java.time.*import java.util.*import org.hibernate.annotations.Cacheimport org.hibernate.annotations.CacheConcurrencyStrategyimport org.springframework.data.annotation.CreatedByimport org.springframework.data.annotation.CreatedDateimport org.springframework.data.annotation.LastModifiedByimport org.springframework.data.annotation.LastModifiedDateimport org.springframework.data.jpa.domain.support.AuditingEntityListener
/** */@Entity@Table(name = "order_table")@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)@EntityListeners(AuditingEntityListener::class)data class Order( @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) var id: Long? = null, @Version var version: Int? = null,
/** prefix doc comment */ @NotNull @Size(max = 36) @Column(name = "order_number", nullable = false, unique = true, length = 36) @org.hibernate.annotations.NaturalId var orderNumber: String? = null,
/** suffix inline doc comment */ @NotNull @Column(name = "status", nullable = false) @Convert(converter = OrderStatus.OrderStatusConverter::class) var status: OrderStatus = OrderStatus.PLACED, @NotNull @Column(name = "total_amount", nullable = false) var totalAmount: BigDecimal? = null, @NotNull @Size(max = 3) @Column(name = "currency", nullable = false, length = 3) var currency: String? = null, @Column(name = "payment_reference") var paymentReference: String? = null, @Column(name = "tracking_number") var trackingNumber: String? = null,
/** Order lines stored as JSON for simplicity in the demo */ @Size(min = 1) @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) @Column(name = "items") var items: MutableList<OrderItem> = mutableListOf(),) : Serializable {
companion object { private const val serialVersionUID = 1L }
@CreatedBy @Column(name = "created_by", updatable = false) var createdBy: String? = null
@CreatedDate @Column(name = "created_date", columnDefinition = "TIMESTAMP", updatable = false) var createdDate: LocalDateTime? = null
@LastModifiedBy @Column(name = "last_modified_by") var lastModifiedBy: String? = null
@LastModifiedDate @Column(name = "last_modified_date", columnDefinition = "TIMESTAMP") var lastModifiedDate: LocalDateTime? = null
override fun toString(): String { return this::class.java.name + "#" + id }
/* https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-with-jpa-and-hibernate/ */ override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Order) return false return id != null && id == other.id }
override fun hashCode(): Int { return javaClass.hashCode() }}OrderStatus.kt - Generated Kotlin Enum
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.AttributeConverterimport jakarta.persistence.Converter
/** Enum for OrderStatus. */enum class OrderStatus(val value: Int) {
PLACED(1), PAID(2), SHIPPED(3), CANCELLED(4);
companion object { fun fromValue(value: Int): OrderStatus? { return values().find { it.value == value } } }
@Converter class OrderStatusConverter : AttributeConverter<OrderStatus, Int> { override fun convertToDatabaseColumn(attribute: OrderStatus?): Int? { return attribute?.value }
override fun convertToEntityAttribute(dbData: Int?): OrderStatus? { return if (dbData == null) null else OrderStatus.fromValue(dbData) } }}OrderItem.kt - Generated Kotlin Value Object
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.*import jakarta.validation.constraints.*import java.io.Serializableimport java.math.*import java.time.*import java.util.*
/** */// @Embeddable // json embeddeddata class OrderItem( @NotNull @Column(name = "product_id", nullable = false) var productId: String? = null, @NotNull @Size(max = 254) @Column(name = "product_name", nullable = false, length = 254) var productName: String? = null, @NotNull @Min(1) @Column(name = "quantity", nullable = false) var quantity: Integer? = null, @NotNull @Column(name = "unit_price", nullable = false) var unitPrice: BigDecimal? = null,) : Serializable {
companion object { private const val serialVersionUID = 1L }
override fun toString(): String { return this::class.java.name + "@" + Integer.toHexString(hashCode()) }}Aggregates will also get generated a Spring Data Repository interface and an InMemory implementation for testing purposes.
OrderRepository.kt - Generated Repository Interface
package io.zenwave360.example.orderfulfillment
import io.zenwave360.example.orderfulfillment.domain.*import java.math.*import java.time.*import java.util.*import org.springframework.data.jpa.repository.*import org.springframework.stereotype.Repository
/** Spring Data JPA repository for the Order entity. */@Suppress("unused")@Repositoryinterface OrderRepository : JpaRepository<Order, Long> { fun findByOrderNumber(orderNumber: String): Order?}Services
Section titled “Services”Services are the entry point to the core domain, and are generated as Spring @Service classes. The Order service defines commands for each state transition in the order lifecycle.
State transitions are defined by the combination of @lifecycle and @transition annotations in the ZDL model.
OrdersService in ZDL with State Transitions annotations
@rest("/orders")service OrderService for (Order) {
/** Customer places an order */ @post @transition(to: PLACED) placeOrder(PlaceOrderInput) Order withEvents OrderPlaced
/** Set Order as paid */ @post("/{orderNumber}/pay") @transition(from: PLACED, to: PAID) payOrder(@natural id, PayOrderInput) Order withEvents OrderPaid
/** Set Order as shipped */ @post("/{orderNumber}/ship") @transition(from: PAID, to: SHIPPED) shipOrder(@natural id, ShipOrderInput) Order withEvents OrderShipped
/** Set Order as cancelled */ @post("/{orderNumber}/cancel") @transition(from: [PLACED, PAID], to: CANCELLED) cancelOrder(@natural id) Order withEvents OrderCancelled
/** Query order */ @get("/{orderNumber}") getOrder(@natural id) Order?}This ZDL service will generate the following Kotlin service interface and implementation:
OrderService.kt - Generated Service Interface
package io.zenwave360.example.orderfulfillment
import io.zenwave360.example.orderfulfillment.domain.*import io.zenwave360.example.orderfulfillment.dtos.*import java.math.*import java.time.*
/** Inbound Service Port for managing [Order]. */interface OrderService {
/** * Customer places an order * * With Events: [OrderPlaced]. */ fun placeOrder(input: PlaceOrderInput): Order
/** * Set Order as paid * * With Events: [OrderPaid]. */ fun payOrder(orderNumber: String, input: PayOrderInput): Order
/** * Set Order as shipped * * With Events: [OrderShipped]. */ fun shipOrder(orderNumber: String, input: ShipOrderInput): Order
/** * Set Order as cancelled * * With Events: [OrderCancelled]. */ fun cancelOrder(orderNumber: String): Order
/** Query order */ fun getOrder(orderNumber: String): Order?}OrderServiceImpl.kt - Generated Service Implementation
package io.zenwave360.example.orderfulfillment
// import io.zenwave360.example.orderfulfillment.events.dtos.*import io.zenwave360.example.orderfulfillment.*import io.zenwave360.example.orderfulfillment.domain.*import io.zenwave360.example.orderfulfillment.dtos.*import io.zenwave360.example.orderfulfillment.events.*import io.zenwave360.example.orderfulfillment.mappers.*import java.math.*import java.time.*import org.slf4j.Loggerimport org.slf4j.LoggerFactoryimport org.springframework.stereotype.Serviceimport org.springframework.transaction.annotation.Transactionalimport java.util.UUID
/** Service Implementation for managing [Order]. */@Service@Transactionalopen class OrderServiceImpl( private val orderRepository: OrderRepository, private val eventsProducer: OrderEventsProducer,) : OrderService {
private val log: Logger = LoggerFactory.getLogger(javaClass)
private val orderServiceMapper: OrderServiceMapper = OrderServiceMapper.INSTANCE
private val eventsMapper: EventsMapper = EventsMapper.INSTANCE
override fun placeOrder(input: PlaceOrderInput): Order { log.debug("Request placeOrder: {}", input)
val order = orderServiceMapper.update(Order(), input) .apply { orderNumber = UUID.randomUUID().toString() status = OrderStatus.PLACED totalAmount = input.items.sumOf { it.unitPrice!! * BigDecimal.valueOf(it.quantity!!.toLong()) } } .let { orderRepository.save(it) }
// emit events eventsProducer.onOrderPlaced(eventsMapper.asOrderPlaced(order)) return order }
override fun payOrder(orderNumber: String, input: PayOrderInput): Order { log.debug("Request payOrder: {} {}", orderNumber, input)
return orderRepository .findByOrderNumber(orderNumber) ?.let { existingOrder -> OrderTransitions.ensureCanPayOrder(existingOrder) orderServiceMapper.update(existingOrder, input) existingOrder.status = OrderStatus.PAID existingOrder } ?.let { orderRepository.save(it) } ?.also {
// emit events eventsProducer.onOrderPaid(eventsMapper.asOrderPaid(it)) } ?: throw NoSuchElementException("Order not found with id: orderNumber=$orderNumber") }
override fun shipOrder(orderNumber: String, input: ShipOrderInput): Order { log.debug("Request shipOrder: {} {}", orderNumber, input)
return orderRepository .findByOrderNumber(orderNumber) ?.let { existingOrder -> OrderTransitions.ensureCanShipOrder(existingOrder) orderServiceMapper.update(existingOrder, input) existingOrder.status = OrderStatus.SHIPPED existingOrder } ?.let { orderRepository.save(it) } ?.also {
// emit events eventsProducer.onOrderShipped(eventsMapper.asOrderShipped(it)) } ?: throw NoSuchElementException("Order not found with id: orderNumber=$orderNumber") }
override fun cancelOrder(orderNumber: String): Order { log.debug("Request cancelOrder: {}", orderNumber)
val existingOrder = orderRepository.findByOrderNumber(orderNumber) ?: throw NoSuchElementException("Order not found with id: orderNumber=$orderNumber") OrderTransitions.ensureCanCancelOrder(existingOrder) existingOrder.status = OrderStatus.CANCELLED val order = orderRepository.save(existingOrder)
// emit events eventsProducer.onOrderCancelled(eventsMapper.asOrderCancelled(order)) return order }
@Transactional(readOnly = true) override fun getOrder(orderNumber: String): Order? { log.debug("Request getOrder: {}", orderNumber) val order = orderRepository.findByOrderNumber(orderNumber) return order }}Exposing REST APIs
Section titled “Exposing REST APIs”The REST API is generated from the OpenAPI specification, creating Spring MVC controllers that delegate to the service layer.
OrderApiController.kt - Generated REST Controller
package io.zenwave360.example.orderfulfillment
import io.zenwave360.example.orderfulfillment.domain.*import io.zenwave360.example.orderfulfillment.*import io.zenwave360.example.orderfulfillment.dtos.*import io.zenwave360.example.orderfulfillment.web.*import io.zenwave360.example.orderfulfillment.web.model.*import io.zenwave360.example.orderfulfillment.*import io.zenwave360.example.orderfulfillment.mappers.*
import java.net.URIimport java.net.URISyntaxExceptionimport java.math.*import java.time.*import java.util.*import jakarta.validation.Validimport jakarta.validation.constraints.NotNullimport org.mapstruct.factory.Mappersimport org.slf4j.Loggerimport org.slf4j.LoggerFactoryimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.beans.factory.annotation.Valueimport org.springframework.http.MediaTypeimport org.springframework.http.ResponseEntityimport org.springframework.web.bind.annotation.*import org.springframework.core.io.ByteArrayResourceimport org.springframework.core.io.Resourceimport org.springframework.data.domain.Pageimport org.springframework.data.domain.PageRequestimport org.springframework.data.domain.Pageableimport org.springframework.data.domain.Sortimport org.springframework.web.context.request.NativeWebRequest
/** * REST controller for OrderApi. */@RestController@RequestMapping("/api")open class OrderApiController( private val orderService: OrderService) : OrderApi {
private val log: Logger = LoggerFactory.getLogger(javaClass)
@Autowired private lateinit var request: NativeWebRequest
private val mapper = OrderDTOsMapper.INSTANCE
override fun placeOrder(reqBody: PlaceOrderInputDTO): ResponseEntity<OrderDTO> { log.debug("REST request to placeOrder: {}", reqBody) val input = mapper.asPlaceOrderInput(reqBody) val order = orderService.placeOrder(input) val responseDTO = mapper.asOrderDTO(order) return ResponseEntity.status(201).body(responseDTO) }
override fun payOrder(orderNumber: String, reqBody: PayOrderInputDTO): ResponseEntity<OrderDTO> { log.debug("REST request to payOrder: {}, {}", orderNumber, reqBody) val input = mapper.asPayOrderInput(reqBody) val order = orderService.payOrder(orderNumber, input) val responseDTO = mapper.asOrderDTO(order) return ResponseEntity.status(201).body(responseDTO) }
override fun shipOrder(orderNumber: String, reqBody: ShipOrderInputDTO): ResponseEntity<OrderDTO> { log.debug("REST request to shipOrder: {}, {}", orderNumber, reqBody) val input = mapper.asShipOrderInput(reqBody) val order = orderService.shipOrder(orderNumber, input) val responseDTO = mapper.asOrderDTO(order) return ResponseEntity.status(201).body(responseDTO) }
override fun cancelOrder(orderNumber: String): ResponseEntity<OrderDTO> { log.debug("REST request to cancelOrder: {}", orderNumber) val order = orderService.cancelOrder(orderNumber) val responseDTO = mapper.asOrderDTO(order) return ResponseEntity.status(201).body(responseDTO) }
override fun getOrder(orderNumber: String): ResponseEntity<OrderDTO> { log.debug("REST request to getOrder: {}", orderNumber) val order = orderService.getOrder(orderNumber) return if (order != null) { val responseDTO = mapper.asOrderDTO(order) ResponseEntity.status(200).body(responseDTO) } else { ResponseEntity.notFound().build() } }
protected fun pageOf(page: Int?, limit: Int?, sort: List<String>?): Pageable { val sortOrder = sort?.let { Sort.by(it.map { sortParam -> val parts = sortParam.split(":") val property = parts[0] val direction = if (parts.size > 1) Sort.Direction.fromString(parts[1]) else Sort.Direction.ASC Sort.Order(direction, property) }) } ?: Sort.unsorted() return PageRequest.of(page ?: 0, limit ?: 10, sortOrder) }}OrderDTOsMapper.kt - Generated DTO Mapper
package io.zenwave360.example.orderfulfillment.mappers
import io.zenwave360.example.orderfulfillment.mappers.*import io.zenwave360.example.orderfulfillment.domain.*import io.zenwave360.example.orderfulfillment.dtos.*import io.zenwave360.example.orderfulfillment.web.model.*
import org.mapstruct.Mapperimport org.mapstruct.Mappingimport org.mapstruct.factory.Mappersimport java.math.*import java.time.*import java.util.*import org.springframework.data.domain.Page
@Mapper(uses = [BaseMapper::class])interface OrderDTOsMapper {
companion object { val INSTANCE: OrderDTOsMapper = Mappers.getMapper(OrderDTOsMapper::class.java) }
// request mappings fun asPlaceOrderInput(dto: PlaceOrderInputDTO): PlaceOrderInput fun asShipOrderInput(dto: ShipOrderInputDTO): ShipOrderInput fun asPayOrderInput(dto: PayOrderInputDTO): PayOrderInput
// response mappings
fun asOrderDTO(entity: Order): OrderDTO
}Publishing Domain Events
Section titled “Publishing Domain Events”Service commands publish Domain Events as part of their operations. Events are defined in the ZDL model and generated code handles the publishing to Kafka.
OrderServiceImpl.kt - Using Generated Events Producer
/** Service Implementation for managing [Order]. */@Service@Transactionalopen class OrderServiceImpl( private val orderRepository: OrderRepository, private val eventsProducer: OrderEventsProducer,) : OrderService {
private val log: Logger = LoggerFactory.getLogger(javaClass)
private val orderServiceMapper: OrderServiceMapper = OrderServiceMapper.INSTANCE
private val eventsMapper: EventsMapper = EventsMapper.INSTANCE
override fun placeOrder(input: PlaceOrderInput): Order { log.debug("Request placeOrder: {}", input)
val order = orderServiceMapper.update(Order(), input) .apply { orderNumber = UUID.randomUUID().toString() status = OrderStatus.PLACED totalAmount = input.items.sumOf { it.unitPrice!! * BigDecimal.valueOf(it.quantity!!.toLong()) } } .let { orderRepository.save(it) }
// emit events eventsProducer.onOrderPlaced(eventsMapper.asOrderPlaced(order)) return order }OrderEventsProducer is generated from the AsyncAPI definition using ZenWave SDK Maven Plugin.
Running the Order Fulfillment Service
Section titled “Running the Order Fulfillment Service”Prerequisites
Section titled “Prerequisites”- JDK 24
- 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 order lifecycle:
- Create a new order via POST
/api/orders - Pay for the order via POST
/api/orders/{orderNumber}/pay - Ship the order via POST
/api/orders/{orderNumber}/ship - Retrieve order details via GET
/api/orders/{orderNumber}
- Create a new order via POST
What’s Running
Section titled “What’s Running”- PostgreSQL (port 5432) - Order data persistence
- Kafka (port 9092) - Domain events messaging
- Spring Boot App (port 8080) - REST API and business logic
Happy Coding! 🚀