Persistence with PostgreSQL

Spring Data JPA

ReadingPreview

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

এখন পর্যন্ত persistence নিয়ে আমরা framework-independentভাবে চিন্তা করেছি।

আমরা design করেছি:

products

inventory

orders

order_items

আমরা ঠিক করেছি:

কোন data persist হবে

কোন keys থাকবে

কোন foreign keys থাকবে

কোন constraints PostgreSQL enforce করবে

এখন Java application-কে সেই relational schema-এর সঙ্গে connect করতে হবে।

Spring Boot ecosystem-এ আমরা ব্যবহার করব:

Jakarta Persistence / JPA

Hibernate

Spring Data JPA

এই তিনটি নাম প্রায়ই একসঙ্গে শোনা যায়, তাই beginners অনেক সময় ধরে নেয়:

JPA = Hibernate = Spring Data JPA

কিন্তু এগুলো একই জিনিস নয়।

Jakarta Persistence হলো Java-তে persistence এবং object-relational mapping-এর standard API/specification। Hibernate একটি ORM implementation যা object model এবং relational model-এর মধ্যে mapping করে। Spring Data JPA সেই JPA foundation-এর উপর repository support এবং boilerplate reduction দেয়।

এই lesson-এর goal:

JPA, Hibernate, এবং Spring Data JPA-এর responsibility আলাদা করে বোঝা, তারপর এগুলোকে আমাদের existing Handler -> UseCase -> Repository architecture-এর persistence implementation হিসেবে ব্যবহার করা।


The Persistence Stack

আমাদের application-এর persistence flow conceptually:

UseCase
    ↓
Application Repository
    ↓
JPA Persistence Adapter
    ↓
Spring Data JPA
    ↓
Jakarta Persistence API
    ↓
Hibernate
    ↓
JDBC Driver
    ↓
PostgreSQL

সব layer সবসময় code-এ আলাদা class হিসেবে visible হবে না।

কিন্তু mental model হিসেবে distinction গুরুত্বপূর্ণ।


PostgreSQL

সবচেয়ে নিচে আছে:

PostgreSQL

এটাই আমাদের actual durable datastore।

এখানে থাকবে:

tables

rows

constraints

foreign keys

indexes

transactions

Hibernate বা Spring Data JPA PostgreSQL-এর replacement নয়।

তারা Java application থেকে PostgreSQL-এর সঙ্গে কাজ করা সহজ করে।


JDBC Driver

Java application এবং PostgreSQL-এর মধ্যে low-level database communication-এর জন্য PostgreSQL JDBC driver প্রয়োজন।

Conceptually:

Java
    ↓
JDBC
    ↓
PostgreSQL

আমরা সাধারণ application code-এ প্রতিটি persistence operation-এর জন্য manually JDBC connection, prepared statement, result-set mapping লিখব না।

Hibernate এই lower-level interaction-এর বড় অংশ handle করবে।


What Is JPA?

Historically নামটি ছিল:

Java Persistence API

Modern Java ecosystem-এ specification হলো:

Jakarta Persistence

এবং package names:

jakarta.persistence.*

Jakarta Persistence standardizes object-relational persistence concepts such as entities, persistence contexts, EntityManager, relationships, queries, and lifecycle operations.

JPA নিজে database engine নয়।

এটি primarily:

API + specification

Specification vs Implementation

Think about Java interfaces.

Suppose specification defines conceptually:

persist entity

find entity

remove entity

query entity

Someone still needs to implement those operations।

That implementation is our persistence provider।

For this course, that provider is:

Hibernate ORM

What Is Hibernate?

Hibernate is an Object/Relational Mapping solution for Java applications. Its job is to map between object-oriented representations and relational database representations.

Conceptually:

Java object
    ↕
Hibernate
    ↕
database row

For example:

ProductEntity

could map to:

products

table।

Hibernate understands metadata such as:

which class is an entity

which field is the primary key

which column maps to which property

which relationships exist

What Hibernate Saves Us From

Without ORM, loading Product might require code conceptually like:

PreparedStatement statement =
        connection.prepareStatement(
                """
                SELECT id, name, price, active
                FROM products
                WHERE id = ?
                """
        );

