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

  • 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

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:

  • DRAFT β†’ PLACED β†’ PAID β†’ SHIPPED β†’ CANCELLED

The Order aggregate contains:

  • Order metadata (orderNumber, status, totalAmount, currency)
  • Payment information (paymentReference)
  • Shipping information (trackingNumber)
  • An array of OrderItem entities stored in a JSON column

Business Rules:

  • Order numbers must be unique
  • Shipped orders cannot be cancelled

Domain Model Diagram

classDiagram
class Order {
<<aggregate>>
+String orderNumber
+OrderStatus status
+BigDecimal totalAmount
+String currency
+String paymentReference
+String trackingNumber
+OrderItem[] items
}
class OrderStatus {
<<enumeration>>
DRAFT : 1
PLACED : 2
PAID : 3
SHIPPED : 4
CANCELLED : 5
}
class OrderItem {
+String productId
+String productName
+Integer quantity
+BigDecimal unitPrice
}
Order *-- "1..*" OrderItem : contains
Order --> OrderStatus : has

REST API defined with OpenAPI

The application exposes REST endpoints for managing the order lifecycle:

  • POST /api/orders - Place a new order
  • POST /api/orders/{orderNumber}/pay - Pay for an order
  • POST /api/orders/{orderNumber}/ship - Ship an order
  • POST /api/orders/{orderNumber}/cancel - Cancel an order
  • GET /api/orders/{orderNumber} - Get order details
OpenAPI Definition for Order Fulfillment Service (πŸ‘‡ view source)
openapi: 3.0.1
info:
title: "Order Fulfillment DDD Example"
version: 0.0.1
description: "Order Fulfillment DDD Example"
contact:
email: email@domain.com
servers:
- 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: "Order is shipped"
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: "Order is cancelled"
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: "Query order"
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: "getOrder"
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:
- "orderNumber"
- "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: "Order lines stored as JSON for simplicity in the demo"
items:
type: "array"
items:
$ref: "#/components/schemas/OrderItem"
minLength: 1
OrderStatus:
type: "string"
x-business-entity: "OrderStatus"
enum:
- "DRAFT"
- "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 purposes
security:
- basicAuth: [] # <-- use the same name here
- bearerAuth: [] # <-- use the same name here
πŸ”—Navigate to source

Domain Events with AsyncAPI

The application publishes domain events to Kafka topics for each state transition:

  • orders.placed - OrderPlaced events
  • orders.paid - OrderPaid events
  • orders.shipped - OrderShipped events
  • orders.cancelled - OrderCancelled events

Events include CloudEvents headers and Kafka message keys for proper routing and traceability.

AsyncAPI Definition for Order Events (πŸ‘‡ view source)
asyncapi: 3.0.0
info:
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: "Order lines stored as JSON for simplicity in the demo"
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: "Order lines stored as JSON for simplicity in the demo"
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: "Order lines stored as JSON for simplicity in the demo"
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:
- "DRAFT"
- "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"
πŸ”—Navigate to source

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 Order Fulfillment Service (πŸ‘‡ view source)
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
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"
}
policies {
shipedOrdersCanNotBeCancelled "Shipped orders can not be cancelled"
}
enum OrderStatus {
DRAFT(1),
PLACED(2),
PAID(3),
SHIPPED(4),
CANCELLED(5)
}
/**
* 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
placeOrder(PlaceOrderInput) Order withEvents OrderPlaced
/** Order is paid */
@post("/{orderNumber}/pay")
payOrder(@natural id, PayOrderInput) Order withEvents OrderPaid
/** Order is shipped */
@post("/{orderNumber}/ship")
shipOrder(@natural id, ShipOrderInput) Order withEvents OrderShipped
/** Order is cancelled */
@post("/{orderNumber}/cancel")
@policy(shipedOrdersCanNotBeCancelled)
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
}
πŸ”—Navigate to source
`zenwave-scripts.zw` for Order Fulfillment Service (πŸ‘‡ view source)
@import("io.zenwave360.sdk.plugins.customizations:kotlin-backend-application:2.3.0-SNAPSHOT")
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
}
}
}
πŸ”—Navigate to source

NOTE: You can name these 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.

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 (πŸ‘‡ view source)
/**
* Order aggregate root
*/
@aggregate
@auditing
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"
}
πŸ”—Navigate to source

