
Using MongoDB with Spring Boot and Spring Data
If you've built a Spring Boot app on a relational database, you already know the drill: define an entity, extend a repository interface, and let Spring generate the queries. Spring Data MongoDB brings the same model to MongoDB, which makes it one of the fastest ways for a Java team to get productive with a document database.
The catch is that the familiar abstractions can hide what MongoDB is actually doing. Derived query methods are great until you need a partial update, an atomic counter, or an aggregation pipeline. Index annotations look authoritative but don't create anything by default. Transactions need a bean you have to declare yourself. None of this is hard, but you need to know where the edges are.
This guide covers setting up Spring Data MongoDB in Spring Boot 3, mapping documents, repositories and derived queries, pagination, MongoTemplate for updates and aggregations, auditing and optimistic locking, indexes, transactions, and testing with Testcontainers.
Project Setup
Add the starter to your build. With Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Or with Gradle:
implementation "org.springframework.boot:spring-boot-starter-data-mongodb"
implementation "org.springframework.boot:spring-boot-starter-web"
The starter pulls in Spring Data MongoDB and the MongoDB Java sync driver, with versions managed by Spring Boot's dependency BOM. Don't pin the driver version yourself unless you have a specific reason, since Spring Data is tested against the version Boot ships.
Configure the connection in application.yml:
spring:
data:
mongodb:
uri: ${MONGODB_URI:mongodb://localhost:27017/library}
auto-index-creation: true
The database name comes from the path in the URI (library here). If your URI has no database path, set spring.data.mongodb.database explicitly. The auto-index-creation flag matters and is covered in the indexes section below.
Spring Boot auto-configures a single MongoClient bean, with its connection pool, and shares it across the whole application. You don't need to create a client yourself.
Mapping Documents
A document class is a plain Java class (or record) annotated with @Document:
package com.example.library.book;
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.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
@Document(collection = "books")
@CompoundIndex(name = "author_published", def = "{ 'author': 1, 'publishedAt': -1 }")
public class Book {
@Id
private String id;
private String title;
private String author;
@Indexed(unique = true)
private String isbn;
@Field(targetType = FieldType.DECIMAL128)
private BigDecimal price;
private List<String> tags = List.of();
private Publisher publisher;
private Instant publishedAt;
private int stock;
@Version
private Long version;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
public record Publisher(String name, String country) {}
// constructors, getters, and setters omitted
}
What each piece does:
@Idon aStringmaps to_id. Spring Data converts it to and from anObjectIdautomatically when the value is a valid 24-character hex string, so your API deals in strings while the database stores proper ObjectIds.@Field(targetType = FieldType.DECIMAL128)storesBigDecimalas BSONDecimal128. Without it, recent Spring Data versions may storeBigDecimalas a string, which breaks range queries and sorting on prices.- Nested types like
Publisherbecome embedded documents. You don't need any annotation for that; embedding is the default. @Versionenables optimistic locking, and@CreatedDate/@LastModifiedDateenable auditing. Both are covered later.
A stored document looks like this:
{
"_id": { "$oid": "66f5a1c2e4b0a7d3c1f00a11" },
"title": "The Left Hand of Darkness",
"author": "Ursula K. Le Guin",
"isbn": "9780441478125",
"price": { "$numberDecimal": "15.99" },
"tags": ["sci-fi", "classic"],
"publisher": { "name": "Ace", "country": "US" },
"publishedAt": { "$date": "1969-03-01T00:00:00Z" },
"stock": 12,
"version": 0,
"_class": "com.example.library.book.Book"
}
That _class field is Spring Data's type hint, used when a collection holds several subtypes. If you don't use inheritance, you can drop it by customizing the MappingMongoConverter with a DefaultMongoTypeMapper(null). Leaving it in is harmless but adds bytes to every document.
Repositories and Derived Queries
Extend MongoRepository and Spring generates the implementation:
package com.example.library.book;
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 BookRepository extends MongoRepository<Book, String> {
Optional<Book> findByIsbn(String isbn);
List<Book> findByAuthorOrderByPublishedAtDesc(String author);
Page<Book> findByTagsContaining(String tag, Pageable pageable);
boolean existsByIsbn(String isbn);
long countByAuthor(String author);
@Query(value = "{ 'price': { $lte: ?0 }, 'stock': { $gt: 0 } }",
fields = "{ 'title': 1, 'author': 1, 'price': 1 }")
List<Book> findInStockUnder(java.math.BigDecimal maxPrice);
}
Spring parses the method names into MongoDB filters. findByTagsContaining("sci-fi") becomes { tags: "sci-fi" } (MongoDB matches array elements directly), and the Pageable parameter adds skip, limit, and sort. When a name gets unwieldy, switch to @Query with a raw filter. The fields attribute adds a projection so you don't pull whole documents.
Using the repository from a service:
@Service
public class BookService {
private final BookRepository books;
public BookService(BookRepository books) {
this.books = books;
}
public Page<Book> byTag(String tag, int page, int size) {
var pageable = PageRequest.of(page, size, Sort.by("publishedAt").descending());
return books.findByTagsContaining(tag, pageable);
}
}
A Word on Page and Count Queries
Returning Page<T> runs two queries: the page itself and a count over all matching documents. On large collections that count can be the slow part. If your UI only needs "next page" links, return Slice<T> instead, which fetches one extra document to know whether there's more and skips the count entirely. For deep pagination, range-based pagination (filtering on the last seen publishedAt and _id) scales much better than page numbers; see How to Implement Pagination in MongoDB.
MongoTemplate for Updates and Complex Queries
Repositories work on whole entities. save() replaces the entire document, which is fine for simple edits but wrong for concurrent counters or partial updates. For those, inject MongoTemplate (auto-configured by Boot):
import static org.springframework.data.mongodb.core.query.Criteria.where;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
@Service
public class InventoryService {
private final MongoTemplate mongo;
public InventoryService(MongoTemplate mongo) {
this.mongo = mongo;
}
/** Atomically reserve stock; returns null if not enough is available. */
public Book reserve(String isbn, int qty) {
var query = Query.query(where("isbn").is(isbn).and("stock").gte(qty));
var update = new Update().inc("stock", -qty).currentDate("updatedAt");
return mongo.findAndModify(query, update,
FindAndModifyOptions.options().returnNew(true), Book.class);
}
public long addTag(String author, String tag) {
var result = mongo.updateMulti(
Query.query(where("author").is(author)),
new Update().addToSet("tags", tag),
Book.class);
return result.getModifiedCount();
}
}
reserve is a single atomic operation: the filter checks stock and the $inc decrements it in the same step, so two concurrent requests can never oversell. Doing the same thing with findById followed by save would be a race condition.
Criteria covers most of the query language: in, regex, elemMatch, exists, near for geospatial, and so on. Typed queries with Query also let you set projections (query.fields().include("title")), sorting, and limits.
Aggregations
MongoTemplate also runs aggregation pipelines. Spring Data provides a fluent builder:
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
public record AuthorStats(String author, long books, double avgPrice) {}
public List<AuthorStats> topAuthors() {
var agg = newAggregation(
match(where("stock").gt(0)),
group("author").count().as("books").avg("price").as("avgPrice"),
project("books", "avgPrice").and("_id").as("author"),
sort(Sort.Direction.DESC, "books"),
limit(10));
return mongo.aggregate(agg, "books", AuthorStats.class).getMappedResults();
}
For pipelines the builder doesn't express nicely, @Aggregation on a repository method accepts raw stages as strings:
@Aggregation(pipeline = {
"{ $match: { tags: ?0 } }",
"{ $group: { _id: '$author', books: { $sum: 1 } } }",
"{ $sort: { books: -1 } }"
})
List<AuthorCount> countByAuthorForTag(String tag);
The raw form is often easier to read and lets you paste pipelines straight from mongosh or Compass after testing them there.
Auditing and Optimistic Locking
To populate @CreatedDate and @LastModifiedDate, enable auditing on a configuration class:
@Configuration
@EnableMongoAuditing
public class MongoConfig {
}
Spring now sets createdAt on the first save and updatedAt on every save through a repository or MongoTemplate.save. It does not touch them on updateFirst or findAndModify, which is why the reserve method above sets updatedAt manually with currentDate.
The @Version field turns on optimistic locking. Every save includes the current version in the filter and increments it. If another request saved the document in between, the filter matches nothing and Spring throws OptimisticLockingFailureException:
@PutMapping("/books/{id}")
public ResponseEntity<Book> update(@PathVariable String id, @RequestBody Book incoming) {
try {
return ResponseEntity.ok(books.save(incoming));
} catch (OptimisticLockingFailureException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
This is the right default for edit forms, where two users might load the same record and save conflicting changes.
Indexes: Don't Trust the Annotations Blindly
Here's the edge that catches the most teams. @Indexed and @CompoundIndex describe indexes, but since Spring Data MongoDB 3.0, automatic index creation is disabled by default. Without auto-index-creation: true, your unique ISBN constraint doesn't exist and duplicates go straight in.
Even with it enabled, think twice for production. Automatic creation runs when the mapping context initializes at startup, and building an index on a large collection takes time and resources. Many teams enable it in development and tests, then manage production indexes explicitly, either with a migration tool or with a startup component they control:
@Component
public class IndexInitializer {
private final MongoTemplate mongo;
public IndexInitializer(MongoTemplate mongo) {
this.mongo = mongo;
}
@EventListener(ApplicationReadyEvent.class)
public void ensureIndexes() {
mongo.indexOps(Book.class).ensureIndex(
new Index().on("isbn", Sort.Direction.ASC).unique());
mongo.indexOps(Book.class).ensureIndex(
new Index().on("author", Sort.Direction.ASC)
.on("publishedAt", Sort.Direction.DESC));
}
}
Whichever approach you choose, verify in mongosh with db.books.getIndexes() that the indexes you expect actually exist.
Transactions
Spring's @Transactional works with MongoDB, but only after you register a transaction manager. Boot doesn't create one automatically for MongoDB:
@Configuration
@EnableMongoAuditing
public class MongoConfig {
@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
return new MongoTransactionManager(factory);
}
}
Then annotate service methods as usual:
@Transactional
public void checkout(String orderId, List<LineItem> items) {
for (var item : items) {
var book = inventory.reserve(item.isbn(), item.qty());
if (book == null) {
throw new OutOfStockException(item.isbn());
}
}
orders.save(new Order(orderId, items, Instant.now()));
}
If any reservation fails, the exception rolls back every change in the method. Keep in mind that transactions require a replica set (Atlas clusters are always replica sets) and that they're a tool for genuinely multi-document invariants. If you can model the data so one document update is enough, that's faster and simpler. See MongoDB Transactions: When and How to Use Multi-Document ACID Transactions for the trade-offs.
Testing with Testcontainers
The cleanest integration tests run against a real MongoDB in a container. Spring Boot 3.1+ supports @ServiceConnection, which wires the container's connection details into the app automatically:
@DataMongoTest
@Testcontainers
class BookRepositoryTest {
@Container
@ServiceConnection
static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0");
@Autowired
BookRepository books;
@Test
void findsByIsbn() {
var book = new Book();
book.setTitle("Kindred");
book.setAuthor("Octavia E. Butler");
book.setIsbn("9780807083697");
books.save(book);
assertThat(books.findByIsbn("9780807083697"))
.map(Book::getTitle)
.contains("Kindred");
}
}
Add org.springframework.boot:spring-boot-testcontainers and org.testcontainers:mongodb as test dependencies. @DataMongoTest loads only the MongoDB slice of your app, so tests start quickly. The MongoDBContainer starts a single-node replica set, which means transaction tests work too.
Common Pitfalls
Using save() for partial updates. save() replaces the whole document. If two requests load, modify different fields, and save, the second silently overwrites the first. Use MongoTemplate with Update for targeted changes, or @Version to detect the conflict.
Assuming @Indexed created your index. Without auto-index-creation, it didn't. Check getIndexes() and make index management an explicit part of deployment.
Loading whole documents to display lists. Derived queries return full entities. For list views, use @Query with fields, a projection interface, or a DTO class to fetch only what the page shows.
Returning Page<T> everywhere. The extra count query can dominate response time on large collections. Prefer Slice<T> or range-based pagination when you don't need a total.
Storing money as double or string. Use BigDecimal with FieldType.DECIMAL128 so values stay exact and remain queryable as numbers.
Forgetting the transaction manager. Without a MongoTransactionManager bean, @Transactional on MongoDB operations does nothing, and there's no error to tell you so.
Conclusion
Spring Data MongoDB lets Java developers work with MongoDB in familiar terms: repositories, derived queries, @Transactional, and auditing all feel like home. The key is knowing when to step down to MongoTemplate for atomic updates and aggregations, and being deliberate about the things the annotations don't do for you, like creating indexes and enabling transactions.
As a next step, open your app's document classes, run db.<collection>.getIndexes() for each one in mongosh, and compare the result with your @Indexed annotations. If they don't match, you've just found your first production bug before it found you.


