00:00

Spring Data MongoDB

Last updated:

Spring Data MongoDB connects a Spring Boot application to MongoDB, a document database that stores data as flexible JSON-like documents instead of rows in tables. It maps your Java classes to documents, gives you repositories that write queries from method names, and offers MongoTemplate for anything more advanced. You get MongoDB's flexible schema without writing low-level driver code.

This tutorial explains how Spring Data MongoDB works, how to map documents with @Document, @Id and @Indexed, and when to embed data or reference it. It covers repository queries, projections and paging, atomic updates and aggregations with MongoTemplate, and auditing and optimistic locking. It then builds a complete product catalog on Spring Boot 4.1, shows the real errors behind the most common pitfalls, and tests everything with @DataMongoTest and Testcontainers.

1. What Is Spring Data MongoDB?

MongoDB stores records as documents: nested key-value structures, saved in a binary JSON format called BSON. Documents of the same kind live in a collection, which plays the role of a table. Unlike a table, a collection does not force every document to have the same fields, and a document can contain lists and sub-documents. A product can hold its own list of colour and size variants, for example, instead of needing a second table and a join.

Spring Data MongoDB is the Spring module that works with MongoDB. It is part of the same Spring Data family as Spring Data JPA, so repositories, derived queries, paging and auditing will feel familiar. What differs is the mapping: there is no JPA, no Hibernate and no SQL. Your objects become documents directly.

2. How Spring Data MongoDB Works

Your code talks to either a repository, an interface that Spring implements for you at startup, or to MongoTemplate, a class with methods for every kind of query and update. Repositories use MongoTemplate internally. Both pass objects to the MappingMongoConverter, which turns Java objects into BSON documents and back. The official MongoDB Java driver then sends the result over the network:

Layers between your code and MongoDBProductService calls either ProductRepository, an interface implemented by a Spring proxy, or MongoTemplate, which handles queries, updates and aggregations. The repository uses MongoTemplate internally. MongoTemplate passes objects to MappingMongoConverter, which converts Java objects to BSON documents and back, and the MongoDB Java driver (MongoClient) sends them to the products collection in MongoDB. ProductService your code ProductRepository interface, Spring proxy MongoTemplate queries, updates, aggregations MappingMongoConverter Java object ↔ BSON MongoDB driver MongoClient MongoDB products collection uses
Figure 1: repositories and MongoTemplate share the same converter and driver; only the API you write against differs.

Spring Boot auto-configures all of it. Once the starter is on the classpath and a connection URI is set, the MongoClient, MongoTemplate and your repositories are ready to inject.

3. Setup: Connecting Spring Boot to MongoDB

Add the MongoDB starter, along with the web starter for the REST API. The test dependencies are used in section 10:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-mongodb</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Then point the application at your database:

application.properties
spring.application.name=shop

# Spring Boot 4 key (was spring.data.mongodb.uri in 3.x)
spring.mongodb.uri=mongodb://localhost:27017/shop

# Create the indexes declared with @Indexed when the app starts
spring.data.mongodb.auto-index-creation=true

# Log every query MongoTemplate and the repositories send
logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG

The URI holds the host, port and database name (shop). For MongoDB Atlas or a server with a password, use the full connection string, for example mongodb+srv://user:password@cluster0.example.net/shop, and keep the password in an environment variable rather than in the file.

Version note: Spring Boot 4 moved the connection settings from spring.data.mongodb.* to spring.mongodb.*: spring.mongodb.uri, spring.mongodb.host, spring.mongodb.database and so on. The old keys no longer work (section 9.1 shows what happens). Spring Data settings such as spring.data.mongodb.auto-index-creation keep their names. Spring Boot 4.1 uses Spring Data MongoDB 5.1 and MongoDB Java driver 5.8.

4. Mapping Java Classes to MongoDB Documents

4.1 Mapping Annotations

