Persistence with PostgreSQL

Avoiding Common JPA Problems

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

JPA এবং Hibernate আমাদের অনেক repetitive persistence code থেকে মুক্ত করে।

আমরা manually লিখছি না:

open JDBC connection

prepare SQL

execute query

read ResultSet

map every column

track modified rows

কিন্তু convenience-এর একটি cost আছে।

ORM অনেক database interaction-কে Java object operations-এর মতো দেখায়।

এই code:

order.getItems();

দেখতে simple object access।

কিন্তু persistence context-এর উপর নির্ভর করে এটি potentially:

database query

trigger করতে পারে।

এই code:

orderEntity.setStatus(
        OrderStatus.PAID
);

দেখতে ordinary field mutation।

কিন্তু managed entity হলে transaction শেষ হওয়ার সময় Hibernate potentially:

UPDATE orders ...

execute করতে পারে।

JPA ব্যবহার করতে হলে তাই শুধু annotations জানা যথেষ্ট নয়।

একজন backend engineer-এর বুঝতে হবে:

কোথায় SQL execute হতে পারে

কতগুলো query execute হচ্ছে

কোন entities managed

কোন relationships load হচ্ছে

transaction কোথায় শুরু/শেষ

কোন data bounded

কোন behaviour ORM-এর convenience থেকে আসছে

এই lesson-এর goal:

JPA/Hibernate-এর common production pitfalls চিনতে শেখা এবং এমন persistence code লেখা যার database behaviour predictable, bounded, এবং application architecture-এর সঙ্গে consistent।


Problem 1 — N+1 Queries

এটি ORM-এর সবচেয়ে পরিচিত performance problem-গুলোর একটি।

Suppose Order history query returns:

20 Orders

Initial query:

SELECT ...
FROM orders
WHERE customer_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 20;

So far:

1 query

Then code does:

for (
        OrderEntity order : orders
) {
    order.getItems().size();
}

If each items collection is lazy and Hibernate loads each one separately:

1 query
→ Orders

20 queries
→ OrderItems

Total:

21 queries

This pattern is called:

N+1

because:

1 parent query
+
N child queries

Why N+1 Is Dangerous

Local development may contain:

3 Orders

So:

1 + 3 = 4 queries

feels fine।

Production may return:

100 rows

Now:

101 queries

per request।

At concurrent load, that can create:

database connection pressure

higher latency

more network round trips

higher database CPU

lower throughput

The problem is not necessarily that any individual SQL query is slow।

The problem is:

too many database round trips

Fix the Query, Not the Symptom

A bad response to N+1:

make every relationship EAGER

That usually creates a different problem।

Better question:

What does this UseCase actually need?

For:

GET /api/v1/orders

we decided Order history should use:

OrderSummary projection

rather than loading full Orders + OrderItems।

That avoids the relationship entirely।

For:

GET /api/v1/orders/{orderId}

we need:

Order + OrderItems

so Repository should intentionally fetch both using an appropriate query strategy।


Fetch Per UseCase

Think:

Order history
→ summary query
Order detail
→ Order + items
Cancel Order
→ Order + information required to restore Inventory

rather than:

One universal entity fetch strategy for every operation

This is a recurring JPA principle:

Query design should follow application use cases, not entity graph convenience.


Problem 2 — Making Everything EAGER

Suppose someone sees lazy-loading problems and changes:

@OneToMany(
        fetch = FetchType.EAGER
)
private List<OrderItemEntity> items;

Now every time an Order is loaded, Hibernate may need to load its items even when the operation needs only:

Order ID

status

createdAt

Examples:

Order history

admin list

status lookup

may now retrieve unnecessary data։


EAGER Moves Cost Somewhere Else

Lazy loading can cause:

unexpected later queries

Eager loading can cause:

unnecessary immediate queries/data

Neither keyword automatically gives good performance।

The real solution:

intentional query shape

Keep Default Entity Graph Small

Our persistence design already helps।

We deliberately avoided:

ProductEntity
↔ InventoryEntity

OrderItemEntity
→ ProductEntity

OrderEntity
→ CustomerEntity

associations।

Only:

OrderEntity
↔ OrderItemEntity

has a meaningful JPA relationship।

This prevents Hibernate from constructing a giant graph that nobody actually needs।