statement.setLong(1, productId);

ResultSet resultSet =
        statement.executeQuery();

...

Then manually:

read each column

convert each type

construct Java object

That approach is valid and sometimes useful।

But for our current application, JPA/Hibernate can remove a lot of repetitive persistence mapping code।


What Hibernate Does Not Do

Hibernate does not decide:

whether Order can be cancelled

whether Product can be ordered

whether Inventory is sufficient

whether Customer owns an Order

whether payment succeeded

Those remain:

Domain

UseCase

responsibilities।

ORM solves persistence mapping।

It is not our business architecture।


What Is Spring Data JPA?

Spring Data JPA provides repository support on top of Jakarta Persistence and aims to reduce boilerplate in JPA-based data-access layers.

Instead of writing every common repository implementation manually, we can define interfaces such as:

public interface ProductJpaRepository
        extends JpaRepository<ProductEntity, Long> {
}

Spring Data can create the implementation at runtime।


Spring Data Repository Is a Framework Concept

Spring Data's central repository abstraction is based around a Repository interface parameterized by managed type and identifier type.

That creates an important terminology issue for our architecture।

We already have an application boundary called:

ProductRepository

Now Spring Data also uses:

Repository
JpaRepository

These concepts are related, but should not automatically become the same thing।


Our Application Repository

Our architecture:

Handler
    ↓
UseCase
    ↓
Repository

The Repository here represents:

What persistence operations does this application capability require?

For example:

public interface ProductRepository {

    Optional<Product> findById(
            ProductId productId
    );

    Product save(
            Product product
    );
}

This is an application boundary

It talks in:

Product

ProductId

not:

ProductEntity

JpaRepository

EntityManager

Spring Data Repository

Inside persistence implementation, we may have:

interface ProductJpaRepository
        extends JpaRepository<
                ProductEntity,
                Long
        > {
}

This is a framework-facing persistence interface

It knows:

ProductEntity

Long database ID

JPA

This distinction keeps persistence technology behind our application boundary।


Persistence Adapter

Conceptually:

@Component
public class JpaProductRepository
        implements ProductRepository {

    private final ProductJpaRepository repository;

    public JpaProductRepository(
            ProductJpaRepository repository
    ) {
        this.repository = repository;
    }

    ...
}

Now the flow is:

CreateProductUseCase
    ↓
ProductRepository
    ↓
JpaProductRepository
    ↓
ProductJpaRepository
    ↓
Hibernate
    ↓
PostgreSQL

Why Not Inject JpaRepository Directly Into UseCase?

We could write:

@Component
public class CreateProductUseCase {

    private final ProductJpaRepository repository;

    ...
}

This would be shorter।

But now the application workflow directly knows:

Spring Data JPA

ProductEntity

framework repository semantics

Our UseCase becomes coupled to persistence implementation।


The Coupling Becomes More Visible Later

Suppose UseCase starts doing:

repository.findById(id)
        .orElseThrow(...);

Maybe that's fine initially।

Then eventually:

repository.save(entity);

repository.flush();

repository.getReferenceById(...);

Now Spring Data/JPA concepts have spread into application workflow।

Switching query strategy or testing the UseCase becomes more persistence-aware than necessary।


We Are Not Hiding JPA for Theoretical Purity

This boundary is not about pretending Hibernate doesn't exist।

It gives us a practical separation:

UseCase
→ business operation

Repository interface
→ application persistence need

JPA adapter
→ persistence technology

This is useful because we already have meaningful domain objects and application workflows।


Do We Need an Adapter for Every Tiny Query?

Avoid ceremony।

If a capability eventually has a specialized read projection where using a Spring Data projection directly inside a persistence/query component is simpler, that's acceptable।

The principle is not:

Every Spring Data method needs three wrappers.

The principle is:

Don't let framework persistence types become the architecture of the business layer.


Add Spring Data JPA

Spring Boot provides:

spring-boot-starter-data-jpa

as the standard starter for JPA-based persistence; the starter includes Spring Data JPA, Hibernate, and Spring ORM support.

For our Gradle project, conceptually:

dependencies {
    implementation(
        "org.springframework.boot:spring-boot-starter-data-jpa"
    )

    runtimeOnly(
        "org.postgresql:postgresql"
    )
}

We add these dependencies now because persistence implementation actually needs them।

This follows our earlier rule:

Add a dependency when a concrete feature requires it.


Spring Boot Auto-Configuration

Spring Boot uses dependencies and application configuration to auto-configure many infrastructure components. For JPA applications this includes integration around the datasource and persistence stack when the relevant dependencies and configuration are present.

That means we normally do not manually create every object such as:

DataSource

EntityManagerFactory

Hibernate bootstrap configuration

for a conventional application।

Spring Boot handles common setup while still allowing explicit customization when required।


Database Configuration

Our application needs environment-specific connection configuration।

Conceptually:

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

This aligns with our earlier configuration lesson:

config belongs outside business code

Don't Hard-Code Credentials

Avoid:

username: postgres
password: super-secret-production-password

committed into source control।

Use environment/configuration mechanisms appropriate to the deployment।

The UseCase should never read:

System.getenv("DB_PASSWORD");

Database configuration is infrastructure configuration।


JDBC URL

A local PostgreSQL URL might look conceptually like:

jdbc:postgresql://localhost:5432/order_management

The exact host, port, database name, username, and password come from the environment।

We don't bake development infrastructure assumptions into application workflows।


JPA Entity

JPA needs persistent entity mappings।

Conceptually:

@Entity
@Table(name = "products")
public class ProductEntity {
    ...
}

This tells the persistence provider that the Java type participates in persistence mapping।

We'll design our actual entity mappings in the dedicated:

Entity Mapping

lesson।

For now, remember:

@Entity is a persistence annotation, not proof that the class is a good domain model.


Domain Entity vs JPA Entity

We already have:

Product

as a domain concept।

We may choose to have:

ProductEntity

as the persistence representation।

Then:

Product
    ↕
mapping
    ↕
ProductEntity

This gives the domain freedom from JPA requirements।


Could Domain and JPA Entity Be the Same Class?

Yes।

Many applications annotate their domain classes directly with:

@Entity

This reduces mapping code।

For simple CRUD-oriented systems, that can be perfectly pragmatic।


Why We Are More Careful Here

Our application already intentionally separates:

Domain behaviour

Transport DTOs

Persistence concerns

We have methods such as:

product.changePrice()

order.cancel()

order.markPaid()

and domain ownership rules।

Keeping persistence mapping separate lets us avoid designing domain classes around Hibernate requirements।


But Don't Create Duplicate Models Without Benefit

We should not automatically build:

Product

ProductEntity

ProductPersistenceModel

ProductRecord

ProductJpaDto

all representing the same thing।

For our persistence layer, one explicit JPA representation plus one domain representation is enough when separation adds value।

No model explosion।


EntityManager

At the JPA level, one of the core APIs is:

EntityManager

Jakarta Persistence defines EntityManager for operations such as persisting/removing entities, finding them by primary key, and executing queries.

Conceptually:

entityManager.persist(entity);

entityManager.find(
        ProductEntity.class,
        id
);

Spring Data JPA builds higher-level repository conveniences on top of this persistence infrastructure।


Persistence Context

An EntityManager works with a:

Persistence Context

Jakarta Persistence defines a persistence context as a set of managed entity instances where a persistent identity corresponds to a unique managed instance within that context; the EntityManager manages those entity instances and their lifecycle.

This concept is essential for understanding JPA behaviour later।


Managed Entity

Suppose JPA loads:

ProductEntity 101

inside an active persistence context।

That entity can become:

managed

JPA/Hibernate tracks its persistence lifecycle।

This is different from a random Java object that Hibernate knows nothing about।


Dirty Checking

Hibernate tracks changes to managed entities and can synchronize changed state with the database when the persistence context is flushed. Hibernate documentation refers to this change-detection mechanism as dirty checking.

Conceptually:

ProductEntity entity =
        repository.findById(101L)
                .orElseThrow();

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

Inside an appropriate transaction, Hibernate may later generate an SQL update without us explicitly calling SQL ourselves।


This Can Look Like Magic

You may not see:

repository.save(entity);

after every modification and still observe an UPDATE at transaction synchronization।

