Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/microservices-patterns/ftgo-application/llms.txt

Use this file to discover all available pages before exploring further.

This page covers how the FTGO application applies the Command Query Responsibility Segregation (CQRS) pattern through its dedicated Order History Service. Rather than querying the Order Service’s write database directly, a separate read model built on Amazon DynamoDB is kept in sync by consuming domain events published by the Order Service.

What is CQRS and Why FTGO Uses It

In a microservices system the write side of a service (its command model) is optimized for transactional correctness. Queries that span multiple services, require rich filtering, or need a different access pattern would force either a costly join across service boundaries or tight coupling between services. CQRS splits responsibilities:
  • Command side — the Order Service writes to its own MySQL database and publishes domain events.
  • Query side — the Order History Service subscribes to those events and maintains a denormalized DynamoDB table shaped for consumer queries.
The Order History Service is a standalone Spring Boot application in ftgo-order-history-service. It has no write path of its own; its database is populated entirely by events.

Events Consumed to Build the View

OrderHistoryEventHandlers subscribes to events emitted by the Order aggregate and updates the DynamoDB table accordingly.
// OrderHistoryEventHandlers.java
public DomainEventHandlers domainEventHandlers() {
  return DomainEventHandlersBuilder
      .forAggregateType(
          "net.chrisrichardson.ftgo.orderservice.domain.Order")
      .onEvent(OrderCreatedEvent.class, this::handleOrderCreated)
      .onEvent(OrderAuthorized.class,   this::handleOrderAuthorized)
      .onEvent(OrderCancelled.class,    this::handleOrderCancelled)
      .onEvent(OrderRejected.class,     this::handleOrderRejected)
      .build();
}
Each handler calls the DAO to upsert the DynamoDB record:
public void handleOrderCreated(DomainEventEnvelope<OrderCreatedEvent> dee) {
  orderHistoryDao.addOrder(
      makeOrder(dee.getAggregateId(), dee.getEvent()),
      makeSourceEvent(dee));
}

public void handleOrderAuthorized(DomainEventEnvelope<OrderAuthorized> dee) {
  orderHistoryDao.updateOrderState(
      dee.getAggregateId(),
      OrderState.APPROVED,
      makeSourceEvent(dee));
}

public void handleOrderCancelled(DomainEventEnvelope<OrderCancelled> dee) {
  orderHistoryDao.updateOrderState(
      dee.getAggregateId(),
      OrderState.CANCELLED,
      makeSourceEvent(dee));
}

DynamoDB as the Read Store

The OrderHistoryDaoDynamoDb class wraps the AWS DynamoDB Document API. Two tables/indexes are used:
ResourceNameKey
Main tableftgo-order-historyorderId (hash key)
GSIftgo-order-history-by-consumer-id-and-creation-timeconsumerId + creationDate

Adding an Order

When OrderCreatedEvent arrives the handler calls addOrder, which issues a conditional UpdateItem to prevent double-processing of the same event:
// OrderHistoryDaoDynamoDb.java
@Override
public boolean addOrder(Order order, Optional<SourceEvent> eventSource) {
  UpdateItemSpec spec = new UpdateItemSpec()
      .withPrimaryKey("orderId", order.getOrderId())
      .withUpdateExpression(
          "SET orderStatus = :orderStatus, " +
          "creationDate = :creationDate, " +
          "consumerId = :consumerId, " +
          "lineItems = :lineItems, " +
          "keywords = :keywords, " +
          "restaurantId = :restaurantId, " +
          "restaurantName = :restaurantName")
      .withValueMap(new Maps()
          .add(":orderStatus",    order.getStatus().toString())
          .add(":consumerId",     order.getConsumerId())
          .add(":creationDate",   order.getCreationDate().getMillis())
          .add(":lineItems",      mapLineItems(order.getLineItems()))
          .add(":keywords",       mapKeywords(order))
          .add(":restaurantId",   order.getRestaurantId())
          .add(":restaurantName", order.getRestaurantName())
          .map())
      .withReturnValues(ReturnValue.NONE);
  return idempotentUpdate(spec, eventSource);
}

Idempotent Updates

Each event write includes the source event identifier as a DynamoDB condition expression. If the same event is delivered twice ConditionalCheckFailedException is caught and silently ignored, making every update idempotent.
private boolean idempotentUpdate(UpdateItemSpec spec,
                                  Optional<SourceEvent> eventSource) {
  try {
    table.updateItem(
        eventSource.map(es -> es.addDuplicateDetection(spec))
                   .orElse(spec));
    return true;
  } catch (ConditionalCheckFailedException e) {
    logger.error("not updated {}", eventSource);
    return false;
  }
}

