Persistence with PostgreSQL
Why Our Application Needs Persistence
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
totalCentsremains 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
PAIDstate. - 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 = falserather 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.