Problem 3 — Lazy Loading Outside the Persistence Boundary

Suppose Repository returns:

OrderEntity

to UseCase।

UseCase returns it to Handler।

Transaction ends।

Handler does:

order.getItems();

Now items may not have been loaded while the entity was managed।

Higher layer has accidentally become dependent on:

Hibernate persistence context

This creates fragile architecture।


Better Boundary

Repository should return:

Order

or:

OrderSummary

after required data has been loaded and mapped।

Flow:

JPA Entity
    ↓
persistence adapter
    ↓
normal Java domain/application object
    ↓
UseCase

The returned domain object should not secretly require:

open Hibernate Session

to function।


Domain Objects Should Be Fully Usable

Bad domain experience:

Order exists

but order.items()
works only inside a transaction

Good:

Order is a normal Java object

its required state is already available

Persistence mechanics stop at Repository boundary।


Problem 4 — Relying on Open Session in View

A common web application approach keeps persistence context available through much of the HTTP request।

Then Handler/serializer can access lazy relationships after UseCase execution।

This seems convenient:

lazy loading still works

but database I/O can now happen in places such as:

response mapping

JSON serialization

template rendering

That makes query behaviour difficult to reason about।


Our Direction

We do not want:

JSON serializer
→ accidentally runs SQL

We want:

Repository
→ performs intentional persistence work

UseCase
→ receives application data

Handler
→ maps data to HTTP

Database interaction remains explicit।


Problem 5 — Unbounded findAll()

Spring Data makes this easy:

repository.findAll();

For a small test database:

20 rows

it seems harmless।

In production:

3,000,000 rows

it can become catastrophic।

Problems:

huge database result

huge Java allocation

entity materialization cost

GC pressure

long transaction

connection occupation

Collection Endpoints Must Be Bounded

Our API already requires:

page

size

with:

size <= 100

So:

GET /products

GET /orders

must use database-level pagination।

Never let:

JpaRepository.findAll()

override an explicit API requirement just because the method exists।


Framework API Is Larger Than Business API

JpaRepository may expose:

findAll

deleteAll

saveAll

flush

but our UseCases should only see operations that make sense through:

ProductRepository

InventoryRepository

OrderRepository

This is another practical benefit of keeping an application Repository boundary।


Problem 6 — Filtering in Java

Bad:

List<ProductEntity> products =
        repository.findAll();

return products.stream()
        .filter(ProductEntity::isActive)
        .filter(this::hasInventory)
        .toList();

Problems:

loads inactive Products

loads out-of-stock Products

pagination becomes wrong

additional Inventory queries may happen

database cannot optimize the intended query

Push Filtering to PostgreSQL

Customer Product browsing should become a database query logically equivalent to:

SELECT ...
FROM products p
JOIN inventory i
    ON i.product_id = p.id
WHERE p.active = TRUE
  AND i.available_quantity > 0
ORDER BY ...
LIMIT ?
OFFSET ?;

Use PostgreSQL for:

filtering

joining

sorting

pagination

where those operations belong।


Problem 7 — Authorization Filtering After Loading

Bad:

load Order by ID

return it

later check if Customer owns it

Even if eventually rejected, inaccessible data has travelled farther into application code than necessary।

For some read operations, Repository can query:

id = ?

AND

customer_id = ?

directly।


Customer History Is Even More Important

Never:

load all Orders

then filter current Customer

Correct:

WHERE customer_id = authenticatedCustomerId

before:

ORDER BY

LIMIT

OFFSET

This improves:

security boundaries

correct pagination

query efficiency

Problem 8 — Accidental Dirty Checking

Suppose inside a transaction:

ProductEntity entity =
        repository.findById(id)
                .orElseThrow();

entity.setPrice(
        new BigDecimal("120.00")
);

The entity is managed।

Even without calling:

repository.save(entity);

Hibernate may flush the changed state to PostgreSQL।

This is called:

dirty checking

It is useful, but engineers need to understand it।


Why Dirty Checking Can Surprise You

Imagine a read-looking method:

ProductEntity entity =
        repository.findById(id)
                .orElseThrow();

normalize(entity);

return toResponse(entity);

And normalize() accidentally does:

entity.setName(
        entity.getName().trim()
);

If entity is managed inside a transaction, this "read" path may write to the database।

