Persistence with PostgreSQL

Why Our Application Needs Persistence

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

So far, most of our application design has focused on:

Domain objects in memory

and

HTTP contracts around them

We have modeled:

Product
Inventory
Order
OrderItem

and operations such as:

create Product
browse Products
set Inventory
create Order
cancel Order
pay Order

But one fundamental problem remains.

Imagine the application currently knows:

Product
59ba787c-6d83-4a21-9bae-19c3d0469bd5

Inventory
availableQuantity = 12

Order
d2e7b44e-463f-4bbc-b16f-b1c80bc80442

Then the Spring Boot process restarts.

If that state exists only in Java memory:

process stops
    ↓
memory is released
    ↓
business state disappears

That is not acceptable for a production backend.

A deployment must not erase Products.

A restart must not reset Inventory.

A new application instance must not forget Orders.

We need durable storage that survives the lifetime of one Java process.

For this course, that responsibility belongs to:

PostgreSQL

The goal of this lesson is:

Understand which application state must be persisted, why PostgreSQL is needed, where database responsibility begins and ends, and how persistence should support the Domain rather than define it.


Memory Is Temporary

A Java object such as:

Product product =
        Product.create(
                ProductId.newId(),
                "Mechanical Keyboard",
                10000
        );

exists inside the JVM process.

If the process exits:

JVM stops
    ↓
memory is released
    ↓
object disappears

This is normal.

But business state must survive:

deployments
restarts
crashes
new application instances

Persistence exists so important state outlives the process currently using it.


What State Must Survive?

Our current v1 Domain has four clear categories of durable business state:

Product
Inventory
Order
OrderItem

Each has a different purpose.


Product State

A Product contains:

ProductId
name
priceCents
active

For example:

ProductId
59ba787c-6d83-4a21-9bae-19c3d0469bd5

name
Mechanical Keyboard

priceCents
10000

active
true

The Product catalog must survive application restarts.

Therefore Product state must be persisted.

Our identity strategy is already decided:

HTTP
→ UUID string

Domain
→ ProductId(UUID)

PostgreSQL
→ UUID

Product IDs are application-generated.

Persistence stores them.

It does not invent them.


Inventory State

Inventory contains:

ProductId
availableQuantity

Suppose:

availableQuantity = 4

If a restart resets that quantity to an arbitrary default such as:

100

we could oversell Products.

Inventory is therefore durable business state.

It must survive restart exactly as Product state does.


Inventory Has No Separate ID

Our Domain already established:

Inventory identity
=
ProductId

We do not need:

InventoryId

simply because an ORM often gives every table a generated ID.

Persistence should follow the identity model we deliberately chose.


Missing Inventory and Zero Inventory Differ

These states are not equivalent:

no Inventory record exists

and:

Inventory exists
availableQuantity = 0

Our persistence design must preserve that distinction.

Missing Inventory should not automatically become zero Inventory.


Order State

Order contains durable information such as:

OrderId
CustomerId
OrderStatus
createdAt
OrderItems

For example:

OrderId
d2e7b44e-463f-4bbc-b16f-b1c80bc80442

CustomerId
customer-12345

status
UNPAID

createdAt
2026-10-01T10:00:00Z

Customers need Order history later.

Cancellation needs to reload existing Orders.

Payment needs existing Order state.

Order must therefore be persisted.


CustomerId Is Persisted, Customer Is Not Locally Owned

An Order belongs to a Customer, so Order must preserve:

CustomerId

But our application does not own Customer account lifecycle.

We do not currently manage:

registration
passwords
profile lifecycle
Customer account creation

Therefore:

Order persists CustomerId

does not imply:

create a customers table

Our external identity system remains authoritative.

CustomerId is a stable nonblank String.

We do not assume it is a UUID.


OrderItem Is Historical State

OrderItem preserves:

ProductId
quantity
unitPriceCents

For example:

ProductId
59ba787c-6d83-4a21-9bae-19c3d0469bd5

quantity
2

unitPriceCents
10000

This is historical business information.

It must survive application restart.


Why Current Product Price Is Not Enough

Suppose a Product costs:

priceCents = 10000

A Customer creates an Order for:

2 units

The historical total is:

2 × 10000
=
20000 cents

Later an Admin changes Product price to:

priceCents = 12000

If Order history uses current Product price:

2 × 12000
=
24000 cents

the historical Order changes.

That is wrong.

OrderItem therefore persists:

unitPriceCents = 10000

at successful Order creation.

This preserves our earlier rule:

Historical Order price must remain stable even when current Product price changes.


Historical Duplication Can Be Correct

We persist both:

Product.priceCents

and:

OrderItem.unitPriceCents

because they represent different facts.

Product.priceCents
→ current price
OrderItem.unitPriceCents
→ price used by this historical Order

The values may initially be identical.

Later they may differ.

That difference is intentional.


Order Total Remains Derived

Our current design does not persist:

orders.total_cents

as authoritative state.

Order total is derived from:

quantity × unitPriceCents

for every OrderItem.

So persistence must preserve enough historical information to calculate the same total later.

That means preserving:

quantity
unitPriceCents

rather than storing another authoritative total that could become inconsistent.


createdAt Is Durable

Order history uses:

createdAt DESC
OrderId DESC

for deterministic ordering.

createdAt is generated by the application:

Instant.now(clock)

during Order creation.

It must then be persisted.

Reloading an Order after restart must not create a new timestamp.


What Does Persistence Mean?

Persistence means more than:

save object

Our application needs operations such as:

store
retrieve
update
query

Examples include:

save Product
find Product by ProductId
browse orderable Products

find Inventory by ProductId
set Inventory quantity
consume Inventory atomically
restore Inventory

save Order
find Customer-owned Order
find Order for update
load Customer Order history

These needs shape our Repository contracts.


Why PostgreSQL?

Our engineering context already selected:

PostgreSQL

The choice fits our Domain because we have clear relationships:

Product
    ↓
Inventory

and:

Order
    ↓
OrderItems

and:

Order
    ↓
CustomerId

We also need strong consistency for workflows such as:

Create Order
Cancel Order

PostgreSQL gives us:

primary keys
foreign keys
constraints
transactions
indexes
querying
concurrent access

These capabilities fit the application well.


PostgreSQL Is More Than Disk Storage

A production database provides:

durability
concurrent access
querying
transactions
data integrity

These matter because multiple requests can operate on shared state at the same time.

For example:

Customer A creates Order
Customer B creates Order
Admin changes Inventory
Customer C browses Products

The requests cannot assume they are the only actors modifying the system.

PostgreSQL becomes the coordinated durable source of application-owned state.


Persistence Changes the Lifetime of Objects

Without persistence, it is easy to imagine:

Product product = ...

living indefinitely.

A real flow is closer to:

HTTP Request
    ↓
Handler
    ↓
UseCase
    ↓
Repository
    ↓
PostgreSQL

The Repository loads durable state:

PostgreSQL
    ↓
Domain object

The application performs behavior:

Domain object
    ↓
business operation

Then the resulting state is persisted again.

The Java object is temporary.

The business identity is durable.


Example: Product Update

Conceptually:

PATCH /api/v1/products/{productId}
    ↓
UpdateProductHandler
    ↓
UpdateProductUseCase
    ↓
ProductRepository.findById(productId)
    ↓
Product
    ↓
product.rename(...)
and/or
product.changePrice(...)
    ↓
ProductRepository.save(product)

Product still owns its behavior.

Persistence makes the resulting state durable.


Persistence Does Not Replace the Domain

A dangerous direction is:

database table
    ↓
generated Java class
    ↓
public API

Now database structure defines the application.

Our direction remains:

business requirements
    ↓
Domain model
    ↓
application workflows
    ↓
persistence design

The database supports the application.

It does not independently define the business.


Domain Entity vs JPA Entity

A Domain Entity means:

an object with business identity and lifecycle.

Examples:

Product
Order

JPA also uses:

@Entity

But that annotation describes persistence mapping.

The concepts may overlap in implementation, but they are not identical.

A Domain Entity does not need to mirror one database row exactly.


Avoid Database-First Domain Modeling

If we begin by creating:

CREATE TABLE orders (
    ...
);

and generate Java classes directly from the schema, the classes naturally focus on:

columns
foreign keys
getters
setters

Our Domain needs behavior such as:

cancel()
markPaid()
totalCents()

So we first understand business behavior.

Then we design persistence to support it.


Database Responsibilities

PostgreSQL should help us with:

durable storage
atomic local persistence
relationships
structural integrity
efficient querying
concurrent access

Those are legitimate database responsibilities.


Database Is Not the Business Workflow

We do not want Order lifecycle hidden in database triggers.

For example:

when Order becomes CANCELLED
→ automatically restore Inventory

would hide important application behavior outside:

CancelOrderUseCase

Likewise, PostgreSQL should not attempt to call an external Payment provider.

Our architecture remains:

Handler
    ↓
UseCase
    ↓
Domain
    ↓
Repository
    ↓
PostgreSQL

UseCases Own Workflow

For cancellation:

CancelOrderUseCase

coordinates:

load Customer-owned Order
lock required state
call order.cancel()
restore Inventory
persist changes atomically

PostgreSQL stores the result safely.

It does not replace the workflow.


Domain Owns Local Invariants

Examples include:

Product priceCents >= 0

Inventory availableQuantity >= 0

OrderItem quantity > 0

UNPAID → PAID

UNPAID → CANCELLED

Those rules remain explicit in Domain code.

The database can reinforce them.

It does not become the only place they exist.


Database Constraints Reinforce Invariants

Suppose the schema includes:

CHECK (
    available_quantity >= 0
)

We then have layered protection:

request validation
    ↓
Domain/application rules
    ↓
database constraint

If a bug tries to persist:

availableQuantity = -3

PostgreSQL can reject it.

That is useful.

But it does not mean the Domain should stop protecting its invariant.


Create Order Requires Persistence

Our canonical Create Order workflow is:

resolve CustomerId

reject duplicate Product IDs

load Products

verify Products exist

verify Products are active

capture authoritative priceCents

atomically consume Inventory

create OrderItems

generate OrderId

generate createdAt

create UNPAID Order

persist Order and OrderItems

Conceptually:

CreateOrderUseCase
    ↓
ProductRepository
    ↓
InventoryRepository
    ↓
OrderRepository
    ↓
PostgreSQL

Persistence is now central to the workflow.


Do Not Use Load-Check-Save for Order-Time Inventory

A naive workflow is:

load Inventory
    ↓
check available quantity
    ↓
decrease Java object
    ↓
save Inventory

Two concurrent Orders can both observe the same stock before either commits.

That creates a race.

Our canonical Order-time Inventory operation is:

inventoryRepository.consumeIfAvailable(
        productId,
        quantity
);

backed by:

UPDATE inventory
SET available_quantity =
        available_quantity - :quantity
WHERE product_id = :productId
  AND available_quantity >= :quantity;

The affected row count tells us whether consumption succeeded.

This is a persistence-level concurrency concern.


Domain Inventory Still Matters

Inventory Domain behavior can still protect rules for operations such as:

setAvailableQuantity(...)
increase(...)

and the invariant:

quantity cannot be negative

But concurrent Order consumption needs database coordination because the shared durable state exists there.


Create Order Must Be Atomic

Successful Order creation changes:

Inventory

Order

OrderItems

We do not want:

Inventory decreased

but:

Order failed to persist

leaving inconsistent state.

These local changes should happen in one PostgreSQL transaction.

Conceptually:

BEGIN

consume Inventory
insert Order
insert OrderItems

COMMIT

If something fails:

ROLLBACK

The transaction design is covered later.

The important point is:

Several local durable changes can participate in one atomic operation.


Cancellation Also Changes Multiple Durable Facts

Order cancellation changes:

Order.status
UNPAID → CANCELLED

and:

Inventory
→ restored

Both must survive restart.

They should commit together.

The canonical workflow remains:

load owned Order for update

order.cancel()

restore Inventory

commit

The Domain transition happens before Inventory restoration.

That prevents repeated cancellation from restoring stock twice.


Payment State Must Survive

When Payment succeeds and Order becomes:

PAID

that status must persist.

Otherwise a restart could make the system believe the Order is still:

UNPAID

and potentially permit another payment attempt.


Payment Persistence Is Still Deferred

Do we also need a:

payments

table?

Maybe.

The eventual integration may require durable:

provider reference
attempt state
idempotency data
reconciliation state

or perhaps less.

We do not create a Payment table merely because Payment exists as a capability.

Persistence should follow the actual workflow.


External Payment Cannot Join Our PostgreSQL Transaction

Create Order and Cancel Order are local database workflows.

Payment is different.

The external provider owns another system.

We cannot wrap:

our PostgreSQL transaction

around:

the provider's database

So:

local database state
+
external Payment execution

