Skip to content

feat(example): connect the order and inventory contexts of ddk-mall through integration events - #77

Merged
poppycoderr merged 2 commits into
mainfrom
feat/mall-integration
Oct 5, 2026
Merged

poppycoderr merged 2 commits into
mainfrom
feat/mall-integration

Conversation

@poppycoderr

Copy link
Copy Markdown
Owner

Background

Third step of the ddk-mall reference application. After steps 1 and 2 the order and inventory contexts existed side by side: an order did not reserve anything. This step connects them, and it is the first time DDK's reliable events run in a business flow with two sides: a publisher, a consumer that answers, and the publisher reacting to the answer.

Changes

  • An order starts in PENDING_STOCK. The order context publishes order.placed and order.cancelled; the inventory context reserves or releases stock and answers with inventory.stock-reserved or inventory.stock-reservation-rejected; the order then moves to PENDING_PAYMENT or CANCELLED.
  • Contracts live in each context's application.integration package as records marked @IntegrationEvent. A handler translates domain events into them inside the business transaction, so DDK records them in the event publication registry together with the business data.
  • Consumers live in adapter.messaging. Each context declares its own payload record with only the fields it uses and imports nothing from the publisher.
  • platform.messaging: a dispatcher and two message sources. With mall.messaging.transport=rocketmq (the compose profile) messages come from RocketMQ, consumed in order per queue. With local (the default) they are forwarded in-process on a single thread, through the same JSON decoding and the same handlers.
  • docker-compose.yml gains RocketMQ.
sequenceDiagram
    participant O as Order context
    participant MQ as RocketMQ
    participant I as Inventory context
    O->>O: save order (PENDING_STOCK) + order.placed, one transaction
    O-->>MQ: delivered after commit
    MQ-->>I: order.placed
    I->>I: lock SKUs → transaction → inbox + reserve + outcome message
    I-->>MQ: stock-reserved / stock-reservation-rejected
    MQ-->>O: outcome
    O->>O: PENDING_PAYMENT / CANCELLED
Loading

Where the inbox goes, in InventoryService.reserveForOrder:

lock every SKU of the order
  begin transaction
    IdempotentConsumer.handle("inventory.order-placed", messageId)     # duplicate → skip everything below
      reserve each line
      publish StockReservedMessage
  commit
unlock
on INSUFFICIENT_STOCK / STOCK_NOT_FOUND (transaction rolled back, inbox entry with it)
  new transaction: inbox + publish StockReservationRejectedMessage      # a business outcome, not a failed consumption

Design decisions:

  • The inbox entry is written inside the lock and inside the transaction. It has to commit or roll back with the reservation, and the transaction has to end before the lock is released.
  • Inbox only where it is needed. Releasing stock and the order's state transitions are repeatable by themselves, so those consumers do not use it. Reserving publishes an outcome, and a duplicate would publish it twice.
  • Insufficient stock is answered, not retried. The consumer returns normally after publishing the rejection.
  • Ordering. Messages are keyed by order ID, DDK routes one key to one queue, and the consumers use orderly consumption, so placed is handled before cancelled for the same order.

MallFlowIntegrationTest runs the flow on MySQL, Redis and RocketMQ containers: place and cancel, an order beyond the stock, and a redelivered order.placed that reserves once and answers once.


背景

ddk-mall 参考应用的第三步。前两步做完后,订单和库存两个上下文只是并排放着:下单并不会预占任何库存。这一步把它们连起来,也是 DDK 的可靠事件第一次跑在一个有来有往的业务流程里:发布方发出消息,消费方处理后回一个结果,发布方再根据结果推进。

改动

  • 订单的初始状态改为 PENDING_STOCK。订单上下文发出 order.placed 与 order.cancelled;库存上下文据此预占或释放库存,并回 inventory.stock-reserved 或 inventory.stock-reservation-rejected;订单随后进入 PENDING_PAYMENT 或 CANCELLED。
  • 契约放在各上下文的 application.integration 包里,是标了 @IntegrationEvent 的 record。一个处理器在业务事务里把领域事件翻译成契约,DDK 因此把它和业务数据一起登记进事件发布记录。
  • 消费方放在 adapter.messaging。每个上下文自己定义消息体,只声明用得到的字段,不引用发布方的任何类型。
  • platform.messaging:一个分发器和两种消息来源。mall.messaging.transport=rocketmq(compose profile)时消息来自 RocketMQ,按队列顺序消费;local(默认)时在进程内用单线程转发,走同样的 JSON 解析和同样的处理器。
  • docker-compose.yml 增加 RocketMQ。

整体流程见上文时序图,去重登记的位置见上文伪代码。设计取舍:

  • 去重登记写在锁和事务的里面。 它必须和预占一起提交、一起回滚,而事务又必须在锁释放之前结束。
  • 只在需要的地方用去重表。 释放库存和订单的状态流转本身可以重复执行,这些消费方不用它。预占会发出结果消息,重复处理就会发出两份。
  • 库存不足是回复,不是重试。 消费方发出「预占失败」后正常返回。
  • 顺序。 消息以订单号为 key,DDK 把同一个 key 送到同一个队列,消费方顺序消费,所以同一个订单的 placed 一定先于 cancelled 被处理。

MallFlowIntegrationTest 在 MySQL、Redis、RocketMQ 容器上跑完整流程:下单再取消、超出库存的订单、以及一条被重复投递的 order.placed 只预占一次、只回复一次。

@poppycoderr
poppycoderr merged commit 3b0bc60 into main Oct 5, 2026
4 checks passed
@poppycoderr
poppycoderr deleted the feat/mall-integration branch October 5, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant