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_-1It 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.048ZNothing 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=falseTwo 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=rs0The 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;
MongoTemplatefor dynamic filters, aggregations and partial updates. - Counters use
$incthrough the template, neversave(). - A nested field named
idis stored as_id— mind your shell queries. @EnableMongoAuditingor your audit dates stay null, silently.- Truncate
Instantto milliseconds so stored and in-memory values agree. - Turn off auto-index-creation and declare indexes in one place.
- Boot 4:
spring.mongodb.uri, notspring.data.mongodb.uri.