MongoDB – Spring Data MongoDB

July 15, 20256 min readUpdated 8/24/2026

Everything so far has been the database. This lesson is the Java side: how Spring Data MongoDB maps your classes onto documents, when to use a repository and when to drop to MongoTemplate, and the three behaviours that surprise people — two of which fail silently.

Mapping a document

A mapped class needs @Document and an @Id. Everything else is convention:

@Document(collection = "reels")
public class Reel {

    @Id
    private String id;

    private String slug;

    private String title;
    private String description;

    private ReelStatus status;

Nested objects need no annotation at all — a field whose type is another class becomes a sub-document automatically. That is what makes the embedding decisions from lesson 6 almost invisible in Java: VideoAsset, CreatorRef and ReelStats are plain classes, and they land as nested objects because that is where they are declared.

Enums store as strings by default, which is what you want. Instant maps to a BSON date. List<String> maps to an array and gets a multikey index if you index it.

Repositories

Extend MongoRepository and Spring derives queries from method names:

public interface ReelRepository extends MongoRepository<Reel, String> {

    Optional<Reel> findBySlug(String slug);

    boolean existsBySlug(String slug);

    List<Reel> findByStatusOrderByPublishedAtDesc(ReelStatus status, Pageable pageable);

That third method name is doing real work: filter on status, sort by publishedAt descending, page the result. It compiles to exactly the query lesson 8 built by hand, and it uses the same {status, publishedAt} index.

Derived queries are excellent until the method name stops being readable. When you find yourself writing findByStatusAndCreatorIdAndPublishedAtBetweenOrderBy..., that is the signal to move to a template.

MongoTemplate

MongoTemplate is the escape hatch, and it is not a fallback — it is the right tool for three jobs a repository cannot do well: dynamic filters, aggregation pipelines, and partial updates.

The partial-update case is the one that matters most, because a repository’s save() rewrites the whole document:

public void incrementStat(String reelId, String statField, long delta) {
    mongo.updateFirst(
            Query.query(Criteria.where("_id").is(reelId)),
            new Update().inc("stats." + statField, delta),
            Reel.class);
}

That is one field, changed atomically on the server. The repository equivalent — load the reel, mutate it, save() — reads the document, sends all of it back, and loses increments under concurrency. The rule from lesson 4 applies here as much as in the shell: counters use $inc, never load-mutate-save.

ReelCMS keeps both in one place, with a DAO interface and an implementation that reaches for whichever fits each method — the same split the relational demos keep between a repository and JdbcTemplate.

Three things that surprise people

1. A nested field called `id` becomes `_id`

Spring Data maps any field named id to _id — including inside a nested object. CreatorRef declares a field called id, so an index declared in Java as creator.id is stored by MongoDB as:

creator._id_1_status_1_publishedAt_-1

It works — the mapper applies the same rename to queries, so Criteria.where("creator.id") hits that index. But a hand-written mongosh query must say creator._id, and anyone comparing the Java config to db.reels.getIndexes() sees two different names for the same index. Worth knowing before you spend an hour on it.

2. Auditing is off until you turn it on

@CreatedDate and @LastModifiedDate do nothing without @EnableMongoAuditing. No error, no warning — the fields simply stay null, usually discovered when a sort by createdAt returns an arbitrary order.

@Configuration
@EnableMongoAuditing(dateTimeProviderRef = "millisecondDateTimeProvider")
public class MongoConfig {

3. BSON keeps milliseconds; Instant.now() has microseconds

This one is subtle and produces a bug that looks impossible. Stamp a field with Instant.now(), save it, read it back, and the two values are not equal:

in memory : 2026-08-24T18:53:07.048898Z
in mongo  : 2026-08-24T18:53:07.048Z

Nothing fails. The document saves and the read succeeds — until something compares them. A POST response and a later GET disagree on a timestamp; a client caching on “did updatedAt change?” re-fetches forever. Truncate at the source and the value written is the value read back:

public static Instant now() {
    return Instant.now().truncatedTo(ChronoUnit.MILLIS);
}

The dateTimeProviderRef in the configuration above routes auditing through the same helper, so audit fields get the same treatment.

Returning less than the whole document

Two ways to avoid loading fields you will not use. A projection interface is the declarative one: declare the getters you want and Spring builds the projection. ReelCMS does not use this — it maps to DTOs instead — but it is worth knowing, and it looks like this:

public interface ReelSummary {
    String getSlug();
    String getTitle();
}

A repository method returning List<ReelSummary> then fetches two fields instead of the whole document. On records carrying a long description and an array that is a real saving on a list endpoint, and it is the Spring Data equivalent of the projections from lesson 4.

MongoTemplate does the same imperatively with Query.fields(), which is what you want when the field list is decided at runtime:

query.fields().include("slug").include("title").exclude("_id");

Where indexes belong

Spring Data can create indexes from @Indexed annotations. Turn that off:

spring.data.mongodb.auto-index-creation=false

Two reasons. Operationally, an annotation that silently triggers an index build against a live multi-gigabyte collection is a production incident that starts as a one-line diff. And an index strategy is only reviewable if you can see all of it at once — scattered across five entity classes, nobody ever does. ReelCMS declares every index in a single configuration class instead.

Testing against a real MongoDB

There are in-memory and embedded MongoDB substitutes. Do not use them for anything in this track.

Almost everything worth testing here is behaviour of the actual server: $inc atomicity, $lookup type matching, text-index ranking and weights, time-series inserts, change streams, unique-index violations. A substitute either does not implement those or implements them differently, so a green test tells you nothing about production.

Point the test context at a real server on a throwaway database instead:

@SpringBootTest(
        properties = {
            "spring.mongodb.uri=mongodb://localhost:27018/reelcms_test?replicaSet=rs0",
            "spring.mongodb.database=reelcms_test",
        })

Setting both the URI and the database property is deliberate, and it is a real scar. spring.mongodb.database overrides the database named inside the URI — silently. If the main application.properties sets it and the test overrides only the URI, every test runs against the application’s live database. When those tests clear collections in @BeforeEach, as integration tests usually do, the result is exactly what it sounds like.

Clean the documents rather than dropping the collections, too. Dropping view_events takes its time-series options with it, and the next insert quietly recreates it as an ordinary collection.

One Boot 4 note that will cost you an afternoon

Spring Boot 4 moved the MongoDB connection properties out of spring.data.mongodb into spring.mongodb:

spring.mongodb.uri=mongodb://localhost:27018/reelcms?replicaSet=rs0

The old names are deprecated at error level, but a properties file is not validated — so spring.data.mongodb.uri is read by nobody, no warning is printed, and the driver falls back to its default of mongodb://localhost:27017. The symptom is a connection refused against a port that appears nowhere in your configuration.

Note that spring.data.mongodb.auto-index-creation above keeps the old prefix, because it is a Spring Data setting rather than a connection one. The two prefixes coexist, which is exactly as confusing as it sounds.

The short version

  • @Document + @Id; nested classes become sub-documents for free.
  • Repositories for derived queries; MongoTemplate for dynamic filters, aggregations and partial updates.
  • Counters use $inc through the template, never save().
  • A nested field named id is stored as _id — mind your shell queries.
  • @EnableMongoAuditing or your audit dates stay null, silently.
  • Truncate Instant to milliseconds so stored and in-memory values agree.
  • Turn off auto-index-creation and declare indexes in one place.
  • Boot 4: spring.mongodb.uri, not spring.data.mongodb.uri.