1. mappedBy: meaning and role
mappedBy becomes important at the exact moment when the owning side is already clear, but there are no fewer problems. We have already found the foreign key, we have already learned to respect flush(), but now a second question comes up: how do you describe a bidirectional relationship so that Hibernate does not try to store it twice, and the in-memory graph does not lie to itself.
mappedBy is often seen as a decorative string: “well, you have to write something so Hibernate does not complain.” In practice, this is one of the most important signals for an ORM: where the source of truth is and which side of the relationship is just a “mirror.” If you confuse the meaning of mappedBy, Hibernate may either create an extra schema or force you to live in a world of strange SQL — and you will spend a long time thinking the problem is in the repository or in save().
You do not need to completely restart the topic of the owner. It is enough to keep one thing in mind: in tables, the relationship is stored once — in the foreign key. And in the Java model, you usually want navigation in both directions, because that is more convenient: from an order, see its items; from an item, see its order.
Let us look at this through the eyes of the DB schema (simplified):
erDiagram
purchase_order ||--o{ order_item : "1 -> many"
purchase_order {
bigint id PK
varchar order_number
}
order_item {
bigint id PK
bigint order_id FK
int quantity
}
Physically, the foreign key lives in order_item.order_id. That means if we want the relationship in the database to change predictably, Hibernate must rely on the Java field that corresponds to this FK. Usually, that is @ManyToOne in OrderItem.
And this is where mappedBy enters the scene. It tells Hibernate something like: “The collection on the parent is not the owner. Do not try to store the relationship ‘one more time.’ Look at the owner field on the other side.”
2. mappedBy: the Java field name
The most common mistake with mappedBy sounds like this: “the column is order_id in the table, so I will write mappedBy = "order_id".” And Hibernate honestly replies: “I do not search for a column by the mappedBy string. I search for a field in the Java class.” In other words, mappedBy is not about SQL or DDL. It is about how your object graph is structured.
The correct rule is simple: mappedBy must match the name of the owning-side field in the child entity.
For example, if the field in OrderItem is called order, then in PurchaseOrder you write mappedBy = "order".
Mini fragment of mapping on the PurchaseOrder side:
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@OneToMany(mappedBy = "order") // inverse side: the FK is not stored here; this is a "mirror"
private List<OrderItem> items = new ArrayList<>(); // the collection is needed for navigation in memory
And the owning side in OrderItem (this is where the FK meaning "lives"):
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@ManyToOne // owning side: this field determines the FK value in the DB
@JoinColumn(name = "order_id") // the column name is here, on the owning side
private PurchaseOrder order;
Pay attention to the “division of responsibilities”: mappedBy looks at the Java field order, while @JoinColumn(...) looks at the SQL column order_id. These are two different “universes,” and mixing them up is like looking for your car keys in the fridge. In theory, they could be there, but that is a different story and probably about stress.
Now let us look at a typical mistake you might want to write “by intuition”:
@OneToMany(mappedBy = "order_id") // do not do this: mappedBy points to the Java field name, not the column
private List<OrderItem> items = new ArrayList<>();
order_id is not a field in OrderItem. It is a column in the table. In this situation, Hibernate usually complains that mappedBy points to a non-existent property.
There is another trap: sometimes a developer tries to “play it safe” and writes extra things on the inverse side, for example @JoinColumn together with mappedBy. But if the side is inverse (with mappedBy), then it must not define the foreign key. It indicates that the foreign key is already “defined there.”
3. Relationship side desynchronization
The problem with bidirectional relationships is that Java does not know that these two references must always stay in sync. Java is an honest language: you put an object into a List — it sits in the List. You set a reference in a field — it stays there. The fact that these two actions should happen together is our responsibility, not ArrayList's responsibility.
So desynchronization looks like very “innocent” code that creates a strange reality:
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 1L);
OrderItem item = entityManager.find(OrderItem.class, 10L);
order.getItems().add(item); // changed only the inverse side: the owning side (item.order) stayed as it was
In memory, the item now “seems” to belong to the order because the collection changed. But item.getOrder() may still be null (or point to another order). And Hibernate, as we already know, uses the owning side when writing the relationship — that is, OrderItem.order.
This creates a “gossip” effect: one side says they are “in a relationship,” while the other side pretends they do not even know each other. Hibernate in this drama is the tax authority: it believes not words, but documents (that is, the owning side).
Reverse desynchronization happens too, and it is psychologically even more unpleasant: you set item.setOrder(order), but forget to add the item to order.items. In the database, this may be saved correctly because the owning side was updated. But in memory, your order.getItems() collection does not contain the item, and further application logic starts making decisions based on an incomplete graph.
If we summarize it, a bidirectional relationship without discipline creates two kinds of bugs:
The one where the database “did not change” even though you “added to the collection” is usually caught in the SQL log and causes confusion: “why is there no UPDATE?”
And the one where the database changed, but memory is lying, is more insidious: you think you saved everything, but the next piece of business logic in the same transaction looks at the collection and makes incorrect conclusions. This is especially unpleasant because the SQL log may be perfect while the problem is in object state.
4. Helper methods for synchronization
The owning side is needed so Hibernate writes SQL honestly. Helper methods are needed for the second half of the task: so that the same relation lives honestly in memory inside the transaction.
If a bidirectional relationship requires two synchronized changes, it makes sense to make it hard to “accidentally” perform only one change. That is the point of helper methods: they turn relationship management into an action “through one door.” Not items.add(...) somewhere in a service, but order.addItem(item) as a single operation.
The smallest, but already useful, helper method for an order:
public void addItem(OrderItem item) {
items.add(item); // update the inverse side (the collection) for a consistent in-memory graph
item.setOrder(this); // synchronize the owning side: this field controls the FK
}
And the symmetric method for removing a relationship:
public void removeItem(OrderItem item) {
items.remove(item); // remove from the collection so the object graph in memory does not lie
item.setOrder(null); // the relationship is broken on the owning side: the FK should become null / the row should be removed (depends on settings)
}
Here it is important not to overpromise the meaning. removeItem() for now means only “break the relationship in the object model.” Whether this becomes a DELETE of the order_item row or an UPDATE order_item set order_id = null depends on cascade settings and orphanRemoval. But we are intentionally not going there in this lecture: right now, it is more important that both sides of the relationship stop living separate lives.
If reparenting is actually allowed
For PurchaseOrder → OrderItem, such a “move” is usually not needed at all: an order item is more a part of an order than an independent object. But the pattern itself is useful to know for relationships where the child really can be reattached to another parent.
In real code, a situation sometimes arises where you want to “move” an item to another order. If you just do item.setOrder(newOrder), you get the owning side, but the old order may still hold the item in its collection. This is a classic “zombie element”: it lives in two places at once, and the logic starts to go crazy.
One simple protective option is to check, when adding, whether the item is already attached to another parent:
public void addItem(OrderItem item) {
if (item.getOrder() != null && item.getOrder() != this) { // protection from the situation "the element already belongs to another parent"
item.getOrder().removeItem(item); // carefully synchronize both sides on the old order
}
items.add(item); // update the inverse side of the current order
item.setOrder(this); // update the owning side: it decides which FK goes to the DB
}
Yes, this is a bit “smarter” than the minimal version. But the idea is the same: the place where the relationship changes should be single and clear. Otherwise, services quickly accumulate “smeared-out” logic: in one place you add to the collection, in another you set the owner, in a third you clean up the old collection... and you end up with not a business operation, but a quest.
Helper methods instead of “I will remember”
There is an old programmer tragicomedy: “I will remember to update both sides.” This usually works exactly until the first deadline, the first refactoring, and the first colleague who did not live in your head and therefore did not read your thoughts.
Helper methods do what good architecture should do: they reduce the number of places where you can make a mistake and make the right path the easiest one. If the code has order.addItem(item), you immediately understand that the relationship will be updated correctly. If the code has order.getItems().add(item), you have to keep asking yourself: “but was the owner updated?”
5. Example: PurchaseOrder ↔ OrderItem
In our end-to-end project Commerce Persistence Lab, the order-to-items relationship appears constantly because this is a very natural aggregate: the order as the root, the items as part of the order. That is why it is especially important here that the relationship is predictable both in memory and in SQL.
Below is a compact PurchaseOrder fragment with the correct mappedBy and a normal helper method. I am showing only what is relevant to the lecture topic (order fields, status, and so on are not important right now):
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class PurchaseOrder {
@OneToMany(mappedBy = "order") // inverse side: the "mirror" of the owning side in OrderItem
private List<OrderItem> items = new ArrayList<>(); // the collection is needed for navigation and business logic
public void addItem(OrderItem item) {
items.add(item); // update the collection so the object graph inside the transaction stays consistent
item.setOrder(this); // owning side updated: this is the change that will be reflected in the FK
}
}
And the child entity OrderItem, where the owning side is the order field:
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class OrderItem {
@ManyToOne // owning side: this field controls the order_id foreign key
@JoinColumn(name = "order_id") // define the physical FK column here
private PurchaseOrder order;
public void setOrder(PurchaseOrder order) {
this.order = order; // this is the field Hibernate will read during flush() to write the relationship
}
}
Now the most important thing: what this looks like at the service-code level. We do not want the service to “know” how exactly to synchronize both sides. The service should express the business operation: “add an item to an order.” The details of synchronizing references are the model’s responsibility.
Example of a very simple service method (again, simplified to highlight the main point):
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
@Transactional
public void attachItem(long orderId, long itemId) {
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId); // load the parent into the persistence context
OrderItem item = entityManager.find(OrderItem.class, itemId); // load the child that will be reattached
order.addItem(item); // single source of truth: helper method synchronizes both sides
}
There is not a single order.getItems().add(item) here. And that is good. This code is easier to review: you can see with your own eyes that the relationship will be updated correctly.
If you later turn on the SQL log and drive the transaction to flush() (explicitly or automatically), Hibernate will have a clear basis for sending SQL because the owning side OrderItem.order changed. The PurchaseOrder.items collection was updated too, but here it is more like a “consistent picture” for your business logic inside the transaction.
6. Example: Customer ↔ CustomerAddress
To avoid the feeling that “this is only about orders,” it is useful to look at another part of the domain. A customer and their addresses are a classic OneToMany scenario. And exactly the same problems appear if you start changing only one side.
From the DB perspective, everything is predictable: the foreign key is usually in the address table, for example customer_address.customer_id. That means the owning side is @ManyToOne in CustomerAddress.
Fragment of Customer with the inverse side and mappedBy:
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
private List<CustomerAddress> addresses = new ArrayList<>();
public void addAddress(CustomerAddress address) {
addresses.add(address);
address.setCustomer(this);
}
}
And the owning side in CustomerAddress:
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class CustomerAddress {
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
public void setCustomer(Customer customer) {
this.customer = customer;
}
}
Notice: we are not doing any “smart” things here like automatically choosing a default address or checking the uniqueness of address types. Those are business rules. Within today’s lecture, we are dealing with a narrow but extremely important engineering concern: an address either belongs to a customer and both sides reflect that, or it does not belong and both sides reflect that.
If you let services do this: customer.getAddresses().add(address);
then you will almost certainly one day get an address that is listed in the collection, but has address.getCustomer() == null. And then Hibernate will trust the owning-side field, while your business logic will trust the collection, and you will get two different realities. By the way, that is a pretty good definition of chaos: “two different realities in one application.”
7. Checking synchronization: transaction and SQL log
It is very tempting to check bidirectional relationships “by eye”: open the debugger, see that the items collection has the right number of elements, and relax. The problem is that the debugger shows you only the object reality, while Hibernate writes to the database according to the rules of the owning side and does so during flush(). That means the correct check must take both memory and SQL into account.
A practical approach looks like this: you reproduce the minimal scenario inside a transaction, make the change through a helper method, and then drive the situation to flush() so you can see what Hibernate really decided to send. Even if you are not writing a test, but just running the application in lab mode, this remains the most honest way to verify things.
For example, you can temporarily call entityManager.flush() after changing the relationship so you do not have to wait until the end of the transaction (in educational code this is fine, because we are learning to observe the mechanics):
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 1L);
OrderItem item = entityManager.find(OrderItem.class, 10L);
order.addItem(item); // helper method should update the owning side (otherwise SQL may not appear)
entityManager.flush(); // we want to see the SQL right now, not at the end of the transaction
If the helper method is correct, the owning side is updated, and Hibernate is allowed to issue an UPDATE for order_item.order_id (or an insert with a populated FK if this is a new item — but then cascades matter, and that is a separate topic). If the helper method is “half-done” and changes only the collection, then at flush() you will often see... nothing. And that “nothing” is the best teacher, because it sharply explains that the collection does not control the FK.
The benefit of this approach is that it builds a healthy habit: any relationship problem is proven not by saying “Hibernate did something wrong,” but by observing “I changed this field, did a flush, and this SQL went out.”
8. Typical mistakes when working with mappedBy and helper methods
Mistake #1: thinking that mappedBy is the name of a column (order_id, customer_id) or a table name.
This is one of the most common confusions because we constantly think “through the database.” But mappedBy is about the object model, and it must refer to the owning-side Java field. If you write mappedBy = "order_id", Hibernate will not “guess” that you meant a column. It will honestly look for a field named order_id in OrderItem and will not find it.
Mistake #2: changing only the inverse side (the collection) and expecting the foreign key to update.
The order.items collection is needed for navigation and convenient business logic, but the decision to write the FK is made by the owning side. If you added an item to the collection but did not call item.setOrder(order), then at flush() Hibernate sees the old relationship value “from the owner’s point of view” — and is not required to write anything to the database.
Mistake #3: making a helper method that looks nice but changes only one side.
Classic example: addItem() adds to items but forgets item.setOrder(this). Such a method is worse than having no method at all, because it creates a false sense of “we already have the right method.” If a helper exists, it must be honest: both sides change synchronously.
Mistake #4: spreading relationship support across services.
When you add to the collection in one service, set the owner in another, and clear the old reference in a third, you lose a single source of truth. Such code is hard to read, hard to test, and almost impossible to refactor reliably. A relationship is part of the model, so it is more logical to keep synchronization inside the entity via helper methods.
Mistake #5: checking “by eye” against objects, without driving the scenario to flush() and without reading SQL.
In Hibernate there is too much implicit behavior to trust only the debugger. You may see a “correct” object graph, but the database will remain unchanged. Or vice versa — the database has already changed, but your in-memory collection does not reflect it. If you are diagnosing relationships, the final checkpoint is always the same: the SQL log after flush().
GO TO FULL VERSION