That behaviour surprises many engineers who treat JPA repositories like simple CRUD wrappers।

Understanding:

managed entity

persistence context

transaction

flush

is necessary to reason about JPA correctly।


Don't Depend on Magic Without Understanding It

A professional backend engineer should be able to answer:

Is this entity managed?

Which transaction owns this change?

When will SQL execute?

Could lazy data trigger another query?

Will this change be flushed?

We will progressively answer those questions throughout this module।


save() Is Not "INSERT SQL"

Spring Data exposes methods such as:

save(...)

But application engineers should not build a mental model where:

save()
=
always execute INSERT immediately

JPA entity state, persistence context, generated identity, and transaction synchronization influence actual persistence behaviour।

Think in terms of entity lifecycle first, generated SQL second।


Repository Interfaces

Spring Data can provide common methods through:

JpaRepository<Entity, Id>

such as persistence and lookup operations।

It also supports defining query methods derived from repository method names.

For example:

interface OrderJpaRepository
        extends JpaRepository<
                OrderEntity,
                Long
        > {

    ...
}

Query Method Derivation

Spring Data can derive certain queries from method names।

Conceptually:

findByCustomerId(...)

or:

findByCustomerIdAndStatus(...)

The framework parses supported property expressions and query keywords to build queries.

This is useful—but should not become a competition to write the longest repository method name possible।


When Derived Queries Are Good

A simple query such as:

find Order by CustomerId

can be very readable as a derived method।

Likewise:

find Inventory by ProductId

may need nothing more complex।

Use the simplest mechanism that keeps query intent clear।


When Derived Queries Become Awkward

Imagine:

findByCustomerIdAndStatusAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc(...)

At some point, the method name becomes harder to understand than the query itself।

Then use a clearer query mechanism।

Spring Data is a convenience, not a goal।


JPQL

JPA provides a query language oriented around entities and their mapped attributes rather than raw table names।

Conceptually:

SELECT o
FROM OrderEntity o
WHERE o.customerId = :customerId

This is:

JPQL

not raw PostgreSQL SQL।

Use it when it gives a clear persistence query।


Native SQL

JPA/Spring Data can also execute native SQL when required।

That can be useful when:

PostgreSQL-specific query is clearer

performance requires explicit SQL

complex projection is easier directly

But don't jump to native SQL for every query before understanding whether normal JPA query mechanisms are sufficient।


ORM Does Not Mean "Never Write SQL"

Professional JPA usage requires understanding SQL।

Why?

Because eventually Hibernate generates SQL against PostgreSQL।

You need to reason about:

joins

indexes

query counts

locking

transactions

execution plans

An ORM can automate mapping।

It cannot make relational database knowledge unnecessary।


Spring Data Projections

Spring Data supports dedicated projection return types so queries can retrieve partial views instead of always returning full aggregate entities.

This can be useful for endpoints such as:

Product browsing

Order history

where we may need only a subset of fields।


Example: Order History

GET /orders may need:

id

status

createdAt

total/summary representation

It may not need full:

OrderItem object graph

for every row।

A dedicated persistence projection can make such reads more efficient।


Projection Is Not the API DTO Automatically

Don't return a Spring Data projection directly from Handler just because fields match।

Better architecture:

Persistence projection
    ↓
application/query result
    ↓
HTTP response DTO

where separation is useful।

Public API should not become coupled to repository framework representations।


Repositories Are Beans

Spring Data repository interfaces are discovered and implemented by Spring's repository infrastructure when configured/scanned as part of the application.

That means our JPA adapter can receive:

ProductJpaRepository

through constructor injection just like other Spring Beans।

No:

new ProductJpaRepository(...)

in application code।


Package Direction

A pragmatic capability-oriented structure might eventually look like:

product/
├── domain/
│   └── Product.java
├── usecase/
├── repository/
│   └── ProductRepository.java
└── persistence/
    ├── ProductEntity.java
    ├── ProductJpaRepository.java
    └── JpaProductRepository.java

We only create these files when actual implementation requires them।

No empty package tree in advance।


Order Capability

Likewise:

order/
├── domain/
├── usecase/
├── repository/
└── persistence/