AnnotationPackagePurpose
@Document("products")o.s.data.mongodb.core.mappingMarks the class as a document and names its collection
@Ido.s.data.annotationMaps to _id. A String id is generated as an ObjectId when you save
@Field("unit_price")o.s.data.mongodb.core.mappingStores the field under a different name
@Indexed / @CompoundIndexo.s.data.mongodb.core.indexDeclares an index, optionally unique
@Transiento.s.data.annotationField is not saved
@Versiono.s.data.annotationOptimistic locking counter
@CreatedDate / @LastModifiedDateo.s.data.annotationAudit timestamps, filled when auditing is enabled
@DocumentReferenceo.s.data.mongodb.core.mappingStores a link to a document in another collection instead of embedding it

(o.s. is short for org.springframework.)

4.2 Embedding vs Referencing

The biggest modelling decision in MongoDB is whether related data goes inside a document or in its own collection:

Embed (a field holding a record or list)Reference (@DocumentReference)
ReadOne query returns everythingExtra lookups to load the referenced documents
Atomic updateYes, a single-document update is atomicChanging both sides needs a transaction
Good forData owned by the parent and read with it: variants, order lines, addressesData shared by many parents, or lists that grow without limit: customers, reviews
LimitA document can be at most 16MBNone per document

The example embeds each product's variants, because they belong to the product and are always shown with it.

4.3 How a Product Is Stored

Here is the kettle from the example as it appears in MongoDB. Spring added the _id ObjectId, the version, the audit dates and a _class field naming the Java type:

products collection (mongosh)
{
  _id: ObjectId('6add9671129ca48adfd2815d'),
  sku: 'KET-100',
  name: 'Steel Kettle 1.7L',
  category: 'kitchen',
  price: Decimal128('49.99'),
  tags: [ 'electric', 'steel' ],
  variants: [
    { color: 'silver', size: '1.7L', stock: 12 },
    { color: 'black', size: '1.7L', stock: 0 }
  ],
  version: 0,
  createdAt: ISODate('2026-10-25T05:41:05.968Z'),
  updatedAt: ISODate('2026-10-25T05:41:05.968Z'),
  _class: 'com.javabytechie.shop.catalog.Product'
}

5. Querying with MongoRepository

5.1 CRUD Methods

Extending MongoRepository<Product, String> gives you save, insert, findById, findAll, count, deleteById and paging variants. Note the difference between two of them: insert always creates a new document and fails on a duplicate key, while save inserts new objects and replaces existing ones.

5.2 Derived Queries

Spring Data builds a MongoDB query from the method name. The example's repository contains:

ProductRepository.java (excerpt)
Optional<Product> findBySku(String sku);

List<ProductSummary> findByCategoryAndPriceLessThanEqualOrderByPriceAsc(String category, BigDecimal maxPrice);

List<ProductSummary> findByTags(String tag);

The log shows the query Spring generated for the first method. It contains only the projected fields and the sort:

Console
DEBUG o.s.data.mongodb.core.MongoTemplate : find using query: { "category" : "kitchen", "price" : { "$lte" : { "$numberDecimal" : "40"}}} fields: Document{{price=1, name=1, sku=1}} sort: { "price" : 1} for class: class com.javabytechie.shop.catalog.Product in collection: products
Method name keywordMongoDB operatorExample
And / Orimplicit AND / $orfindByCategoryAndName
LessThanEqual, GreaterThan, Between$lte, $gt, $gt + $ltfindByPriceBetween
In$infindBySkuIn(List<String>)
Regex, StartingWith, Containing (strings)$regexfindByNameStartingWith
Exists, IsNull$exists, null matchfindByTagsExists(boolean)
OrderBy...AscsortfindByCategoryOrderByPriceAsc

A query on an array field such as tags matches when any element equals the value, so findByTags("gift") finds every product tagged "gift".

5.3 Custom Queries with @Query

When the name would get too long, or you need an operator such as $elemMatch, write the query as JSON. ?0 is the first method parameter. $elemMatch requires that a single array element meets all the conditions, here a variant that is both black and in stock:

ProductRepository.java (excerpt)
@Query("{ 'variants': { $elemMatch: { 'color': ?0, 'stock': { $gt: 0 } } } }")
List<ProductSummary> findInStockByColor(String color);

