Persistence with PostgreSQL
Relationships
আপনি একটি free preview lesson দেখছেন।
আমাদের PostgreSQL schema-তে already several relationships আছে:
products
↑
│
inventory
orders
↑
│
order_items
products
↑
│
order_items
Relational database-এর perspective থেকে এগুলো foreign-key relationships।
JPA চাইলে এগুলোকে Java object associations হিসেবে map করতে পারে:
@OneToOne
@ManyToOne
@OneToMany
কিন্তু একটি important distinction আছে:
Every database foreign key does not need to become a navigable JPA relationship.
Jakarta Persistence supports unidirectional and bidirectional relationships. In a bidirectional one-to-many/many-to-one relationship, the ManyToOne side is the owning side, while the inverse OneToMany side points back to it using mappedBy.
এই lesson-এ আমরা আমাদের relationships individually evaluate করব।
Our goal is not:
maximum number of JPA annotations
Our goal:
clear persistence model
predictable SQL
small object graphs
correct ownership
minimal accidental loading
Our Relationships
Current schema:
Product
↕
Inventory
Order
↕
OrderItems
OrderItem
↓
Product
There is also:
Order
↓
CustomerId
but Customer is externally owned।
We need to decide for each:
Does a JPA association improve our persistence implementation?
Database Relationship vs JPA Relationship
Suppose PostgreSQL says:
inventory.product_id
→ products.id
This already provides:
referential integrity
JPA does not need:
@OneToOne
private ProductEntity product;
for PostgreSQL to enforce that relationship।
We can perfectly well map:
@Id
@Column(name = "product_id")
private Long productId;
while the database foreign key remains active।
Why This Matters
JPA associations do more than document foreign keys।
They also create:
object navigation
entity lifecycle interactions
fetch behaviour
cascade behaviour
persistence-context relationships
Adding an association therefore changes how Java persistence code behaves।
It should be deliberate।
A Dangerous Mental Model
Avoid:
foreign key
→ add JPA relationship
→ add reverse relationship
→ add cascade
→ make everything bidirectional
This quickly creates:
Product
↕
Inventory
↕
OrderItem
↕
Order
↕
...
One large Hibernate object graph।
Then code that looks innocent:
product.getInventory()
.getSomething()
.getOrders();
can trigger unexpected database access।
Our Relationship Strategy
For this application we'll use a deliberately small persistence graph.
Current direction:
Product ↔ Inventory
→ no JPA association
→ scalar ProductId
Order ↔ OrderItem
→ JPA association is useful
→ Order owns items conceptually
OrderItem → Product
→ no JPA association
→ scalar ProductId
Order → Customer
→ no JPA association
→ external CustomerId
This gives us one meaningful JPA aggregate relationship:
Order
↓
OrderItems
without connecting the entire application into one entity graph।
Product and Inventory
Database relationship:
inventory.product_id
→ products.id
Cardinality:
one Product
→ at most one Inventory row
At first glance this looks like an obvious:
@OneToOne
mapping।
But object navigation isn't required by our workflows।
How Application Uses Product and Inventory
Create Order conceptually does:
ProductRepository
→ load Products
InventoryRepository
→ load/check Inventory
Admin Inventory management also works around:
ProductId
We do not have a requirement where domain/application code needs:
product.getInventory();
or:
inventory.getProduct();
Keep Inventory Mapping Simple
Our persistence mapping remains:
@Entity
@Table(name = "inventory")
public class InventoryEntity {
@Id
@Column(name = "product_id")
private Long productId;
@Column(
name = "available_quantity",
nullable = false
)
private int availableQuantity;
protected InventoryEntity() {
}
...
}
PostgreSQL still enforces:
product_id
→ products.id
through the foreign key।
No JPA association is required।
What Would @OneToOne Buy Us?
If we mapped:
@OneToOne
private ProductEntity product;
we could navigate:
inventoryEntity.getProduct();
But then we also need to reason about:
fetch strategy
join mapping
entity lifecycle
whether Product should cascade
whether reverse navigation is needed
None of those solve a current application problem।
So we don't add them।
Could @MapsId Map Inventory to Product?
Yes।
Because:
inventory.product_id
is both:
Inventory primary key
and:
Product foreign key
Jakarta Persistence supports derived identity mappings with @MapsId, including cases where a OneToOne relationship supplies the dependent entity's primary-key mapping.
Conceptually we could have:
@Id
private Long productId;
@MapsId
@OneToOne
@JoinColumn(name = "product_id")
private ProductEntity product;
But again:
Supported does not mean necessary.
Our simpler scalar mapping better matches our capability boundaries։
Order and OrderItems Are Different
The relationship:
Order
→ OrderItems
is more important to represent in Java persistence।
Why?
Because our domain itself says:
Order is the aggregate/root concept
OrderItem belongs to Order
When reconstructing an Order, we need:
Order state
+
its OrderItems
to calculate totals and apply domain behaviour correctly।
So this is a relationship where JPA association can provide real value।
Relational Shape
Database:
orders
------
id
...
order_items
-----------
order_id
product_id
quantity
unit_price
with:
order_items.order_id
→ orders.id
One Order has many OrderItems।
So object relationship is naturally:
OrderEntity
↓ @OneToMany
OrderItemEntity
and on the child side:
OrderItemEntity
↓ @ManyToOne
OrderEntity
Owning Side
For a bidirectional:
@OneToMany
↔
@ManyToOne
relationship, Jakarta Persistence defines the many side as the owning side. The OneToMany side uses mappedBy to identify that owning relationship.
In our case:
OrderItemEntity.order
owns the relationship because order_items contains:
order_id
foreign key।
Mapping the Owning Side
Our composite key already includes:
orderId
productId
So OrderItemEntity can use:
@MapsId("orderId")
@ManyToOne(
fetch = FetchType.LAZY,
optional = false
)
@JoinColumn(
name = "order_id",
nullable = false
)
private OrderEntity order;
@MapsId("orderId") tells JPA that this relationship supplies the orderId portion of the embedded primary key. Jakarta Persistence explicitly supports this pattern for composite identifiers derived partly from a parent relationship.
Updated OrderItemEntity
Conceptually:
@Entity
@Table(name = "order_items")
public class OrderItemEntity {
@EmbeddedId
private OrderItemEntityId id;
@MapsId("orderId")
@ManyToOne(
fetch = FetchType.LAZY,
optional = false
)
@JoinColumn(
name = "order_id",
nullable = false
)
private OrderEntity order;
@Column(
name = "quantity",
nullable = false
)
private int quantity;
@Column(
name = "unit_price",
nullable = false
)
private BigDecimal unitPrice;
protected OrderItemEntity() {
}
public OrderItemEntity(
Long productId,
int quantity,
BigDecimal unitPrice
) {
this.id =
new OrderItemEntityId(
null,
productId
);
this.quantity = quantity;
this.unitPrice = unitPrice;
}
void attachTo(
OrderEntity order
) {
this.order = order;
}
public Long getProductId() {
return id.getProductId();
}
public int getQuantity() {
return quantity;
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
}
Before the parent Order is persisted:
orderId
may not yet be assigned because PostgreSQL generates it।
The association to the parent supplies that part of the child's derived identity during persistence।
OrderItemEntityId
Still:
@Embeddable
public class OrderItemEntityId
implements Serializable {
@Column(name = "order_id")
private Long orderId;
@Column(name = "product_id")
private Long productId;
protected OrderItemEntityId() {
}
public OrderItemEntityId(
Long orderId,
Long productId
) {
this.orderId = orderId;
this.productId = productId;
}
...
}
The composite key still represents:
OrderId + ProductId
The OrderEntity relationship simply maps the parent-derived portion of that key।
Mapping the Order Side
OrderEntity can now contain:
@OneToMany(
mappedBy = "order",
cascade = CascadeType.PERSIST,
fetch = FetchType.LAZY
)
private List<OrderItemEntity> items =
new ArrayList<>();
Full conceptual portion:
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
@GeneratedValue(
strategy = GenerationType.IDENTITY
)
private Long id;
@Column(
name = "customer_id",
nullable = false
)
private String customerId;
@Enumerated(EnumType.STRING)
@Column(
name = "status",
nullable = false
)
private OrderStatus status;
@Column(
name = "created_at",
nullable = false
)
private Instant createdAt;
@OneToMany(
mappedBy = "order",
cascade = CascadeType.PERSIST,
fetch = FetchType.LAZY
)
private List<OrderItemEntity> items =
new ArrayList<>();
protected OrderEntity() {
}
...
}
mappedBy
This:
mappedBy = "order"
means:
OrderEntity.items
is the inverse side।
The actual relationship mapping is owned by:
OrderItemEntity.order
because that side maps:
order_items.order_id
The mappedBy value refers to the relationship attribute on the owning entity.
Why Does Ownership Matter?
Suppose we only do:
order.getItems()
.add(item);
but never set:
item.order
The owning side may not reflect the relationship correctly।
For bidirectional mappings, application code should keep both Java sides consistent।
A Relationship Helper
A small persistence helper makes this safer:
public void addItem(
OrderItemEntity item
) {
items.add(item);
item.attachTo(this);
}
Now:
orderEntity.addItem(item);
updates both:
OrderEntity.items
OrderItemEntity.order
in memory।
Domain and Persistence Ownership Align
This is a case where relational, persistence, and domain models align nicely:
Domain
Order owns OrderItems
Database
order_items references orders
JPA
OrderEntity has items
OrderItemEntity references OrderEntity
This is a useful association।
Cascade
We've used:
cascade = CascadeType.PERSIST
What does that mean?
Cascade controls which persistence lifecycle operations are propagated from one associated entity to another. Jakarta Persistence defines cascade operations such as PERSIST, MERGE, REMOVE, REFRESH, and DETACH; ALL represents all of them.
For us:
persist new Order
→ persist its new OrderItems
is useful।
So:
PERSIST
has a concrete reason to exist।
Why Not CascadeType.ALL?
This is very common:
cascade = CascadeType.ALL
because it is convenient।
But ALL means much more than:
save my children
It includes other lifecycle operations too.
Our requirements currently need:
create Order with OrderItems
They do not require:
physically remove Order and automatically remove items
or arbitrary detached graph merging।
So we choose the smallest cascade needed:
PERSIST
Don't Treat Cascade as Business Cascade
Important distinction:
JPA cascade
means:
propagate a persistence lifecycle operation.
It does not mean:
when Order is cancelled,
automatically restore Inventory
That is a business workflow।
Cancellation remains:
CancelOrderUseCase
↓
Order.cancel()
↓
Inventory.restore(...)
JPA cascades must never replace application workflows।
CascadeType.REMOVE
Should deleting Order automatically delete OrderItems at JPA level?
Our application does not support physical Order deletion।
So there is no need for:
CascadeType.REMOVE
right now।
Database deletion is also intentionally restrictive।
This keeps persistence aligned with business lifecycle।
orphanRemoval
@OneToMany supports:
orphanRemoval = true
Jakarta Persistence defines orphan removal for privately owned child entities: removing a child from the relationship can cause the child entity to be removed during persistence synchronization.
OrderItem is indeed conceptually owned by Order।
But do we currently support:
remove item from an existing Order
?
No।
After creation, Order items are immutable in v1।
So adding:
orphanRemoval = true
would introduce persistence behaviour we don't need।
We leave it out।
Don't Encode Future Order Editing
We do not have:
add item after Order creation
remove item after Order creation
change OrderItem quantity
Therefore JPA mapping should not accidentally make these operations part of our application model।
Persistence capabilities should follow business capabilities।
Fetching Relationships
Associations introduce another major concern:
When is related data loaded?
Jakarta Persistence defines EAGER and LAZY fetch strategies. ManyToOne defaults to EAGER, while OneToMany defaults to LAZY; for LAZY, the specification describes it as a hint to the persistence provider, whereas EAGER requires eager fetching.
Hibernate supports lazy association fetching and provides multiple fetch strategies.
We Explicitly Request Lazy Loading
For Order items:
@OneToMany(
mappedBy = "order",
fetch = FetchType.LAZY
)
And for item → Order:
@ManyToOne(
fetch = FetchType.LAZY
)
Why explicitly set it on ManyToOne?
Because the JPA default for ManyToOne is:
EAGER
and we do not want loading an OrderItem to automatically imply loading its entire Order when that isn't needed.
Lazy Does Not Mean "Never Load"
It means conceptually:
don't necessarily load this relationship
until it is needed
The real SQL depends on:
query
persistence context
fetch plan
provider
Lazy loading is not a substitute for query design।
An Important Mistake
Suppose:
List<OrderEntity> orders =
repository.findCustomerOrders(...);
for (
OrderEntity order : orders
) {
order.getItems().size();
}
If the initial query loads 20 Orders without items, accessing each lazy collection may result in additional queries per Order depending on the fetch plan։
Conceptually:
1 query
→ Orders
20 more queries
→ each Order's items
This is the classic:
N+1 query problem
Hibernate's documentation emphasizes that fetch strategy must be planned deliberately because association navigation can result in additional SQL.
Lazy Loading Does Not Solve N+1
Lazy loading avoids fetching data you never touch।
That's useful։
But if you later touch the relationship for every row:
lazy
+
loop
can create many queries।
So the correct question is not:
Is this relationship LAZY?
The better question:
What data does this specific use case need, and what query should fetch it efficiently?
EAGER Is Not the Fix Either
Changing every relationship to:
FetchType.EAGER
is usually not a good solution।
Then an operation that only needs:
Order ID
status
createdAt
may unnecessarily retrieve:
OrderItems
or related entities।
Fetching should match the use case rather than be globally maximized।
Order Detail vs Order History
These two endpoints are a perfect example.
Order Detail
GET /api/v1/orders/{orderId}
needs:
Order
OrderItems
because response includes full item information।
Persistence query should intentionally retrieve what is needed।
Order History
GET /api/v1/orders?page=0&size=20
probably needs only summary information:
OrderId
status
createdAt
total/summary data as designed
It should not automatically retrieve every OrderItem object graph merely because Order has items।
This is where projection/query design becomes valuable।
Design Fetching Per Query
For Order detail, persistence may use:
join fetch
entity graph
explicit query
or another appropriate fetch plan।
For Order history:
summary projection
may be better।
Static entity annotations alone cannot express every use case optimally։
Don't Return Lazy Entities Outside Persistence
Suppose Repository returns:
OrderEntity
to Handler।
Transaction ends।
Then Handler calls:
orderEntity.getItems();
Now the persistence context may already be gone।
This creates brittle code and can lead to lazy-loading failures or unexpected transaction dependence।
Our architecture already avoids this:
JpaOrderRepository
↓
maps entities
↓
Order
before returning from persistence boundary։
Keep Lazy Loading Inside Persistence Work
Persistence adapter should load what it needs and construct a complete application/domain result before returning।
Don't make higher layers depend on:
whether Hibernate Session is still open
That is infrastructure leakage।
Open Session in View
Some web applications keep the persistence context available into the web rendering/request layer so lazy relationships can still load later।
That can appear convenient, but it makes SQL execution possible deep inside HTTP serialization/mapping।
For our architecture, we do not rely on that pattern।
Repository/UseCase interactions should make data access deliberate।
Handler Should Not Cause SQL by Accessing an Object
We want:
Handler
→ map already-available application data
not:
Handler
→ call getter
→ Hibernate unexpectedly queries database
This keeps:
Handler
a real transport boundary։
OrderItem to Product
Database:
order_items.product_id
→ products.id
Should we map:
@ManyToOne
private ProductEntity product;
?
For our current use cases:
No.
Why Keep ProductId Scalar?
OrderItem's business history requires:
ProductId
quantity
purchase-time unit price
It does not require current Product state to reconstruct the Order։
We can simply read:
id.getProductId()
and map it to:
ProductId
This Protects Historical Thinking
Suppose Product has since changed:
name changed
price changed
active = false
Historical OrderItem still means:
Product 101
quantity = 2
unitPrice = 100
We should not make loading an Order automatically depend on current:
ProductEntity
state։
It Also Avoids a Common N+1 Source
Imagine loading 20 Orders with 100 OrderItems total।
If each OrderItem has:
@ManyToOne
ProductEntity product;
and mapping accesses:
item.getProduct().getName();
we could create a large number of additional Product loads depending on the fetch plan।
By storing only ProductId in the JPA model we need for this operation, that path doesn't exist accidentally।
But the Database Foreign Key Remains
Even without:
@ManyToOne ProductEntity
PostgreSQL still protects:
order_items.product_id
→ products.id
This is exactly why JPA associations and database relationships should be thought of separately।
Product Browse Still Needs Product + Inventory
How do we implement:
GET /products
if ProductEntity has no relationship to InventoryEntity?
With a query।
For example, persistence can execute a projection/query equivalent to:
SELECT
p.id,
p.name,
p.price
FROM products p
JOIN inventory i
ON i.product_id = p.id
WHERE p.active = TRUE
AND i.available_quantity > 0;
Object navigation is not required to perform relational joins।
This Is an Important ORM Lesson
A good ORM model does not require every useful SQL relationship to exist as:
Java object reference
Queries can join tables directly according to the persistence requirement।
Sometimes that produces a cleaner model।
Order to Customer
Our Order stores:
customerId
There is no local Customer entity։
So:
@ManyToOne
private CustomerEntity customer;
would be wrong।
Persistence remains:
@Column(
name = "customer_id",
nullable = false
)
private String customerId;
System boundaries matter more than ORM convenience।
Bidirectional Relationships Are Not Automatically Better
If both directions are mapped:
A → B
B → A
we need to maintain both sides in memory correctly।
It also creates more navigation possibilities।
Use bidirectional relationships where both sides provide real persistence value։
For Order ↔ OrderItems they do:
Order
→ items needed for aggregate reconstruction
OrderItem
→ parent owns FK/derived ID
So bidirectional mapping is justified।
For Product ↔ Inventory:
not currently justified
Avoid @ManyToMany
Our current domain does not contain a true many-to-many entity relationship requiring:
@ManyToMany
Product appears in many Orders, and Orders contain many Products, but:
OrderItem
is not merely a hidden join table।
It contains real business data:
quantity
unitPrice
So Order ↔ Product should not be modeled as:
@ManyToMany
OrderItem Is a Real Association Entity
Relationally:
orders
↓
order_items
↑
products
order_items carries:
quantity
historical unit price
That means OrderItem has business meaning।
It is not an invisible ORM join table।
This is a very important modeling distinction।
Why @ManyToMany Would Lose Meaning
A simple many-to-many mapping thinks in terms of:
Order ↔ Product
But where would we put:
quantity = 5
and:
unitPrice = 100
?
We would immediately need to introduce an association entity anyway।
So model OrderItem explicitly from the beginning।
Cascade from Order to Product Would Be Wrong
Suppose OrderItem had a Product association։
Never do:
@ManyToOne(
cascade = CascadeType.ALL
)
private ProductEntity product;
Then persistence operations on OrderItem could propagate into Product lifecycle।
But Product is not owned by Order։
Existing Product has its own lifecycle।
Order references Product identity; it does not create/delete Product as a child।
Cascade Follows Ownership
Useful mental model:
Order
→ owns new OrderItems
→ PERSIST cascade makes sense
OrderItem
→ references Product
→ Product is independent
→ cascade does not make sense
Inventory
→ references Product
→ Product is independent
→ cascade does not make sense
Cascade should reflect lifecycle ownership, not just relationship existence।
optional = false
For:
@ManyToOne(
optional = false
)
we communicate that an OrderItem cannot exist without its parent Order।
Jakarta Persistence notes that optional=false represents a required association and may also contribute to schema-generation metadata.
But again:
database NOT NULL + foreign key
from Flyway are the actual persisted constraints in our architecture।
Don't Let JPA Generate the Constraint
We still keep:
order_id BIGINT NOT NULL
and:
FOREIGN KEY
in Flyway migration।
JPA mapping communicates the same relationship to Hibernate।
It does not replace our migration।
Collection Type
Our OrderEntity uses:
List<OrderItemEntity>
because it is convenient for mapping into domain Order items।
But our relational schema does not currently store:
item position
There is no:
order_index
column։
So we should not assume PostgreSQL preserves insertion order of OrderItems।
Don't Add @OrderColumn Without a Requirement
JPA can persist list position with additional mapping metadata।
But our business rules don't say:
OrderItem #1 must always appear before OrderItem #2
The item collection is logically a set of unique Product lines for the Order।
If a stable API display order becomes required later, define it deliberately in the query/response contract।
Don't add persistence ordering by accident।
Could We Use Set?
Because:
one Product per Order
a Set may seem natural।
But entity equality becomes more complicated with generated/derived persistence identity।
A List plus database composite-key uniqueness is simple enough for our persistence model।
The collection type doesn't need to encode every relational constraint by itself।
Relationship Mapping and Domain Mapping Are Separate
Persistence:
OrderEntity.items
→ mutable List<OrderItemEntity>
Domain:
Order.items
→ immutable/exposed safely
The persistence adapter can map between them।
We don't need to copy Hibernate's mutable collection semantics into the domain।
Loading an Order
A good Order repository flow conceptually:
query Order + required OrderItems
↓
OrderEntity
+
OrderItemEntity
↓
map
↓
Order
Then Repository returns:
Order
to the UseCase।
Loading Order History
Different flow:
query bounded Order summaries
↓
projection
↓
application result
No need to reconstruct 20 complete Order aggregates with all their item collections if the endpoint doesn't need them।
Aggregate Loading Should Match Behaviour
If UseCase needs:
order.cancel()
it may need complete data relevant to cancellation।
If it only needs:
status
ownership
item quantities needed for inventory restoration
the repository must provide those reliably।
Repository contract, not accidental lazy access, should determine that।
Avoid Repository Returning Half-Loaded Domain Objects
A domain Order should not behave like:
items exist
but maybe unavailable depending on Hibernate session
Domain objects should be normal Java objects once reconstructed।
Lazy persistence semantics stop at the persistence boundary।
No Hibernate Proxies in Domain
Avoid domain fields such as:
PersistentBag
HibernateProxy
or other ORM-specific collection/object types।
Mapping should produce normal:
List<OrderItem>
ProductId
CustomerId
values।
Relationships and Transactions
Lazy relationships are generally tied to the persistence context that manages the entity।
So relationship traversal and transaction/persistence boundaries are connected։
This is another reason our UseCase transaction should encompass required persistence work rather than relying on later HTTP-layer navigation։
Relationship Changes
Do we allow:
orderEntity.getItems()
.remove(item);
?
JPA technically lets Java code mutate collections։
But our business model says:
Order items do not change after creation
So application persistence code should not expose arbitrary item mutation simply because the entity collection is mutable।
Framework capability does not create business capability।
Keep Persistence Collection Encapsulated
Instead of:
public List<OrderItemEntity> getItems() {
return items;
}
returning a freely mutable collection to arbitrary callers, persistence code can keep access restricted or return an unmodifiable view where practical।
However, don't fight Hibernate with overly clever entity design।
The main protection is that persistence entities remain inside:
persistence package
and UseCases never manipulate them directly।
Relationship Code Belongs in Persistence
Domain:
Order
does not know:
@OneToMany
mappedBy
@JoinColumn
@MapsId
Persistence:
OrderEntity
does։
That keeps the domain model understandable without knowing Hibernate।
Query Performance Comes Before Graph Convenience
Suppose mapping every relationship makes this code easy:
order.getItems()
.get(0)
.getProduct()
.getInventory();
But if it runs:
four database queries
or loads data the operation doesn't need, that convenience is not free।
Backend engineering values:
predictability
bounded I/O
clear query behaviour
more than unlimited object navigation।
Mapping Decisions So Far
Let's summarize.
Product → Inventory
Database:
1 : 0..1
JPA:
no association
Reason:
separate capabilities
no navigation requirement
queries can join when needed
Inventory → Product
Database:
foreign key
JPA:
Long productId
Reason:
ProductId is Inventory identity
No ProductEntity graph needed।
Order → OrderItems
Database:
1 : many
JPA:
@OneToMany
Reason:
Order aggregate persistence
Order reconstruction
parent owns item lifecycle conceptually
OrderItem → Order
Database:
order_items.order_id
JPA:
@ManyToOne
@MapsId("orderId")
Reason:
child owns the FK mapping
OrderId is part of composite key
OrderItem → Product
Database:
foreign key
JPA:
scalar productId
Reason:
historical Order reconstruction needs identity,
not current Product object
Order → Customer
Database:
customer_id TEXT
JPA:
String customerId
Reason:
Customer identity is external
no local Customer entity
Common Mistake 1 — Every Foreign Key Gets @ManyToOne
Foreign keys can remain scalar identifiers when object navigation provides no value।
Common Mistake 2 — Every Relationship Is Bidirectional
Bidirectional mapping adds state synchronization and navigation complexity।
Use it only when both directions have real persistence value।
Common Mistake 3 — CascadeType.ALL Everywhere
Cascade should reflect lifecycle ownership and required persistence operations, not convenience।
Common Mistake 4 — Cascade from OrderItem to Product
Order does not own Product lifecycle।
Common Mistake 5 — orphanRemoval = true by Habit
Our v1 Order does not support removing items after creation।
Common Mistake 6 — Everything Is EAGER
This can load substantial related data even when a use case doesn't need it।
Common Mistake 7 — Everything Is LAZY, Then Every Getter Is Called in a Loop
That can create N+1 query behaviour।
Fetch strategy must match each query/use case।
Common Mistake 8 — Lazy Entities Escape Repository Boundary
Higher layers should not depend on an open persistence context।
Common Mistake 9 — Order ↔ Product Modeled as @ManyToMany
OrderItem contains real business state and must remain an explicit entity/model।
Common Mistake 10 — JPA Object Graph Replaces Application Architecture
UseCases still coordinate ProductRepository, InventoryRepository, and OrderRepository according to application responsibilities।
Relationship Review Checklist
Before adding a JPA association, ask:
Does the database have a relationship?
Does Java persistence actually need navigation?
Who owns the foreign key?
Who owns the child lifecycle?
Is the relationship unidirectional or bidirectional
for a real reason?
Which cascade operations are actually required?
Could cascade delete historical data?
What is the fetch strategy?
What query needs this relationship?
Could this create N+1 queries?
Will lazy entities escape the persistence boundary?
Would a scalar ID be simpler?
Would an explicit query/projection be better?
If the only answer is:
"There is a foreign key"
then a JPA association may not be necessary।
Updated Persistence Shape
Our persistence graph stays intentionally small:
ProductEntity
InventoryEntity
productId
OrderEntity
│
│ @OneToMany
↓
OrderItemEntity
│
│ scalar
↓
ProductId
Customer remains:
String customerId
No CustomerEntity।
This is enough to support our current system।
Engineering Principle
The core principle:
A foreign key describes a relational relationship. A JPA association introduces Java navigation, lifecycle, and fetch semantics. Add the second only when those semantics are useful.
Another:
Cascade should follow ownership, and fetching should follow use cases—not annotation convenience.
And:
The best Hibernate object graph is often smaller than the database relationship graph. Explicit queries are frequently clearer than making every table reachable from every entity.
Summary
In this lesson, we learned that:
- Database foreign keys and JPA associations are related but distinct concepts.
- A foreign key does not require a Java object association.
- JPA relationships may be unidirectional or bidirectional.
- In a bidirectional one-to-many/many-to-one mapping, the many side owns the relationship and the inverse collection uses
mappedBy. - Product and Inventory remain connected through their PostgreSQL foreign key but do not need a JPA
@OneToOneassociation. - Inventory continues to store ProductId as its primary key.
@MapsIdcould model Product/Inventory shared identity, but we do not need that additional association.- Order and OrderItems justify a JPA relationship because OrderItems belong to the Order aggregate and are needed for Order reconstruction.
OrderItemEntity.orderis the owning@ManyToOneside.OrderEntity.itemsis the inverse@OneToMany(mappedBy = "order")side.@MapsId("orderId")lets the Order relationship supply the OrderId portion of the OrderItem composite key.- Both sides of a bidirectional Java relationship should be kept consistent in memory.
CascadeType.PERSISTis enough for our current create-Order-with-items requirement.- We do not use
CascadeType.ALL,REMOVE, ororphanRemovalwithout a concrete lifecycle requirement. - JPA cascade is persistence lifecycle propagation, not business workflow propagation.
ManyToOnedefaults to eager fetching andOneToManydefaults to lazy fetching in Jakarta Persistence, so fetch behaviour must be understood rather than assumed.- We explicitly request lazy loading where appropriate but still design query-specific fetch plans.
- Lazy relationships can create N+1 query patterns when repeatedly accessed after loading a collection of parent entities.
- Making everything eager is not a good substitute for query design.
- Order detail and Order history have different data-fetch needs.
- Persistence should load the data required by a use case before returning domain/application objects.
- JPA entities and lazy proxies should not escape into Handlers or the public API.
- OrderItem keeps ProductId as a scalar value rather than a
ProductEntityassociation. - The Product foreign key remains enforced by PostgreSQL even without
@ManyToOne. - OrderItem is a real association entity with quantity and purchase-time price, so Order ↔ Product must not be modeled as
@ManyToMany. - Customer remains an external identifier and therefore has no JPA Customer relationship.
- Our persistence graph intentionally remains smaller than our relational relationship graph.
Next lesson:
Repositories
There we will implement the persistence boundary around these entities—application Repository interfaces, Spring Data repositories, JPA adapters, save/load mapping, generated IDs, bounded queries, and why repository APIs should describe application needs instead of exposing generic CRUD everywhere.