diff --git a/README.md b/README.md
index 1d52365..002290f 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
| Module | Post | Stack |
|---|---|---|
| [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) |
-| `mongodb/` | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 8 replica set, Spring Data MongoDB |
+| [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB |
| `elasticsearch/` | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9, Spring Data Elasticsearch |
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
diff --git a/mongodb/docs/output/26-index-explain.txt b/mongodb/docs/output/26-index-explain.txt
new file mode 100644
index 0000000..8504fe8
--- /dev/null
+++ b/mongodb/docs/output/26-index-explain.txt
@@ -0,0 +1,2 @@
+query on indexed fields (customerId, createdAt): winning plan stage = FETCH IXSCAN
+query on unindexed field (total): winning plan stage = COLLSCAN
diff --git a/mongodb/docs/output/26-order-transaction-commit.txt b/mongodb/docs/output/26-order-transaction-commit.txt
new file mode 100644
index 0000000..808e178
--- /dev/null
+++ b/mongodb/docs/output/26-order-transaction-commit.txt
@@ -0,0 +1,2 @@
+order_id=6acfd210bf211a8919681ce9 status=PLACED total=199.90
+inventory_before=50 inventory_after=40 (decremented by order quantity)
diff --git a/mongodb/docs/output/26-order-transaction-rollback.txt b/mongodb/docs/output/26-order-transaction-rollback.txt
new file mode 100644
index 0000000..44bd37d
--- /dev/null
+++ b/mongodb/docs/output/26-order-transaction-rollback.txt
@@ -0,0 +1,3 @@
+attempted_quantity=999 available=5 -> Insufficient stock for WIDGET-2: available=5 requested=999
+inventory_before=5 inventory_after=5 (unchanged - transaction rolled back)
+orders_collection_count=0 (no order document was inserted)
diff --git a/mongodb/docs/output/26-revenue-aggregation.txt b/mongodb/docs/output/26-revenue-aggregation.txt
new file mode 100644
index 0000000..5097d1f
--- /dev/null
+++ b/mongodb/docs/output/26-revenue-aggregation.txt
@@ -0,0 +1,2 @@
+status=PLACED total_revenue=350.00 order_count=2
+status=REJECTED_INSUFFICIENT_STOCK total_revenue=40.00 order_count=1
diff --git a/mongodb/pom.xml b/mongodb/pom.xml
new file mode 100644
index 0000000..c9508d7
--- /dev/null
+++ b/mongodb/pom.xml
@@ -0,0 +1,70 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+ com.ankurm
+ mongodb-demo
+ 1.0.0
+ jar
+
+
+ 25
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-mongodb
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-mongodb
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
+
+
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ 2.0.5
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/InventoryItem.java b/mongodb/src/main/java/com/ankurm/mongodb/InventoryItem.java
new file mode 100644
index 0000000..4be902e
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/InventoryItem.java
@@ -0,0 +1,14 @@
+package com.ankurm.mongodb;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.Version;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+/**
+ * Stock for one SKU in one warehouse. @Version gives optimistic locking on
+ * top of the multi-document transaction used when placing an order - see
+ * OrderService.placeOrder().
+ */
+@Document("inventory")
+public record InventoryItem(@Id String id, String sku, String warehouse, int quantityOnHand, @Version Long version) {
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/InventoryRepository.java b/mongodb/src/main/java/com/ankurm/mongodb/InventoryRepository.java
new file mode 100644
index 0000000..395292b
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/InventoryRepository.java
@@ -0,0 +1,9 @@
+package com.ankurm.mongodb;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.Optional;
+
+public interface InventoryRepository extends MongoRepository {
+ Optional findBySkuAndWarehouse(String sku, String warehouse);
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/MongoConfig.java b/mongodb/src/main/java/com/ankurm/mongodb/MongoConfig.java
new file mode 100644
index 0000000..7a85e00
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/MongoConfig.java
@@ -0,0 +1,21 @@
+package com.ankurm.mongodb;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.mongodb.MongoDatabaseFactory;
+import org.springframework.data.mongodb.MongoTransactionManager;
+
+/**
+ * Spring Boot does NOT auto-configure a PlatformTransactionManager for
+ * MongoDB the way it does for JDBC - without this bean, @Transactional on
+ * OrderService is a silent no-op (no error, no rollback, just two
+ * independent writes). See the post's transactions section.
+ */
+@Configuration
+public class MongoConfig {
+
+ @Bean
+ MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
+ return new MongoTransactionManager(dbFactory);
+ }
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/MongoDemoApplication.java b/mongodb/src/main/java/com/ankurm/mongodb/MongoDemoApplication.java
new file mode 100644
index 0000000..bd225fe
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/MongoDemoApplication.java
@@ -0,0 +1,7 @@
+package com.ankurm.mongodb;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+@SpringBootApplication
+public class MongoDemoApplication {
+ public static void main(String[] args) { SpringApplication.run(MongoDemoApplication.class, args); }
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/Order.java b/mongodb/src/main/java/com/ankurm/mongodb/Order.java
new file mode 100644
index 0000000..157ab83
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/Order.java
@@ -0,0 +1,30 @@
+package com.ankurm.mongodb;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * An order document. The compound index on (customerId, createdAt) is what
+ * turns "this customer's recent orders" from a collection scan into an
+ * index scan - see the post's "indexes" section for the explain() output
+ * that proves it.
+ */
+@Document("orders")
+@CompoundIndex(name = "customer_created_idx", def = "{'customerId': 1, 'createdAt': -1}")
+public record Order(
+ @Id String id,
+ String customerId,
+ List items,
+ OrderStatus status,
+ BigDecimal total,
+ Instant createdAt
+) {
+ public record OrderItem(String sku, int quantity, BigDecimal unitPrice) {}
+
+ public enum OrderStatus { PLACED, REJECTED_INSUFFICIENT_STOCK }
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/OrderAnalytics.java b/mongodb/src/main/java/com/ankurm/mongodb/OrderAnalytics.java
new file mode 100644
index 0000000..ce4616b
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/OrderAnalytics.java
@@ -0,0 +1,45 @@
+package com.ankurm.mongodb;
+
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.aggregation.AggregationResults;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
+
+/**
+ * Derived queries and @Query cover point lookups; an aggregation pipeline
+ * is for reshaping many documents into a report MongoRepository has no
+ * vocabulary for - here, revenue and order count per status.
+ */
+@Service
+public class OrderAnalytics {
+
+ private final MongoTemplate mongoTemplate;
+
+ public OrderAnalytics(MongoTemplate mongoTemplate) {
+ this.mongoTemplate = mongoTemplate;
+ }
+
+ public record RevenueByStatus(String status, BigDecimal totalRevenue, long orderCount) {}
+
+ public List revenueByStatus() {
+ Aggregation pipeline = newAggregation(
+ group("status")
+ .sum("total").as("totalRevenue")
+ .count().as("orderCount"),
+ project("totalRevenue", "orderCount").and("status").previousOperation(),
+ sort(org.springframework.data.domain.Sort.Direction.DESC, "totalRevenue")
+ );
+ AggregationResults results =
+ mongoTemplate.aggregate(pipeline, "orders", RevenueByStatusRaw.class);
+ return results.getMappedResults().stream()
+ .map(r -> new RevenueByStatus(r.status(), r.totalRevenue(), r.orderCount()))
+ .toList();
+ }
+
+ record RevenueByStatusRaw(String status, BigDecimal totalRevenue, long orderCount) {}
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/OrderRepository.java b/mongodb/src/main/java/com/ankurm/mongodb/OrderRepository.java
new file mode 100644
index 0000000..6dcd662
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/OrderRepository.java
@@ -0,0 +1,13 @@
+package com.ankurm.mongodb;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.List;
+
+public interface OrderRepository extends MongoRepository {
+
+ // Derived query - served by the customerId/createdAt compound index.
+ List findByCustomerIdOrderByCreatedAtDesc(String customerId);
+
+ List findByStatus(Order.OrderStatus status);
+}
diff --git a/mongodb/src/main/java/com/ankurm/mongodb/OrderService.java b/mongodb/src/main/java/com/ankurm/mongodb/OrderService.java
new file mode 100644
index 0000000..7bfabb3
--- /dev/null
+++ b/mongodb/src/main/java/com/ankurm/mongodb/OrderService.java
@@ -0,0 +1,53 @@
+package com.ankurm.mongodb;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Placing an order touches two collections - inventory (decrement stock)
+ * and orders (insert the order) - and both must succeed or neither does.
+ * Without the transaction, a crash between the two writes leaves inventory
+ * decremented with no order to show for it.
+ */
+@Service
+public class OrderService {
+
+ private final OrderRepository orders;
+ private final InventoryRepository inventory;
+
+ public OrderService(OrderRepository orders, InventoryRepository inventory) {
+ this.orders = orders;
+ this.inventory = inventory;
+ }
+
+ @Transactional
+ public Order placeOrder(String customerId, String warehouse, List items) {
+ for (Order.OrderItem item : items) {
+ InventoryItem stock = inventory.findBySkuAndWarehouse(item.sku(), warehouse)
+ .orElseThrow(() -> new IllegalStateException("No stock record for " + item.sku()));
+ if (stock.quantityOnHand() < item.quantity()) {
+ // Throwing here rolls back any inventory decrements already
+ // applied earlier in this loop, in the same transaction.
+ throw new InsufficientStockException(item.sku(), stock.quantityOnHand(), item.quantity());
+ }
+ inventory.save(new InventoryItem(stock.id(), stock.sku(), stock.warehouse(),
+ stock.quantityOnHand() - item.quantity(), stock.version()));
+ }
+
+ BigDecimal total = items.stream()
+ .map(i -> i.unitPrice().multiply(BigDecimal.valueOf(i.quantity())))
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ return orders.save(new Order(null, customerId, items, Order.OrderStatus.PLACED, total, Instant.now()));
+ }
+
+ public static class InsufficientStockException extends RuntimeException {
+ public InsufficientStockException(String sku, int available, int requested) {
+ super("Insufficient stock for %s: available=%d requested=%d".formatted(sku, available, requested));
+ }
+ }
+}
diff --git a/mongodb/src/test/java/com/ankurm/mongodb/MongoDemoTest.java b/mongodb/src/test/java/com/ankurm/mongodb/MongoDemoTest.java
new file mode 100644
index 0000000..1dcd767
--- /dev/null
+++ b/mongodb/src/test/java/com/ankurm/mongodb/MongoDemoTest.java
@@ -0,0 +1,157 @@
+package com.ankurm.mongodb;
+
+import com.mongodb.client.MongoCollection;
+import org.bson.Document;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.testcontainers.containers.MongoDBContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Runs the repository, aggregation, transaction and index behaviour
+ * against a real MongoDB 9.0.2 single-node replica set (Testcontainers'
+ * MongoDBContainer configures replica-set mode automatically - no
+ * .withReplicaSet() call needed on this version, unlike what older
+ * tutorials show). Every number in the post's transcripts comes from
+ * these tests.
+ */
+@Testcontainers
+@SpringBootTest
+class MongoDemoTest {
+
+ @Container
+ @ServiceConnection
+ static MongoDBContainer mongo = new MongoDBContainer("mongo:9.0.2");
+
+ @Autowired OrderRepository orders;
+ @Autowired InventoryRepository inventory;
+ @Autowired OrderService orderService;
+ @Autowired OrderAnalytics analytics;
+ @Autowired MongoTemplate mongoTemplate;
+
+ @BeforeEach
+ void cleanDatabase() {
+ mongoTemplate.getDb().drop();
+ }
+
+ @Test
+ void transactionCommitsBothWritesTogether() {
+ inventory.save(new InventoryItem(null, "WIDGET-1", "blr-wh", 50, null));
+
+ Order placed = orderService.placeOrder("cust-1", "blr-wh",
+ List.of(new Order.OrderItem("WIDGET-1", 10, new BigDecimal("19.99"))));
+
+ InventoryItem afterOrder = inventory.findBySkuAndWarehouse("WIDGET-1", "blr-wh").orElseThrow();
+
+ Transcript t = new Transcript("26-order-transaction-commit.txt");
+ t.line("order_id=%s status=%s total=%s", placed.id(), placed.status(), placed.total());
+ t.line("inventory_before=50 inventory_after=%d (decremented by order quantity)", afterOrder.quantityOnHand());
+ t.write();
+
+ assertEquals(Order.OrderStatus.PLACED, placed.status());
+ assertEquals(40, afterOrder.quantityOnHand());
+ assertEquals(1, orders.count());
+ }
+
+ @Test
+ void transactionRollsBackBothWritesOnInsufficientStock() {
+ inventory.save(new InventoryItem(null, "WIDGET-2", "blr-wh", 5, null));
+
+ OrderService.InsufficientStockException ex = assertThrows(OrderService.InsufficientStockException.class,
+ () -> orderService.placeOrder("cust-2", "blr-wh",
+ List.of(new Order.OrderItem("WIDGET-2", 999, new BigDecimal("5.00")))));
+
+ InventoryItem afterFailedOrder = inventory.findBySkuAndWarehouse("WIDGET-2", "blr-wh").orElseThrow();
+
+ Transcript t = new Transcript("26-order-transaction-rollback.txt");
+ t.line("attempted_quantity=999 available=5 -> %s", ex.getMessage());
+ t.line("inventory_before=5 inventory_after=%d (unchanged - transaction rolled back)", afterFailedOrder.quantityOnHand());
+ t.line("orders_collection_count=%d (no order document was inserted)", orders.count());
+ t.write();
+
+ assertEquals(5, afterFailedOrder.quantityOnHand());
+ assertEquals(0, orders.count());
+ }
+
+ @Test
+ void aggregationGroupsRevenueByStatus() {
+ Instant now = Instant.now();
+ orders.saveAll(List.of(
+ new Order(null, "cust-3", List.of(), Order.OrderStatus.PLACED, new BigDecimal("100.00"), now),
+ new Order(null, "cust-4", List.of(), Order.OrderStatus.PLACED, new BigDecimal("250.00"), now),
+ new Order(null, "cust-5", List.of(), Order.OrderStatus.REJECTED_INSUFFICIENT_STOCK, new BigDecimal("40.00"), now)
+ ));
+
+ List result = analytics.revenueByStatus();
+
+ Transcript t = new Transcript("26-revenue-aggregation.txt");
+ for (OrderAnalytics.RevenueByStatus r : result) {
+ t.line("status=%s total_revenue=%s order_count=%d", r.status(), r.totalRevenue(), r.orderCount());
+ }
+ t.write();
+
+ assertEquals(2, result.size());
+ OrderAnalytics.RevenueByStatus placed = result.stream()
+ .filter(r -> r.status().equals("PLACED")).findFirst().orElseThrow();
+ assertEquals(new BigDecimal("350.00"), placed.totalRevenue());
+ assertEquals(2, placed.orderCount());
+ }
+
+ @Test
+ void compoundIndexTurnsTheQueryIntoAnIndexScan() {
+ // Explicit, rather than relying on spring.data.mongodb.auto-index-creation
+ // (false by default) - see the post's indexes section.
+ mongoTemplate.indexOps(Order.class).createIndex(
+ new org.springframework.data.mongodb.core.index.CompoundIndexDefinition(
+ new Document("customerId", 1).append("createdAt", -1)).named("customer_created_idx"));
+
+ Instant now = Instant.now();
+ for (int i = 0; i < 500; i++) {
+ orders.save(new Order(null, "cust-" + (i % 5), List.of(), Order.OrderStatus.PLACED,
+ BigDecimal.valueOf(i), now.minusSeconds(i)));
+ }
+
+ MongoCollection raw = mongoTemplate.getCollection("orders");
+
+ Document indexedExplain = raw.find(new Document("customerId", "cust-0")
+ .append("createdAt", new Document("$lte", now)))
+ .sort(new Document("createdAt", -1))
+ .explain();
+ String indexedStage = winningPlanStage(indexedExplain);
+
+ Document unindexedExplain = raw.find(new Document("total", new Document("$gt", 490))).explain();
+ String unindexedStage = winningPlanStage(unindexedExplain);
+
+ Transcript t = new Transcript("26-index-explain.txt");
+ t.line("query on indexed fields (customerId, createdAt): winning plan stage = %s", indexedStage);
+ t.line("query on unindexed field (total): winning plan stage = %s", unindexedStage);
+ t.write();
+
+ assertTrue(indexedStage.contains("IXSCAN"), "expected an index scan, got: " + indexedStage);
+ assertTrue(unindexedStage.contains("COLLSCAN"), "expected a collection scan, got: " + unindexedStage);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String winningPlanStage(Document explain) {
+ Document queryPlanner = (Document) explain.get("queryPlanner");
+ Document winningPlan = (Document) queryPlanner.get("winningPlan");
+ StringBuilder stages = new StringBuilder();
+ Document stage = winningPlan;
+ while (stage != null) {
+ stages.append(stage.getString("stage")).append(" ");
+ stage = (Document) stage.get("inputStage");
+ }
+ return stages.toString().trim();
+ }
+}
diff --git a/mongodb/src/test/java/com/ankurm/mongodb/Transcript.java b/mongodb/src/test/java/com/ankurm/mongodb/Transcript.java
new file mode 100644
index 0000000..31ad903
--- /dev/null
+++ b/mongodb/src/test/java/com/ankurm/mongodb/Transcript.java
@@ -0,0 +1,40 @@
+package com.ankurm.mongodb;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes docs/output/NN-*.txt while a test asserts the same numbers, so
+ * every figure quoted in the blog post is backed by an assertion that
+ * fails the build when it stops being true.
+ */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final Path file;
+
+ Transcript(String name) {
+ this.file = Path.of("docs/output/" + name);
+ }
+
+ Transcript line(String s) {
+ lines.add(s);
+ System.out.println(s);
+ return this;
+ }
+
+ Transcript line(String fmt, Object... args) {
+ return line(fmt.formatted(args));
+ }
+
+ void write() {
+ try {
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, String.join("\n", lines) + "\n");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}