CodeGym /Courses /Spring Data JPA /Navigation in JPA: one side or two

Navigation in JPA: one side or two

Spring Data JPA
Level 8, Lesson 3
Available

1. Navigation in entities: the choice

When you first see JPA associations, a very natural desire appears: "Let us make it so that everywhere we can go both ways." A product knows its category — then the category should know its products. An order item knows its order — then the order should know its items. It seems logical: the more references, the more convenient. But in ORM, convenience almost always comes in a package with responsibilities.

In JPA, navigation is not just "so that it is convenient to move around objects". It is part of the object model that you will maintain in code, and it affects how you create objects, how you move them between entities, how you write toString(), how you avoid desynchronization, and how you keep invariants. That is why a beginner rule is very useful here, one that is rarely written in the first tutorials: start with the minimally sufficient navigation and expand it only when it really makes the model better, not just "prettier".

2. Minimal model: only @ManyToOne

Let us start with the calmest and most predictable form of association: the child stores a reference to the parent. This is @ManyToOne. At the SQL level, it looks as honest as possible: the FK lives in the child table. At the Java level, it looks like an ordinary reference field. And the nicest part: if you have only @ManyToOne, you can hardly "accidentally" desynchronize the two sides, because there is simply no second side.

Imagine our mini-shop. A product (Product) belongs to a category (Category). Inside Product it looks roughly like this (I am showing only the important part; we already did the rest in previous days):

import jakarta.persistence.ManyToOne;

// Owning side of the association: the FK lives in the Product table.
@ManyToOne
private Category category;

And that can truly be enough if your main scenario sounds like "a product has a category", "an order item has an order", "an order item has a product". In other words, when you usually move along the chain from child to parent.

Now pay attention to an important psychological point. Many beginners perceive the absence of a reverse collection as "the model is incomplete". But a model becomes incomplete only when you actually try to solve a use case and suddenly realize: "I need to get the products from a category directly through the object." If there is no such scenario, then you do not have a problem; you just invented one for yourself in advance.

In practical code, this is reflected like this: to "attach a product to a category", you need to update only one field on the product. For example, in CatalogService (which we started building in level 7), you can write a very straightforward method:

import org.springframework.stereotype.Service;

@Service
public class CatalogService {

    public void assignCategory(Product product, Category category) {
        // Update the owning side of the association (Product.category).
        // That is enough, because there is no second side in the model.
        product.setCategory(category);
    }
}

Yes, here I am intentionally not showing repositories and save(): today, what matters to us is the shape of the model, not exactly how you persist the changes. The point is that in a unidirectional model, you do not have the dilemma "did I update the collection too?" You updated the owning side — and that is all.

3. The price of bidirectionality: the "second truth"

Now let us add the reverse side. Suppose Category also contains a collection of products. Technically, it looks like this:

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

// Reverse side of the association: we read the "list of category products" from the object model.
// Important: the owning side is still Product.category.
@OneToMany(mappedBy = "category")
private List<Product> products = new ArrayList<>(); // Initialization protects against NPE during add/remove

At this point, an inner joy usually sounds like: "Oh! Now the category has getProducts()." But together with getProducts() you have received a second "point of view on reality". In the database there is one FK. The owning side of the association is still Product.category. And the collection on Category is a reflection of the association in memory.

On the mechanical level, we already covered this: a collection with mappedBy does not write the FK itself. Here another conclusion matters — together with convenient navigation, you take on the duty to keep the field and the collection synchronized. If you limit yourself to just category.getProducts().add(product), it will seem in memory that the association exists, while the FK in the database may remain unchanged. That is why a bidirectional model almost immediately requires a helper method. It looks very simple, but it saves a ton of nerves:

public void addProduct(Product product) {
    // 1) Update the collection on the parent (reverse side).
    products.add(product);

    // 2) Update the field on the child (owning side; this is what affects the FK).
    product.setCategory(this);
}

From a beginner's point of view, this looks like a ritual. But in fact, it is the honest engineering cost of bidirectionality: if you have two pointers in memory, you must keep them consistent. It is like storing the same phone number in two address books: convenient until you change the number and forget to update the second one.

And one more important consequence. Bidirectionality almost always makes the object graph cyclic: the category contains products, and the product contains the category. This can unexpectedly surface where you do not expect it, for example in toString().

