Persistence with PostgreSQL
Avoiding Common JPA Problems
আপনি একটি 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.
EAGERrelationships 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.ALLandorphanRemovalshould not be added without real business requirements.- Generated-ID JPA entity equality should not be generated blindly.
- Composite persistence IDs such as
OrderItemEntityIddo 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
EntityManageror 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.