5.4 Projections, Paging and Sorting

Returning ProductSummary, a record with only sku, name and price, is a DTO projection: MongoDB sends back only those fields. Paging works as in JPA. Pass a Pageable and get a Page with the content and the total count:

ProductRepositoryTest.java (excerpt)
Page<Product> page = repository.findByCategory("kitchen", PageRequest.of(0, 2, Sort.by("price")));

assertThat(page.getContent()).extracting(Product::getSku).containsExactly("MUG-310", "PAN-220");
assertThat(page.getTotalElements()).isEqualTo(3);
assertThat(page.getTotalPages()).isEqualTo(2);

The pagination and sorting tutorial explains Pageable in more detail.

6. MongoTemplate: Atomic Updates and Aggregation

6.1 MongoRepository vs MongoTemplate

MongoRepositoryMongoTemplate
StyleInterface; queries from method names or @QueryClass you inject; queries built in Java with Criteria
UpdatesLoad, change, save the whole documentPartial, atomic updates ($inc, $set, $push)
AggregationSimple pipelines with @AggregationFull pipeline builder
Dynamic queriesAwkwardNatural: add criteria conditionally
Use forMost reads and simple writesCounters, stock levels, reports, complex searches

They work well together. The example's service uses the repository for CRUD and MongoTemplate for the two operations a repository can't do well.

6.2 Atomic Updates

Reserving stock with load, change, save is unsafe: two orders can both read "1 left" and both succeed. Instead, send a single update with the condition built in. MongoDB checks the filter and applies the change as one atomic step on the document:

Lost update versus atomic updateLeft: with load, change and save, order A and order B both read a stock of 1, then each saves a stock of 0, so two items are sold although only one existed. Right: with a single atomic update, order A updates only if stock is at least 1 and decrements it, matching the document and leaving stock 0; order B sends the same update, matches nothing, gets modifiedCount 0 and receives a 409, so only one item is sold and stock never goes negative. Load, change, save (unsafe) One atomic update (safe) Order A: findBySku → stock 1 Order B: findBySku → stock 1 Order A: save(stock 0) Order B: save(stock 0) Result: 2 sold, but only 1 existed Order A: update if stock ≥ 1, $inc −1 matched: modifiedCount 1, stock 0 Order B: update if stock ≥ 1, $inc −1 no match: modifiedCount 0 → 409 Result: 1 sold, stock never negative
Figure 2: putting the stock check inside the update filter makes check and change one atomic step.
ProductService.java (excerpt)
Query inStock = query(where("sku").is(sku)
        .and("variants").elemMatch(where("color").is(request.color())
                .and("size").is(request.size())
                .and("stock").gte(request.quantity())));
Update decrement = new Update().inc("variants.$.stock", -request.quantity());

UpdateResult result = mongoTemplate.updateFirst(inStock, decrement, Product.class);

The positional operator variants.$.stock updates the array element that $elemMatch matched. If no document matches, because the variant is missing or has too little stock, getModifiedCount() is 0 and the service throws OutOfStockException.

6.3 Aggregation

An aggregation pipeline processes documents in stages on the server, like SQL's GROUP BY. This one counts products and averages prices per category:

ProductService.java (excerpt)
TypedAggregation<Product> aggregation = newAggregation(Product.class,
        group("category").count().as("products").avg("price").as("averagePrice"),
        sort(Sort.by(Sort.Direction.DESC, "products").and(Sort.by("_id"))));
return mongoTemplate.aggregate(aggregation, CategoryStats.class).getMappedResults();
Console
DEBUG o.s.data.mongodb.core.MongoTemplate : Executing aggregation: [{ "$group" : { "_id" : "$category", "products" : { "$sum" : 1}, "averagePrice" : { "$avg" : "$price"}}}, { "$sort" : { "products" : -1, "_id" : 1}}] in collection products

7. A Complete Example: Product Catalog API

