Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Spring Data MongoDB

MongoDB Basics

  • Spring Data MongoDB
  • Indexing & Performance

Aggregation Pipeline

  • Aggregation Pipeline
  • Transactions in MongoDB
  • Schema Design Patterns
Chaturmind
← Spring Data MongoDB

MongoDB Basics

  • Spring Data MongoDB
  • Indexing & Performance

Aggregation Pipeline

  • Aggregation Pipeline
  • Transactions in MongoDB
  • Schema Design Patterns
HomeLearnSpring BootSpring Data MongoDBMongoDB Basics
✓ FreeIntermediate· 8 min read

Spring Data MongoDB

@Document, MongoRepository, custom queries — MongoDB persistence in Spring.

Published September 21, 2026


Spring Data MongoDB

Spring Data MongoDB connects a Spring Boot application to MongoDB. It does two jobs for you:

  1. Mapping: converting between your Java classes and MongoDB's JSON-like documents (BSON), so you work with Product objects instead of raw documents.
  2. Repositories: generating the code for common queries from method names like 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.

Mapping a class to a collection

@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.

Repositories: queries from method names

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.

MongoTemplate: when you need full control

@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).

The save() trap: whole-document replacement

repository.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:

  1. Optimistic locking with @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.
  2. Targeted updates with 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.

Configuration and indexes

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.

Transactions

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.

Testing

@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.

Follow-up questions this topic invites — and their answers

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.

Next

Indexing & Performance

AI Tutor

Lesson: Spring Data MongoDB

Quick actions

AI responses can be inaccurate. Verify critical information.