From OpenAPI contract to domain event — a hands-on walkthrough of one bounded context, end to end.
If you've followed the series so far, you know the big picture: a multi-module Maven monorepo, a services layer that enforces DDD boundaries, an observability stack that boots with a single command. Good. Now let's open the hood.
This post focuses on the Order Service — the most central bounded context in SmartOrder. We'll trace a single request from the moment it hits the API contract all the way to the RabbitMQ event on the other side, looking at the real code along the way.
What does the Order Service actually own?
Before writing a single line of code, it's worth asking: what does this service know, and what is explicitly none of its business?
The Order Service owns:
- the lifecycle of an order (created → confirmed → shipped → delivered, or cancelled)
- the order's line items and quantities
- its own MongoDB collection
It does not own:
- product details (that's the Product Service)
- stock levels (that's Inventory)
- pricing rules (at least not yet — fun contribution opportunity 👀)
This hard boundary is not just a design choice. It's enforced structurally. There's no cross-service database join, no shared schema, no sneaky @FeignClient call to fetch product names inside a transaction. If another service's data is needed, you go through its API or you listen to its events. Period.
The module structure: three modules, three responsibilities
The Order Service is itself a multi-module Maven project:
order-service/
├── api/ ← OpenAPI contract, generated interfaces, DelegateImpl, HATEOAS helpers
├── bootstrap/ ← test data initialization
├── business/ ← domain model, Drools rules, application layer, infrastructure adapters
└── pom.xml
This split is deliberate.
api is the public face of the service. It contains the OpenAPI spec, the Mustache-generated interfaces, the OrdersApiDelegateImpl that bridges the HTTP layer to the application layer, and the HATEOAS helper classes. Nothing in here knows about MongoDB or RabbitMQ. If the contract changes, this is the only module that needs to recompile — and the rest of the codebase fails at the right place, at the right time.
bootstrap handles test data initialization. When the full stack boots locally, you don't want to hit an empty database on every demo or integration test run. The bootstrap module seeds the MongoDB collection with a coherent initial dataset, so the service is immediately usable and the Grafana dashboards have something meaningful to display. Keeping it in its own module means that concern never leaks into production logic — no if (isDev) scattered around the codebase. It can be excluded from production builds entirely.
business is where the actual work happens. Domain model, application-layer use cases, infrastructure adapters (MongoDB repositories, RabbitMQ via Spring Cloud Stream), and — the part worth talking about at length — the Drools rule engine integration.
The separation between api and business means you can evolve the HTTP contract and the domain logic independently. Small monorepo trick, big dividend when the project grows.
Business rules with Drools: the state machine lives in .drl files
Most Spring Boot services handle business rules the obvious way: a chain of if statements in a service class. That works until the rules multiply, start interacting with each other, or need to change without touching Java code.
SmartOrder takes a different approach in the business module. Business rules — including the order state machine — are expressed as Drools .drl files. Here's the rule that seeds the working memory with valid state transitions:
rule "Initialize valid transitions"
salience 100
ruleflow-group "validation"
when
// always fires — seeds the working memory with valid transitions
then
// PENDING → PENDING / CONFIRMED / CANCELLED / OUT_OF_STOCK
insert(new ValidTransition(OrderStatus.PENDING, OrderStatus.PENDING));
insert(new ValidTransition(OrderStatus.PENDING, OrderStatus.CONFIRMED));
insert(new ValidTransition(OrderStatus.PENDING, OrderStatus.CANCELLED));
insert(new ValidTransition(OrderStatus.PENDING, OrderStatus.OUT_OF_STOCK));
// CONFIRMED → CONFIRMED / SHIPPED / CANCELLED
insert(new ValidTransition(OrderStatus.CONFIRMED, OrderStatus.CONFIRMED));
insert(new ValidTransition(OrderStatus.CONFIRMED, OrderStatus.SHIPPED));
insert(new ValidTransition(OrderStatus.CONFIRMED, OrderStatus.CANCELLED));
// SHIPPED → SHIPPED / DELIVERED
insert(new ValidTransition(OrderStatus.SHIPPED, OrderStatus.SHIPPED));
insert(new ValidTransition(OrderStatus.SHIPPED, OrderStatus.DELIVERED));
end
query "validTransitionsFor" ( OrderStatus $from )
ValidTransition( current == $from, $vt := this )
end
The .drl is the single source of truth for the state machine. There's no switch statement, no EnumSet somewhere else that could drift out of sync. When a new business requirement arrives — say, an ON_HOLD state — you change the rule file, and every part of the system that queries valid transitions picks it up automatically.
The Drools query at the bottom is also notable: it lets the application layer ask "what transitions are valid from this state?" and get back a list driven by the same rules, not by a hardcoded enum.
The rule engine: Strategy pattern over raw sessions
The integration on the Spring side is where it gets interesting. Rather than exposing KieSession directly, the engine uses the Strategy pattern to encapsulate each use case:
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderRuleEngineImpl implements OrderRuleEngine {
private final KieBase orderKieBase;
@Override
public Order applyRules(Order order) {
return executeWithStrategy(order, new SimpleProcessingStrategy());
}
@Override
public Order applyStatusTransition(Order order, OrderStatus newStatus) {
return executeWithStrategy(order, new StatusTransitionStrategy(newStatus));
}
@Override
public List<OrderStatus> getTransitionStatuses(Order order) {
try (KieSession session = orderKieBase.newKieSession()) {
return new TransitionStrategy().execute(session, order);
}
}
private Order executeWithStrategy(Order order, OrderRuleStrategy strategy) {
try (KieSession session = orderKieBase.newKieSession()) {
return strategy.execute(session, order);
}
}
}
A few things worth noting here. KieBase — not KieContainer — is injected as a singleton: it holds the compiled rules and is thread-safe by design. KieSession is created fresh per execution and closed immediately with try-with-resources. Forgetting to close a KieSession is a common source of memory leaks in Drools applications; the try-with-resources makes it impossible to forget.
Each use case (applyRules, applyStatusTransition, getTransitionStatuses) gets its own strategy class. Adding a new rule use case means adding a new strategy — the engine itself doesn't change.
Contract-first: two YAML files, two roles
The api module exists before any controller. Two distinct YAML files drive the whole thing, and understanding their roles is important.
config.spring.api-v1.yml is the generator configuration — not the spec itself. It's the instruction set for the openapi-generator-maven-plugin: which Spring features to activate, where to find the spec, which Mustache templates to use. Think of it as the build-time manifest.
api-v1.yml is the actual OpenAPI specification. It lives under static/ so it's served at runtime — point Swagger UI or any OpenAPI tool at the running service and you get the live spec. Here's the relevant excerpt for the orders endpoints:
paths:
/orders:
post:
operationId: addOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: "Created"
x-hateoas: true
content:
application/hal+json:
schema:
$ref: '#/components/schemas/Order'
get:
operationId: getOrders
responses:
'200':
description: "Success"
x-spring-page-type: it.portus.smartorder.ms.orderservice.api.v1.openapi.model.Order
x-hateoas: true
content:
application/prs.hal-forms+json:
schema:
$ref: '#/components/schemas/PageOrder'
Two things stand out. The POST returns application/hal+json — standard HATEOAS. The GET returns application/prs.hal-forms+json — HAL-FORMS, which also includes affordances describing what actions are available. And those x-hateoas and x-spring-page-type vendor extensions are not standard OpenAPI; they're the hook for the custom code generation described next.
Where the standard generator falls short: custom extensions + Mustache templates
Anyone who has tried to combine openapi-generator, Spring HATEOAS and pagination will have hit the same wall: the standard plugin has no concept of PagedModel<EntityModel<T>>. Out of the box it generates List<Order>, which is useless for a HATEOAS-first API.
SmartOrder solves this with two things working together.
Custom vendor extensions in the spec — x-hateoas: true flags an endpoint as needing HATEOAS wrapping. x-spring-page-type carries the fully-qualified entity class name for paginated responses. The generator ignores these (they're not in the spec), but custom Mustache templates can read them.
Custom Mustache templates in commons-service — the generator config points to a shared template directory:
services/commons/commons-service/src/main/resources/openapi/templates/
These override the default generator output. When a template encounters x-hateoas: true, it emits the correct Spring HATEOAS return type. The result, instead of ResponseEntity<List<Order>>, is:
ResponseEntity<PagedModel<EntityModel<Order>>>
That's the exact signature in OrdersApiDelegateImpl:
@Component
public class OrdersApiDelegateImpl implements OrdersApiDelegate {
@Override
public ResponseEntity<PagedModel<EntityModel<Order>>> getOrders(
Integer page, Integer size, List<String> sort, @Nullable OrderStatus status) {
// application layer call → page of orders → assembled into PagedModel
}
}
The templates live in commons-service so every other service in the platform reuses the same generation logic. Add x-hateoas: true to a new endpoint anywhere in the repo and the right code comes out automatically, with no per-service configuration.
HATEOAS with affordances: more than just links
The GET /orders endpoint returns application/prs.hal-forms+json. That's not a typo — it's HAL-FORMS, which goes one step further than plain HATEOAS links by also including affordances: machine-readable descriptions of available actions (DELETE, PUT) with their expected payloads.
Look at HateoasOrderHelper:
public EntityModel<Order> toEntityModel(Order entity) {
Link self = buildSelfLink(entity);
return EntityModel.of(
entity,
HATEOASLinkUtils.buildLinks(
CONTROLLER_CLAZZ,
environment,
self.andAffordance(
afford(WebMvcLinkBuilder.afford(
methodOn(CONTROLLER_CLAZZ).deleteOrder(entity.getId()))))
.andAffordance(
afford(WebMvcLinkBuilder.afford(
methodOn(CONTROLLER_CLAZZ).updateOrder(entity.getId(), null))))
.andAffordances(buildUpdateAffordances(self))));
}
A HAL-FORMS client receiving this response knows not just where the resource is, but also that it can be deleted and updated, and what shape those requests should take. The client doesn't need out-of-band documentation to discover this — it's in the response. This is relatively rare to see implemented correctly in a public reference project.
The valid state transitions are also built into the links dynamically, sourced from the Drools engine via getTransitionStatuses. The link set changes as the order moves through its lifecycle, driven by the same rules that govern transitions.
The domain event: outbox pattern, not wishful thinking
Once an order is validated by Drools and persisted, the service needs to notify the rest of the platform. The naive approach — persist to MongoDB, then publish to RabbitMQ — has a well-known flaw: if the publish fails after a successful write, you have an order with no event. The system is inconsistent.
SmartOrder solves this with a proper transactional outbox pattern. The event is first saved to MongoDB as an OrderOutboxEvent record, then sent asynchronously via OutboxSender:
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxSender {
private final OrderOutboxRepository outboxRepository;
private final StreamBridge streamBridge;
private final ObjectMapper objectMapper;
private final ExecutorService executor = Executors.newFixedThreadPool(5);
public void sendAsync(OrderOutboxEvent outboxEvent) {
CompletableFuture.runAsync(() -> send(outboxEvent), executor);
}
private void send(OrderOutboxEvent event) {
try {
Class<?> eventClass = Class.forName(event.getEventType());
Object eventPayload = objectMapper.readValue(event.getPayload(), eventClass);
boolean sent = streamBridge.send(BindingNames.PUBLISH_ORDER_CREATED, eventPayload);
event.setStatus(sent ? EventStatus.SENT : EventStatus.FAILED);
outboxRepository.save(event);
} catch (Exception e) {
log.error("Error sending outbox event {}", event.getId(), e);
event.setStatus(EventStatus.FAILED);
outboxRepository.save(event);
}
}
}
If the send fails, the event stays in MongoDB with FAILED status and can be retried. The outbox record is the durable guarantee — the message broker is best-effort on top of it.
The events themselves use the CloudEvents standard (contentType: application/cloudevents+json), which means they're interoperable with any broker or consumer that supports the spec, not tied to RabbitMQ specifics.
# async-api.yml (excerpt)
channels:
orderEventsChannel:
address: pending-orders
messages:
OrderCreatedMessage:
$ref: '#/components/messages/OrderCreatedMessage'
components:
messages:
OrderCreatedMessage:
name: OrderCreatedEvent
contentType: application/cloudevents+json
payload:
$ref: '#/components/schemas/OrderCreatedEvent'
Service discovery: Consul registration without thinking about it
The Order Service doesn't know it exists in a cluster. Spring Cloud Consul handles registration automatically at startup:
spring:
cloud:
consul:
enabled: true
discovery:
prefer-ip-address: true
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
fail-fast: false
The instance-id with ${random.value} allows multiple replicas to register without collisions. fail-fast: false is worth noting: the service starts even if Consul is temporarily unavailable, rather than refusing to boot — a pragmatic choice for local dev where startup order isn't guaranteed.
When you open the Consul UI after a docker-compose up, the service appears, goes green, and becomes routable through the gateway — no manual step required.
Observability: Micrometer out of the box
Every Spring Boot service in SmartOrder is instrumented with Micrometer. The Actuator and Prometheus endpoints are configured, and the Grafana dashboards in docker/config-services/grafana pick them up automatically — the bootstrap module ensures there's real data flowing from the start.
What you get without writing anything: http_server_requests_seconds latency histograms per endpoint, JVM memory metrics, and Spring Cloud Stream message throughput counters. Custom business metrics are four lines:
Counter.builder("orders.created")
.tag("tier", customerTier)
.register(meterRegistry)
.increment();
That counter shows up in Prometheus and Grafana with zero additional config. The infrastructure is already there.
The full flow, end to end
Let's put it all together. A client calls POST /orders through the Gateway:
- Gateway resolves the route from Consul, forwards to an Order Service instance.
-
apimodule — theDelegateImplreceives the request via the OpenAPI-generated contract. -
businessmodule — the application layer runs the use case, passing theOrderaggregate to the Drools engine. - Drools inserts the order and its items as facts, fires all matching rules. If a constraint fails, the use case returns an error response immediately.
- If validation passes, the
Orderis persisted to MongoDB. AnOrderOutboxEventrecord is saved in the same write, thenOutboxSenderdispatches the event to RabbitMQ asynchronously via Spring Cloud Stream. -
Response goes back as
EntityModel<Order>with HAL-FORMS affordances and HTTP 201. - Meanwhile, Inventory Service consumes the
OrderCreatedCloudEvent from RabbitMQ and reserves stock — fully decoupled, no synchronous dependency.
The consistency problem at step 5 — the classic "what if the broker is down?" — is already handled by the outbox pattern. The event record in MongoDB is the guarantee; the broker delivery is an implementation detail on top of it. A retry scheduler over FAILED events is the natural next piece to add, and a good open contribution.
What's next
We've covered the full Order Service: its module boundaries, the Drools-driven state machine, the custom OpenAPI generator templates that produce real HATEOAS types, HAL-FORMS affordances, and the transactional outbox pattern for reliable event publishing.
Next up: the Inventory Service — the consumer side of that same OrderCreated event, idempotency handling, and what happens when stock is insufficient.
If something here sparked an idea, the repo is open. The issues tab is quiet right now — that's a hint. 🙂

Top comments (0)