The catalog stores products with embedded variants, enforces unique SKUs, reserves stock atomically and reports category statistics. It returns the same ProblemDetail errors throughout, as described in the exception handling tutorial: 404 for an unknown SKU and 409 for a duplicate SKU, insufficient stock or a stale update.

7.1 Documents and DTOs

package com.javabytechie.shop.catalog;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

@Document("products")
public class Product {

    @Id
    private String id;

    @Indexed(unique = true)
    private String sku;

    private String name;

    @Indexed
    private String category;

    private BigDecimal price;

    private List<String> tags;

    private List<Variant> variants;

    @Version
    private Long version;

    @CreatedDate
    private Instant createdAt;

    @LastModifiedDate
    private Instant updatedAt;

    public Product(String sku, String name, String category, BigDecimal price,
                   List<String> tags, List<Variant> variants) {
        this.sku = sku;
        this.name = name;
        this.category = category;
        this.price = price;
        this.tags = tags;
        this.variants = variants;
    }

    public void changePrice(BigDecimal newPrice) {
        this.price = newPrice;
    }

    public String getId() { return id; }
    public String getSku() { return sku; }
    public String getName() { return name; }
    public String getCategory() { return category; }
    public BigDecimal getPrice() { return price; }
    public List<String> getTags() { return tags; }
    public List<Variant> getVariants() { return variants; }
    public Long getVersion() { return version; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

7.2 Repository, Service and Configuration

package com.javabytechie.shop.catalog;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;

public interface ProductRepository extends MongoRepository<Product, String> {

    Optional<Product> findBySku(String sku);

    // Derived query: { category: ?0, price: { $lte: ?1 } }, sorted by price
    List<ProductSummary> findByCategoryAndPriceLessThanEqualOrderByPriceAsc(String category, BigDecimal maxPrice);

    // tags is an array; MongoDB matches a document if any element equals the value
    List<ProductSummary> findByTags(String tag);

    Page<Product> findByCategory(String category, Pageable pageable);

    // Hand-written query: at least one variant with this color still in stock
    @Query("{ 'variants': { $elemMatch: { 'color': ?0, 'stock': { $gt: 0 } } } }")
    List<ProductSummary> findInStockByColor(String color);
}

7.3 Controller and Exceptions

package com.javabytechie.shop.catalog;

import java.math.BigDecimal;
import java.net.URI;
import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody CreateProductRequest request) {
        Product product = productService.create(request);
        return ResponseEntity.created(URI.create("/api/products/" + product.getSku())).body(product);
    }

    @GetMapping("/{sku}")
    public Product findBySku(@PathVariable String sku) {
        return productService.findBySku(sku);
    }

    @GetMapping
    public List<ProductSummary> search(@RequestParam String category, @RequestParam BigDecimal maxPrice) {
        return productService.search(category, maxPrice);
    }

    @GetMapping("/in-stock")
    public List<ProductSummary> inStock(@RequestParam String color) {
        return productService.findInStockByColor(color);
    }

    @GetMapping("/stats")
    public List<CategoryStats> stats() {
        return productService.categoryStats();
    }

    @PatchMapping("/{sku}/price")
    public Product changePrice(@PathVariable String sku, @RequestBody PriceChangeRequest request) {
        return productService.changePrice(sku, request.price());
    }

    @PostMapping("/{sku}/reservations")
    public ResponseEntity<Void> reserve(@PathVariable String sku, @RequestBody StockReservationRequest request) {
        productService.reserveStock(sku, request);
        return ResponseEntity.noContent().build();
    }
}

7.4 Run It

Start MongoDB locally, for example with docker run -d -p 27017:27017 mongo:8.0, then run the app with ./mvnw spring-boot:run. Create a product, then try to create it again:

Terminal
$ curl -s -i -X POST localhost:8080/api/products -H 'Content-Type: application/json' -d '{
    "sku":"KET-100","name":"Steel Kettle 1.7L","category":"kitchen","price":49.99,
    "tags":["electric","steel"],
    "variants":[{"color":"silver","size":"1.7L","stock":12},{"color":"black","size":"1.7L","stock":0}]}'
HTTP/1.1 201
Location: /api/products/KET-100

{"sku":"KET-100","name":"Steel Kettle 1.7L","category":"kitchen","price":49.99,"tags":["electric","steel"],"variants":[{"color":"silver","size":"1.7L","stock":12},{"color":"black","size":"1.7L","stock":0}],"createdAt":"2026-10-25T05:41:05.968969Z","id":"6add9671129ca48adfd2815d","updatedAt":"2026-10-25T05:41:05.968969Z","version":0}

$ curl -s -i -X POST localhost:8080/api/products -H 'Content-Type: application/json' \
    -d '{"sku":"KET-100","name":"Copy","category":"kitchen","price":1,"tags":[],"variants":[]}'
HTTP/1.1 409
{"detail":"A product with SKU KET-100 already exists","instance":"/api/products","status":409,"title":"Conflict"}

After adding a pan, a mug set and a desk lamp the same way, query the catalog:

Terminal
$ curl -s 'localhost:8080/api/products?category=kitchen&maxPrice=40'
[{"sku":"MUG-310","name":"Ceramic Mug Set","price":18.00},{"sku":"PAN-220","name":"Cast Iron Pan 28cm","price":39.50}]

$ curl -s 'localhost:8080/api/products/in-stock?color=black'
[{"sku":"PAN-220","name":"Cast Iron Pan 28cm","price":39.50},{"sku":"LMP-400","name":"Desk Lamp","price":27.25}]

$ curl -s localhost:8080/api/products/stats
[{"category":"kitchen","products":3,"averagePrice":35.83},{"category":"office","products":1,"averagePrice":27.25}]

Reserve stock, change the price and look at the result. The silver stock dropped from 12 to 9, and version is now 2: the atomic update also increments the @Version field because it targets the Product class, and the price change saved the document once more.

Terminal
$ curl -s -i -X POST localhost:8080/api/products/KET-100/reservations -H 'Content-Type: application/json' \
    -d '{"color":"silver","size":"1.7L","quantity":3}'
HTTP/1.1 204

$ curl -s -i -X POST localhost:8080/api/products/KET-100/reservations -H 'Content-Type: application/json' \
    -d '{"color":"silver","size":"1.7L","quantity":10}'
HTTP/1.1 409
{"detail":"Not enough stock for KET-100 (silver, 1.7L): requested 10","instance":"/api/products/KET-100/reservations","status":409,"title":"Conflict"}

$ curl -s -X PATCH localhost:8080/api/products/KET-100/price -H 'Content-Type: application/json' -d '{"price":44.99}'
{"sku":"KET-100","name":"Steel Kettle 1.7L","category":"kitchen","price":44.99,"tags":["electric","steel"],"variants":[{"color":"silver","size":"1.7L","stock":9},{"color":"black","size":"1.7L","stock":0}],"createdAt":"2026-10-25T05:41:05.968Z","id":"6add9671129ca48adfd2815d","updatedAt":"2026-10-25T05:41:06.153988Z","version":2}

$ curl -s -i localhost:8080/api/products/NOPE-1
HTTP/1.1 404
{"detail":"Product not found: NOPE-1","instance":"/api/products/NOPE-1","status":404,"title":"Not Found"}

8. Configuration Options

PropertyDefaultPurpose
spring.mongodb.urimongodb://localhost/testConnection string, including the database
spring.mongodb.databaseFrom the URIOverrides the database name
spring.mongodb.host / port / username / passwordNoneIndividual settings, as an alternative to the URI
spring.data.mongodb.auto-index-creationfalseCreates @Indexed and @CompoundIndex indexes at startup
spring.data.mongodb.representation.big-decimalunspecified (Decimal128)How BigDecimal is stored: decimal128 or string
spring.data.mongodb.field-naming-strategyNone (field names as written)For example org.springframework.data.mapping.model.SnakeCaseFieldNamingStrategy

Auditing is off until you add @EnableMongoAuditing to a configuration class, as MongoConfig does.

9. Common Pitfalls and How to Fix Them

9.1 Spring Boot Ignores spring.data.mongodb.uri

After an upgrade to Spring Boot 4, an old spring.data.mongodb.uri is silently ignored. The app connects to the default localhost:27017, and with automatic index creation on, startup fails after the driver's 30-second timeout:

Console
INFO  org.mongodb.driver.cluster : Exception in monitor thread while connecting to server localhost:27017
...
ERROR o.s.boot.SpringApplication : Application run failed
Caused by: org.springframework.dao.DataAccessResourceFailureException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:27017, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]

How to fix it: rename the key to spring.mongodb.uri. Adding the spring-boot-properties-migrator dependency temporarily makes Spring Boot report renamed keys at startup.

9.2 @Indexed(unique = true) Does Not Stop Duplicates

Spring Data does not create indexes by default. Without the index, a second product with SKU KET-100 is saved without error, and the next findBySku fails with a 500:

Console
org.springframework.dao.IncorrectResultSizeDataAccessException: Query { "$java" : Query: { "sku" : "KET-100"}, Fields: {}, Sort: {} } returned non unique result

How to fix it: set spring.data.mongodb.auto-index-creation=true, or, often better in production, create indexes with a migration tool or a startup script that you control. Remove existing duplicates first, because MongoDB refuses to build a unique index over them.

9.3 Prices Stored as Strings

If BigDecimal values are stored as strings, for example with representation.big-decimal=string, MongoDB compares them as text and cannot do arithmetic on them. The same aggregation returned "averagePrice":null for a "49.99" string price. How to fix it: keep the default Decimal128 representation and migrate old string values.

Version note: with Spring Boot 3.x (Spring Data MongoDB 4.x), BigDecimal was stored as a string unless configured otherwise. Check older data before running numeric queries on it.

9.4 Lost Updates with save()

save() replaces the whole document. Without @Version, two users who load the same product and save it lose one of their changes. With @Version, Spring adds the expected version to the update filter, and a stale save throws OptimisticLockingFailureException, which the example returns as 409. For counters and stock, use an atomic update instead (section 6.2).

9.5 Arrays That Grow Without Limit

Embedding every review or order in a product works at first, but a document is capped at 16MB and large documents are slow to load and rewrite. How to fix it: embed only bounded lists, and store unbounded ones in their own collection with a field pointing back to the parent.

9.6 Renaming a Document Class

The _class field stores the fully qualified class name. Spring uses it to read documents back into the right subclass. If a class name was saved that no longer exists after a refactoring, documents may no longer map back to the type you expect. How to fix it: give the class a short, stable alias with @TypeAlias("product") before you store data, or migrate the field when you move classes.

10. Testing with @DataMongoTest and Testcontainers

@DataMongoTest starts only the MongoDB part of the application: MongoTemplate, repositories and document mapping, without controllers. Testcontainers starts a real MongoDB in Docker for the tests, and @ServiceConnection points spring.mongodb.uri at it automatically, so no properties are needed:

TestcontainersConfiguration.java
package com.javabytechie.shop;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mongodb.MongoDBContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    // Starts MongoDB in Docker and points spring.mongodb.uri at it automatically
    @Bean
    @ServiceConnection
    MongoDBContainer mongoDbContainer() {
        return new MongoDBContainer(DockerImageName.parse("mongo:8.0"));
    }
}

The repository test checks queries, paging, the unique index (the count is the same before and after the rejected insert) and auditing:

ProductRepositoryTest.java
package com.javabytechie.shop.catalog;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.util.List;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.MongoConfig;

@DataMongoTest
@Import({TestcontainersConfiguration.class, MongoConfig.class})
class ProductRepositoryTest {

    @Autowired
    ProductRepository repository;