An anti-example that seems harmless:

@Override
public String toString() {
    // Careful: if Product.toString() prints category,
    // then it is easy to get infinite recursion here.
    return "Category{id=" + id + ", products=" + products + "}";
}

If Product also has a toString() that prints the category, you can get infinite recursion. We are not going deep into that topic, but it is important to note: bidirectional navigation makes such surprises more likely.

4. Bidirectionality for parent/child: CustomerOrder -> OrderItem

Now to the most "real-life" part: how do you understand that bidirectionality is needed, not just "possible"? A very simple heuristic: bidirectional navigation is usually justified when the parent, in its own domain logic, works with a set of children as part of itself. In other words, the children do not look like independent objects "outside the parent"; they are the parent's contents.

In our project, CustomerOrder and OrderItem are a great example. An order without items usually does not make sense. Order items live "inside the order". Even if you do not dive into DDD, the logic is obvious at a practical level: an order is a box, and items are what is inside the box.

That is why having a collection of items on the order is not a whim, but a reflection of the model:

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

// Reverse side: the order "contains" items in the object model.
// The owning side remains OrderItem.customerOrder.
@OneToMany(mappedBy = "customerOrder")
private List<OrderItem> items = new ArrayList<>();

After that, a helper method almost inevitably appears:

public void addItem(OrderItem item) {
    // Keep both sides of the association synchronized in one place.
    items.add(item);              // reverse side
    item.setCustomerOrder(this);   // owning side (FK)
}

And now creating an order in code starts to look natural. Even a beginner who is still a bit shaky in JPA will read it and understand:

CustomerOrder order = new CustomerOrder();
order.setCustomerEmail("alice@example.com"); // basic order data

OrderItem item = new OrderItem();
item.setQuantity(2);          // business data for the item
item.setProduct(product);     // the item knows the product (usually @ManyToOne)

order.addItem(item);          // important: connect through the helper so both sides are updated

This feels like an ordinary object model, without the sensation that "I am writing SQL in Java".

Here it is enough to see that as an object model this bidirectional association helps. A separate question is what the parent is allowed to do with the lifecycle of the items: save them together with itself, delete them, or leave them. That is another axis of the decision, and it does not follow automatically from navigation alone.

If you keep only @ManyToOne on OrderItem, then the order will not have "contents" in the form of a collection. Creating an order will turn into a more "warehouse-like" procedure: create order, save, create item, connect, save item... This is also possible, but then it is no longer the model of an order as an object; it is the model of "a set of records in tables". Sometimes that is appropriate, but for our educational project and for a typical order domain, it is usually worse.

5. Bidirectionality and reference data: Category -> Product

Now an example where bidirectionality is not obvious: category and products. In our domain, category is reference data. A product as a catalog entity is quite independent: it has sku, price, status, and so on. It can move to another category, the category can be disabled, and the products do not "disappear from the world" because of that. In our baseline, a product should not become an "orphan without a category"; the usual operation here is to reassign it to another category.

And here an important question arises: do we need Category.products inside the entity? Sometimes yes, but very often it is just "for convenience", without any clear business meaning.

If you keep only Product.category, the model becomes simpler. Moving a product to another category is just changing one reference:

public void moveProduct(Product product, Category newCategory) {
    // Unidirectional model: change only the owning side of the association.
    product.setCategory(newCategory);
}

If you have a bidirectional association, and you honestly want to keep the object model consistent, then moving a product turns into two actions: remove it from the old category and add it to the new one. And you still need to remember and update the "old category" as well. Even if we show the idea directly, without helper methods, the code is already longer:

public void moveProduct(Product product, Category newCategory) {
    // In a bidirectional model, it is important to synchronize both sides.
    Category oldCategory = product.getCategory();

    // Update reverse collections (in memory).
    oldCategory.getProducts().remove(product);
    newCategory.getProducts().add(product);

    // Update the owning side (this is what affects the FK in the DB).
    product.setCategory(newCategory);
}

The point here is the fact itself: besides the field on the product, you also have to remember the old and the new collection. Even without cascade and the rest of those topics, this already looks like "it was a simple operation, but the code got longer." And that is exactly the signal you need: bidirectionality must justify its price.

