Add the saga module: Saga Pattern in Spring Boot, orchestration vs choreography with Kafka

This commit is contained in:
Claude
2026-10-03 19:56:40 +00:00
parent c1b40b0db9
commit 4a5d3318bf
44 changed files with 1296 additions and 3 deletions
@@ -0,0 +1,17 @@
$ mvn -pl saga test -Dtest=ChoreographyHappyPathTest
(real embedded Kafka broker via @EmbeddedKafka; ChoreographySagaStarter.placeOrder() publishes
OrderCreated, and every step after that is a listener reacting to the previous one's event --
no single class decides the whole saga)
Order 1 status: CONFIRMED
Stock remaining for GADGET-1: 8
Payment record status: RESERVED
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Stock started at 10, the order asked for 2, and 8 is what's left -- the reservation really ran.
Five separate listeners fired in sequence to get here: PaymentChoreographyListener reacted to
OrderCreated and reserved payment; InventoryChoreographyListener reacted to PaymentReserved,
found enough stock, and published InventoryReserved; OrderChoreographyListener reacted to that
and confirmed the order. None of those three classes knows about the other two's existence --
each one only knows "when I see event X, I do Y, then publish Z".
@@ -0,0 +1,15 @@
$ mvn -pl saga test -Dtest=ChoreographyCompensationTest
(same saga, stock deliberately too low: 1 unit on hand, order asks for 5)
Order 1 status: CANCELLED
Stock remaining for GADGET-1 (untouched by the rejected reservation): 1
Payment record status: REFUNDED
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
InventoryChoreographyListener's tryReserve() refused before touching the stock map at all --
the 1 unit is exactly where it started. That rejection published InventoryRejected, which
PaymentChoreographyListener is also subscribed to; it refunded the payment it had reserved
earlier and published PaymentRefunded, which OrderChoreographyListener used to cancel the
order. The compensating transaction (the refund) is ordinary code in a class that already
existed for an unrelated reason -- nothing new coordinates it.
+20
View File
@@ -0,0 +1,20 @@
$ mvn -pl saga test -Dtest=OrchestrationSagaTest
(the same two scenarios -- enough stock, and not enough stock -- run through
OrderSagaOrchestrator instead of through independent listeners)
[happy path] Order 1 status: CONFIRMED
[happy path] Stock remaining for GADGET-ORCH-OK: 8
[happy path] Payment record status: RESERVED
[compensation] Order 2 status: CANCELLED
[compensation] Stock remaining for GADGET-ORCH-FAIL (untouched): 1
[compensation] Payment record status: REFUNDED
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Same outcomes as the choreography transcripts, reached a structurally different way. Here,
PaymentOrchestrationHandler and InventoryOrchestrationHandler never decide what happens next --
they carry out exactly one command each and reply. OrderSagaOrchestrator is the only class that
reads a reply and decides what command to send next, including the decision, in the second
test, to send RefundPaymentCommand once InventoryReply says no. Compare this transcript's two
tests with the two choreography transcripts above: identical business outcomes, from listener
classes that know nothing about the saga as a whole versus one class that knows all of it.
@@ -0,0 +1,16 @@
$ mvn -pl saga test -Dtest=IdempotentConsumerTest
(the exact same ReserveInventoryCommand -- same commandId -- published twice by hand, to
simulate a redelivery; stock starts at 10, the command asks for 3)
ProcessedCommand rows for commandId f07ec35c-853c-4405-a024-b62d304dfabb: 1
Stock remaining for GADGET-DUP after two deliveries of the same command: 7
InventoryReply messages actually published: 1
reply: InventoryReply[commandId=f07ec35c-853c-4405-a024-b62d304dfabb, orderId=999, success=true, reason=null]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Two deliveries, one row in processed_commands, one actual reservation: 10 - 3 = 7, not 4. The
second delivery hit IdempotencyGuard.claim(), got false back because the UNIQUE constraint on
commandId rejected the second insert, and returned from the listener before calling
InventoryService.tryReserve() at all -- it never touched stock, and it never published a second
InventoryReply. Exactly one reply reached the topic for two deliveries of the same command.