Persistence with PostgreSQL
Repositories
আপনি একটি free preview lesson দেখছেন।
আমাদের persistence stack এখন প্রায় complete shape পেয়েছে।
Database:
PostgreSQL
Persistence mapping:
ProductEntity
InventoryEntity
OrderEntity
OrderItemEntity
Application architecture:
Handler
↓
UseCase
↓
Repository
এখন missing piece:
Repository boundary-এর actual implementation কীভাবে হবে?
Spring Data already gives us interfaces such as:
Repository
CrudRepository
JpaRepository
এবং methods such as:
save()
findById()
findAll()
delete()
Spring Data's repository abstraction intentionally provides generic CRUD and persistence-store capabilities; JpaRepository adds JPA-specific repository functionality.
কিন্তু আমাদের application-এর Repository-এর purpose slightly different।
আমরা চাই:
UseCase
জানুক:
application কোন data operation চায়
কিন্তু না জানুক:
JpaRepository
Pageable
ProductEntity
EntityManager
Hibernate
এই lesson-এর goal:
Application Repository interfaces এবং Spring Data repositories আলাদা রেখে persistence adapters তৈরি করা, generated IDs handle করা, bounded queries implement করা, এবং generic CRUD convenience-কে business architecture হতে না দেওয়া।
Two Kinds of Repository
আমাদের codebase-এ এখন "Repository" শব্দটি দুই context-এ আসবে।
Application Repository
Example:
public interface ProductRepository {
...
}
এটি UseCase-এর dependency।
It speaks in:
Product
ProductId
application query results
Spring Data Repository
Example:
public interface ProductJpaRepository
extends JpaRepository<
ProductEntity,
Long
> {
}
এটি persistence infrastructure।
It speaks in:
ProductEntity
Long
Pageable
Page
Spring Data creates repository implementations for these repository interfaces and provides generic persistence operations.
Why Separate Them?
Consider:
CreateProductUseCase
What does it actually need to know?
Probably:
persist Product
obtain persisted Product identity
It does not need to know:
Hibernate is used
the database ID is Long
Spring Data uses save()
EntityManager uses persist or merge
Those are implementation details।
The Boundary
Our architecture becomes:
CreateProductUseCase
↓
ProductRepository
↓
JpaProductRepository
↓
ProductJpaRepository
↓
Hibernate
↓
PostgreSQL
There are two repository-looking classes because they solve two different problems।
Application Repository Describes Needs
A simple Product Repository might begin with:
public interface ProductRepository {
Optional<Product> findById(
ProductId productId
);
Product save(
Product product
);
}
This is deliberately small।
No:
delete()
deleteAll()
findAll()
flush()
getReferenceById()
unless our application actually needs those operations।
Why Not Expose Everything?
JpaRepository provides many useful methods।
But framework capability does not imply business capability।
For example Spring Data gives us deletion operations.
Our Product business model says:
deactivate Product
not:
delete Product
If UseCase only sees:
ProductRepository
and that interface contains no delete method, physical deletion is harder to perform accidentally।
This Is an Important Boundary
Compare:
public class DeactivateProductUseCase {
private final ProductJpaRepository repository;
}
The UseCase can now access:
deleteById()
deleteAll()
flush()
getReferenceById()
findAll()
even though none of those belong to the operation।
Better:
public class DeactivateProductUseCase {
private final ProductRepository repository;
}
Now the dependency exposes only our application's persistence vocabulary।
Repository Interface Should Emerge From UseCases
Don't begin by writing:
public interface BaseRepository<T, ID> {
T save(T value);
Optional<T> findById(ID id);
List<T> findAll();
void delete(ID id);
}
Then force:
Product
Inventory
Order
through it।
Each capability has different needs।
Product Repository Needs
Current Product workflows require:
persist Product
find Product by ID
browse orderable Products
Perhaps later:
admin Product listing
when implementation requires it।
No physical deletion।
Inventory Repository Needs
Inventory workflows require:
find Inventory by ProductId
persist quantity
list Inventory for admin
Later Create Order will need:
concurrency-safe Inventory modification
That may require a specialized Repository operation rather than generic:
save()
We will evolve the interface when that correctness requirement is implemented।
Order Repository Needs
Order workflows require:
persist Order
find Order by OrderId
find customer's bounded Order history
Potentially admin queries later।
Again, no:
delete Order
business operation।
Spring Data Interface
Inside persistence:
package io.liveklass.ordermanagement.product.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductJpaRepository
extends JpaRepository<
ProductEntity,
Long
> {
}
Spring creates the concrete repository implementation at runtime।
We don't write:
class ProductJpaRepositoryImpl
for basic Spring Data functionality ourselves।
The Adapter
Then:
@Component
public class JpaProductRepository
implements ProductRepository {
private final ProductJpaRepository repository;
public JpaProductRepository(
ProductJpaRepository repository
) {
this.repository = repository;
}
@Override
public Optional<Product> findById(
ProductId productId
) {
return repository
.findById(
productId.value()
)
.map(
this::toDomain
);
}
@Override
public Product save(
Product product
) {
ProductEntity entity =
toEntity(product);
ProductEntity saved =
repository.save(entity);
return toDomain(saved);
}
private Product toDomain(
ProductEntity entity
) {
...
}
private ProductEntity toEntity(
Product product
) {
...
}
}
Now:
ProductEntity
Long
JpaRepository
remain inside persistence।
Why Return Product From save()?
Our Product ID is generated by PostgreSQL।
For a newly persisted Product:
before persistence
→ no assigned database identity
After persistence:
database assigns ProductId
So Repository must give application code access to the persisted result containing the generated identity।
Generated ID Requires Deliberate Modeling
We should not represent "new Product" as:
ProductId(0)
or:
ProductId(-1)
A ProductId should represent a real identity।
Our implementation therefore needs a legitimate transient-creation representation।
That could be:
a Product whose identity is not assigned yet
or:
a small creation-specific domain/application value
The exact constructor shape should be chosen when implementing Product creation।
What matters for Repository design is:
persistence returns the generated Product identity
without fake IDs।
Spring Data save() Is More Than "INSERT"
A common misunderstanding:
save(entity)
=
INSERT row
Spring Data JPA documents that save() delegates to JPA persistence semantics: a new entity is persisted, while an existing entity is merged.
So this method:
repository.save(entity);
does not mean:
Always execute one INSERT immediately.
JPA entity state matters।
Application save() and Spring save() Are Different Abstractions
Our:
ProductRepository.save(product)
means:
Persist the application's Product state.
How JpaProductRepository achieves that can change।
Today it might use:
Spring Data save()
Tomorrow a specialized implementation could:
load managed entity
apply changes
rely on dirty checking
The application contract need not change।
Avoid One-Line Adapter Dogma
It would be easy to assume every adapter must be:
return jpaRepository.save(
mapper.toEntity(domain)
);
But some aggregates need more careful synchronization।
For example:
Order
+
OrderItems
contains:
composite IDs
parent/child relationships
immutable historical items
Blindly merging arbitrary reconstructed graphs can become harder to reason about।
Repository implementation may legitimately be more deliberate।
Application Repository Hides That Complexity
UseCase should still see something simple:
Order saved =
orderRepository.save(order);
It should not care whether persistence implementation:
uses save()
uses managed entities
uses EntityManager
runs JPQL
runs native SQL
That is the Repository boundary doing its job।
Finding Product
Application:
Optional<Product> product =
productRepository.findById(
productId
);
Persistence:
repository.findById(
productId.value()
);
This maps:
ProductId
→ Long
and:
ProductEntity
→ Product
Why Return Optional?
Missing Product is an expected lookup outcome।
Repository can represent:
found
not found
without deciding HTTP semantics։
UseCase decides what missing means।
For example:
Product product =
productRepository
.findById(productId)
.orElseThrow(
ProductNotFoundException::new
);
Later HTTP boundary maps that application failure to:
404
PRODUCT_NOT_FOUND
Repository Should Not Throw HTTP Errors
Avoid:
throw new ResponseStatusException(
HttpStatus.NOT_FOUND
);
inside:
JpaProductRepository
Persistence does not own:
HTTP status
ApiProblem
request instance path
It returns application-meaningful outcomes।
findById() vs getReferenceById()
Spring Data JPA also provides:
getReferenceById(...)
but its semantics are different: it returns a JPA reference, and depending on the provider, a missing row may only cause EntityNotFoundException when that reference is first accessed.
Therefore if our application needs explicit:
does this Product/Order exist?
semantics, using:
findById(...)
is usually clearer।
Don't use reference APIs merely because they look like faster findById()।
Inventory Repository
Application boundary:
public interface InventoryRepository {
Optional<Inventory> findByProductId(
ProductId productId
);
void save(
Inventory inventory
);
}
Persistence interface:
public interface InventoryJpaRepository
extends JpaRepository<
InventoryEntity,
Long
> {
}
Why ID type:
Long
?
Because:
InventoryEntity @Id
=
productId
Adapter
@Component
public class JpaInventoryRepository
implements InventoryRepository {
private final InventoryJpaRepository repository;
public JpaInventoryRepository(
InventoryJpaRepository repository
) {
this.repository = repository;
}
@Override
public Optional<Inventory>
findByProductId(
ProductId productId
) {
return repository
.findById(
productId.value()
)
.map(
this::toDomain
);
}
@Override
public void save(
Inventory inventory
) {
repository.save(
toEntity(inventory)
);
}
...
}
Because Inventory identity already exists before its row is created, generated-ID handling is not needed here।
Generic save() May Not Be Enough for Inventory Later
For admin:
set available quantity
a normal load/change/save flow may be enough।
But Create Order introduces concurrency।
A future Repository contract might need something more explicit, conceptually:
boolean decreaseIfAvailable(
ProductId productId,
int quantity
);
or another mechanism depending on the concurrency strategy we choose।
Why Specialized Repository Methods Can Be Better
Generic:
find
→ modify
→ save
does not automatically guarantee concurrency safety।
A Repository operation can express:
atomic persistence intent
when the database needs to enforce it。
This is not "breaking repository abstraction."
It is exactly why Repository exists:
Expose the persistence capability the application actually needs.
Don't Choose the Inventory Strategy Yet
Possible implementations later include:
locking
conditional update
optimistic concurrency
We have intentionally deferred that decision।
So don't add:
@Version
or a native SQL decrement method yet।
We'll choose based on the actual Create Order implementation।
Order Repository
Application boundary might look like:
public interface OrderRepository {
Optional<Order> findById(
OrderId orderId
);
Order save(
Order order
);
PageResult<OrderSummary>
findByCustomer(
CustomerId customerId,
PageQuery page
);
}
Notice this Repository contains both:
aggregate persistence
and:
a bounded application query
That's acceptable।
PageResult Is Our Model
We don't want:
Page<OrderEntity>
escaping into the UseCase։
Instead, an application-owned result can be:
public record PageResult<T>(
List<T> items,
int page,
int size,
long totalItems,
int totalPages
) {
}
This mirrors our API pagination semantics without depending on Spring Data।
PageQuery
Likewise, now that pagination is actually needed by Repository code, a tiny application value becomes useful:
public record PageQuery(
int page,
int size
) {
}
The Handler has already validated:
page >= 0
1 <= size <= 100
before invoking the UseCase।
If PageQuery becomes broadly reused, it can live in an appropriate real shared/application location।
Don't create a huge pagination framework around two integers।
Spring Data Pagination Inside the Adapter
Persistence converts:
PageQuery
into Spring Data:
Pageable
For example:
Pageable pageable =
PageRequest.of(
page.page(),
page.size(),
Sort.by(
Sort.Order.desc(
"createdAt"
),
Sort.Order.desc(
"id"
)
)
);
Spring Data's pagination abstractions use Pageable and Page; Page also contains total-element/page metadata, which generally requires the infrastructure to perform a count query.
This Is Persistence-Specific
UseCase does not need:
PageRequest.of(...)
because:
Pageable
is Spring Data infrastructure।
Only the adapter performs this conversion।
Customer Ownership Must Be in the Query
Customer Order history must be scoped by:
authenticated CustomerId
So Spring Data repository may expose:
Page<OrderEntity>
findByCustomerId(
String customerId,
Pageable pageable
);
Spring Data supports deriving queries from repository method names and can add paging/sorting through Pageable.
But Do We Want Full OrderEntity Here?
Probably not for Order history।
Remember:
GET /orders
is a list/read use case।
It may not need:
full Order aggregate
all OrderItems
for every row।
A projection can be more appropriate।
Order Summary Projection
Persistence-only projection:
public interface OrderSummaryRow {
Long getId();
OrderStatus getStatus();
Instant getCreatedAt();
}
Then:
Page<OrderSummaryRow>
findByCustomerId(
String customerId,
Pageable pageable
);
Spring Data supports dedicated projection return types so repositories can retrieve partial views instead of always returning full managed entities.
Projection Stays Inside Persistence
Do not return:
OrderSummaryRow
from UseCase।
Adapter maps:
OrderSummaryRow
→ OrderSummary
Then:
Page<OrderSummaryRow>
→ PageResult<OrderSummary>
Mapping a Spring Page
Conceptually:
private PageResult<OrderSummary> toPageResult(
Page<OrderSummaryRow> page
) {
List<OrderSummary> items =
page.getContent()
.stream()
.map(this::toSummary)
.toList();
return new PageResult<>(
items,
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
Now Spring Data pagination stops at the persistence boundary।
Why Not Return Page Directly?
Because then application code depends on:
org.springframework.data.domain.Page
and Handler may eventually serialize Spring-specific metadata accidentally।
Our API contract is:
items
page
size
totalItems
totalPages
not:
whatever Spring Data Page exposes
Page vs Slice
Spring Data supports both concepts।
A Page contains total count/page information and normally requires a count query; a Slice can determine whether another slice exists without providing the total-page metadata.
Our current API contract explicitly returns:
totalItems
totalPages
so:
Page
matches our current requirement well।
If counting later becomes expensive and API requirements change, we could revisit this design deliberately।
Never Use findAll() for Order History
Spring Data makes this available:
findAll()
but official Spring Data documentation explicitly distinguishes unbounded collection retrieval from paginated repository access.
Bad:
List<OrderEntity> orders =
repository.findAll();
return orders
.stream()
.filter(...)
.skip(...)
.limit(...)
.toList();
This defeats our pagination contract।
Correct Direction
CustomerId
page
size
sort
↓
PostgreSQL query
↓
bounded rows
Database performs:
filtering
ordering
pagination
before rows reach Java।
Stable Order History
Our accepted Order history order:
createdAt DESC
id DESC
should reach the database through Pageable sorting or an explicit query।
This provides deterministic page ordering।
Do not load a page and sort it afterward in Java।
Product Browsing Is More Interesting
Customer Product browse requires:
Product active
AND
Inventory available quantity > 0
But we intentionally did not add:
ProductEntity.inventory
JPA association।
Does that prevent a database join?
No।
Query Without Object Association
Spring Data supports declared queries in addition to method-name derivation.
A persistence query can conceptually be:
@Query("""
select p
from ProductEntity p,
InventoryEntity i
where i.productId = p.id
and p.active = true
and i.availableQuantity > 0
""")
Page<ProductEntity> findOrderable(
Pageable pageable
);
This performs the relational composition without introducing:
@OneToOne
between ProductEntity and InventoryEntity।
This Is Why Associations Are Optional
We can have:
database relationship
+
join query
without:
Java navigation relationship
That keeps the persistence graph small while still letting PostgreSQL do relational work efficiently।
Application Repository Can Express the Business Query
Application interface:
PageResult<ProductSummary>
findOrderable(
PageQuery page
);
Notice how much clearer this is than:
findByActiveTrueAndInventoryAvailableQuantityGreaterThan(...)
leaking into application code।
Spring Data Naming Stays Inside Persistence
The infrastructure interface may have:
findByCustomerId(...)
or:
findOrderable(...)
with @Query।
Application Repository can use names based on application intent:
findOrderable
findByCustomer
findById
We don't need every layer to mimic Spring Data method naming rules।
Derived Queries Are Good When They Stay Readable
For example:
findByCustomerId(
String customerId,
Pageable pageable
);
is clear।
Spring Data can derive queries by parsing supported repository method names.
This is a good use of framework convenience।
Derived Queries Become Bad When the Name Becomes the Query Language
Avoid monsters such as:
findByCustomerIdAndStatusAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc(...)
just to avoid writing:
@Query
or an explicit persistence implementation।
Clarity matters more than annotation-free cleverness।
Repository Adapter Can Use More Than Spring Data Interfaces
Suppose later Inventory concurrency needs a PostgreSQL-specific operation।
The adapter can use:
EntityManager
native SQL
custom Spring Data repository fragment
inside persistence।
Spring Data JPA supports custom repository implementations/fragments when base repository behaviour is not enough.
UseCase still depends on:
InventoryRepository
only।
This Is the Value of the Adapter
We can change:
Spring Data derived method
to:
JPQL
to:
native PostgreSQL query
without rewriting:
CreateOrderUseCase
as long as Repository semantics remain unchanged।
Repository Does Not Mean One Table
This is worth repeating।
Our database has:
orders
order_items
but application has:
OrderRepository
not necessarily:
OrderRepository
OrderItemRepository
because:
OrderItem belongs to Order
as an aggregate child।
Persistence Can Use Multiple Spring Data Repositories Internally
If implementation eventually needs:
OrderJpaRepository
OrderItemJpaRepository
for a technical reason, the application still doesn't need two Repository boundaries।
JpaOrderRepository can coordinate persistence mechanics internally।
But our current JPA relationship mapping may allow OrderItems to persist through Order association, so a separate OrderItemJpaRepository may not be necessary।
Don't create one simply because there is a table।
Product and Inventory Are Different
For Product and Inventory we do have:
ProductRepository
InventoryRepository
because those are independently meaningful application capabilities।
Again:
Repository boundaries
follow:
application responsibility
not table count।
Where Should Transactions Live?
Spring Data repository CRUD methods have transaction configuration of their own, but Spring Data's own guidance shows an outer facade/service transaction being used to define a transaction across multiple repository operations; that outer boundary then determines the overall transaction.
For our architecture that outer application boundary is generally:
UseCase
for operations that need one transaction।
Why Repository-Level Transactions Are Not Enough for Create Order
Create Order needs:
load Products
check Inventory
decrease Inventory
persist Order
persist OrderItems
If each Repository call independently commits, we cannot guarantee:
all-or-nothing workflow
So later:
CreateOrderUseCase
will own the transaction around the whole operation।
Repository Still Participates in That Transaction
The Repository implementations operate inside the transaction established by the UseCase।
Conceptually:
@Transactional
CreateOrderUseCase.execute()
↓
ProductRepository
↓
InventoryRepository
↓
OrderRepository
↓
commit
If the operation fails:
rollback
can cover the local PostgreSQL changes together।
We explore this deeply in Module 7।
Don't Put @Transactional Everywhere
Avoid:
Handler @Transactional
UseCase @Transactional
Repository @Transactional
Mapper @Transactional
because nobody can tell which boundary actually owns the transaction।
Spring Data repositories may have transactional behaviour internally, but application-wide business transaction semantics should remain deliberate.
Repository Exceptions
Persistence can fail due to:
connection error
constraint violation
transaction conflict
query failure
These are infrastructure-level events।
UseCases should not parse:
SQLState
Hibernate exception text
constraint-name strings
directly।
Expected Missing Data
For normal lookup:
Product missing
Repository can return:
Optional.empty()
UseCase translates that into application meaning।
Expected Persistence Conflict
Some future operations may use PostgreSQL itself to resolve concurrent behaviour।
For example Inventory persistence may report:
no row could be safely decreased
Repository can translate that database-level result into a clear persistence/application outcome such as:
insufficient quantity
without exposing SQL mechanics upward।
Unexpected Infrastructure Failure
Suppose PostgreSQL connection fails।
Repository should not pretend:
Product not found
That would hide a system failure as a client error।
Unexpected infrastructure failures should propagate as technical failure and ultimately become:
500 / INTERNAL_ERROR
through our centralized error boundary।
Don't Catch Everything in Repository
Bad:
try {
...
} catch (Exception exception) {
return Optional.empty();
}
Now:
database outage
looks identical to:
Product doesn't exist
That destroys observability and correctness।
Only translate persistence exceptions when there is a specific meaningful abstraction to preserve।
Repository Mapping Errors Are Bugs
Suppose database contains valid Product:
id = 101
price = 100
but mapper crashes due to implementation bug।
Do not return:
PRODUCT_NOT_FOUND
The Product exists।
This is an unexpected application failure and should surface accordingly।
Saving an Order
Application:
Order saved =
orderRepository.save(order);
Persistence implementation needs to handle:
orders row
OrderItemEntity collection
composite key
generated OrderId
Because we configured:
OrderEntity
→ CascadeType.PERSIST
→ OrderItemEntity
creating a new Order can conceptually be:
map Order
→ OrderEntity
attach OrderItems
persist OrderEntity
database generates OrderId
OrderItems receive parent identity
persist children
The application Repository hides this ORM-specific workflow।
Loaded Order
For:
findById(OrderId)
the application expects:
complete Order needed by business operation
It should not receive:
Order with a lazy items collection
that may fail later
JpaOrderRepository must load/map required persistence data before returning the domain Order।
Use a Query Appropriate to the Operation
For Order detail/cancellation, Repository may intentionally fetch:
Order + OrderItems
using a suitable JPA query/fetch plan।
For Order history, Repository may use:
projection + pagination
Different operations can use different persistence queries even though both ultimately involve Orders।
Repository Method Name Reflects Semantics
Potential application interface:
public interface OrderRepository {
Optional<Order> findById(
OrderId orderId
);
Order save(
Order order
);
PageResult<OrderSummary>
findByCustomer(
CustomerId customerId,
PageQuery page
);
}
This is more useful than generic:
findAll()
findAllSorted()
findAllPaged()
because ownership is built into the query need।
Customer Scope Cannot Be Forgotten
Bad:
orderRepository.findAll(page);
Then UseCase filters:
order.customerId()
.equals(currentCustomer)
in memory।
This is:
less safe
less efficient
Correct:
findByCustomer(
authenticatedCustomerId,
page
)
Authorization scope reaches the database query։
Resource Lookup and Ownership
For:
GET /orders/{orderId}
we may eventually choose Repository operations such as:
findByIdAndCustomerId(
OrderId orderId,
CustomerId customerId
);
instead of:
load Order
then check owner
for customer-specific reads।
This can help enforce:
existence hiding
and avoid loading inaccessible resources।
But Repository Shouldn't Know "CURRENT USER"
Avoid:
findForCurrentUser(...)
Repository should receive:
CustomerId
explicitly।
Authentication context remains outside persistence।
Repository Is Not Authorization Framework
Repository can efficiently apply:
customer_id = ?
but:
who is the authenticated Customer?
is determined before calling it।
This keeps responsibilities clear։
Persistence Query Names Should Stay Purposeful
Good:
findByCustomer
findOrderable
findByProductId
Potentially good later:
decreaseIfAvailable
Bad:
executeQuery3
findEverything
genericSearch
dynamicFilter
until there is a real requirement for those abstractions।
Repository Tests
Repository behaviour deserves PostgreSQL integration tests։
For Product:
save new Product
→ generated ID assigned
find Product
→ domain state reconstructed
update Product
→ state persists
orderable page
→ inactive/out-of-stock excluded
Inventory Repository Tests
Useful persistence tests:
save quantity
reload quantity
negative quantity rejected by DB constraint
missing Product cannot own Inventory
Later:
concurrent decrease behaviour
once concurrency strategy is selected।
Order Repository Tests
Useful cases:
persist Order + multiple items
generated OrderId returned
reload Order with items
historical unit prices preserved
duplicate Product line rejected
find customer's Orders only
pagination bounded
ordering deterministic
These are places where a real PostgreSQL integration test gives value that a mocked repository cannot।
Unit Tests Still Use Repository Doubles
UseCase tests can replace application Repository interfaces with:
fake
stub
mock
as appropriate।
They test:
application workflow
not JPA mapping।
Because UseCase depends on:
OrderRepository
instead of:
JpaRepository<OrderEntity, Long>
those tests remain straightforward।
Don't Mock ProductJpaRepository to Prove Persistence
A test such as:
verify repository.save() called once
does not prove:
database mapping works
generated IDs work
constraints work
query works
For persistence implementation, use PostgreSQL integration tests।
Package Structure
A capability may now naturally look like:
product/
├── domain/
│ ├── Product.java
│ └── ProductId.java
├── usecase/
├── repository/
│ └── ProductRepository.java
└── persistence/
├── ProductEntity.java
├── ProductJpaRepository.java
└── JpaProductRepository.java
Inventory:
inventory/
├── domain/
├── usecase/
├── repository/
└── persistence/
Order:
order/
├── domain/
├── usecase/
├── repository/
└── persistence/
This preserves capability ownership।
Do We Need repository/ and persistence/ for Every Capability?
Only once real code exists।
If a capability has:
one interface
one JPA implementation
these packages can still make the boundary clear।
But don't create empty folders and dozens of interfaces ahead of implementation।
Our structure continues to emerge from tickets।
Don't Put All Spring Data Repositories Globally
Avoid:
repository/
├── ProductJpaRepository
├── InventoryJpaRepository
├── OrderJpaRepository
└── ...
at application root if it destroys capability ownership।
Persistence implementation belongs close to the capability it implements।
Application Repositories Are Not Service
Avoid:
ProductPersistenceService
InventoryDatabaseService
Our terminology stays consistent:
Repository
→ persistence
UseCase
→ internal workflow
Service
→ external integration
Later:
PaymentService
is appropriate because it represents an external Payment Provider boundary।
Repository Interface Should Not Be Over-Abstract
Avoid:
public interface Repository<
Aggregate,
Id,
Query,
Result
> {
...
}
to remove ten lines of duplicated Java।
Our concrete repositories communicate business meaning more effectively।
A small amount of duplication is cheap।
A generic abstraction that hides capability semantics is expensive।
Don't Abstract Spring Data Away Twice
We already have:
application Repository
between UseCase and persistence।
We do not also need:
DatabaseRepository
GenericPersistencePort
JpaGateway
RepositoryDelegate
before reaching Spring Data।
One useful boundary is enough।
What Belongs in JPA Adapter?
Good responsibilities:
domain ↔ entity mapping
ID conversion
PageQuery ↔ Pageable mapping
Spring Page ↔ PageResult mapping
choosing Spring Data query
persistence-specific exception translation
when genuinely needed
What Does Not Belong There?
Avoid:
authorization policy
Order cancellation rules
Product orderability business decisions
outside query semantics
Payment orchestration
HTTP status mapping
Repository implements persistence semantics।
It does not become another UseCase।
findOrderable() Is Not Business Logic Leakage
This deserves nuance।
Our customer Product browse definition is:
active
AND
available quantity > 0
Applying this efficiently in a database query is appropriate।
The business meaning was decided by application requirements।
Repository implements that query efficiently։
That's different from Repository deciding:
I personally think Products with price under 100 should be orderable.
Repository implements decisions; it doesn't invent them।
Query Result vs Domain Aggregate
Not every Repository read method must return a full domain Entity।
For commands:
find Order for cancellation
returning:
Order
makes sense because domain behaviour is required।
For read endpoints:
Order history
returning:
OrderSummary
can be better।
This is pragmatic query design, not CQRS architecture।
Don't Hydrate Rich Domain Objects Just to Display a Table
If UI needs:
OrderId
status
createdAt
don't load:
full Order
all OrderItems
Product state
just to throw most of it away։
Repository/query model should fit the operation।
But Don't Build a Read Framework Either
We do not need:
CommandBus
QueryBus
CQRS module
projection database
to return an OrderSummary।
A focused persistence query is enough।
Repository Review Checklist
Before adding a Repository method, ask:
Which UseCase needs this?
Does the method express application intent?
Does it return domain/application types
instead of JPA infrastructure types?
Is the query bounded where required?
Is ownership scope applied in the query?
Am I exposing delete/findAll merely because
JpaRepository provides them?
Would a projection be better than a full Entity?
Does this operation need a specialized
database operation for correctness?
Am I hiding a real infrastructure error
as "not found"?
Is persistence technology leaking upward?
Common Mistake 1 — UseCase Injects JpaRepository
Application workflow becomes coupled to Spring Data infrastructure।
Common Mistake 2 — Application Repository Extends JpaRepository
The persistence boundary and implementation become the same abstraction।
Common Mistake 3 — Expose Every CRUD Method
Framework capabilities can contradict business lifecycle।
Common Mistake 4 — findAll() Then Filter in Java
Filtering, authorization scope, ordering, and pagination should reach PostgreSQL।
Common Mistake 5 — Spring Page Returned From Handler
Framework pagination becomes accidental public API contract।
Common Mistake 6 — Full Entity Graph for Every Read
Use projections/read models where they better match the query।
Common Mistake 7 — Generic save() Assumed Concurrency-Safe
Correctness-sensitive writes may require specialized Repository operations।
Common Mistake 8 — Every Database Exception Becomes Optional.empty()
Infrastructure failures must not masquerade as missing resources।
Common Mistake 9 — One Repository Per Table
Repository boundaries follow application responsibility and aggregate ownership।
Common Mistake 10 — Generic Repository Abstraction Over Everything
Concrete application semantics are more valuable than reducing a few duplicated method signatures।
Our Repository Direction
Product
Application:
ProductRepository
Responsibilities:
find Product
persist Product
bounded orderable Product query
Persistence:
ProductJpaRepository
JpaProductRepository
Inventory
Application:
InventoryRepository
Responsibilities:
find Inventory
persist/set quantity
eventually concurrency-safe quantity mutation
Persistence:
InventoryJpaRepository
JpaInventoryRepository
Order
Application:
OrderRepository
Responsibilities:
persist Order aggregate
load Order aggregate
bounded customer Order history
Persistence:
OrderJpaRepository
JpaOrderRepository
No application-level:
OrderItemRepository
is currently necessary।
Overall Architecture
We now have:
HTTP
↓
Handler
↓
UseCase
↓
Application Repository
↓
JPA Adapter
↓
Spring Data Repository
↓
Hibernate / JPA
↓
PostgreSQL
For example:
CancelOrderHandler
↓
CancelOrderUseCase
↓
OrderRepository
↓
JpaOrderRepository
↓
OrderJpaRepository
↓
PostgreSQL
UseCase doesn't know:
which table
which query mechanism
which Spring Data interface
which EntityManager operation
It knows the application operation it needs।
Engineering Principle
The core principle:
Application Repositories describe what the application needs from persistence; Spring Data repositories describe how JPA can access persisted entities. Do not collapse those two responsibilities merely because their method names sometimes look similar.
Another:
A Repository is not a CRUD menu. Its API should reflect the operations and queries the application actually needs.
And:
Keep filtering, ownership scope, ordering, and pagination close to the database, while keeping Spring Data types and persistence mechanics behind the Repository boundary.
Summary
In this lesson, we learned that:
- Spring Data provides generic Repository, CRUD, JPA, pagination, and query capabilities.
- Our application Repository and Spring Data repository serve different purposes.
- UseCases should depend on application repositories such as
ProductRepository,InventoryRepository, andOrderRepository. - Spring Data interfaces such as
ProductJpaRepositorybelong inside persistence infrastructure. - A JPA adapter such as
JpaProductRepositorybridges the two. - JPA entities, Spring
Page,Pageable,EntityManager, and raw database ID types should not leak into UseCases. - Application repositories should expose only persistence capabilities required by actual workflows.
- Product and Order physical delete methods should not appear merely because
JpaRepositoryprovides delete operations. - Spring Data
save()persists new entities and merges existing entities according to JPA entity-state detection. - Application-level
save()does not have to map one-to-one to Spring Datasave(). - Generated database IDs must be returned to the application without creating fake IDs such as
ProductId(0). - Missing resources can be represented with
Optional; UseCases decide the application meaning. getReferenceById()has reference/proxy semantics and is not a direct replacement for explicitfindById()existence checks.- Inventory uses ProductId as its persistence ID.
- Generic load/change/save may later be insufficient for concurrency-safe Inventory mutation.
- Repository interfaces may expose specialized persistence operations when database-level atomicity requires them.
- Pagination is converted from application
PageQueryinto Spring DataPageableinside the adapter. - Spring
Pagecan provide total elements/pages and typically requires count-query work. - Spring Data
Pageshould be translated into our ownPageResult. - Customer Order history must apply CustomerId scope in the database query rather than filtering all Orders in Java.
- Spring Data supports derived query methods and explicit declared queries.
- Persistence projections are useful for bounded read endpoints such as Order history.
- Product browsing can join Product and Inventory in a query even though we deliberately have no JPA Product↔Inventory association.
- One table does not automatically require one application Repository.
- OrderItems remain persisted through the Order persistence boundary rather than creating a separate application
OrderItemRepository. - Application-wide transaction boundaries for workflows spanning multiple repositories should be owned deliberately by the UseCase; repository-level transaction defaults do not replace that workflow boundary.
- Expected persistence outcomes and unexpected infrastructure failures must remain distinguishable.
- Repository integration tests should run against PostgreSQL, while UseCase tests can use doubles for the application Repository interfaces.
Next lesson:
Database Migrations with Flyway
There we will turn our schema into version-controlled migration files, integrate Flyway with Spring Boot, establish how migrations run across local/test/production environments, and define why Hibernate must validate the schema rather than silently modifying production tables.