@Document, MongoRepository, custom queries — MongoDB persistence in Spring.
Published September 21, 2026
Spring Data MongoDB connects a Spring Boot application to MongoDB. It does two jobs for you:
Product objects instead of raw documents.findBySlug, so most data access needs no implementation at all.When the generated repositories aren't enough, MongoTemplate gives you full control over queries and updates. Knowing when to use each is most of the skill.
@Document("products") // stored in the "products" collection
@CompoundIndex(name = "idx_category_status", def = "{'category': 1, 'status': 1}")
public class Product {
@Id
private String id; // maps to MongoDB's _id; generated as an ObjectId if null
@Indexed(unique = true)
private String slug;
private String name;
@Field("cat") // stored as "cat" in the document, "category" in Java
private String category;
private BigDecimal price;
private ProductStatus status; // enums are stored as their name, e.g. "ACTIVE"
private List<String> tags = new ArrayList<>();
private Dimensions dimensions; // a nested object becomes an embedded sub-document
@Transient
private boolean recentlyViewed; // exists in Java only, never persisted
@CreatedDate private Instant createdAt; // filled automatically (needs @EnableMongoAuditing)
@LastModifiedDate private Instant updatedAt;
@Version private Long version; // optimistic locking (see below)
}
The resulting document looks like this:
{
"_id": ObjectId("6652f0c1..."),
"slug": "mechanical-keyboard",
"name": "Mechanical Keyboard",
"cat": "electronics",
"price": Decimal128("89.99"),
"status": "ACTIVE",
"tags": ["keyboard", "usb-c"],
"dimensions": { "widthCm": 44, "depthCm": 13 },
"createdAt": ISODate("2026-09-20T10:00:00Z"),
"version": 0,
"_class": "com.example.Product"
}
The _class field is added so Spring knows which Java type to create when reading a document back. That matters when one collection holds several subtypes.
Embedding vs referencing. Nested objects (dimensions) and lists are stored inside the document. That's the normal MongoDB approach: data that's read together is stored together. To point at a document in another collection, store its ID as a plain field (private String categoryId). Avoid @DBRef, which makes Spring issue an extra query for every referenced document, and those queries add up quickly.
public interface ProductRepository extends MongoRepository<Product, String> {
Optional<Product> findBySlug(String slug);
List<Product> findByCategoryAndStatusOrderByPriceAsc(String category, ProductStatus status);
Page<Product> findByStatus(ProductStatus status, Pageable pageable);
List<Product> findByTagsContaining(String tag); // matches documents whose tags array contains it
boolean existsBySlug(String slug);
long countByStatus(ProductStatus status);
// When the method name would get unreadable, write the query yourself:
@Query(value = "{ 'price': { $gte: ?0, $lte: ?1 }, 'status': 'ACTIVE' }",
fields = "{ 'name': 1, 'price': 1 }") // projection: load only these fields
List<Product> findActiveInPriceRange(BigDecimal min, BigDecimal max);
}
Spring parses the method name into a query: findBy + property names + keywords (And, Or, Between, GreaterThan, Containing, In, OrderBy...). A typo in a property name fails at startup, not at runtime. That's a nice safety net. MongoRepository also gives you save, findById, findAll, deleteById, count and paging for free.
@Service
public class ProductCommands {
private final MongoTemplate mongo;
// Partial update: change ONE field atomically, without loading the document
public void markOutOfStock(String productId) {
mongo.updateFirst(
Query.query(Criteria.where("id").is(productId)),
new Update().set("status", ProductStatus.OUT_OF_STOCK).currentDate("updatedAt"),
Product.class);
}
// Atomic counter: $inc is safe under concurrency, read-modify-save is not
public void recordView(String productId) {
mongo.updateFirst(Query.query(Criteria.where("id").is(productId)),
new Update().inc("viewCount", 1), Product.class);
}
// Dynamic filters built at runtime
public List<Product> search(String category, BigDecimal maxPrice) {
Criteria c = Criteria.where("status").is(ProductStatus.ACTIVE);
if (category != null) c = c.and("category").is(category);
if (maxPrice != null) c = c.and("price").lte(maxPrice);
return mongo.find(Query.query(c).limit(50), Product.class);
}
}
Reach for MongoTemplate when you need partial updates ($set, $inc, $push), queries built dynamically from optional filters, bulk operations, or aggregations (see Aggregation Pipeline).
save() trap: whole-document replacementrepository.save(product) replaces the entire document with the Java object's current state. Two requests can each load a product, change different fields, and save. The second save then overwrites the first request's change, because it saves a stale copy of the field it never touched. This is the lost update problem.
Two standard fixes:
@Version. Each save checks that the version hasn't changed since the document was read, and increments it. If another save happened in between, the update matches nothing and Spring throws OptimisticLockingFailureException. You then reload and retry, or return 409 Conflict to the client.MongoTemplate ($set on only the changed fields, $inc for counters). The database applies them atomically, so there's no read-modify-write window at all.spring:
data:
mongodb:
uri: ${MONGODB_URI} # e.g. mongodb+srv://user:pass@cluster.example.net/shop
auto-index-creation: false # the default since Spring Data MongoDB 3.0
@Indexed and @CompoundIndex do not create anything by default. Automatic index creation was switched off in Spring Data MongoDB 3.0, because building indexes on a large collection at application startup can lock up a production deployment. You either enable auto-index-creation (fine in development) or create indexes deliberately: with a migration tool, from the Atlas UI, or programmatically at startup with mongoTemplate.indexOps(Product.class).ensureIndex(...). Forgetting this is a classic cause of queries that are fast locally and slow in production. The Indexing & Performance lesson covers designing the indexes themselves.
Auditing annotations (@CreatedDate, @LastModifiedDate) also need switching on, with @EnableMongoAuditing on a configuration class.
A single-document write in MongoDB is always atomic. That's one reason to model data so that things that change together live in one document. Multi-document transactions exist (MongoDB 4.0+) but need a replica set or sharded cluster, not a standalone server. In Spring you enable them by declaring a MongoTransactionManager bean and using @Transactional. Details are in Transactions in MongoDB.
@DataMongoTest starts only the MongoDB layer, and Testcontainers can run a real MongoDB in Docker for the test:
@DataMongoTest
@Testcontainers
class ProductRepositoryTest {
@Container @ServiceConnection
static MongoDBContainer mongo = new MongoDBContainer("mongo:7");
@Autowired ProductRepository repo;
@Test
void findsBySlug() {
repo.save(new Product("mechanical-keyboard", "Mechanical Keyboard"));
assertThat(repo.findBySlug("mechanical-keyboard")).isPresent();
}
}
Testing against real MongoDB rather than an in-memory fake catches mapping, index and query-syntax problems early.
Q: When would you choose MongoTemplate over a repository?
A: Repositories cover straightforward CRUD and fixed queries with almost no code. Switch to MongoTemplate for partial or atomic updates ($set, $inc, $push), queries whose filters are decided at runtime, bulk writes and aggregations. Many services use both: a repository for simple reads, and a small MongoTemplate-based class for complex commands. You can also add custom methods to a repository by writing a fragment interface with an ...Impl class that uses MongoTemplate.
Q: What does @Version protect against, and what happens on a conflict?
A: It protects against lost updates when two writers change the same document based on stale reads. On save, Spring includes the version it read in the update's filter and increments it. If someone else saved first, the filter matches nothing and OptimisticLockingFailureException is thrown. The caller should reload and retry the change, or report 409 Conflict.
Q: Why avoid @DBRef?
A: A @DBRef makes Spring run an extra query to load each referenced document. Loading a list of 50 orders with a @DBRef customer means 51 queries, the N+1 problem. It also can't be used in aggregations easily. Storing the referenced ID as a plain field and loading related data in a batch (findAllById), or embedding it, is faster and more explicit.
Q: Are @Indexed indexes created in production?
A: Not by default. Automatic index creation has been off since Spring Data MongoDB 3.0. Create indexes explicitly (migrations, IndexOperations.ensureIndex at startup, or database tooling) so that you control when potentially long index builds happen.
Q: String or ObjectId for the @Id field?
A: Both work. With a String field, Spring converts values that look like an ObjectId to and from ObjectId automatically, and your API deals in plain strings. Use ObjectId in Java only if you need its methods (for example the embedded timestamp). Whatever you choose, be consistent, because querying an ObjectId-stored _id with a raw string in a hand-written query won't match.