Persistence remains inside the capability that owns it rather than creating one giant global:

repository/

package for the whole application।


Repository Mapping

Suppose application repository expects:

Optional<Product> findById(
        ProductId productId
);

JPA adapter may do:

@Override
public Optional<Product> findById(
        ProductId productId
) {
    return repository
            .findById(
                    productId.value()
            )
            .map(
                    ProductEntity::toDomain
            );
}

This is conceptual; exact mapping method design comes later।

The important boundary is:

Long / ProductEntity

stays inside persistence।


Saving

Likewise:

@Override
public Product save(
        Product product
) {
    ProductEntity entity =
            ProductEntity.from(product);

    ProductEntity saved =
            repository.save(entity);

    return saved.toDomain();
}

Again, this is only a conceptual mapping।

Generated identity handling needs more careful treatment when we build the actual entities।


Don't Implement Mapping Twice Everywhere

If every adapter contains dozens of repeated conversions, extract a mapper only when repetition/complexity justifies it।

For simple mapping:

ProductEntity.from(product)

entity.toDomain()

may be enough।

No mapper framework required yet।


Do We Need Lombok?

No।

JPA does not require us to introduce Lombok merely because entities contain boilerplate।

We already intentionally kept Lombok out of the project bootstrap।

Use plain Java until a concrete productivity problem justifies another dependency।


JPA and Domain Encapsulation

A common anti-pattern is turning entities into:

public getters

public setters for everything

because Hibernate needs access।

Then application code can do:

order.setStatus(PAID);

and bypass:

order.markPaid();

We don't want persistence requirements to destroy domain encapsulation।


Persistence Entities Can Absorb ORM Requirements

If our JPA representation is separate:

OrderEntity

it can satisfy persistence mapping requirements while:

Order

remains focused on business behaviour।

That's one practical benefit of separation in this application।


Schema Generation

Hibernate can generate/update schemas in some configurations।

But our accepted architecture says:

Flyway owns database migrations

So JPA mapping must match our version-controlled schema rather than become the production migration mechanism।


Avoid ddl-auto=update as Production Design

It can be tempting to configure Hibernate to alter the database automatically based on entity mappings।

But that makes production schema evolution less:

explicit

reviewable

controlled

Our production-style direction is:

Flyway migration
    ↓
database schema

JPA mappings
    ↓
map to that schema

validate Is a Useful Target

Once Flyway migrations are in place, Hibernate schema validation can be useful for detecting mismatches between entity mappings and the database schema at application startup।

The important point is:

JPA should verify/map the schema

not silently own migration history

We will configure this coherently when Flyway is introduced later in this module।


Transactions Matter

JPA behaviour is tightly connected to transactions।

For example:

load managed entity

change state

flush change

normally makes sense inside a transaction boundary।

For our architecture, mutating transaction boundaries generally belong at:

UseCase

level।


Example

Eventually:

@Component
public class CancelOrderUseCase {

    @Transactional
    public Order execute(...) {
        ...
    }
}

may coordinate:

load Order

load Inventory

cancel Order

restore Inventory

persist changes

as one local database transaction।

We cover transaction behaviour deeply in Module 7।


Don't Put @Transactional Everywhere

Avoid adding:

@Transactional

to every:

Handler

Repository

domain method

without understanding transaction ownership।

Our preferred reasoning remains:

one application operation
→ one transaction boundary where required

JPA Is Stateful Inside a Persistence Context

This matters because JPA is not simply:

run SQL
→ return data
→ forget everything

The persistence context tracks managed entity identity and lifecycle.

This behaviour enables useful features such as dirty checking, but it can also create surprises if engineers don't understand entity states।


Entity Lifecycle Mental Model

You will encounter states such as:

new/transient

managed

detached

removed

Exact lifecycle terminology belongs to JPA।

For now:

managed

is the most important concept।

A managed entity participates in the current persistence context।

A random plain Java object does not automatically do that।


Detached Does Not Mean Deleted

If an entity becomes:

detached

it means the persistence context is no longer managing that Java instance।

It does not mean the database row disappeared।

This distinction becomes important when mapping data between layers and transaction boundaries।


Avoid Passing JPA Entities Through the Whole Application

