Persistence with PostgreSQL

Relationships

ReadingPreview

আপনি একটি 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 @OneToOne association.
  • Inventory continues to store ProductId as its primary key.
  • @MapsId could 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.order is the owning @ManyToOne side.
  • OrderEntity.items is 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.PERSIST is enough for our current create-Order-with-items requirement.
  • We do not use CascadeType.ALL, REMOVE, or orphanRemoval without a concrete lifecycle requirement.
  • JPA cascade is persistence lifecycle propagation, not business workflow propagation.
  • ManyToOne defaults to eager fetching and OneToMany defaults 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 ProductEntity association.
  • 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.