Spring Data MongoDB
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:
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:
<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:
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.
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
| Annotation | Package | Purpose |
|---|---|---|
@Document("products") | o.s.data.mongodb.core.mapping | Marks the class as a document and names its collection |
@Id | o.s.data.annotation | Maps to _id. A String id is generated as an ObjectId when you save |
@Field("unit_price") | o.s.data.mongodb.core.mapping | Stores the field under a different name |
@Indexed / @CompoundIndex | o.s.data.mongodb.core.index | Declares an index, optionally unique |
@Transient | o.s.data.annotation | Field is not saved |
@Version | o.s.data.annotation | Optimistic locking counter |
@CreatedDate / @LastModifiedDate | o.s.data.annotation | Audit timestamps, filled when auditing is enabled |
@DocumentReference | o.s.data.mongodb.core.mapping | Stores 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) | |
|---|---|---|
| Read | One query returns everything | Extra lookups to load the referenced documents |
| Atomic update | Yes, a single-document update is atomic | Changing both sides needs a transaction |
| Good for | Data owned by the parent and read with it: variants, order lines, addresses | Data shared by many parents, or lists that grow without limit: customers, reviews |
| Limit | A document can be at most 16MB | None 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:
{
_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:
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:
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 keyword | MongoDB operator | Example |
|---|---|---|
And / Or | implicit AND / $or | findByCategoryAndName |
LessThanEqual, GreaterThan, Between | $lte, $gt, $gt + $lt | findByPriceBetween |
In | $in | findBySkuIn(List<String>) |
Regex, StartingWith, Containing (strings) | $regex | findByNameStartingWith |
Exists, IsNull | $exists, null match | findByTagsExists(boolean) |
OrderBy...Asc | sort | findByCategoryOrderByPriceAsc |
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:
@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:
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
MongoRepository | MongoTemplate | |
|---|---|---|
| Style | Interface; queries from method names or @Query | Class you inject; queries built in Java with Criteria |
| Updates | Load, change, save the whole document | Partial, atomic updates ($inc, $set, $push) |
| Aggregation | Simple pipelines with @Aggregation | Full pipeline builder |
| Dynamic queries | Awkward | Natural: add criteria conditionally |
| Use for | Most reads and simple writes | Counters, 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:
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:
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();
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; }
}
package com.javabytechie.shop.catalog;
// Embedded in Product: stored inside the product document, not in its own collection
public record Variant(String color, String size, int stock) {
}
package com.javabytechie.shop.catalog;
import java.math.BigDecimal;
// DTO projection: only these fields are read from MongoDB
public record ProductSummary(String sku, String name, BigDecimal price) {
}
package com.javabytechie.shop.catalog;
import java.math.BigDecimal;
import org.springframework.data.annotation.Id;
// One row of the aggregation result; the group key arrives in _id
public record CategoryStats(@Id String category, long products, BigDecimal averagePrice) {
}
package com.javabytechie.shop.catalog;
import java.math.BigDecimal;
import java.util.List;
public record CreateProductRequest(String sku, String name, String category, BigDecimal price,
List<String> tags, List<Variant> variants) {
}
package com.javabytechie.shop.catalog;
import java.math.BigDecimal;
public record PriceChangeRequest(BigDecimal price) {
}
package com.javabytechie.shop.catalog;
public record StockReservationRequest(String color, String size, int quantity) {
}
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);
}
package com.javabytechie.shop.catalog;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.group;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.sort;
import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;
import java.math.BigDecimal;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import com.mongodb.client.result.UpdateResult;
@Service
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
private final MongoTemplate mongoTemplate;
public ProductService(ProductRepository productRepository, MongoTemplate mongoTemplate) {
this.productRepository = productRepository;
this.mongoTemplate = mongoTemplate;
}
public Product create(CreateProductRequest request) {
Product product = new Product(request.sku(), request.name(), request.category(),
request.price(), request.tags(), request.variants());
try {
Product saved = productRepository.insert(product);
log.info("Created product {} with id {}", saved.getSku(), saved.getId());
return saved;
} catch (DuplicateKeyException e) {
// The unique index on sku rejected the insert
throw new DuplicateSkuException(request.sku());
}
}
public Product findBySku(String sku) {
return productRepository.findBySku(sku)
.orElseThrow(() -> new ProductNotFoundException(sku));
}
public List<ProductSummary> search(String category, BigDecimal maxPrice) {
return productRepository.findByCategoryAndPriceLessThanEqualOrderByPriceAsc(category, maxPrice);
}
public List<ProductSummary> findInStockByColor(String color) {
return productRepository.findInStockByColor(color);
}
// Read, change, save: @Version makes the save fail if someone else saved in between
public Product changePrice(String sku, BigDecimal newPrice) {
Product product = findBySku(sku);
product.changePrice(newPrice);
return productRepository.save(product);
}
// One atomic update on the server: the filter and the decrement happen together,
// so two concurrent orders can never take the last item twice
public void reserveStock(String sku, StockReservationRequest request) {
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);
if (result.getModifiedCount() == 0) {
findBySku(sku); // throws 404 if the product itself is missing
throw new OutOfStockException(sku, request.color(), request.size(), request.quantity());
}
log.info("Reserved {} x {} ({}, {})", request.quantity(), sku, request.color(), request.size());
}
public List<CategoryStats> categoryStats() {
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();
}
}
package com.javabytechie.shop.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.config.EnableMongoAuditing;
// Fills @CreatedDate and @LastModifiedDate fields on save
@Configuration
@EnableMongoAuditing
public class MongoConfig {
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
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();
}
}
package com.javabytechie.shop.catalog;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class CatalogExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail notFound(ProductNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler({DuplicateSkuException.class, OutOfStockException.class})
public ProblemDetail conflict(RuntimeException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail staleUpdate(OptimisticLockingFailureException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The product was changed by someone else; reload it and try again");
}
}
package com.javabytechie.shop.catalog;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(String sku) {
super("Product not found: " + sku);
}
}
package com.javabytechie.shop.catalog;
public class DuplicateSkuException extends RuntimeException {
public DuplicateSkuException(String sku) {
super("A product with SKU " + sku + " already exists");
}
}
package com.javabytechie.shop.catalog;
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String sku, String color, String size, int quantity) {
super("Not enough stock for " + sku + " (" + color + ", " + size + "): requested " + quantity);
}
}
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:
$ 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:
$ 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.
$ 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
| Property | Default | Purpose |
|---|---|---|
spring.mongodb.uri | mongodb://localhost/test | Connection string, including the database |
spring.mongodb.database | From the URI | Overrides the database name |
spring.mongodb.host / port / username / password | None | Individual settings, as an alternative to the URI |
spring.data.mongodb.auto-index-creation | false | Creates @Indexed and @CompoundIndex indexes at startup |
spring.data.mongodb.representation.big-decimal | unspecified (Decimal128) | How BigDecimal is stored: decimal128 or string |
spring.data.mongodb.field-naming-strategy | None (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:
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:
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.
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:
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:
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:
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")));
}
}
[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
@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 fit | Poor fit |
|---|---|
| Product catalogs whose items have different attributes | Heavily related data that is queried across many entities with joins |
| Data read and written as one unit: orders with lines, user profiles with settings | Many multi-document updates that must all succeed together |
| Fast-changing schemas, event logs, content and metadata | Reports that need ad-hoc SQL and strict relational constraints |
| Horizontal scaling across shards | Small, 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
DuplicateKeyExceptioninto 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
doubleor 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
MongoTemplateto 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.
Frequently Asked Questions (FAQ)
Q1. What is the difference between MongoRepository and MongoTemplate?
MongoRepository is an interface that Spring implements for you, with CRUD methods and queries derived from method names. MongoTemplate is a class you inject and call directly, with full control over queries, partial updates and aggregation pipelines. Most applications use repositories for everyday reads and writes and MongoTemplate for atomic updates and reports.
Q2. Why is spring.data.mongodb.uri not working in Spring Boot 4?
Spring Boot 4 renamed the connection properties to spring.mongodb.*, so the old key is ignored and the app tries the default localhost:27017. Rename it to spring.mongodb.uri. The same applies to host, port, database, username and password.
Q3. Why is my @Indexed(unique = true) not creating an index?
Spring Data MongoDB does not create indexes automatically by default. Set spring.data.mongodb.auto-index-creation=true, or create the index yourself with a migration or script. If the collection already contains duplicates, MongoDB refuses to build a unique index until they are removed.
Q4. How do I connect Spring Boot to MongoDB Atlas?
Copy the mongodb+srv:// connection string from Atlas and set it as spring.mongodb.uri, including the database name after the host. Supply the password through an environment variable, for example spring.mongodb.uri=${MONGODB_URI}, and allow your server's IP address in the Atlas network settings.
Q5. Does Spring Data MongoDB support transactions?
Yes, on a replica set or sharded cluster, not on a standalone server. Spring Boot does not create a transaction manager for MongoDB, so declare a MongoTransactionManager bean, then use @Transactional as usual. Updates to a single document are already atomic, so design documents to need transactions rarely.
Q6. What is the _class field in my MongoDB documents?
Spring Data MongoDB stores the Java class name in _class so it can read documents back into the right type, which matters when a collection holds subclasses. Use @TypeAlias to store a short, stable name instead. You can also configure the converter not to write the field, but only if the collection never holds more than one type.
Q7. Should I embed documents or use references?
Embed data that belongs to its parent and is read with it, such as order lines or product variants, because one query then returns everything and updates are atomic. Use a separate collection with a reference (@DocumentReference or a plain id field) for data shared between documents or lists that can grow without limit, because a document cannot exceed 16MB.
Q8. How do I use a String id with MongoDB?
Annotate a String field with @Id and leave it null when saving. MongoDB generates an ObjectId, which Spring stores as an ObjectId and converts to its hex string in your object. You can also set your own id value, such as a UUID, before saving.