That is dangerous।


Read Code Should Not Mutate Managed Entities Casually

A persistence entity is not always a harmless DTO।

When managed:

field mutation

can become:

database mutation

later।

Keep mapping/read transformations pure।

Do not use managed entities as temporary scratch objects।


Dirty Checking Is Not Bad

For update workflows it can simplify persistence։

Example:

load managed ProductEntity

apply mapped domain state

commit transaction

Hibernate generates UPDATE

This can be perfectly reasonable।

The rule is:

Understand when you're relying on it.

Not:

Dirty checking must never be used.


Problem 9 — Calling save() Everywhere

Another common reaction is:

always call save after every setter

even when entity is already managed।

Example:

ProductEntity entity =
        repository.findById(id)
                .orElseThrow();

entity.setPrice(price);

repository.save(entity);

This may be harmless, but it can hide whether the engineer understands:

managed state

merge semantics

transaction lifecycle

Use save() Deliberately

Typical scenarios:

new entity
→ persistence operation clearly needed
detached entity
→ merge semantics may matter
managed entity inside transaction
→ dirty checking may be sufficient

Application Repository can still expose:

save(Product)

while JPA adapter chooses the correct implementation strategy।

The UseCase should not reason about entity state։


Problem 10 — Blindly Merging Detached Graphs

Suppose Repository mapping produces an entire:

OrderEntity

OrderItemEntity list

from a detached domain Order every time save(order) is called।

Then calls:

jpaRepository.save(entity);

For an existing aggregate, JPA may enter merge-style semantics।

Large detached graphs can become hard to reason about:

which children already exist?

which were removed?

which are new?

what gets copied to managed instances?

what cascades?

Our Order model is simpler because:

OrderItems are immutable after creation

but the general danger remains।


Prefer Explicit Aggregate Persistence Behaviour

For existing Order lifecycle changes:

UNPAID → PAID

UNPAID → CANCELLED

we don't need to rebuild arbitrary item graphs।

Repository implementation can:

load existing managed OrderEntity

apply changed status

while preserving historical OrderItems।

This may be clearer than merging a fully reconstructed detached graph।


Repository Contract Can Stay Simple

Application:

orderRepository.save(order);

Persistence implementation:

new Order
→ create persistent graph

existing Order
→ update existing persistence state deliberately

The complexity stays behind Repository।


Problem 11 — CascadeType.ALL Everywhere

We discussed relationships already, but this mistake is common enough to repeat।

Example:

@ManyToOne(
        cascade = CascadeType.ALL
)
private ProductEntity product;

on OrderItem would be dangerous।

Now OrderItem persistence could potentially propagate lifecycle operations to Product։

But Order does not own Product।


Cascade Must Follow Lifecycle Ownership

Our valid case:

Order
→ new OrderItems

So:

CascadeType.PERSIST

has a reason।

Invalid assumption:

OrderItem references Product
→ therefore cascade Product

No।

Reference does not mean ownership।


Problem 12 — orphanRemoval Without a Business Delete

Our v1 OrderItems do not support:

remove item after Order creation

So:

orphanRemoval = true

has no application need।

If code accidentally removes an item from a managed collection, orphan removal could turn an in-memory collection mutation into a physical delete।

Don't enable lifecycle behaviour you do not need।


Problem 13 — Wrong Entity Equality

Generated JPA entities can be tricky with:

equals()

hashCode()

Suppose ProductEntity equality is generated from every field:

id

name

price

active

Then changing:

price

can change:

hashCode

while object is inside a HashSet

That can create broken collection behaviour।


Generated ID Equality Is Also Nuanced

Suppose equality only compares:

id

Two new ProductEntities before persistence both have:

id = null

Should they be considered equal?

Clearly:

No.

This is why blindly generating entity equality is dangerous।


Our Practical Direction

For persistence entities:

do not automatically generate equals/hashCode

without an actual need।

For:

OrderItemEntityId

we do implement value-based equality because it is explicitly a composite identifier value:

orderId + productId

That case is clear।


Domain Equality Is a Separate Question

Domain Value Objects such as:

ProductId

OrderId

CustomerId

naturally use value equality।

Domain Entity equality can follow domain identity semantics।

Do not let Hibernate entity equality rules define domain semantics accidentally।


