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
OrderItementities stored in a JSON column
Business Rules:
- Order numbers must be unique
- Shipped orders cannot be cancelled
Domain Model Diagram
classDiagramclass Order {<<aggregate>>+String orderNumber+OrderStatus status+BigDecimal totalAmount+String currency+String paymentReference+String trackingNumber+OrderItem[] items}class OrderStatus {<<enumeration>>DRAFT : 1PLACED : 2PAID : 3SHIPPED : 4CANCELLED : 5}class OrderItem {+String productId+String productName+Integer quantity+BigDecimal unitPrice}Order *-- "1..*" OrderItem : containsOrder --> OrderStatus : has
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.1description: "Order Fulfillment DDD Example"contact:email: email@domain.comservers:- description: localhosturl: http://localhost:8080/api- description: customurl: "{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: placeOrderdescription: "Customer places an order"tags: [Order]requestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/PlaceOrderInput"responses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Order"/orders/{orderNumber}/pay:post:operationId: payOrderdescription: "Order is shipped"tags: [Order]parameters:- name: "orderNumber"in: pathrequired: trueschema:type: stringrequestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/PayOrderInput"responses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Order"/orders/{orderNumber}/ship:post:operationId: shipOrderdescription: "Order is cancelled"tags: [Order]parameters:- name: "orderNumber"in: pathrequired: trueschema:type: stringrequestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/ShipOrderInput"responses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Order"/orders/{orderNumber}/cancel:post:operationId: cancelOrderdescription: "Query order"tags: [Order]parameters:- name: "orderNumber"in: pathrequired: trueschema:type: stringresponses:"201":description: "OK"content:application/json:schema:$ref: "#/components/schemas/Order"/orders/{orderNumber}:get:operationId: getOrderdescription: "getOrder"tags: [Order]parameters:- name: "orderNumber"in: pathrequired: trueschema:type: stringresponses:"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: 1currency:type: "string"maxLength: 3PayOrderInput: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: trueversion:type: "integer"default: "null"description: "Version of the document (required in PUT for concurrency control,\\ should be null in POSTs)."orderNumber:type: "string"maxLength: 36description: "prefix doc comment"status:$ref: "#/components/schemas/OrderStatus"totalAmount:type: "number"format: "double"currency:type: "string"maxLength: 3paymentReference: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: 1OrderStatus: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: 254quantity:type: "integer"format: "int32"unitPrice:type: "number"format: "double"Page:type: objectrequired:- "content"- "totalElements"- "totalPages"- "size"- "number"properties:number:type: integerminimum: 0numberOfElements:type: integerminimum: 0size:type: integerminimum: 0maximum: 200multipleOf: 25totalElements:type: integertotalPages:type: integerparameters:page:name: pagein: querydescription: The number of results pageschema:type: integerformat: int32default: 0limit:name: limitin: querydescription: The number of results in a single pageschema:type: integerformat: int32default: 20sort:name: sortin: querydescription: Sort results by field name and direction (asc or desc)schema:type: arrayitems:type: stringsecuritySchemes:basicAuth: # <-- arbitrary name for the security schemetype: httpscheme: basicbearerAuth: # <-- arbitrary name for the security schemetype: httpscheme: bearerbearerFormat: JWT # optional, arbitrary value for documentation purposessecurity:- basicAuth: [] # <-- use the same name here- bearerAuth: [] # <-- use the same name here
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.1tags:- name: "Default"- name: "Order"defaultContentType: application/jsonchannels: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: sendtags:- name: Orderchannel:$ref: '#/channels/OrdersPlacedChannel'onOrderPaid:action: sendtags:- name: Orderchannel:$ref: '#/channels/OrdersPaidChannel'onOrderShipped:action: sendtags:- name: Orderchannel:$ref: '#/channels/OrdersShippedChannel'onOrderCancelled:action: sendtags:- name: Orderchannel:$ref: '#/channels/OrdersCancelledChannel'components:messages:OrderPlacedMessage:name: OrderPlacedMessagetitle: "Domain Events"summary: "Domain Events"traits:- $ref: '#/components/messageTraits/CommonHeaders'payload:$ref: "#/components/schemas/OrderPlaced"OrderPaidMessage:name: OrderPaidMessagetitle: ""summary: ""traits:- $ref: '#/components/messageTraits/CommonHeaders'payload:$ref: "#/components/schemas/OrderPaid"OrderShippedMessage:name: OrderShippedMessagetitle: ""summary: ""traits:- $ref: '#/components/messageTraits/CommonHeaders'payload:$ref: "#/components/schemas/OrderShipped"OrderCancelledMessage:name: OrderCancelledMessagetitle: ""summary: ""traits:- $ref: '#/components/messageTraits/CommonHeaders'payload:$ref: "#/components/schemas/OrderCancelled"messageTraits:CommonHeaders:headers:type: objectproperties:kafka_messageKey:type: "integer"format: "int64"description: This header value will be populated automatically at runtimex-runtime-expression: $message.payload#/id# CloudEvents Attributesce-id:type: stringdescription: Unique identifier for the eventx-runtime-expression: $message.payload#{#this.id}ce-source:type: stringdescription: URI identifying the context where event happenedx-runtime-expression: $message.payload#{"Order"}ce-specversion:type: stringdescription: CloudEvents specification versionx-runtime-expression: $message.payload#{"1.0"}ce-type:type: stringdescription: Event typex-runtime-expression: $message.payload#{#this.getClass().getSimpleName()}ce-time:type: stringdescription: Timestamp of when the event happenedx-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: 36description: "prefix doc comment"status:$ref: "#/components/schemas/OrderStatus"totalAmount:type: "number"format: "double"currency:type: "string"maxLength: 3paymentReference: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: 1id: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: 36description: "prefix doc comment"status:$ref: "#/components/schemas/OrderStatus"totalAmount:type: "number"format: "double"currency:type: "string"maxLength: 3paymentReference: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: 1id: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: 36description: "prefix doc comment"status:$ref: "#/components/schemas/OrderStatus"totalAmount:type: "number"format: "double"currency:type: "string"maxLength: 3paymentReference:type: "string"trackingNumber:type: "string"items:type: "array"items:$ref: "#/components/schemas/OrderItem"minLength: 1id: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: 36description: "prefix doc comment"status:$ref: "#/components/schemas/OrderStatus"totalAmount:type: "number"format: "double"currency:type: "string"maxLength: 3paymentReference: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: 1id: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: 254quantity:type: "integer"format: "int32"unitPrice:type: "number"format: "double"
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 jpadatabaseType postgresql// you can choose: DefaultProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout// CleanHexagonalProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayoutlayout SimpleDomainProjectLayout// these should match what you have configured in your pom.xml for asyncapi-generator-maven-pluginlayout.asyncApiModelPackage "{{basePackage}}.events.dtos"layout.asyncApiProducerApiPackage "{{basePackage}}.events"layout.asyncApiConsumerApiPackage "{{basePackage}}.commands"}/*** Order aggregate root*/@aggregate@auditingentity Order(order_table) {/** prefix doc comment */@naturalIdorderNumber String required unique maxlength(36)status OrderStatus required /** suffix inline doc comment */totalAmount BigDecimal requiredcurrency String required maxlength(3)paymentReference StringtrackingNumber String/*** Order lines stored as JSON for simplicity in the demo*/@json items OrderItem[] minlength(1) {productId String requiredproductName 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 */@postplaceOrder(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 Longversion Integer}@copy(Order)@asyncapi({ channel: "OrdersPaidChannel", topic: "orders.paid" })event OrderPaid {id Longversion Integer}@copy(Order)@asyncapi({ channel: "OrdersShippedChannel", topic: "orders.shipped" })event OrderShipped {id Longversion IntegertrackingNumber String}@copy(Order)@asyncapi({ channel: "OrdersCancelledChannel", topic: "orders.cancelled" })event OrderCancelled {id Longversion Integer}
`zenwave-scripts.zw` for Order Fulfillment Service
@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 SpringWebTestClientPluginopenApiApiPackage "{{basePackage}}.web"openApiModelPackage "{{basePackage}}.web.model"openApiModelNameSuffix DTOplugins {/** Generates an OpenAPI 3.0 specification from the ZDL model. */ZDLToOpenAPIPlugin {idType integeridTypeFormat int64targetFile "src/main/resources/public/apis/openapi.yml"}/** Generates an AsyncAPI 3.0 specification from the ZDL model. */ZDLToAsyncAPIPlugin {asyncapiVersion v3idType integeridTypeFormat int64targetFile "src/main/resources/public/apis/asyncapi.yml"includeCloudEventsHeaders trueincludeKafkaCommonHeaders true}/** Generates a Backend Application from the ZDL model. (Headless Core) */BackendApplicationDefaultPlugin {templates "new io.zenwave360.sdk.plugins.kotlin.BackendApplicationKotlinTemplates()"useJSpecify trueincludeEmitEventsImplementation 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 businessFlowbusinessFlowTestName FromPlaceOrderToCancelIntegrationTestoperationIds 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
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@auditingentity Order(order_table) {/** prefix doc comment */@naturalIdorderNumber String required unique maxlength(36)status OrderStatus required /** suffix inline doc comment */totalAmount BigDecimal requiredcurrency String required maxlength(3)paymentReference StringtrackingNumber String/*** Order lines stored as JSON for simplicity in the demo*/@json items OrderItem[] minlength(1) {productId String requiredproductName String required maxlength(254)quantity Integer required min(1)unitPrice BigDecimal required}}policies (Order) {orderNumberUnique "orderNumber must be unique"}
This generates the following Kotlin entity:
Order.kt - Generated Kotlin Entity
package io.zenwave360.example.orderfulfillment.domainimport 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.NaturalIdvar 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? = nulloverride 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 trueif (other !is Order) return falsereturn id != null && id == other.id}override fun hashCode(): Int {return javaClass.hashCode()}}
OrderStatus.kt - Generated Kotlin Enum
package io.zenwave360.example.orderfulfillment.domainimport jakarta.persistence.AttributeConverterimport 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 }}}@Converterclass 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.domainimport 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.orderfulfillmentimport 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
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
package io.zenwave360.example.orderfulfillmentimport 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?}
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.INSTANCEprivate val eventsMapper: EventsMapper = EventsMapper.INSTANCEoverride fun placeOrder(input: PlaceOrderInput): Order {log.debug("Request placeOrder: {}", input)val order = orderServiceMapper.update(Order(), input).apply {orderNumber = UUID.randomUUID().toString()status = OrderStatus.PLACEDtotalAmount = input.items.sumOf { it.unitPrice!! * BigDecimal.valueOf(it.quantity!!.toLong()) }}.let { orderRepository.save(it) }// emit eventseventsProducer.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 eventseventsProducer.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 eventseventsProducer.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 eventseventsProducer.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}}
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.orderfulfillmentimport 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)@Autowiredprivate lateinit var request: NativeWebRequestprivate val mapper = OrderDTOsMapper.INSTANCEoverride 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.ASCSort.Order(direction, property)})} ?: Sort.unsorted()return PageRequest.of(page ?: 0, limit ?: 10, sortOrder)}}
OrderDTOsMapper.kt - Generated DTO Mapper
package io.zenwave360.example.orderfulfillment.mappersimport 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 mappingsfun asPlaceOrderInput(dto: PlaceOrderInputDTO): PlaceOrderInputfun asShipOrderInput(dto: ShipOrderInputDTO): ShipOrderInputfun asPayOrderInput(dto: PayOrderInputDTO): PayOrderInput// response mappingsfun asOrderDTO(entity: Order): OrderDTO}
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.INSTANCEprivate val eventsMapper: EventsMapper = EventsMapper.INSTANCEoverride fun placeOrder(input: PlaceOrderInput): Order {log.debug("Request placeOrder: {}", input)val order = orderServiceMapper.update(Order(), input).apply {orderNumber = UUID.randomUUID().toString()status = OrderStatus.PLACEDtotalAmount = input.items.sumOf { it.unitPrice!! * BigDecimal.valueOf(it.quantity!!.toLong()) }}.let { orderRepository.save(it) }// emit eventseventsProducer.onOrderPlaced(eventsMapper.asOrderPlaced(order))return order}
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
dockerdengine (notcontainerd), which includes bothdockeranddocker-composecommands. - Your favorite IDE
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
- PostgreSQL (port 5432) - Order data persistence
- Kafka (port 9092) - Domain events messaging
- Spring Boot App (port 8080) - REST API and business logic
Happy Coding! π