Persistence with PostgreSQL

Repositories

ReadingPreview

আপনি একটি 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, and OrderRepository.
  • Spring Data interfaces such as ProductJpaRepository belong inside persistence infrastructure.
  • A JPA adapter such as JpaProductRepository bridges 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 JpaRepository provides 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 Data save().
  • 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 explicit findById() 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 PageQuery into Spring Data Pageable inside the adapter.
  • Spring Page can provide total elements/pages and typically requires count-query work.
  • Spring Data Page should be translated into our own PageResult.
  • 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.