DEV Community

Francesco Portus
Francesco Portus

Posted on

SmartOrder — Part 3: Inside the Order Service

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.

👉 services/order-service


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

👉 config.spring.api-v1.yml

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'
Enter fullscreen mode Exit fullscreen mode

👉 api-v1.yml

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 specx-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/
Enter fullscreen mode Exit fullscreen mode

👉 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>>>
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

👉 OrdersApiDelegateImpl.java

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))));
}
Enter fullscreen mode Exit fullscreen mode

👉 HateoasOrderHelper.java

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Consul UI showing order-service healthy


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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. Gateway resolves the route from Consul, forwards to an Order Service instance.
  2. api module — the DelegateImpl receives the request via the OpenAPI-generated contract.
  3. business module — the application layer runs the use case, passing the Order aggregate to the Drools engine.
  4. 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.
  5. If validation passes, the Order is persisted to MongoDB. An OrderOutboxEvent record is saved in the same write, then OutboxSender dispatches the event to RabbitMQ asynchronously via Spring Cloud Stream.
  6. Response goes back as EntityModel<Order> with HAL-FORMS affordances and HTTP 201.
  7. Meanwhile, Inventory Service consumes the OrderCreated CloudEvent 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. 🙂

github.com/portus84/smartorder-ms

Top comments (0)