    @BeforeEach
    void setUp() {
        repository.deleteAll();
        repository.saveAll(List.of(
                new Product("KET-100", "Steel Kettle 1.7L", "kitchen", new BigDecimal("49.99"), List.of("electric", "steel"),
                        List.of(new Variant("silver", "1.7L", 12), new Variant("black", "1.7L", 0))),
                new Product("PAN-220", "Cast Iron Pan 28cm", "kitchen", new BigDecimal("39.50"), List.of("cast-iron"),
                        List.of(new Variant("black", "28cm", 5))),
                new Product("MUG-310", "Ceramic Mug Set", "kitchen", new BigDecimal("18.00"), List.of("ceramic", "gift"),
                        List.of(new Variant("white", "350ml", 40))),
                new Product("LMP-400", "Desk Lamp", "office", new BigDecimal("27.25"), List.of("electric", "gift"),
                        List.of(new Variant("black", "standard", 7)))));
    }

    @Test
    void derivedQueryFiltersSortsAndProjects() {
        assertThat(repository.findByCategoryAndPriceLessThanEqualOrderByPriceAsc("kitchen", new BigDecimal("40")))
                .extracting(ProductSummary::sku)
                .containsExactly("MUG-310", "PAN-220");
    }

    @Test
    void arrayAndEmbeddedQueries() {
        assertThat(repository.findByTags("gift")).extracting(ProductSummary::sku)
                .containsExactlyInAnyOrder("MUG-310", "LMP-400");
        // KET-100 has a black variant, but it is out of stock
        assertThat(repository.findInStockByColor("black")).extracting(ProductSummary::sku)
                .containsExactlyInAnyOrder("PAN-220", "LMP-400");
    }

    @Test
    void pagingReturnsOnePageAndTheTotal() {
        Page<Product> page = repository.findByCategory("kitchen", PageRequest.of(0, 2, Sort.by("price")));

        assertThat(page.getContent()).extracting(Product::getSku).containsExactly("MUG-310", "PAN-220");
        assertThat(page.getTotalElements()).isEqualTo(3);
        assertThat(page.getTotalPages()).isEqualTo(2);
    }

    @Test
    void uniqueIndexRejectsSecondProductWithSameSku() {
        assertThat(repository.count()).isEqualTo(4);

        Product copy = new Product("KET-100", "Kettle copy", "kitchen", BigDecimal.ONE, List.of(), List.of());
        assertThatThrownBy(() -> repository.insert(copy)).isInstanceOf(DuplicateKeyException.class);

        assertThat(repository.count()).isEqualTo(4);
    }

    @Test
    void auditingAndVersionAreFilledOnSave() {
        Product kettle = repository.findBySku("KET-100").orElseThrow();

        assertThat(kettle.getId()).isNotNull();
        assertThat(kettle.getCreatedAt()).isNotNull();
        assertThat(kettle.getVersion()).isZero();
    }
}

The service test adds ProductService with @Import. It checks the stock before and after each reservation, that a failed reservation changes nothing, and that a stale copy cannot overwrite a newer save:

ProductServiceTest.java
package com.javabytechie.shop.catalog;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.util.List;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.OptimisticLockingFailureException;

import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.MongoConfig;

@DataMongoTest
@Import({TestcontainersConfiguration.class, MongoConfig.class, ProductService.class})
class ProductServiceTest {

    @Autowired
    ProductService productService;

    @Autowired
    ProductRepository repository;

    @BeforeEach
    void setUp() {
        repository.deleteAll();
        productService.create(new CreateProductRequest("KET-100", "Steel Kettle 1.7L", "kitchen",
                new BigDecimal("49.99"), List.of("electric"), List.of(new Variant("silver", "1.7L", 12))));
        productService.create(new CreateProductRequest("LMP-400", "Desk Lamp", "office",
                new BigDecimal("27.25"), List.of("electric"), List.of(new Variant("black", "standard", 7))));
    }

    private int silverStock() {
        return productService.findBySku("KET-100").getVariants().getFirst().stock();
    }

    @Test
    void reserveStockDecrementsTheMatchingVariant() {
        assertThat(silverStock()).isEqualTo(12);

        productService.reserveStock("KET-100", new StockReservationRequest("silver", "1.7L", 3));

        assertThat(silverStock()).isEqualTo(9);
    }