If Handler returns a managed JPA entity directly:

HTTP serialization
    ↓
touches relationships
    ↓
Hibernate may need more data

we can get unexpected persistence behaviour and tight coupling।

This is another reason we already designed explicit:

Request DTOs

Response DTOs

Lazy Loading Will Matter Later

JPA relationships may be loaded immediately or deferred depending on mapping and access patterns.

This can lead to:

unexpected queries

N+1 problems

access outside persistence context

We'll cover those issues in:

Relationships

Avoiding Common JPA Problems

lessons।

For now, don't treat object navigation as free।


findAll() Is Still Dangerous

Spring Data makes:

repository.findAll();

very easy।

That does not mean it is appropriate for:

GET /orders

Our API contract requires bounded pagination।

So persistence must use:

database-level pagination

rather than convenient unbounded repository methods।


Framework Convenience Does Not Override Requirements

If JpaRepository gives us:

deleteById(...)

that does not mean our Product capability should expose physical delete।

If it gives us:

findAll()

that does not mean Order API should return all rows।

Spring Data API is larger than our business API।

Use only the operations our application needs।


Why Our Repository Boundary Helps

Application:

ProductRepository

may expose:

findById

save

findOrderablePage

but not:

deleteAll

deleteById

So UseCases don't even see persistence operations that conflict with the business model।


Do Not Extend JpaRepository From the Domain Repository

Avoid:

public interface ProductRepository
        extends JpaRepository<
                ProductEntity,
                Long
        > {
}

inside the application repository package।

Now our domain/application repository boundary is itself Spring Data infrastructure।

Better:

ProductRepository
→ application contract

ProductJpaRepository
→ persistence framework contract

What About Boilerplate?

Yes, the adapter adds some mapping code।

For a small system, you may reasonably ask:

Is the extra class worth it?

In our course architecture, yes, because we intentionally want students to see:

business workflow

persistence contract

ORM implementation

as different concerns।

But we still keep each layer small and pragmatic।


Repository Naming

Use names that reveal which side they belong to।

For example:

ProductRepository
→ application
ProductJpaRepository
→ Spring Data interface
JpaProductRepository
→ application Repository implementation

This avoids ambiguous code where three different repository concepts all have the same name।


Don't Call Persistence Adapter a Service

Avoid:

ProductPersistenceService

Our terminology remains:

Repository
→ persistence boundary

Service remains reserved for external integrations such as:

PaymentService

Spring Data JPA Is Infrastructure

This should affect package direction and testing strategy।

UseCase tests should be able to test application behaviour without booting Hibernate whenever persistence mechanics are irrelevant।

Repository integration tests should test:

JPA mapping

generated SQL behaviour where relevant

constraints

queries

PostgreSQL integration

against a real database।


Use Testcontainers for Persistence Integration

Our established testing direction is PostgreSQL via Testcontainers for automated integration tests, so tests exercise the actual datastore technology instead of relying only on a different in-memory database. Spring Boot provides official Testcontainers integration support for tests that need real backend services.

We will implement those tests in the testing module।


Spring Data Slice Tests

Spring Boot also provides JPA-focused test support such as @DataJpaTest for testing JPA repositories/entities with a narrower application context. Current Spring Boot documentation describes it as configuring JPA entities/repositories and transactional rollback behaviour for tests.

For our PostgreSQL-specific integration confidence, we can combine focused persistence tests with PostgreSQL Testcontainers rather than relying on an unrelated embedded database।


Don't Mock Hibernate

We shouldn't write tests pretending to verify persistence by mocking:

EntityManager

Hibernate Session

line by line।

That mostly tests our mocking setup।

For real persistence mapping:

run against PostgreSQL

is much more valuable।


What Unit Tests Still Don't Need JPA

Domain:

Order.cancel()

Order.markPaid()

Inventory.decrease()

Product.changePrice()

can remain plain Java tests।

JPA shouldn't appear there at all।

This is a major benefit of keeping the domain framework-light।


Repository Interface Example

A Product application repository might evolve toward:

public interface ProductRepository {

    Optional<Product> findById(
            ProductId productId
    );

    Product save(
            Product product
    );
}