There is another nuance: a category, unlike an order, rarely "manages the lifecycle of products". A product should not be deleted just because you removed it from a category. If the model has a products collection, it is very easy to start thinking "the category owns the products", and that is already a questionable claim for a normal catalog. In other words, bidirectionality can quietly push you toward the wrong ownership model, and then that surfaces in the next topic of the day (lifecycle).

Therefore, a good practical position sounds like this: for reference data, a unidirectional association is often enough (the product knows the category), and the reverse collection should be added only if the category really behaves like a "container" that often manages a set of products as part of its logic.

6. How to choose navigation: a memo and a diagram

When you are at the beginning of the path, you want some kind of "rule". There is no absolute rule (otherwise half the JPA discussions would not exist), but there is a practical memo that is enough for junior level and an educational project. I like to phrase it like this: do not make an association bidirectional just for reading — make it bidirectional for the model and its invariants.

Below is a small comparison you can keep in front of you:

Question Unidirectional (@ManyToOne only on the child) Bidirectional (@ManyToOne + @OneToMany(mappedBy))
How many "places of truth" are in memory? One (the field on the child) Two (the field on the child + the collection on the parent)
Risk of desynchronization Minimal Real, helper methods are needed
Code for building an object graph Sometimes more "manual" Often more "natural" (especially for parent/child)
Good for reference data Often yes Sometimes, but carefully
Good for "containers" (an order and its items) Possible, but less convenient Usually yes

And here is a decision tree in diagram form. It is intentionally simple: we are not discussing loading, optimization, or other future topics — only the model's meaning and code maintainability.

flowchart TD
    A["We want to connect entities"] --> B{"Does the parent really manage
a set of children as part of itself?"} B -->|Yes| C["Bidirectional association is appropriate:
@OneToMany(mappedBy) + helper methods"] B -->|No| D{"Do we need navigation from parent to children
directly in the object model?"} D -->|No| E["Keep only @ManyToOne
the model is simpler, fewer responsibilities"] D -->|Yes| F["Add @OneToMany, but consciously:
be ready to maintain synchronization"]

If you want a very short "mantra", it is this: bidirectional navigation is not "more convenience", but "more responsibility". And responsibility makes sense to take on when it reduces chaos in code and reflects the domain more accurately.

7. Typical mistakes when choosing navigation

Mistake #1: "Let us make everything bidirectional so that it is convenient."
At first, this approach seems mature and "like in real projects", but it usually ends with a huge object graph where everyone knows everyone. As a result, helper methods become harder to write, the model is harder to keep consistent, and any operation turns into a dance of "update here, do not forget there".

Mistake #2: thinking that the collection on the parent is the owning side.
This is one of the most painful confusions. In the pair Category.products and Product.category, the owning side remains Product.category. If you change only the collection, you create a beautiful picture in memory, but it is not guaranteed to turn into the correct FK in the database. When that happens, a beginner usually blames "JPA magic", although the real problem is the ownership model.

Mistake #3: adding bidirectionality but not introducing helper methods.
Technically, in the service layer you can manually do category.getProducts().add(product) and product.setCategory(category). In practice, this almost always leads to desynchronization: in one place you forgot to update the field, in another the collection, and a week later you no longer remember where it is "correct". A helper method in the entity is not a luxury; it is a way to keep association rules in one place.

Mistake #4: trying to "simplify" the model and remove the collection where the parent manages the children.
This is the opposite extreme: you keep only @ManyToOne on the child even for relationships like CustomerOrder -> OrderItem, and then the business logic of the order starts to look like work with scattered table rows. As a result, service code grows, becomes harder to read, and becomes more fragile, because order invariants (for example, "an order must contain at least one item") are harder to maintain without an explicit collection in the model.

Mistake #5: expecting bidirectionality to automatically mean "the parent owns the lifecycle".
A collection by itself says nothing about the lifecycle. A category can have a collection of products, but that does not mean the products should be deleted when the category is deleted. It is very important to separate "navigation" and "lifecycle". The same association shape does not yet mean the same object life rules.

1
Task
Spring Data JPA, level 8, lesson 3
Locked
Unidirectional catalog association without a reverse collection
Unidirectional catalog association without a reverse collection
1
Task
Spring Data JPA, level 8, lesson 3
Locked
Bidirectional Order Model with Item List
Bidirectional Order Model with Item List
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION