Skip to main content

Spring Boot 4 with MongoDB: Transactions, Aggregations and Indexes That Actually Work

Spring Data MongoDB makes a document database feel so much like a relational one — repositories, derived query methods, @Id — that it’s easy to forget the two places where the resemblance stops being helpful: transactions and indexes. Both are opt-in, both fail silently when you skip them, and both are exactly the kind of thing a tutorial glosses over because the happy path doesn’t need them.

This post builds a small order-placement service on Spring Boot 4.1 and MongoDB 9.0 — repositories for the simple lookups, an aggregation pipeline for a report no repository method can express, a multi-document transaction across two collections, and a compound index whose absence is provable with explain(), not just asserted in prose.

Versions used in this post. Spring Boot 4.1.1 · Spring Data MongoDB (via spring-data-bom 2026.0.1) · MongoDB Java driver 5.8.1 · MongoDB server 9.0.2 · JDK 25 (Temurin) · Testcontainers 2.0.5 (testcontainers-mongodb). Run against a single-node MongoDB replica set in Docker — Testcontainers’ MongoDBContainer configures replica-set mode automatically on this version; there is no .withReplicaSet() call to make, unlike what older tutorials show.

The problem: a document store that quietly isn’t ACID by default

MongoDB has supported multi-document ACID transactions since version 4.0, and Spring Data MongoDB exposes them through the exact same @Transactional annotation JPA uses. That similarity is the trap: Spring Boot auto-configures a PlatformTransactionManager for a JDBC DataSource automatically, but it does not do the equivalent for MongoDB. Without a MongoTransactionManager bean, @Transactional on a Mongo-backed service method is not an error — it is a silent no-op. Your writes still happen, just without the atomicity you asked for.

@Transactional on a Spring Boot 4.1 service method JDBC DataSource DataSourceTransactionManager auto-configured by Spring Boot commits or rolls back as expected MongoDatabaseFactory no transaction manager auto-configured for it @Transactional is a no-op writes commit independently Declaring a MongoTransactionManager @Bean closes the gap – see below.

The fix is one @Bean method, shown in the next section. The rest of this post is the domain that makes transactions and indexes actually matter: an order-placement flow that decrements inventory and inserts an order, where “decrement without insert” or “insert without decrement” are both real bugs a reader can reproduce.

Repositories: the part that needs nothing extra

For point lookups, MongoRepository works exactly as advertised — derived query methods compiled from the method name, no annotation required:

public interface OrderRepository extends MongoRepository<Order, String> {
    List<Order> findByCustomerIdOrderByCreatedAtDesc(String customerId);
    List<Order> findByStatus(Order.OrderStatus status);
}

Full repository: OrderRepository.java. The Order document itself is a Java record — Spring Data MongoDB has supported immutable, constructor-bound entities for a while now, including the @Version field used later for optimistic locking:

@Document("orders")
@CompoundIndex(name = "customer_created_idx", def = "{'customerId': 1, 'createdAt': -1}")
public record Order(
        @Id String id, String customerId, List<OrderItem> items,
        OrderStatus status, BigDecimal total, Instant createdAt) {
    public record OrderItem(String sku, int quantity, BigDecimal unitPrice) {}
    public enum OrderStatus { PLACED, REJECTED_INSUFFICIENT_STOCK }
}

Full document: Order.java.

Going deeper: why records work here, and the one place they don’t help

Spring Data MongoDB maps a record via its canonical constructor — every field needs a matching constructor parameter, which a record gives you for free. The one place this gets interesting is @Version: records are immutable, so Spring Data can’t bump the version field in place. It instead constructs a brand-new instance with the incremented version when it maps the save result back, which is why InventoryItem is also a record with a Long version parameter rather than a mutable field with a setter — there’s no setter to call.

The transaction: two collections, one outcome

Placing an order has to do two things atomically: decrement the stock for each line item, and insert the order. The transaction manager that makes @Transactional real for MongoDB is one bean:

@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
    return new MongoTransactionManager(dbFactory);
}

Full config: MongoConfig.java. With that bean present, the service method below either fully succeeds or fully rolls back — including the inventory writes already applied earlier in the loop, if a later item in the same order fails:

@Transactional
public Order placeOrder(String customerId, String warehouse, List<OrderItem> items) {
    for (OrderItem item : items) {
        InventoryItem stock = inventory.findBySkuAndWarehouse(item.sku(), warehouse)
                .orElseThrow(...);
        if (stock.quantityOnHand() < item.quantity()) {
            throw new InsufficientStockException(item.sku(), stock.quantityOnHand(), item.quantity());
        }
        inventory.save(/* decremented copy */);
    }
    return orders.save(new Order(null, customerId, items, PLACED, total, Instant.now()));
}

Full service: OrderService.java. The committed case, straight from a real test run against a real MongoDB replica set:

order_id=6acfd210bf211a8919681ce9 status=PLACED total=199.90
inventory_before=50 inventory_after=40 (decremented by order quantity)

Transcript: 26-order-transaction-commit.txt. And the rollback case — an order for more than the available stock, proving both writes were undone, not just the one that threw:

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)