cannot be made atomic through one ordinary PostgreSQL transaction.

This is why we do not hold local database locks across a remote Payment call.

That complexity belongs in the external-services module.


Not Every Class Needs a Table

Types such as:

CreateProductRequest
CreateOrderRequest
ProductResponse
OrderResponse
ApiProblem
PageResponse

are transport representations.

They do not represent durable business state.

We therefore do not create tables such as:

create_order_requests
product_responses
api_problems

Persistence is driven by durable business meaning.

Not Java class count.


UseCases Do Not Need Tables

Likewise:

CreateOrderUseCase
CancelOrderUseCase
PayOrderUseCase

represent application behavior.

They are not data records.

No database table is needed for a UseCase.


Customer Does Not Need a Local Table

We persist:

CustomerId

on Order.

But Customer account lifecycle is externally owned.

So v1 does not need:

customers

simply because Orders belong to Customers.

This keeps persistence aligned with system ownership.


Product and Inventory Remain Separate

We deliberately modeled:

Product

and:

Inventory

as separate responsibilities.

Product changes because of:

catalog decisions
price changes
rename
deactivation

Inventory changes because of:

Order creation
Order cancellation
Admin quantity changes

Their invariants and concurrency patterns differ.

Persistence should preserve that separation.


Separate Tables Do Not Mean Microservices

We may have:

products
inventory
orders
order_items

without creating:

Product Service
Inventory Service
Order Service

as separate deployments.

Our architecture remains:

one modular monolith

one Spring Boot application

one PostgreSQL database

Logical boundaries do not require network boundaries.


One PostgreSQL Database Is Intentional

A shared PostgreSQL database gives us a local transaction boundary across:

Inventory
Order
OrderItems

This is extremely useful for:

Create Order
Cancel Order

We do not need separate databases per capability.


We Do Not Need Redis

Redis is useful in many systems.

But our current requirements do not need it.

Adding another datastore would introduce:

another dependency
consistency questions
operational complexity

without solving our current persistence problem better than PostgreSQL.


We Do Not Need Event Sourcing

Event Sourcing could require:

event store
event replay
event schema evolution
projections
snapshots

Our application does not need that complexity.

We persist current relational state directly.

Historical facts that matter, such as OrderItem purchase-time price, are stored explicitly.


We Do Not Need CQRS

We may use optimized read projections for:

Product browsing
Order history

That does not require:

separate read/write systems
separate databases
full CQRS architecture

Efficient read queries are compatible with our existing modular monolith.


Repository Is the Persistence Boundary

Our architecture already says:

Handler
    ↓
UseCase
    ↓
Repository

A Repository expresses:

what the application needs
from persistence

without forcing the UseCase to know:

SQL
JPA
tables
joins
EntityManager

Avoid Generic CRUD Repositories

A generic abstraction such as:

interface Repository<T, ID> {

    T save(T entity);

    T findById(ID id);

    List<T> findAll();

    void delete(ID id);
}

may expose operations the business does not support.

For example, Product has no physical delete operation in v1.

Repository contracts should emerge from actual application workflows.


Product Persistence Needs

Product may require operations such as:

find by ProductId
save Product
browse orderable Products

The exact interface should reflect those needs.


Inventory Persistence Needs

Inventory may require:

find by ProductId
set exact quantity
list Inventory
consumeIfAvailable
restore quantity

The atomic consumption operation demonstrates why generic CRUD is often insufficient.


Order Persistence Needs

Order may require:

save Order

find Customer-owned Order

find Customer-owned Order for update

load Order detail

find Customer Order history

Later Admin workflows may add broader queries.

Again, Repository methods should reflect application semantics.


Ownership Can Be Built Into Queries

For Customer Order access, our privacy rule is:

missing Order
and
non-owned Order

→ same ORDER_NOT_FOUND

A Repository method such as:

findOwnedBy(
    orderId,
    customerId
)

can directly support that policy.

This is often better than loading arbitrary Orders and checking ownership later.


Do Not Put SQL in UseCases

Avoid:

entityManager.createQuery(...);

or:

jdbcTemplate.query(...);

inside:

CreateOrderUseCase

UseCases coordinate business operations.

Repository and persistence implementations own database mechanics.


Repository Does Not Know HTTP

Repository code should not:

return ResponseEntity

throw HTTP 404

construct ApiProblem