    @Test
    void reservingMoreThanAvailableChangesNothing() {
        assertThat(silverStock()).isEqualTo(12);

        assertThatThrownBy(() -> productService.reserveStock("KET-100", new StockReservationRequest("silver", "1.7L", 13)))
                .isInstanceOf(OutOfStockException.class);

        assertThat(silverStock()).isEqualTo(12);
    }

    @Test
    void duplicateSkuIsReportedAsDuplicateSkuException() {
        CreateProductRequest copy = new CreateProductRequest("KET-100", "Kettle copy", "kitchen",
                BigDecimal.ONE, List.of(), List.of());

        assertThatThrownBy(() -> productService.create(copy))
                .isInstanceOf(DuplicateSkuException.class)
                .hasMessage("A product with SKU KET-100 already exists");
    }

    @Test
    void staleCopyCannotOverwriteANewerSave() {
        Product first = repository.findBySku("KET-100").orElseThrow();
        Product second = repository.findBySku("KET-100").orElseThrow();

        first.changePrice(new BigDecimal("44.99"));
        repository.save(first);

        second.changePrice(new BigDecimal("59.99"));
        assertThatThrownBy(() -> repository.save(second))
                .isInstanceOf(OptimisticLockingFailureException.class);

        assertThat(productService.findBySku("KET-100").getPrice()).isEqualByComparingTo("44.99");
    }

    @Test
    void categoryStatsGroupsByCategory() {
        assertThat(productService.categoryStats()).containsExactly(
                new CategoryStats("kitchen", 1, new BigDecimal("49.99")),
                new CategoryStats("office", 1, new BigDecimal("27.25")));
    }
}
Console
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.catalog.ProductRepositoryTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.catalog.ProductServiceTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
Version note: in Spring Boot 4, @DataMongoTest is in org.springframework.boot.data.mongodb.test.autoconfigure and comes from spring-boot-starter-data-mongodb-test. Testcontainers 2 moved MongoDBContainer to org.testcontainers.mongodb, and its module is now testcontainers-mongodb. The old org.testcontainers.containers.MongoDBContainer is deprecated. See the Spring Boot testing tutorial for other test slices.

11. When to Use MongoDB (and When Not To)

Good fitPoor fit
Product catalogs whose items have different attributesHeavily related data that is queried across many entities with joins
Data read and written as one unit: orders with lines, user profiles with settingsMany multi-document updates that must all succeed together
Fast-changing schemas, event logs, content and metadataReports that need ad-hoc SQL and strict relational constraints
Horizontal scaling across shardsSmall, stable, tabular data that a relational database already handles well

MongoDB supports multi-document transactions on replica sets, but if most of your operations need them, a relational database with Spring Data JPA is usually the simpler choice.

12. Best Practices

  • Design documents around how you read them: embed data that is loaded together, and reference data that is shared or unbounded.
  • Declare indexes for every field you filter or sort on, and make sure they are actually created.
  • Use a unique index for natural keys such as SKUs, and turn DuplicateKeyException into a clear 409.
  • Use atomic updates for counters and stock instead of load, change and save.
  • Add @Version to documents edited by users so concurrent edits are detected.
  • Store money as BigDecimal in Decimal128, never as double or strings.
  • Return projections for list endpoints so MongoDB sends only the fields you show.
  • Keep the connection string out of source code, and use the spring.mongodb.* keys in Spring Boot 4.
  • Log queries while developing by setting MongoTemplate to DEBUG.
  • Test against real MongoDB with Testcontainers rather than a fake.

13. Summary

Spring Data MongoDB lets a Spring Boot application store Java objects as MongoDB documents with very little code. Add the starter, set spring.mongodb.uri, and map classes with @Document, @Id and @Indexed, embedding data that belongs to the parent. Use MongoRepository for CRUD, derived queries, projections and paging. Switch to MongoTemplate for atomic updates and aggregations. Enable auditing and add @Version for safe concurrent edits, make sure indexes are created, and test against a real database with @DataMongoTest and Testcontainers. The main recommendation: model documents around your reads, and change shared values with atomic updates rather than read-modify-write.