Mastering MongoDB Relationships in Spring Boot

    Mastering MongoDB Relationships in Spring Boot

    Learn MongoDB relationships and embedding addresses for data that belongs together and referencing products for shared, independent data.

    default profile

    Anuj Kumar Sharma

    September 13, 2025

    6 min read

    In the previous parts, we installed MongoDB and built a Spring Boot application with basic CRUD operations. Now it's time to tackle one of the most important aspects of document database design: relationships. If you're coming from the SQL world, prepare to think differently about how data relates to each other.

    This article is part of our comprehensive 4-part MongoDB with Spring Boot series.

    Part 1: Getting Started with MongoDB: Installation and Basic Commands

    Part 2: MongoDB with Spring Boot using Spring-Data-MongoDB

    Part 3 (Current): MongoDB Relationships in Spring Boot

    Part 4: Advanced MongoDB Queries: Mastering Criteria API and MongoTemplate

    The MongoDB Relationship Philosophy#

    In traditional SQL databases, we normalize data into separate tables and use JOINs to bring them together. MongoDB takes a different approach - it encourages you to model your data based on how your application uses it. This means sometimes embedding documents within documents, and sometimes referencing them separately.

    The golden rule? If data is always accessed together, store it together. If data has its own lifecycle or is shared across multiple documents, reference it.

    Embedding Documents#

    Let's start with embedding. Remember our Order entity from the last post? Orders always need shipping addresses, and an address doesn't make sense without an order. This is a perfect case for embedding.

    Creating an Embedded Address#

    First, let's create our Address as a nested class within Order:

    @Data @Builder @Document(collection = "orders") public class Order { @Id private String id; @CreatedDate private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; private String status; private int quantity; private double totalPrice; @Indexed private Address address; @Data @Builder public static class Address { private String line1; private String city; private String state; private String zipCode; private String country; } }

    Notice how Address is a static nested class? It doesn't need @Document because it's not a separate collection - it's part of the Order document. The @Indexed annotation on the address field creates an index on the entire embedded document, making queries on address fields faster.

    Working with Embedded Documents#

    Creating an order with an embedded address is intuitive:

    @Test void createOrderWithAddress() { Order.Address address = Order.Address.builder() .line1("123 Tech Street") .city("Bangalore") .state("Karnataka") .zipCode("560001") .country("India") .build(); Order order = Order.builder() .status("pending") .quantity(2) .totalPrice(199.99) .address(address) .build(); Order saved = orderRepository.save(order); assertNotNull(saved.getAddress()); assertEquals("Bangalore", saved.getAddress().getCity()); }

    In MongoDB, this creates a single document that looks like:

    { "_id": ObjectId("..."), "status": "pending", "quantity": 2, "totalPrice": 199.99, "address": { "line1": "123 Tech Street", "city": "Bangalore", "state": "Karnataka", "zipCode": "560001", "country": "India" }, "createdAt": ISODate("..."), "updatedAt": ISODate("...") }

    Querying Embedded Documents#

    Spring Data MongoDB makes querying embedded documents natural:

    public interface OrderRepository extends MongoRepository<Order, String> { List<Order> findByAddressCity(String city); List<Order> findByAddressStateAndAddressCountry(String state, String country); @Query("{ 'address.zipCode': ?0 }") List<Order> findByZipCode(String zipCode); }

    The dot notation (address.city) navigates into embedded documents. Spring automatically understands this pattern in method names.

    Document References#

    Now let's look at references. Products exist independently of orders - multiple orders can reference the same product, and products have their own lifecycle. This calls for a separate collection with references.

    Creating the Product Entity#

    @Data @Document(collection = "products") public class Product { @Id private String id; private String name; @Indexed private String category; @Indexed private double price; private List<String> tags; private int stock; private double reviews; }

    Adding Product References to Order#

    Now, let's update our Order to reference products:

    @Data @Builder @Document(collection = "orders") public class Order { @Id private String id; // ... @Indexed private Address address; @DBRef(lazy = true) private List<Product> products; // Address class remains the same }

    The @DBRef annotation tells Spring to store references to Product documents rather than embedding them. The lazy = true parameter means products are only loaded when accessed, improving performance.

    Working with References#

    Let's see how to create and query orders with product references:

    @Test void createOrderWithProducts() { // First, create some products Product laptop = Product.builder() .name("Gaming Laptop") .category("Electronics") .price(1299.99) .stock(10) .build(); laptop = productRepository.save(laptop); Product mouse = Product.builder() .name("Wireless Mouse") .category("Accessories") .price(49.99) .stock(50) .build(); mouse = productRepository.save(mouse); // Create order with product references Order order = Order.builder() .status("pending") .quantity(2) .totalPrice(1349.98) .products(Arrays.asList(laptop, mouse)) .build(); Order saved = orderRepository.save(order); // Products are saved as references assertEquals(2, saved.getProducts().size()); }

    In MongoDB, the order document stores product references like this:

    { "_id": ObjectId("..."), "status": "pending", "quantity": 2, "totalPrice": 1349.98, "products": [ { "$ref": "products", "$id": ObjectId("...") }, { "$ref": "products", "$id": ObjectId("...") } ] }

    Lazy Loading in Action#

    With lazy loading, products are fetched only when accessed:

    @Test void lazyLoadingExample() { Order order = orderRepository.findById(orderId).orElseThrow(); // Products not loaded yet System.out.println("Order status: " + order.getStatus()); // Now products are loaded List<Product> products = order.getProducts(); products.forEach(p -> System.out.println(p.getName())); }

    Querying Embedded Documents with Complex Criteria#

    For more complex queries on embedded documents, use MongoDB's query syntax:

    @Query("{ 'address.city': { $in: ?0 }, 'totalPrice': { $gte: ?1 } }") List<Order> findOrdersInCitiesWithMinPrice(List<String> cities, double minPrice); @Query("{ $and: [ { 'address.state': ?0 }, { 'status': 'delivered' } ] }") List<Order> findDeliveredOrdersInState(String state);

    Manual References Without @DBRef#

    Sometimes you want more control over references. Instead of using @DBRef, you can store just the ID:

    @Document(collection = "orders") public class Order { // Other fields... private List<String> productIds; }

    Then manually fetch products when needed:

    @Service public class OrderService { @Autowired private OrderRepository orderRepository; @Autowired private ProductRepository productRepository; public OrderDetails getOrderWithProducts(String orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); List<Product> products = productRepository.findAllById(order.getProductIds()); return OrderDetails.builder() .order(order) .products(products) .build(); } }

    This approach gives you more flexibility but requires more code.

    When to Embed vs When to Reference#

    Embed When:#

    • The data is always accessed together
    • The embedded data doesn't change independently
    • The relationship is one-to-few (not one-to-millions)
    • You want atomic updates on the entire document

    Reference When:#

    • The data has its own lifecycle
    • Multiple documents need to share the same data
    • The relationship is one-to-many or many-to-many
    • The referenced data changes frequently
    • You need to query the data independently

    Performance Considerations#

    Indexing Strategies for Relationships#

    Create compound indexes for frequently queried embedded fields:

    @CompoundIndex(name = "city_status_idx", def = "{'address.city': 1, 'status': 1}") @Document(collection = "orders") public class Order { // fields... }

    Projection to Optimize Queries#

    Fetch only what you need:

    @Query(value = "{ 'address.city': ?0 }", fields = "{ 'products': 0 }") List<Order> findOrdersInCityWithoutProducts(String city);

    The fields parameter excludes the products field, reducing data transfer.

    What's Next?#

    You've now mastered relationships in MongoDB - embedding addresses for data that belongs together and referencing products for shared, independent data. You understand when to use each approach and how Spring Data MongoDB makes both patterns elegant.

    But what happens when repository methods aren't enough? In our next post, we'll explore the powerful world of MongoDB queries using Criteria API and MongoTemplate. You'll learn to build dynamic queries, perform complex aggregations, and handle scenarios that go beyond simple CRUD operations.

    Next part here:

    Part 4: Advanced MongoDB Queries: Mastering Criteria API and MongoTemplate

    Want to Master Spring Boot and Land Your Dream Job?

    Struggling with coding interviews? Learn Data Structures & Algorithms (DSA) with our expert-led course. Build strong problem-solving skills, write optimized code, and crack top tech interviews with ease

    Learn more
    Spring Boot
    Spring Data MongoDB
    Embedding
    MongoDB Relationships

    Subscribe to our newsletter

    Read articles from Coding Shuttle directly inside your inbox. Subscribe to the newsletter, and don't miss out.

    More articles