return HttpStatus

Persistence communicates application-relevant outcomes.

The HTTP boundary maps those outcomes into:

404
409
500

and Problem Details responses.


Domain and Relational Representations May Differ

Domain:

Order
    ↓
List<OrderItem>

Relational storage:

orders row

+

multiple order_items rows

These representations solve different problems.

Mapping connects them.

JPA can help bridge this difference, but we still need deliberate mapping.


Identity Decisions Come Before JPA

Our Domain already established:

ProductId
→ UUID

OrderId
→ UUID

CustomerId
→ String

Inventory
→ ProductId

OrderItem
→ OrderId + ProductId

Those decisions influence schema design.

The ORM does not decide them.


Avoid Accidental @GeneratedValue

A common mistake is:

@Id
@GeneratedValue
private Long id;

followed by:

Our Domain uses Long IDs because Hibernate generated them.

That is backwards.

ProductId and OrderId are application-generated UUIDs.

We do not need @GeneratedValue for them.


OrderItem Does Not Need a Synthetic ID

OrderItem naturally belongs to:

OrderId + ProductId

So persistence does not need to invent:

OrderItemId

merely for ORM convenience.

The actual mapping will be designed later.


Product Deactivation Is Persistent State

Product deactivation means:

active = false

The Product remains persisted.

We do not physically delete it.

Historical Orders may still reference its ProductId.

Our business operation is:

deactivate

not:

erase Product history

Persistence should reflect that lifecycle.


Queryability Matters

Known queries include:

find Product by ID
browse orderable Products

find Inventory by ProductId
list Inventory

find Order by ID
find Customer Order history
paginate Orders

These query patterns will influence:

schema
indexes
Repository methods

later.


Product Browsing Is a Relational Query

Customer Product browsing means:

Product.active = true

AND

Inventory.availableQuantity > 0

with:

name ASC
id ASC

and bounded pagination.

Persistence should query this efficiently.

We do not need one Inventory query per Product.


Order History Has a Concrete Access Pattern

Customer Order history needs:

WHERE customer_id = authenticated CustomerId

ORDER BY created_at DESC, id DESC

LIMIT / OFFSET

This access pattern will influence future index design.

Database design should consider both:

Domain meaning

and:

application access patterns

Do Not Index Everything

Indexes consume:

disk
write overhead
maintenance

We will create them based on actual query needs.

Our API design already tells us which queries matter.


Persistence Introduces New Failure Modes

Once PostgreSQL exists, we must consider:

database unavailable
connection failure
transaction failure
constraint violation
deadlock
timeout

Persistence is not an infallible object store.

These failures must be handled deliberately.

They should not leak raw SQL or database details into the public API.


Schema Must Evolve Safely

Production databases contain real state.

We cannot handle every model change with:

DROP everything
recreate everything

For this course:

Flyway

will manage versioned schema migrations.

Database changes should be:

explicit
versioned
reviewable
repeatable

JPA Does Not Replace Flyway

Our direction is:

Flyway
→ owns schema evolution

JPA
→ maps Java persistence structures
  to that schema

We do not rely on Hibernate to silently redesign production tables.


ddl-auto=create Is Not Our Migration Strategy

Automatic schema creation may be useful for experiments.

But our production-style application needs controlled migrations.

A durable-data feature may require:

Flyway migration
Repository changes
mapping changes
integration tests
safe schema evolution

as part of Definition of Done.


Local Development

PostgreSQL runs as a dependency through Docker Compose:

docker compose up -d

Spring Boot still runs normally through:

./gradlew bootRun

or:

IDE
packaged JAR

We do not need to containerize the application itself yet.


Application and Database Lifecycles Are Separate

Locally:

Docker Compose
→ PostgreSQL

while:

Gradle / IDE
→ Spring Boot

The Java application can restart without destroying PostgreSQL state.

That is the central idea of persistence in practical form:

application process lifetime
≠
business data lifetime

Persistence Integration Testing

Automated tests should not require a developer to manually start local Compose.

Later we will use:

Testcontainers

to start real PostgreSQL instances during integration tests.

That gives us tests that are:

isolated
repeatable
CI-friendly
close to production behavior

Why Not H2 for Everything?

Databases differ in:

SQL behavior
types
constraints
locking
transactions
query planning

Our application depends on PostgreSQL behavior for things such as:

UUID
TIMESTAMPTZ
conditional Inventory update
row locking
transactions

So important persistence behavior should be tested against real PostgreSQL.


Not Every Test Needs a Database

Domain behavior such as:

order.cancel();

or:

product.changePrice(...);

can be tested with plain Java.

A balanced strategy is:

Domain test
→ no database

UseCase unit test
→ test doubles where appropriate

Repository integration test
→ PostgreSQL Testcontainer

selected API integration tests
→ Spring Boot + PostgreSQL

Each test type proves a different concern.


Mocks Cannot Prove Database Behavior

A mock Repository cannot prove:

JPA mapping works

Flyway migration works

foreign keys work

queries return correct rows

transactions roll back correctly

Inventory concurrency is safe

Those concerns require real PostgreSQL integration tests.


Inventory Concurrency Needs Real PostgreSQL Tests

Our atomic operation:

consumeIfAvailable(...)

exists specifically because concurrent requests can race.

A mock cannot prove:

two concurrent Orders cannot oversell

Later, Testcontainers-based concurrency tests will verify this against PostgreSQL.


Persistence Should Be Boring

A healthy persistence layer should prefer:

clear schema
clear mappings
explicit migrations
predictable queries
measured indexes

over:

magic triggers
generic abstractions everywhere
reflection-heavy persistence layers
clever dynamic SQL without need

Simple persistence is easier to understand and operate.


Example: State Survives Restart

Suppose PostgreSQL contains:

Product
id = 59ba787c-6d83-4a21-9bae-19c3d0469bd5
name = Mechanical Keyboard
priceCents = 10000
active = true
Inventory
productId = 59ba787c-6d83-4a21-9bae-19c3d0469bd5
availableQuantity = 4
Order
id = d2e7b44e-463f-4bbc-b16f-b1c80bc80442
customerId = customer-12345
status = UNPAID
createdAt = 2026-10-01T10:00:00Z
OrderItem
productId = 59ba787c-6d83-4a21-9bae-19c3d0469bd5
quantity = 2
unitPriceCents = 10000

The Spring Boot process restarts.

The new process loads:

the same business state

That is the fundamental value of persistence.


Example: Historical Price Survives

Initially:

Product.priceCents = 10000

Order creation captures:

OrderItem.unitPriceCents = 10000

Later:

Product.priceCents = 12000

Persistence now answers two different questions correctly:

What does the Product cost now?
→ 12000
What price did this Order use?
→ 10000

Current state and historical state coexist.


Example: Cancellation Survives

Before cancellation:

Order.status = UNPAID

Inventory.availableQuantity = 4

The Order contains:

quantity = 2

Cancellation changes:

Order
UNPAID → CANCELLED

and:

Inventory
4 → 6

Both changes commit together.

After restart:

Order remains CANCELLED
Inventory remains 6

That is durable consistency.


Persistence Review Questions

Before storing something, ask:

Does this state need to survive restart?

Does this application own the state?

Is it current or historical state?

What identifies it?

What workflows modify it?

What queries need it?

What concurrency concerns exist?

What integrity rules should
the database reinforce?

Does it really need its own table?

These questions help avoid both under-modeling and over-modeling.


What We Currently Expect to Persist

Our durable business state is:

Product
Inventory
Order
OrderItem

Order also persists:

CustomerId
createdAt

Identity:

ProductId
→ UUID

OrderId
→ UUID

CustomerId
→ String

Inventory
→ ProductId

OrderItem
→ OrderId + ProductId

Payment-specific persistence remains deferred until the external integration establishes what additional durable state is actually required.


What We Do Not Persist as Business Tables

Examples include:

CreateProductRequest
UpdateProductRequest
SetInventoryRequest
CreateOrderRequest

ProductResponse
OrderResponse
OrderSummaryResponse

ApiProblem
FieldProblem
PageResponse

Handler
UseCase

These are transport or application constructs.

They are not durable business records.


Responsibility Map

Handler

Owns:

HTTP transport
request mapping
response mapping

No SQL or JPA mechanics.

UseCase

Owns:

application workflow
transaction intent

and coordinates Repositories.

Domain

Owns:

business state
local invariants
behavior

Its meaning is not defined by JPA.

Repository

Defines:

application persistence
and query needs

without exposing database mechanics to UseCases.

PostgreSQL / Persistence Layer

Owns:

durable storage
relational representation
queries
constraints
transaction mechanics
indexes

Common Mistakes

Do not turn every Java class into a table.

Do not let the schema define the entire Domain.

Do not create generic CRUD repositories that expose unsupported business operations.

Do not place SQL or EntityManager calls inside UseCases.

Do not hide business workflows inside database triggers without a real reason.

Do not remove Domain validation merely because PostgreSQL also has constraints.

Do not create a local Customer table when Customer lifecycle is externally owned.

Do not create a Payment table before the Payment integration defines what must be persisted.

Do not physically delete Product when the business operation is deactivation.

Do not use load-check-save as the concurrency strategy for Order-time Inventory consumption.

Do not assume an in-memory test database behaves exactly like PostgreSQL.

Do not turn every Domain test into a database integration test.


Engineering Principle

The core principle is:

Persistence exists to make business state durable; it should support the Domain, not become the Domain.

Another:

Use PostgreSQL to reinforce integrity, coordinate shared durable state, and provide local transactional consistency while keeping business workflows explicit in UseCases and Domain objects.

And:

Persist only state the application genuinely owns and needs to survive—not every class, DTO, operation, or noun in the codebase.


Summary

In this lesson, we established that:

  • Java memory is temporary.
  • PostgreSQL is our authoritative durable store for application-owned business state.
  • Product, Inventory, Order, and OrderItem require persistence.
  • Product persists UUID ProductId, name, priceCents, and active state.
  • Inventory persists ProductId and availableQuantity.
  • Inventory uses ProductId as its identity.
  • Missing Inventory and zero Inventory are different states.
  • Order persists UUID OrderId, external String CustomerId, status, createdAt, and OrderItems.
  • v1 does not need a local Customer table.
  • OrderItem persists ProductId, quantity, and historical unitPriceCents.
  • Product current price and OrderItem historical price represent different business facts.
  • Order totalCents remains derived rather than authoritative persisted state.
  • PostgreSQL provides durability, querying, concurrency coordination, constraints, and transactions.
  • Persistence supports Domain behavior rather than replacing it.
  • Domain Entity and JPA Entity are not the same concept.
  • Business requirements and Domain semantics continue to drive persistence design.
  • Database constraints reinforce Domain invariants.
  • UseCases continue to own business workflows.
  • Order-time Inventory consumption uses atomic consumeIfAvailable(...).
  • Create Order requires Inventory, Order, and OrderItems to commit atomically.
  • Cancellation requires Order status and Inventory restoration to commit atomically.
  • Payment success must persist Order's PAID state.
  • Payment-specific persistence remains deferred until the provider workflow requires it.
  • External Payment cannot participate in one ordinary PostgreSQL transaction with our local database.
  • DTOs, Handlers, UseCases, API Problems, and pagination models are not business tables.
  • Product and Inventory remain separate persistence responsibilities.
  • Separate tables do not imply microservices.
  • We continue using one modular monolith, one Spring Boot application, and one PostgreSQL database.
  • Redis, Event Sourcing, and CQRS are not required for the current scope.
  • Repository contracts should reflect actual application needs rather than generic CRUD.
  • SQL and JPA mechanics stay behind Repository boundaries.
  • Repository code does not know HTTP.
  • Domain and relational representations may differ.
  • ProductId and OrderId remain application-generated UUIDs.
  • CustomerId remains an external String.
  • JPA does not decide our Domain identity strategy.
  • Product deactivation persists active = false rather than deleting historical Product data.
  • API access patterns will later guide schema and index design.
  • Persistence introduces new operational failure modes that must be handled deliberately.
  • Flyway will manage explicit database schema evolution.
  • Hibernate schema auto-creation is not our production migration strategy.
  • PostgreSQL runs locally through Docker Compose.
  • Spring Boot continues to run through Gradle, IDE, or JAR.
  • PostgreSQL integration tests use Testcontainers.
  • Domain tests remain database-free where persistence is irrelevant.
  • Real PostgreSQL is required to verify mappings, constraints, transactions, and concurrency.
  • Persistence should remain explicit, predictable, and operationally boring.

Next lesson:

Relational Database Thinking

There, we will stop thinking in Java object graphs for a moment and learn how PostgreSQL represents our Domain through relations, rows, columns, primary keys, foreign keys, constraints, and joins before designing the actual Product, Inventory, Order, and OrderItem schema.