Problem 14 — Lombok @Data on Entities

We are not using Lombok, but this is worth understanding।

A broad generated annotation may create:

setters for everything

equals/hashCode from fields

toString across relationships

This can be problematic for JPA entities।

Especially once relationships exist, generated toString() or equality may:

walk object graphs

touch lazy relationships

cause recursion

trigger SQL unexpectedly

Explicit entity code is often safer।


Problem 15 — Logging JPA Entities

Imagine:

log.info(
        "Loaded order: {}",
        orderEntity
);

If toString() includes:

items

logging could potentially traverse a lazy relationship।

Now:

logging
→ database query

That is extremely surprising।


Log Stable Identifiers and Useful Fields

Prefer:

log.info(
        "Loaded orderId={}",
        orderId
);

instead of dumping entire persistence graphs।

This is also better for:

privacy

log volume

operational readability

Structured logging comes later, but the persistence principle matters now।


Problem 16 — Serializing Entities Directly

Returning:

OrderEntity

from a Controller can create multiple problems:

lazy relationships

recursive parent/child references

database fields leaking into API

internal fields exposed

serialization triggering queries

Our explicit DTO design prevents this։


Bidirectional Relationship Recursion

Consider:

OrderEntity
→ items

and each:

OrderItemEntity
→ order

A naive serializer may conceptually traverse:

Order
→ Item
→ Order
→ Item
→ Order
...

This is not an API design problem we should solve with random serializer annotations।

Better:

don't serialize persistence entities

Use:

OrderResponse

Problem 17 — Assuming Object Navigation Is Free

Code:

orderEntity.getItems()

looks like:

memory access

but may represent:

I/O

Similarly:

item.getProduct()

could become another database query if we had mapped that association।

This hidden I/O is one of the biggest conceptual differences between:

normal Java objects

and:

ORM-managed entities

A Useful Question

Whenever reading JPA code, ask:

Could this line trigger SQL?

Especially around:

relationship getters

repository methods

flush

transaction commit

This habit makes ORM behaviour far less mysterious।


Problem 18 — Ignoring Generated SQL

Some developers treat Hibernate SQL as:

framework internals

and never inspect it।

That's risky।

A repository method that looks elegant could generate:

unexpected joins

multiple selects

unbounded query

wrong pagination

N+1

Backend engineers should understand the SQL shape of important operations।


What to Inspect

For key workflows, be able to answer:

How many queries?

Which tables?

Which joins?

Where clause correct?

Customer scope applied?

Pagination in DB?

Ordering deterministic?

Unexpected relationship query?

You don't need to memorize every generated statement।

You need to understand its behaviour।


Problem 19 — Solving Every Query Through Entity Navigation

Suppose customer Product browse requires:

Product active

Inventory > 0

One solution is to create:

ProductEntity.inventory

association solely so code can navigate:

product.getInventory()

But we don't need that।

A relational query/projection can join:

products

inventory

directly।

ORM does not mean all SQL relationships must be object navigation relationships।


Query-Centric Reads Are Often Better

For list/read endpoints:

ProductSummary

OrderSummary

can come directly from focused queries।

For mutation workflows:

Order

Inventory

domain reconstruction makes more sense।

Use persistence tools according to the operation।


Problem 20 — Treating JPA Entities as Records

JPA Entity is not just:

row DTO

It participates in:

identity

persistence context

lifecycle

dirty checking

relationships

This means copying entities around, mutating them, storing them in caches, or passing them across layers deserves more caution than plain DTOs।


Keep Entity Lifetime Short

A useful default:

load entity inside persistence/transaction work

use it for required persistence mapping

return normal domain/application data

Do not store managed JPA entities in:

singleton Bean fields

static collections

HTTP session

long-lived application state

Never Store Request State in Repository Beans

Spring repository/adapters are normally singleton Beans।

Avoid:

private OrderEntity lastOrder;

or:

private CustomerId currentCustomer;

inside them।

Repositories should be stateless infrastructure components।

Request-specific state belongs in method parameters/local variables।


Problem 21 — Wrong Transaction Boundary

Suppose Create Order does:

decrease Inventory

in one transaction।

Then separately:

save Order

in another transaction।

If second step fails:

Inventory decreased

Order missing

Database mappings may all be technically correct।