Transcript: 26-order-transaction-rollback.txt. The inventory quantity after the failed order is identical to before it — not partially decremented, not decremented-then-restored, simply never changed, because the whole operation ran inside one MongoDB transaction and MongoDB discarded it.

Going deeper: why this needs a replica set, even with one node

MongoDB multi-document transactions are built on the same oplog machinery as replication, which is why a standalone mongod cannot run them at all — it has no oplog. A single-node replica set has one, even though there’s nothing to replicate to, which is exactly what Testcontainers’ MongoDBContainer sets up by default now. Production deployments are replica sets or sharded clusters anyway, so this isn’t a sandbox-only concession; a standalone MongoDB node in production can’t do this either.

The aggregation: a report no repository method can express

Derived queries are for fetching documents; an aggregation pipeline is for reshaping many documents into a different-shaped answer — here, total revenue and order count grouped by status:

Aggregation pipeline = newAggregation(
        group("status").sum("total").as("totalRevenue").count().as("orderCount"),
        project("totalRevenue", "orderCount").and("status").previousOperation(),
        sort(Sort.Direction.DESC, "totalRevenue")
);
AggregationResults<RevenueByStatusRaw> results =
        mongoTemplate.aggregate(pipeline, "orders", RevenueByStatusRaw.class);

Full class: OrderAnalytics.java. Real output, three seeded orders, two different statuses:

status=PLACED total_revenue=350.00 order_count=2
status=REJECTED_INSUFFICIENT_STOCK total_revenue=40.00 order_count=1

Transcript: 26-revenue-aggregation.txt.

Going deeper: MongoTemplate vs the repository’s @Aggregation annotation

Spring Data MongoDB also supports an @Aggregation annotation directly on a repository method, with the pipeline written as raw MongoDB JSON strings. It reads naturally for a short, fixed pipeline, but loses Java-side type checking on stage names and field references — a typo in a stage name fails at runtime, not compile time. MongoTemplate.aggregate() with the fluent builder used here catches more of that by construction, at the cost of being more verbose for a one-line pipeline. For anything with conditional stages built up in code, the fluent builder is the only realistic option.

The index: provable, not just asserted

“This query is indexed” is a claim worth checking rather than trusting, and MongoDB’s explain() gives a straight answer: the winningPlan‘s stage name is either IXSCAN (index scan) or COLLSCAN (collection scan, visiting every document).

mongoTemplate.indexOps(Order.class).createIndex(
        new CompoundIndexDefinition(new Document("customerId", 1).append("createdAt", -1))
                .named("customer_created_idx"));

Document explain = ordersCollection.find(new Document("customerId", "cust-0")
                .append("createdAt", new Document("$lte", now)))
        .sort(new Document("createdAt", -1))
        .explain();

Full test: MongoDemoTest.java. Run against 500 seeded orders, querying by the indexed fields versus an unindexed one:

query on indexed fields (customerId, createdAt): winning plan stage = FETCH IXSCAN
query on unindexed field (total): winning plan stage = COLLSCAN

Transcript: 26-index-explain.txt.

500 documents, two queries, two different plans {customerId, createdAt} matches customer_created_idx IXSCAN walks the index, not the data {total: {$gt: 490}} no index covers “total” COLLSCAN visits all 500 documents At 500 documents the difference is milliseconds; at 50 million it’s the difference between a dashboard and a timeout.
spring.data.mongodb.auto-index-creation defaults to false. Annotating a document with @CompoundIndex or @Indexed declares the intent; it does not create anything on its own unless that property is set to true, or the index is created explicitly through IndexOperations as shown above. A reader who adds the annotation, skips this property, and trusts the annotation alone will get a COLLSCAN in production with no error anywhere telling them so.

What the defaults don’t do

  • No schema validation was configured. MongoDB can enforce a JSON Schema per collection ($jsonSchema validators), but nothing here stops a document with the wrong shape from being inserted outside of what the Java type system catches on the write path.
  • No write concern was tuned. The driver’s default write concern (w: majority on a replica set) is safe but not free; a write-heavy workload that doesn’t need majority acknowledgment on every write can trade some durability for throughput deliberately, not by accident.
  • The optimistic lock on InventoryItem is not exercised by this post’s tests — the transaction already serializes concurrent access to the same document within MongoDB’s own snapshot isolation. @Version still matters for code paths that read, think, and write outside a transaction.

Should you reach for MongoDB transactions at all?

If your writes naturally land in one document — an order with its line items embedded, a user profile with its settings — MongoDB’s single-document atomicity already covers you for free, and a multi-document transaction is overhead with no payoff. Reach for an explicit transaction, as here, specifically when a business operation has to touch more than one collection (or more than one document) and “partially happened” is a real, reportable bug, not just a theoretical one. If that describes most of your write paths, it may be a sign the schema wants to be more document-oriented — fewer collections, more embedding — before it’s a sign you need more transactions.

SituationWhat to use
Fields that always change together, in one documentNothing extra — MongoDB’s per-document atomicity already covers it
A write that spans two or more collections or documentsExplicit MongoTransactionManager + @Transactional, as in this post
A report reshaping many documents (sums, grouping, joins across collections)MongoTemplate.aggregate() or an @Aggregation repository method
A query that will run often against a growing collectionAn explicit index, verified with explain() — never assume the annotation alone did it

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.