Updating Order Status

State transitions (authorize, cancel, reject) use a targeted UpdateItem that touches only the orderStatus attribute:
@Override
public boolean updateOrderState(String orderId, OrderState newState,
                                Optional<SourceEvent> eventSource) {
  UpdateItemSpec spec = new UpdateItemSpec()
      .withPrimaryKey("orderId", orderId)
      .withUpdateExpression("SET #orderStatus = :orderStatus")
      .withNameMap(Collections.singletonMap(
          "#orderStatus", ORDER_STATUS_FIELD))
      .withValueMap(Collections.singletonMap(
          ":orderStatus", newState.toString()))
      .withReturnValues(ReturnValue.NONE);
  return idempotentUpdate(spec, eventSource);
}

Querying Order History

findOrderHistory queries the GSI with an optional keyword and status filter. DynamoDB pagination is handled by passing through the LastEvaluatedKey token.
// OrderHistoryDaoDynamoDb.java
@Override
public OrderHistory findOrderHistory(String consumerId,
                                     OrderHistoryFilter filter) {
  QuerySpec spec = new QuerySpec()
      .withScanIndexForward(false)
      .withHashKey("consumerId", consumerId)
      .withRangeKeyCondition(
          new RangeKeyCondition("creationDate")
              .gt(filter.getSince().getMillis()));

  filter.getStartKeyToken().ifPresent(token ->
      spec.withExclusiveStartKey(toStartingPrimaryKey(token)));

  // optional keyword and status filter expressions ...
  filter.getPageSize().ifPresent(spec::withMaxResultSize);

  ItemCollection<QueryOutcome> result = index.query(spec);

  return new OrderHistory(
      StreamSupport.stream(result.spliterator(), false)
          .map(this::toOrder)
          .collect(toList()),
      Optional.ofNullable(
          result.getLastLowLevelResult()
                .getQueryResult()
                .getLastEvaluatedKey())
          .map(this::toStartKeyToken));
}
Keywords are tokenized at write time and stored as a DynamoDB set, allowing contains(keywords, :keyword) filter expressions at query time.

REST API

OrderHistoryController exposes two endpoints under /orders:
// OrderHistoryController.java
@RestController
@RequestMapping(path = "/orders")
public class OrderHistoryController {

  // GET /orders?consumerId=<id>
  @RequestMapping(method = RequestMethod.GET)
  public ResponseEntity<GetOrdersResponse> getOrders(
      @RequestParam(name = "consumerId") String consumerId) {
    OrderHistory orderHistory = orderHistoryDao
        .findOrderHistory(consumerId, new OrderHistoryFilter());
    return new ResponseEntity<>(
        new GetOrdersResponse(
            orderHistory.getOrders().stream()
                .map(this::makeGetOrderResponse)
                .collect(toList()),
            orderHistory.getStartKey().orElse(null)),
        HttpStatus.OK);
  }

  // GET /orders/{orderId}
  @RequestMapping(path = "/{orderId}", method = RequestMethod.GET)
  public ResponseEntity<GetOrderResponse> getOrder(
      @PathVariable String orderId) {
    return orderHistoryDao.findOrder(orderId)
        .map(order -> new ResponseEntity<>(
            makeGetOrderResponse(order), HttpStatus.OK))
        .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
  }
}
The GET /orders endpoint is routed through the API Gateway. The OrderConfiguration Spring Cloud Gateway bean maps GET /orders to the Order History Service URL rather than the Order Service URL.

Data Flow Summary

Order Service (MySQL write model)
        │  publishes domain events via Eventuate Tram

  Kafka (event bus)
        │  consumed by OrderHistoryEventHandlers

Order History Service (DynamoDB read model)
        │  queried via REST

  Consumer clients / API Gateway
FieldTypeNotes
orderIdString (hash key)Matches the Order Service ID
consumerIdStringGSI hash key for history queries
creationDateNumberEpoch ms; GSI sort key
orderStatusStringAPPROVAL_PENDING, APPROVED, CANCELLED, REJECTED
lineItemsListDenormalized menu item name, ID, price, quantity
keywordsStringSetTokenized restaurant name + item names for free-text filter
restaurantIdNumber
restaurantNameString
deliveryStatusStringSet when courier picks up the order

Build docs developers (and LLMs) love