Business state is still broken।


JPA Does Not Choose Business Transaction Boundaries

Transaction boundary belongs to the application operation।

For:

CreateOrderUseCase

we want:

Inventory decrease

Order insert

OrderItems insert

inside one local PostgreSQL transaction।

For:

CancelOrderUseCase

we want:

Order → CANCELLED

Inventory restore

inside one local transaction।

We cover implementation in Module 7।


Problem 22 — Long Transactions

The opposite mistake is holding a database transaction while doing unrelated slow work।

Example:

start DB transaction
    ↓
load Order
    ↓
call external Payment Provider
    ↓
wait several seconds
    ↓
update Order
    ↓
commit

This can keep database resources/locks active while waiting on a remote system।

Our Payment workflow is cross-system and cannot be made atomic through one PostgreSQL transaction anyway։


Keep Transactions Around Local Database Work

External Service calls require separate failure/idempotency design।

Do not assume:

@Transactional

can make:

PostgreSQL
+
remote Payment Provider

one atomic system।

It cannot।


Problem 23 — Repository Method Hides Too Much Business Workflow

Repository may contain:

createOrderAndChargeCustomer(...)

That mixes:

database persistence

PaymentService orchestration

business workflow

Wrong boundary।

Correct:

CreateOrderUseCase
→ Repositories

and later:

PayOrderUseCase
→ OrderRepository
→ PaymentService

Repository handles persistence, not application orchestration।


Problem 24 — Raw SQL/EntityManager Inside UseCase

If JPA becomes inconvenient, don't bypass Repository with:

entityManager.createQuery(...)

inside:

CreateOrderUseCase

That mixes persistence mechanics into application workflow।

Instead:

evolve Repository operation

or:

implement specialized query in persistence adapter

The boundary remains useful precisely when persistence becomes non-trivial।


Problem 25 — Treating Every Exception as "Not Found"

Bad:

try {
    return repository.findById(id);
} catch (Exception exception) {
    return Optional.empty();
}

Now:

database unavailable

SQL bug

mapping bug

all become:

resource missing

That destroys correctness and observability।


Preserve Failure Meaning

Expected:

no matching row
→ Optional.empty()

Unexpected:

database connection failure
→ infrastructure failure

These should remain different।

Later HTTP mapping can produce:

404 PRODUCT_NOT_FOUND

for expected absence and:

500 INTERNAL_ERROR

for unexpected system failure։


Problem 26 — Constraint Violations as Primary Business Logic

Database constraints are valuable, but don't make every normal invalid operation reach PostgreSQL just to discover the rule।

Example:

quantity = -1

should already fail at:

request validation

or domain boundary।

The database:

CHECK quantity > 0

is final integrity reinforcement।


When DB Outcome May Be Authoritative

Concurrency is different।

Two transactions may simultaneously try to consume Inventory।

The database may need to decide which one succeeds through:

locking

conditional update

version conflict

Then persistence result is part of correctness, not merely validation backup।

This is why database constraints and application validation have different roles।


Problem 27 — Assuming save() Solves Concurrency

Naive Create Order:

load Inventory quantity = 1

check quantity >= 1

inventory.decrease(1)

save Inventory

Two concurrent requests can both load:

1

before either commits।

Both domain objects may locally pass the invariant।

Generic JPA save() does not automatically prevent this business race।


Concurrency Needs Explicit Persistence Design

We previously deferred options such as:

row locking

conditional UPDATE

optimistic locking

This decision belongs when implementing the workflow।

The lesson:

ORM persistence does not eliminate concurrency problems.


Problem 28 — Assuming DB Constraint Alone Solves Overselling

We have:

CHECK (
    available_quantity >= 0
)

This prevents negative persisted quantity।

Useful।

But if concurrency implementation overwrites values incorrectly, possible anomalies still need consideration։

For example, two requests might each calculate:

new quantity = 0

and both persist 0 without ever going negative।

The constraint passes։

But two Orders may have consumed the same unit।

So:

non-negative constraint

is necessary but not sufficient for inventory correctness।


Problem 29 — Ignoring Persistence Context Scope in Tests

A JPA integration test can accidentally pass because:

entity remains managed

and the test never truly proves data can be reloaded from PostgreSQL։

For example:

repository.save(entity);

