Spring Boot and MongoDB REST API Tutorial
Published Updated Spring Boot 12 min read
A document-backed REST API with Spring Data MongoDB: mapping and indexes, the auto-index setting that is off by default, aggregation through MongoTemplate, and when to embed rather than reference.
MongoDB stores documents rather than rows, which changes two things about building an API on it: the shape of your data is a design decision rather than a schema migration, and the interesting queries happen in an aggregation pipeline rather than in SQL.
Spring Data MongoDB covers the ordinary cases with the repository abstraction you already know. This
covers those, and then the parts that need MongoTemplate.
Written against Spring Boot 3.2, Java 17 and MongoDB 7. The front end is deliberately out of scope. Any client that speaks JSON works against this API, and the AngularJS the original version of this guide paired it with reached end of life in 2022.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
spring.data.mongodb.uri=mongodb://127.0.0.1:27017/notes
spring.data.mongodb.auto-index-creation=true
That second property matters and is easy to miss. Automatic index creation has been off by default
since Spring Boot 2.2. Your @Indexed annotations are documentation until you turn it on, and the
symptom is not an error. It is a collection scan on every query, which only becomes visible at scale.
Turning it on is convenient for development and questionable for production, where index builds are an operational event you want to schedule rather than trigger on deploy. The mature setup leaves it off and creates indexes through migrations.
Use 127.0.0.1 rather than localhost: on a host resolving localhost to IPv6 first, a MongoDB
listening only on IPv4 produces a connection refusal that looks like the server being down.
The document
@Document(collection = "notes")
@CompoundIndex(name = "author_created", def = "{'authorId': 1, 'createdAt': -1}")
public class Note {
@Id
private String id;
@NotBlank
@Size(max = 200)
@TextIndexed(weight = 3)
private String title;
@NotBlank
@TextIndexed
private String content;
@Indexed
private String authorId;
private List<String> tags = new ArrayList<>();
private Map<String, Object> metadata = new HashMap<>();
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@Version
private Long version;
}
Five things worth explaining.
@Id as a String. MongoDB’s _id is an ObjectId; Spring converts to and from a hex string, so
your API returns "66f1a2b3c4d5e6f7a8b9c0d1" rather than an object. Declaring it as ObjectId leaks
the driver type into your JSON; declaring it String is almost always right.
A compound index in the order you query. {authorId: 1, createdAt: -1} serves “this author’s notes,
newest first” and also “this author’s notes”, a prefix of a compound index is usable, a suffix is not.
Getting the field order wrong produces an index that never gets used.
Embedded collections need no join. tags and metadata are part of the document. This is the
actual advantage of the model, and the reason an @ElementCollection-style join table has no
equivalent here.
Auditing needs enabling, exactly as with JPA:
@SpringBootApplication
@EnableMongoAuditing
public class NotesApplication { }
Without @EnableMongoAuditing, @CreatedDate and @LastModifiedDate stay null and nothing warns.
@Version gives optimistic locking. Two concurrent updates to one document: the second gets
OptimisticLockingFailureException rather than silently overwriting. Without it, last write wins.
Repository
public interface NoteRepository extends MongoRepository<Note, String> {
Page<Note> findByAuthorIdOrderByCreatedAtDesc(String authorId, Pageable pageable);
List<Note> findByTagsContaining(String tag);
Optional<Note> findByIdAndAuthorId(String id, String authorId);
boolean existsByTitleIgnoreCase(String title);
// raw MongoDB query when derivation cannot express it
@Query("{ 'metadata.source': ?0, 'tags': { $in: ?1 } }")
List<Note> findBySourceAndAnyTag(String source, List<String> tags);
// projection: fetch only what you need
@Query(value = "{ 'authorId': ?0 }", fields = "{ 'title': 1, 'createdAt': 1 }")
List<NoteSummary> findSummariesByAuthor(String authorId);
}
public interface NoteSummary {
String getId();
String getTitle();
Instant getCreatedAt();
}
Query derivation works as it does for JPA. @Query takes a MongoDB query document with ?0, ?1
positional parameters, for the cases derivation cannot express, $in, $regex, nested field paths.
The fields projection is worth using on wide documents. A note whose content is fifty kilobytes
costs fifty kilobytes per row in a list endpoint that only shows titles.
Controller
@RestController
@RequestMapping("/api/notes")
public class NoteController {
private final NoteRepository notes;
public NoteController(NoteRepository notes) {
this.notes = notes;
}
@GetMapping
public Page<Note> list(@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
return notes.findAll(pageable);
}
@GetMapping("/{id}")
public ResponseEntity<Note> get(@PathVariable String id) {
return notes.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Note create(@Valid @RequestBody NoteRequest request) {
Note note = new Note();
note.setTitle(request.title());
note.setContent(request.content());
note.setTags(request.tags());
return notes.save(note);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
if (!notes.existsById(id)) {
return ResponseEntity.notFound().build();
}
notes.deleteById(id);
return ResponseEntity.noContent().build();
}
}
public record NoteRequest(@NotBlank @Size(max = 200) String title,
@NotBlank String content,
List<String> tags) { }
A request record rather than binding straight to the document. Without it a client can set authorId,
version or createdAt: MongoDB has no schema to stop it, so the boundary is the only place that
check exists.
An invalid id is worth handling explicitly. A malformed ObjectId hex string produces a
conversion failure before any query runs, and unhandled that is a 500 rather than the 400 it should
be:
@ExceptionHandler(org.springframework.core.convert.ConversionFailedException.class)
ResponseEntity<Map<String, String>> badId(ConversionFailedException e) {
return ResponseEntity.badRequest().body(Map.of("message", "malformed id"));
}
Aggregation
Anything involving grouping, counting or reshaping goes through MongoTemplate. There is no
repository equivalent:
@Service
public class NoteStats {
private final MongoTemplate mongo;
public NoteStats(MongoTemplate mongo) {
this.mongo = mongo;
}
public List<TagCount> topTags(int limit) {
Aggregation pipeline = Aggregation.newAggregation(
Aggregation.match(Criteria.where("createdAt")
.gt(Instant.now().minus(30, ChronoUnit.DAYS))),
Aggregation.unwind("tags"),
Aggregation.group("tags").count().as("count"),
Aggregation.project("count").and("_id").as("tag"),
Aggregation.sort(Sort.Direction.DESC, "count"),
Aggregation.limit(limit));
return mongo.aggregate(pipeline, "notes", TagCount.class).getMappedResults();
}
}
public record TagCount(String tag, long count) { }
unwind turns each element of an array into its own document, so a note with three tags becomes three
documents that group can then count. That pattern (unwind, group, sort, limit) covers most
“top N by embedded value” questions.
Put match first. The pipeline runs in order, and filtering before unwinding means the expensive stage
processes far fewer documents. A match at the front can also use an index; later stages cannot.
Embed or reference
The design decision the document model forces on you, and the one worth spending time on.
Embed when the child is read with the parent, is bounded in size, and does not need to be queried on its own. Comments on a note, addresses on a user, line items on an order.
Reference when the child is large, unbounded, shared between parents, or updated independently. Store the id and fetch separately.
// embedded — one read, no join
private List<Comment> comments = new ArrayList<>();
// referenced — store the id and look it up
private String authorId;
Two hard constraints shape this. A single document cannot exceed 16 MB, so an unbounded embedded array eventually fails, and it fails on a write, in production, at whatever size crosses the line. And an array that grows steadily forces the document to be relocated as it outgrows its allocated space, which costs write throughput.
Avoid @DBRef. It looks like a foreign key and behaves like an N+1 query: Spring resolves each
reference with a separate round trip, lazily, so a list of twenty documents becomes twenty-one
queries. Store the id yourself and batch the lookups, findAllById on a set of ids is one query.
Transactions need a replica set
@Transactional
public void moveNote(String noteId, String newAuthorId) { ... }
Multi-document transactions work from MongoDB 4.0 and require a replica set, a standalone
mongod throws IllegalStateException: Sessions are not supported. A single-node replica set is fine
for development:
$ mongod --replSet rs0 --dbpath /data/db
$ mongosh --eval 'rs.initiate()'
Single-document writes are atomic without any of this, which is why a well-embedded design needs transactions far less often than a relational one.
Frequently asked questions
Why are my @Indexed annotations not creating indexes?
Automatic index creation is off by default
since Spring Boot 2.2. Set spring.data.mongodb.auto-index-creation=true, or create indexes through
migrations, which is the better answer for production.
Should the id field be String or ObjectId?
String. Spring converts to and from the underlying
ObjectId, and your API returns a plain hex string instead of a driver type.
Why are createdAt and updatedAt null?
@EnableMongoAuditing is missing. The annotations do nothing
without it.
Why does a bad id return 500?
The hex string cannot be converted to an ObjectId, and the failure
happens before any query. Handle ConversionFailedException and return 400.
When should I embed rather than reference?
Embed when the child is read with the parent, bounded, and not queried alone. Reference when it is large, unbounded, shared or independently updated — remembering the 16 MB document limit.
Why avoid @DBRef?
It resolves each reference with its own round trip, producing an N+1 pattern.
Store ids and batch with findAllById.
How do I do a group-by?
Through MongoTemplate and an Aggregation pipeline. Repositories have
no equivalent. Put match first so later stages see fewer documents.
Do transactions work?
From MongoDB 4.0, and only on a replica set, a standalone server rejects the session. Single-document writes are atomic regardless.
How do I stop clients setting fields they should not?
Bind to a request record rather than the document. MongoDB has no schema to enforce this, so the API boundary is the only place it happens.
How do I avoid transferring huge fields in a list endpoint?
Use a projection (fields on
@Query, or an interface-based projection) so only the fields you display are read.
Where should I go next?
The reactive MongoDB version covers the same API on a non-blocking stack, and the JPA guide is the relational counterpart.