Later browsing requirements may add a bounded query abstraction։

Do not add:

findAll

deleteAll

countEverything

unless actual workflows require them।


Inventory Repository

Conceptually:

public interface InventoryRepository {

    Optional<Inventory> findByProductId(
            ProductId productId
    );

    Inventory save(
            Inventory inventory
    );
}

Later the concurrency-safe decrease strategy may require a more specialized persistence operation।

When that happens, evolve the Repository based on actual correctness requirements।


Order Repository

Conceptually:

public interface OrderRepository {

    Optional<Order> findById(
            OrderId orderId
    );

    Order save(
            Order order
    );

    ...
}

Customer history will require a bounded query by:

CustomerId

pagination

stable ordering

Again, repository methods emerge from UseCases and API requirements।


Repository Doesn't Have to Mirror the Table

OrderRepository is not required to expose:

OrderItemRepository

just because:

order_items

is a separate table।

Domain says:

OrderItem belongs to Order

Persistence can manage OrderItems as part of the Order persistence boundary।

Database tables do not dictate application repositories one-to-one।


Product and Inventory Are Different

By contrast:

Product

Inventory

are separate domain responsibilities and have different workflows।

Separate:

ProductRepository

InventoryRepository

is natural।

Again:

table count
≠
repository count automatically

Spring Data Is Not Our Query Language Design

Spring Data supports method derivation, JPQL, projections, pagination, and native queries.

We choose among them based on:

clarity

correctness

query complexity

performance

not because one technique is considered "more Spring."


Inspect the SQL

Even if Hibernate generates SQL, engineers should inspect it when performance or correctness matters।

For example Product browsing might need a query equivalent to:

products
JOIN inventory
WHERE active
AND available_quantity > 0

If ORM mapping generates:

21 queries

for a page of 20 Products, we have a problem।

"Generated by Hibernate" does not mean "efficient."


ORM Can Hide Database Cost

This Java code:

order.getItems()

looks cheap।

But depending on persistence state/mapping, it may imply:

database query

That invisible I/O is one of the biggest things backend engineers must learn to recognize when working with ORM।


Keep Persistence Access Deliberate

A useful rule:

When reading Java persistence code, be able to identify where database interaction may happen.

Don't let:

object-looking syntax

make you forget that PostgreSQL is on the other side।


JPA Doesn't Remove the Need for Schema Knowledge

We already designed:

PRIMARY KEY

FOREIGN KEY

CHECK

NUMERIC

TIMESTAMPTZ

before introducing JPA։

That was intentional।

If we had started with Hibernate annotations, we might accept whatever schema defaults appeared without understanding why।


Schema Is Still Authoritative

Our relational design says:

inventory.product_id
→ primary key + foreign key

order_items
→ composite key

Product
→ no physical delete

Order total
→ derived, not stored

JPA mapping should implement this model।

We do not change those decisions merely because another mapping is easier in a tutorial।


Spring Data JPA Review Checklist

When adding persistence code, ask:

Is this an application Repository need
or merely a JpaRepository convenience?

Is a JPA entity leaking into the UseCase?

Is Spring Data leaking into the domain?

Does this query remain bounded?

Does the ORM mapping match our accepted schema?

Could this object access trigger another SQL query?

Does this operation require a transaction?

Are we relying on Hibernate magic
without understanding entity state?

Would a direct projection be better for this read?

Are we creating abstractions because they help
or simply because an architecture diagram says so?

Common Mistake 1 — JPA, Hibernate, and Spring Data JPA Treated as the Same Thing

They operate at different levels of the persistence stack।


Common Mistake 2 — JpaRepository Becomes the Domain Architecture

Framework repositories are infrastructure tools, not business boundaries by default।


Common Mistake 3 — JPA Entity Returned From Controller

This leaks persistence representation into the public API।


Common Mistake 4 — UseCase Uses EntityManager

Database mechanics now live inside application workflow।


Common Mistake 5 — findAll() Used Because It's Convenient

Collection endpoints must remain bounded।


Common Mistake 6 — ORM Means SQL Knowledge Is Optional

Generated SQL still determines database performance and correctness।


Common Mistake 7 — Hibernate Generates Production Schema