ProductEntity found =
        repository.findById(entity.getId())
                .orElseThrow();

within the same persistence context may reuse managed state।


Stronger Persistence Test

For mapping confidence, sometimes:

save

flush

clear persistence context

reload

is useful।

Conceptually:

entityManager.flush();
entityManager.clear();

Then reload from Repository।

Now the test better proves:

state survived in database

mapping reconstructs it

rather than simply reading the same managed object instance।


Don't Overuse flush() in Application Code

flush() can be useful in tests or specific persistence scenarios।

But calling:

repository.flush();

after every write because "otherwise it isn't saved" misunderstands JPA transaction semantics।

Normally transaction commit/synchronization handles flushing।

Use explicit flush only when there is a clear reason such as needing a constraint failure at a specific point or testing persistence behaviour।


Problem 30 — H2 Gives False Confidence

If PostgreSQL is the production datastore, important persistence behaviour should be tested against PostgreSQL.

Another database may differ in:

SQL syntax

data types

constraint behaviour

locking

query planning

transaction semantics

Our direction remains:

Testcontainers
→ PostgreSQL

for persistence integration tests।


Domain Tests Still Stay Fast

This does not mean every test starts PostgreSQL।

Plain Java:

Order.cancel()

Order.markPaid()

Inventory.decrease()

Product.changePrice()

should remain fast domain tests।

Persistence integration is used where persistence matters।


A Healthy Test Pyramid for JPA

Conceptually:

many domain/unit tests
    ↓
focused UseCase tests
    ↓
Repository/JPA integration tests
    ↓
selected API workflow integration tests

Not:

every method
→ start full Spring + PostgreSQL

and not:

everything mocked
→ never test actual JPA mapping

Balance matters।


Problem 31 — JPA Schema Generation Drifts From Flyway

Entity developer changes:

@Column(...)

and enables:

ddl-auto=update

locally।

Their database changes automatically।

But no Flyway migration is added।

CI/production using migration history does not receive the change।

Now code and schema diverge।


One Schema Owner

Our rule:

Flyway
→ schema evolution

Hibernate
→ mapping + validation

So every real schema change needs:

new migration

No invisible production DDL from entity annotations।


Problem 32 — Entity Mapping Adds Constraints Not in Migration

Suppose database intentionally uses:

NUMERIC

but entity suddenly changes to:

@Column(
        precision = 10,
        scale = 2
)

Now code implies a different persisted contract than Flyway।

Avoid accidental mapping restrictions।

Schema and entity mapping must describe the same persistence model।


Problem 33 — Database Column Changes Without Entity Review

The opposite also happens।

Migration changes:

orders.status

representation।

But entity still expects old:

OrderStatus

mapping।

Hibernate validation/startup or runtime may fail।

Schema change and JPA mapping should normally be reviewed together in one engineering change।


Problem 34 — Generic BaseEntity Forces Wrong Model

We already avoided:

abstract class BaseEntity {
    Long id;
    Instant createdAt;
    Instant updatedAt;
}

because our model has different identities:

Product
→ generated ID

Inventory
→ ProductId

Order
→ generated ID

OrderItem
→ composite key

Generic ORM abstractions often create incorrect schema/domain assumptions just to reduce a little duplication।


Problem 35 — One Repository Per Table

Database has:

orders

order_items

but domain has:

Order aggregate

Creating:

OrderRepository

OrderItemRepository

at application level would allow UseCases to manipulate OrderItems independently։

That contradicts our domain ownership।

JPA table structure does not dictate application repository boundaries।


Problem 36 — Relationship Collections Mutated Arbitrarily

Persistence entity has:

List<OrderItemEntity> items;

A developer sees a List and does:

items.clear();

Framework technically allows the mutation।

Business model does not।

OrderItems are immutable after Order creation in v1।

Keeping persistence entities inside persistence package reduces the risk that UseCases treat ORM collections as business APIs।


Framework Capability Is Not Business Capability

This principle applies repeatedly:

JpaRepository has delete()
≠ Product may be deleted
List supports remove()
≠ Order item may be removed
Entity setter exists
≠ domain state may be arbitrarily assigned
Hibernate can cascade
≠ business workflow should cascade

Framework mechanisms implement business design; they do not define it।