This generates the following Kotlin entity:

Order.kt - Generated Kotlin Entity (πŸ‘‡ view source)
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.io.Serializable
import java.math.*
import java.time.*
import java.util.*
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
/** */
@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? = null,
@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,
/** Order lines stored as JSON for simplicity in the demo */
@Column(name = "tracking_number") var trackingNumber: String? = null,
@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()
}
}
πŸ”—Navigate to source
OrderStatus.kt - Generated Kotlin Enum (πŸ‘‡ view source)
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
/** Enum for OrderStatus. */
enum class OrderStatus(val value: Int) {
DRAFT(1),
PLACED(2),
PAID(3),
SHIPPED(4),
CANCELLED(5);
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)
}
}
}
πŸ”—Navigate to source
OrderItem.kt - Generated Kotlin Value Object (πŸ‘‡ view source)
package io.zenwave360.example.orderfulfillment.domain
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.io.Serializable
import java.math.*
import java.time.*
import java.util.*
/** */
// @Embeddable // json embedded
data 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())
}
}
πŸ”—Navigate to source

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

OrderRepository.kt - Generated Repository Interface (πŸ‘‡ view source)
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")
@Repository
interface OrderRepository : JpaRepository<Order, Long> {
fun findByOrderNumber(orderNumber: String): Order?
}
πŸ”—Navigate to source

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.

OrderService.kt - Generated Service Interface (πŸ‘‡ view source)
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
/**
* Order is shipped
*
* With Events: [OrderPaid].
*/
fun payOrder(orderNumber: String, input: PayOrderInput): Order
/**
* Order is cancelled
*
* With Events: [OrderShipped].
*/
fun shipOrder(orderNumber: String, input: ShipOrderInput): Order
/**
* Query order
*
* <ul><li><b>shipedOrdersCanNotBeCancelled</b>: Shipped orders can not be cancelled</li></ul> With Events:
* [OrderCancelled].
*/
fun cancelOrder(orderNumber: String): Order
/** */
fun getOrder(orderNumber: String): Order?
}
πŸ”—Navigate to source
OrderServiceImpl.kt - Generated Service Implementation (πŸ‘‡ view source)
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.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
/** Service Implementation for managing [Order]. */
@Service
@Transactional
open 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 -> orderServiceMapper.update(existingOrder, input) }
?.apply { status = OrderStatus.PAID }
?.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 -> orderServiceMapper.update(existingOrder, input) }
?.apply { status = OrderStatus.SHIPPED }
?.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)
return orderRepository.findByOrderNumber(orderNumber)
?.also {
if (it.status == OrderStatus.SHIPPED) {
throw IllegalStateException("Shipped orders can not be cancelled")
}
}
?.apply { status = OrderStatus.CANCELLED }
?.let { orderRepository.save(it) }
?.also {
// emit events
eventsProducer.onOrderCancelled(eventsMapper.asOrderCancelled(it))
}
?: throw NoSuchElementException("Order not found with id: orderNumber=$orderNumber")
}
@Transactional(readOnly = true)
override fun getOrder(orderNumber: String): Order? {
log.debug("Request getOrder: {}", orderNumber)
val order = orderRepository.findByOrderNumber(orderNumber)
return order
}
}
πŸ”—Navigate to source

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 (πŸ‘‡ view source)
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.URI
import java.net.URISyntaxException
import java.math.*
import java.time.*
import java.util.*
import jakarta.validation.Valid
import jakarta.validation.constraints.NotNull
import org.mapstruct.factory.Mappers
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import 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)
}
}
πŸ”—Navigate to source
OrderDTOsMapper.kt - Generated DTO Mapper (πŸ‘‡ view source)
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.Mapper
import org.mapstruct.Mapping
import org.mapstruct.factory.Mappers
import 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
}
πŸ”—Navigate to source

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 (πŸ‘‡ view source)
/** Service Implementation for managing [Order]. */
@Service
@Transactional
open 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
}
πŸ”—Navigate to source

OrderEventsProducer is generated from the AsyncAPI definition using ZenWave SDK Maven Plugin.

Running the Order Fulfillment Service

Prerequisites

  • JDK 24
  • 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 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}

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! πŸš€