Mastering MongoDB Relationships in Spring Boot: @DBRef Annotation for Seamless Collection Linking
This chapter covers linking independent MongoDB collections in a Spring Boot application using the @DBRef annotation. You will learn how to transition a single-user journal application into a multi-user, relational-style system where each User owns a list of referenced JournalEntry documents, while maintaining data consistency, explicit indexing, and cascade deletion workflows.
1. Context & Architecture Evolution
Prior to introducing multi-user support, the application operated on a single decoupled model where all journal entries were stored in a standalone journal_entries collection without ownership tracking.
To support authentication and multi-tenancy, every journal entry must be associated with a specific user. Instead of duplicate data or storing giant embedded arrays, the database schema is refactored into two distinct collections: users and journal_entries.
2. Designing the User Entity
The User entity establishes user identity and establishes relationships with journal entries.
Key Architectural Decisions
- Anonymity Support: No email field is required; identity is solely based on a unique
usernameandpassword. - Field-Level Validation: Lombok's
@NonNullannotation is used on critical fields to ensure setters and builders throw aNullPointerExceptionif null values are supplied, preventing invalid saves. - Serialization Requirement: Jackson JSON deserialization (JSON to POJO) requires a no-argument constructor when custom/required-argument constructors exist. Lombok's
@NoArgsConstructormust be declared alongside@Data.
package com.example.journalApp.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.ArrayList;
import java.util.List;
@Document(collection = "users")
@Data
@NoArgsConstructor
public class User {
@Id
private ObjectId id;
@Indexed(unique = true)
@NonNull
private String username;
@NonNull
private String password;
@DBRef
private List<JournalEntry> journalEntries = new ArrayList<>();
}
Note: Initializing
journalEntries = new ArrayList<>()ensures that whenever aUserinstance is instantiated, the list is empty rather thannull. This preventsNullPointerExceptionduring runtime collection additions (user.getJournalEntries().add(...)).
3. MongoDB Relationship Architecture with @DBRef
In document-oriented databases like MongoDB, relationships between entities can be structured in two primary ways:
How @DBRef Works Under the Hood
When @DBRef is annotated above List<JournalEntry> journalEntries, Spring Data MongoDB does not embed the complete JournalEntry documents inside the users document. Instead, it stores reference metadata holding the target collection name and the target ObjectId.
MongoDB Storage View (users collection document):
{
"_id": { "$oid": "60d5ec49f1a2c82340d8d111" },
"username": "ram",
"password": "123",
"journalEntries": [
{ "$ref": "journal_entries", "$id": { "$oid": "60d5ec72f1a2c82340d8d222" } },
{ "$ref": "journal_entries", "$id": { "$oid": "60d5ec8bf1a2c82340d8d333" } }
]
}
4. Enabling Automatic Index Creation in Spring Boot
Annotating a field with @Indexed(unique = true) tells Spring Data MongoDB that the field should have a unique index in the database. However, Spring Boot disables automatic index creation by default in modern versions to prevent performance bottlenecks on startup in production environments.
Without explicit configuration, duplicate usernames will be allowed into the database despite the @Indexed(unique = true) annotation.
Required Configuration (application.properties)
To instruct Spring Data MongoDB to build indexes automatically based on entity annotations, set the following property:
# MongoDB Server Connection
spring.data.mongodb.host=localhost
spring.data.mongodb.port=27017
spring.data.mongodb.database=journaldb
# Enable Automatic Index Creation for @Indexed Annotations
spring.data.mongodb.auto-index-creation=true
5. Implementing User Repository, Service, and Controller
5.1 UserRepository
Define custom query methods relying on Spring Data JPA/MongoDB interface method resolution naming conventions:
package com.example.journalApp.repository;
import com.example.journalApp.entity.User;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface UserRepository extends MongoRepository<User, ObjectId> {
User findByUsername(String username);
}
5.2 UserService
Encapsulate database operations and inject UserRepository:
package com.example.journalApp.service;
import com.example.journalApp.entity.User;
import com.example.journalApp.repository.UserRepository;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public void saveUser(User user) {
userRepository.save(user);
}
public List<User> getAll() {
return userRepository.findAll();
}
public Optional<User> findById(ObjectId id) {
return userRepository.findById(id);
}
public void deleteById(ObjectId id) {
userRepository.deleteById(id);
}
public User findByUsername(String username) {
return userRepository.findByUsername(username);
}
}
5.3 UserController
Expose RESTful endpoints for basic user management:
package com.example.journalApp.controller;
import com.example.journalApp.entity.User;
import com.example.journalApp.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getAllUsers() {
return userService.getAll();
}
@PostMapping
public void createUser(@RequestBody User user) {
userService.saveUser(user);
}
@PutMapping("/{username}")
public ResponseEntity<?> updateUser(@RequestBody User user, @PathVariable String username) {
User userInDb = userService.findByUsername(username);
if (userInDb != null) {
userInDb.setUsername(user.getUsername());
userInDb.setPassword(user.getPassword());
userService.saveUser(userInDb);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
6. Refactoring Journal Workflows for User Ownership
With the User entity and relationship established, all JournalEntry actions (Create, Read, Update, Delete) must be modified to accept a username context.
6.1 Creating an Entry Linked to a User
Creating an entry requires a two-write workflow:
- Save the new
JournalEntryinstance into thejournal_entriescollection to generate itsObjectId. - Push the newly created
JournalEntryobject (or reference) into the parentUserdocument'sjournalEntrieslist, then save the updatedUser.
Workflow Execution Lineage:
Refactored Service Implementation:
public void saveEntry(JournalEntry journalEntry, String username) {
try {
User user = userService.findByUsername(username);
journalEntry.setDate(LocalDateTime.now());
JournalEntry saved = journalEntryRepository.save(journalEntry);
user.getJournalEntries().add(saved);
userService.saveUser(user);
} catch (Exception e) {
throw new RuntimeException("An error occurred while saving the entry.", e);
}
}
6.2 Fetching User-Specific Journal Entries
Rather than returning all global journal entries, query the database for the user first, then return their linked entries array directly:
@GetMapping("/{username}")
public ResponseEntity<?> getAllJournalEntriesOfUser(@PathVariable String username) {
User user = userService.findByUsername(username);
List<JournalEntry> allEntries = user.getJournalEntries();
if (allEntries != null && !allEntries.isEmpty()) {
return new ResponseEntity<>(allEntries, HttpStatus.OK);
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
7. Manual Cascading Deletes & Relational Integrity
MongoDB does not support automatic cascade operations for @DBRef collections out of the box. Deleting a document from journal_entries leaves a broken/orphaned reference (ObjectId) inside the parent user's journalEntries array.
Explicit Unlinking Workflow
To keep the application consistent during deletions, you must manually perform two actions:
- Remove the reference from the
user.getJournalEntries()array using a Java predicate (removeIf). - Persist the updated
Userdocument back to MongoDB. - Delete the target document from
journal_entries.
Cascade Deletion Service Implementation:
public boolean deleteById(ObjectId id, String username) {
boolean removed = false;
try {
User user = userService.findByUsername(username);
// Step 1: Remove DBRef reference from in-memory user collection
removed = user.getJournalEntries().removeIf(x -> x.getId().equals(id));
if (removed) {
// Step 2: Persist updated user state back to DB
userService.saveUser(user);
// Step 3: Delete actual document from journal_entries collection
journalEntryRepository.deleteById(id);
}
} catch (Exception e) {
log.error("Error occurred while deleting entry", e);
throw new RuntimeException("An error occurred while deleting entry: " + id, e);
}
return removed;
}
8. Updating Entries & In-Memory Reference Lifecycles
When updating an existing JournalEntry (e.g., changing its title or content), you do not need to update the parent User array.
Rationale
The User document stores only the ObjectId reference of the JournalEntry. Updating internal properties of an existing JournalEntry does not alter its primary key _id. Consequently, the reference stored inside the user document remains valid and static.
@PutMapping("/id/{username}/{myId}")
public ResponseEntity<?> updateJournalById(
@PathVariable String username,
@PathVariable ObjectId myId,
@RequestBody JournalEntry newEntry) {
JournalEntry old = journalEntryService.findById(myId).orElse(null);
if (old != null) {
old.setTitle(newEntry.getTitle() != null && !newEntry.getTitle().equals("") ? newEntry.getTitle() : old.getTitle());
old.setContent(newEntry.getContent() != null && !newEntry.getContent().equals("") ? newEntry.getContent() : old.getContent());
// Directly update entry; user DBRef array requires zero changes
journalEntryService.saveEntry(old);
return new ResponseEntity<>(old, HttpStatus.OK);
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
Spring Data Mongo Lazy Resolution Behavior
If an entry document is manually removed from the journal_entries collection in MongoDB without updating the parent users collection:
- Querying
user.getJournalEntries()will temporarily loadnullelements for non-existent references. - On the next subsequent
userService.saveUser(user)call, Spring Data MongoDB automatically cleans up dead/unresolvable DBRef elements from the database array, re-establishing eventual consistency. - However, relying on eventual consistency introduces runtime bugs. Immediate manual removal using
removeIf()is required for standard application consistency.
9. Production Pitfalls & The Need for Transactions
The dual-write approach exposes a potential data consistency risk:
The Inconsistency Problem
If Step 1 succeeds but Step 3 fails, an orphaned entry is created in journal_entries that is not attached to any user.
To resolve this issue and execute both operations as an atomic unit, Spring's transaction support (@Transactional) along with a configured MongoTransactionManager must be used.
Summary and Key Takeaways
- Entity Linking with
@DBRef:@DBReflinks documents across collections by storing target documentObjectIds inside parent documents instead of embedding entire sub-documents. - Index Auto-Creation: Spring Boot disables automatic index generation by default. Set
spring.data.mongodb.auto-index-creation=trueinapplication.propertiesto enforce@Indexed(unique = true)constraints. - Initialization of Collection Fields: Always initialize reference collections (e.g.,
List<JournalEntry> journalEntries = new ArrayList<>()) in entities to prevent runtimeNullPointerExceptions. - Lombok JSON Deserialization Constraints: When combining
@Dataor custom constructors with Jackson JSON parsing, include@NoArgsConstructorto prevent HTTP400 Bad Requesterrors during deserialization. - Two-Step Creation Workflow: Linked creation requires saving the child document first to generate its ID, adding it to the parent entity's collection, and saving the parent entity.
- Manual Cascade Deletion: MongoDB does not automatically delete references when child documents are removed. Developers must manually execute
removeIf()on parent list references before saving the parent user. - Static References during Updates: Modifying a child document's content does not require resaving the parent entity because the primary
ObjectIdremains unchanged. - Transactional Necessity: Multi-collection writes require transactional wrapping (e.g., Spring's
@Transactional) to prevent partial failure state inconsistencies across collections.