Problem 37 — Query Method Names Become Unreadable

Spring Data derived queries are convenient:

findByCustomerId(...)

Good।

But this:

findByCustomerIdAndStatusAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc(...)

can become harder to understand than an explicit query।

Use:

derived method

when simple।

Use:

JPQL / projection / custom persistence code

when it communicates intent better।


Problem 38 — Read Models Become Managed Entities Without Need

Order history only needs summary data։

Returning full:

OrderEntity

creates:

entity lifecycle tracking

relationship risk

more columns

more mapping

than needed।

Projection:

OrderSummaryRow

is often a better read model।

Use rich entity persistence where business mutation/reconstruction needs it।

Use focused queries where reads need less।


Problem 39 — Pagination Over a Collection Graph

Trying to page:

OrderEntity + all OrderItems

with one collection fetch join can create duplicated relational rows per parent।

Parent-level pagination semantics become harder to reason about।

Our design avoids that by separating:

Order history
→ projection + Page

from:

Order detail
→ full aggregate fetch

Again, different query shapes for different needs։


Problem 40 — Assuming a Fast Local Query Is Production-Ready

Local:

10 Orders

means nearly every query is fast।

Production performance depends on:

row count

indexes

query selectivity

relationship loading

concurrency

round trips

JPA convenience can hide poor query design until data grows।

Use realistic persistence tests/performance investigation when it matters।


A Practical JPA Review Process

When reviewing persistence code, ask:

1. What SQL can this operation execute?

2. How many queries can it execute?

3. Is the result bounded?

4. Are filters applied in PostgreSQL?

5. Is authorization scope applied before loading?

6. Which relationships are loaded?

7. Could a getter trigger another query?

8. Are entities leaking outside persistence?

9. Is the entity managed when it is mutated?

10. What transaction contains the operation?

11. Are cascade/orphan rules actually justified?

12. Does schema come from Flyway?

13. Does mapping match the schema?

14. Could concurrency make the read/change/save flow incorrect?

15. Is the behaviour tested against PostgreSQL where needed?

These questions catch far more problems than:

Are the annotations syntactically correct?

Our Preferred Persistence Patterns

Product lookup

Repository query
→ one Product

map Entity → Product

return domain

No unnecessary relationship graph।


Product browse

Product + Inventory relational query

filter in DB

paginate in DB

return projection/application summary

No:

load all Products

lazy-load Inventory one by one

Inventory

find by ProductId

simple scalar mapping

specialized concurrency-safe persistence later

No Product entity association needed।


Order history

customer_id scope

stable ordering

database pagination

summary projection

No complete Order aggregate loading։


Order detail

one Order

required OrderItems fetched intentionally

map to complete domain/application result

No lazy persistence dependency after Repository returns।


Order mutation

UseCase transaction

Repository loads required state

domain applies transition

Repository persists result

Persistence entities remain implementation detail।


Create Order

Eventually:

UseCase transaction

load Products

concurrency-safe Inventory operation

create Order domain state

persist Order + OrderItems

commit atomically

Generic JPA convenience must not undermine Inventory correctness।


JPA Anti-Pattern Summary

Avoid:

findAll everywhere

EAGER everywhere

LAZY everywhere without query planning

entities in Controllers

repositories in domain objects

EntityManager in UseCases

CascadeType.ALL by default

orphanRemoval by default

generic BaseEntity

one repository per table

ordinal enum persistence

fake generated IDs

H2-only persistence confidence

ddl-auto=update as migration strategy

blind save/merge of large graphs

catch Exception → Optional.empty()

authorization filtering after query

pagination after loading

SQL ignorance

Each shortcut can appear convenient।

Together they create fragile systems।


JPA Is Best When It Is Boring

Good persistence code should make it reasonably easy to answer:

What data are we loading?

How many rows?

How many queries?

What transaction owns the change?

What gets written?

Why does this relationship exist?

If the answer is repeatedly:

Hibernate probably handles it

we do not understand our own persistence layer well enough।


Framework Knowledge vs Engineering Knowledge

Knowing:

@Entity

@OneToMany

JpaRepository

@Transactional

is framework knowledge।

Engineering knowledge is knowing:

when not to create an association

when a query needs projection

when pagination must happen in DB

when a transaction must span repositories

when database concurrency matters