Flyway remains our migration mechanism।


Common Mistake 8 — Every Entity Has Public Setters

Persistence convenience should not destroy domain encapsulation।


Common Mistake 9 — Every Table Gets Its Own Repository

Application repositories follow capability/workflow ownership, not table count।


Common Mistake 10 — JPA Behaviour Tested Only With Mocks

Persistence mappings and queries deserve integration tests against PostgreSQL।


Our Persistence Direction

Our architecture now becomes:

HTTP
    ↓
Handler
    ↓
UseCase
    ↓
Application Repository
    ↓
JPA Adapter
    ↓
Spring Data JPA
    ↓
Hibernate / Jakarta Persistence
    ↓
PostgreSQL

For external integration later:

UseCase
    ↓
PaymentService
    ↓
External Provider

These are different boundaries।


What Spring Data JPA Gives Us

It gives us useful persistence infrastructure:

repository implementations

entity lookup

persistence operations

query derivation

JPQL/native-query integration

pagination support

projection support

Spring Data JPA's repository abstraction is specifically designed to reduce repetitive data-access implementation while building on JPA.

We should use those capabilities where they reduce boilerplate without surrendering our domain/application boundaries।


What It Does Not Give Us

It does not give us:

correct Order lifecycle

safe ownership rules

Inventory concurrency strategy

good API design

good database indexes

correct transaction boundaries

good domain modeling

Those are engineering decisions।

Frameworks accelerate implementation।

They do not make those decisions for us।


Engineering Principle

The core principle:

JPA defines the persistence model, Hibernate implements ORM behaviour, and Spring Data JPA reduces repository boilerplate. None of them should replace our application architecture.

Another:

Use Spring Data JPA behind the Repository boundary, not as a reason to make UseCases and domain objects depend on persistence framework semantics.

And:

ORM lets us write less repetitive database-access code, but a backend engineer must still understand SQL, transactions, queries, and the relational schema being accessed.


Summary

In this lesson, we learned that:

  • Jakarta Persistence/JPA is the standard Java persistence and object-relational mapping API/specification.
  • Hibernate is an ORM implementation that maps Java object representations to relational database representations.
  • Spring Data JPA adds repository infrastructure on top of JPA and reduces data-access boilerplate.
  • spring-boot-starter-data-jpa is Spring Boot's standard starter for JPA-based persistence and includes key JPA/Hibernate/Spring Data support.
  • PostgreSQL remains the actual durable datastore.
  • JDBC remains underneath the ORM persistence stack.
  • JPA/Hibernate solve persistence mapping, not business workflows.
  • Spring Data's JpaRepository is a framework repository abstraction, while our ProductRepository, InventoryRepository, and OrderRepository represent application persistence boundaries.
  • UseCases should depend on application repositories rather than directly on JPA entities or JpaRepository.
  • A JPA persistence adapter can translate between domain objects and persistence entities.
  • EntityManager is a core JPA API for interacting with persistent entities and queries.
  • The persistence context manages entity instances and lifecycle by persistent identity.
  • Hibernate dirty checking can detect changes to managed entities and synchronize them to the database.
  • ORM-generated SQL still needs to be understood and inspected.
  • Spring Data derived queries are useful for simple queries but should not become unreadable method-name programming.
  • JPQL, native SQL, and projections are available when query requirements justify them.
  • Spring Data supports projections for partial read views rather than always loading complete managed entities.
  • Public API DTOs should remain separate from JPA persistence entities.
  • Domain models should not become collections of public setters merely to satisfy persistence tooling.
  • Database schema evolution remains owned by Flyway rather than Hibernate auto-update.
  • Transaction ownership remains an application/UseCase concern.
  • findAll() is not appropriate for APIs that require bounded pagination merely because the framework provides it.
  • Repository integration tests should eventually run against PostgreSQL through Testcontainers, while domain tests remain plain Java where persistence is irrelevant.

Next lesson:

Entity Mapping

There we will map our actual products, inventory, orders, and order_items schema into JPA entities—covering @Entity, @Table, @Id, generated IDs, column mappings, composite keys, enum persistence, timestamps, and the boundary between JPA entities and our domain objects.