when an index is justified

when ORM behaviour is hiding I/O

The second is what makes JPA usable in production systems।


Persistence Module Review

At this point our persistence architecture is:

Handler
    ↓
UseCase
    ↓
Application Repository
    ↓
JPA Adapter
    ↓
Spring Data JPA
    ↓
Hibernate
    ↓
PostgreSQL

Schema evolution:

Flyway
    ↓
PostgreSQL schema

Testing:

Domain
→ plain Java

UseCase
→ Repository doubles

Persistence
→ PostgreSQL integration tests

API workflow
→ selected end-to-end integration tests

This gives us clear responsibilities।


Engineering Principle

The core principle:

JPA should reduce persistence boilerplate without hiding database behaviour from the engineer responsible for the system.

Another:

Treat JPA entities as managed persistence objects, not ordinary DTOs. Their getters, setters, relationships, and lifecycle can have database consequences.

And:

Use ORM for mapping convenience; use deliberate queries, transactions, constraints, indexes, and domain boundaries for correctness and performance.


Summary

In this lesson, we learned that:

  • JPA convenience can hide real database I/O.
  • N+1 happens when one parent query is followed by repeated relationship queries.
  • N+1 should be solved with use-case-specific query design rather than making every association eager.
  • EAGER relationships can load unnecessary data and create oversized entity graphs.
  • Lazy JPA entities should not escape Repository/persistence boundaries.
  • Higher layers should receive normal domain/application objects that do not require an active persistence context.
  • We avoid relying on HTTP serialization or web-layer code to trigger lazy SQL.
  • Unbounded findAll() calls are inappropriate for collection APIs requiring pagination.
  • Filtering, authorization scope, ordering, and pagination should happen in PostgreSQL.
  • Managed entity changes may be persisted through dirty checking even without an explicit save() call.
  • Engineers must know when an entity is managed and when field mutation can produce SQL.
  • Calling save() everywhere is not a substitute for understanding JPA entity state.
  • Blindly merging detached aggregate graphs can make update behaviour difficult to reason about.
  • Cascade operations must follow lifecycle ownership.
  • CascadeType.ALL and orphanRemoval should not be added without real business requirements.
  • Generated-ID JPA entity equality should not be generated blindly.
  • Composite persistence IDs such as OrderItemEntityId do need clear value equality.
  • Logging or serializing persistence entities can traverse relationships and potentially trigger unexpected behaviour.
  • Object navigation in ORM code may represent database access.
  • Important Hibernate-generated SQL should be understood and inspected.
  • Not every relational join needs a JPA association; focused queries can be cleaner.
  • Persistence entity lifetime should remain local to persistence/transaction work.
  • Repository Beans should remain stateless.
  • JPA does not choose the correct application transaction boundary.
  • Create Order and Cancel Order require transaction boundaries spanning multiple local persistence operations.
  • External Payment calls must not be treated as atomically protected by a PostgreSQL transaction.
  • UseCases should not contain raw EntityManager or SQL mechanics.
  • Missing rows and infrastructure failures must remain distinct outcomes.
  • Database constraints reinforce application correctness but do not replace domain validation or workflow rules.
  • Generic load/change/save is not automatically concurrency-safe for Inventory.
  • Non-negative Inventory constraints alone cannot prevent every overselling race.
  • Persistence tests should sometimes flush/clear before reloading to prove actual database round trips.
  • PostgreSQL integration through Testcontainers gives stronger confidence than relying on a different in-memory database for JPA behaviour.
  • Flyway remains the schema owner while Hibernate maps and validates the schema.
  • Schema migrations and JPA mappings must evolve coherently.
  • ORM framework capabilities must never be mistaken for business capabilities.
  • Read projections are often preferable to managed entities for list endpoints.
  • Good JPA usage requires understanding SQL, transactions, indexes, query counts, and relational behaviour—not just annotations.

With this, Module 6 — Persistence with PostgreSQL is complete.

Next module:

Module 7 — Implementing Business Workflows

Next lesson:

UseCase Responsibilities

There we will connect everything built so far and define exactly what belongs inside an application UseCase: how it coordinates Repositories and Domain objects, owns a business operation's transaction boundary, distinguishes orchestration from domain invariants, and avoids the common mistake of creating one giant internal OrderService.