Persistence with PostgreSQL

Why Our Application Needs Persistence

ReadingPreview

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

এখন পর্যন্ত আমাদের application-এর বেশিরভাগ design ছিল memory-এর মধ্যে থাকা objects এবং HTTP contracts নিয়ে।

আমরা model করেছি:

Product

Inventory

Order

OrderItem

এবং operations:

create Product

browse Products

set Inventory

create Order

cancel Order

pay Order

কিন্তু একটি fundamental problem এখনো solve হয়নি।

ধরুন application চলছে এবং memory-তে আছে:

Product P-100

Inventory = 12

Order O-1001

তারপর application restart হলো।

যদি state শুধু Java memory-তে থাকে:

সব data হারিয়ে যাবে

এটি production backend-এর জন্য acceptable নয়।

আমাদের এমন একটি persistence mechanism দরকার যা application process-এর lifetime-এর বাইরে state preserve করবে।

এই course-এ সেই responsibility নেবে:

PostgreSQL

এই lesson-এর goal:

কোন application state persist করতে হবে, কেন PostgreSQL দরকার, database-এর responsibility কোথায় শুরু ও শেষ হয়, এবং persistence-কে domain design-এর driver না বানিয়ে কীভাবে system architecture-এর অংশ হিসেবে ভাবতে হয়।


Memory Is Temporary

Java object:

Product product = new Product(...);

application process-এর memory-তে থাকে।

Application বন্ধ হলে:

process ends
    ↓
memory released
    ↓
object disappears

Production system-এ আমরা চাই না:

deployment

বা:

server restart

হওয়ার কারণে সব Order হারিয়ে যাক।

Persistent storage-এর মূল উদ্দেশ্য:

important state survives process lifetime

What State Must Survive?

আমাদের system-এর জন্য persistent state identify করা যাক।

Product

আমাদের preserve করতে হবে:

ProductId

name

current price

active state

কারণ application restart হলেও Product catalog হারানো যাবে না।


Inventory

Inventory state:

ProductId

available quantity

persist করতে হবে।

ধরুন:

availableQuantity = 4

application restart-এর পরে যদি আবার:

100

হয়ে যায়, আমরা overselling করতে পারি।

Inventory is durable business state।


Order

Order-এর জন্য preserve করতে হবে:

OrderId

CustomerId

OrderStatus

OrderItems

কারণ customer পরে:

Order history

দেখবে।

Payment বা cancellation-এর সময় existing Order আবার load করতে হবে।


OrderItem

OrderItem historical business record-এর অংশ।

আমাদের preserve করতে হবে:

ProductId

quantity

purchase-time unit price

কারণ current Product price পরে change হলেও historical Order total change হওয়া যাবে না।


Why Product Price Alone Is Not Enough

Suppose Product:

Keyboard
price = 100

Customer creates an Order:

2 × Keyboard

Order total:

200

এক সপ্তাহ পরে admin changes Product price:

price = 120

যদি historical Order read করার সময় আমরা current Product price ব্যবহার করি:

2 × 120 = 240

এটি ভুল।

তাই OrderItem persist করে:

unitPrice = 100

at purchase/order creation time।

Persistence supports our earlier domain decision:

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


What Does "Persist" Actually Mean?

Persistence means শুধু:

save object somewhere

না।

আমাদের system-এ persistence operations include:

store

retrieve

update

query

delete where appropriate

For example:

save Product

find Product by ProductId

find orderable Products

load Inventory by ProductId

update Inventory quantity

save Order

find Order by OrderId

find Orders by CustomerId

Why PostgreSQL?

Our current engineering context already selected:

PostgreSQL

as the relational database।

This fits our system well because our data has clear relationships:

Product
    ↓
Inventory

Order
    ↓
OrderItems

Order
    ↓
CustomerId

and we need strong consistency for workflows such as:

Create Order

where multiple related state changes must succeed together।


Relational Data Fits Our Domain

Consider Order:

Order
├── OrderItem
├── OrderItem
└── OrderItem

This naturally maps to relational structures such as:

orders

order_items

where rows can be connected through keys।

Likewise Inventory has a clear relationship to Product।

Relational storage gives us mechanisms such as:

primary keys

foreign keys

constraints

transactions

indexes

which will become useful in later lessons।


Database Is Not Just a File

A production database provides more than disk storage।

It helps with:

durability

concurrent access

querying

transactions

data integrity

These properties are crucial for a multi-request backend।


Multiple Requests Access the Same State

Our application can receive concurrent requests:

Customer A creates Order

Customer B creates Order

Admin updates Inventory

Customer C browses Products

They may all interact with the same data।

A relational database provides a coordinated shared state store।

Without this, each application request cannot safely assume it is the only one modifying state।


Persistence Changes How We Think About Objects

So far we could imagine:

Product product = ...

existing indefinitely।

With persistence, a typical flow becomes:

HTTP Request
    ↓
UseCase
    ↓
Repository
    ↓
Database

Repository loads data:

Database
    ↓
Product

UseCase/domain operates on it:

Product
    ↓
business behaviour

Then changes are persisted again।


Example: Product Update

Conceptually:

PATCH /products/P-100
    ↓
UpdateProductHandler
    ↓
UpdateProductUseCase
    ↓
ProductRepository.findById(P-100)
    ↓
Product
    ↓
product.changePrice(...)
    ↓
ProductRepository.save(product)

The domain object still owns its behaviour।

Persistence provides durable storage before and after that behaviour।


Example: Create Order

Our accepted workflow:

identify Customer

load Products

check Product state

load Inventory

check quantity

capture current prices

decrease Inventory

create Order

persist Order

Now persistence becomes essential।

Conceptually:

CreateOrderUseCase
    ↓
ProductRepository
    ↓
InventoryRepository
    ↓
OrderRepository

all backed by PostgreSQL।


Persistence Does Not Replace the Domain

A dangerous mindset is:

database table
    ↓
Java class
    ↓
API

where database schema becomes the entire application model।

We deliberately avoid that।

Our direction remains:

business requirements
    ↓
domain model
    ↓
application workflows
    ↓
persistence design

Database exists to persist application state।

It does not define the business by itself।


Entity Does Not Mean Database Row

We already learned:

Domain Entity

means:

an object with identity and lifecycle

For example:

Order

is a domain Entity।

Later JPA will also use the annotation:

@Entity

That is a persistence concept।

These meanings overlap sometimes, but they are not identical।


Avoid Database-First Domain Modeling

Suppose we begin with:

CREATE TABLE orders (
    ...
);

then generate Java classes directly from the schema and call that the domain model।

This can produce objects focused on:

columns

foreign keys

getters/setters

instead of:

cancel()

markPaid()

total()

Our approach is the opposite:

understand business behaviour first

then design persistence that supports it

Database Responsibilities

PostgreSQL should help us with:

durable storage

atomic persistence

relationships

structural integrity

efficient querying

concurrent data access

These are legitimate database responsibilities।


Database Is Not the Business Workflow

PostgreSQL should not become the place where all application behaviour lives।

For example, avoid designing the entire Order lifecycle as obscure database triggers such as:

when order status changes
automatically update inventory
automatically call payment system

This hides application behaviour outside the Java workflow।

Our application architecture remains:

Handler
    ↓
UseCase
    ↓
Domain
    ↓
Repository
    ↓
Database

UseCase Owns Workflow

For cancellation:

CancelOrderUseCase

coordinates:

load Order

verify ownership

cancel Order

restore Inventory

persist changes

Database participates in storing these changes atomically।

It does not replace the UseCase।


Domain Owns Invariants

For example:

Inventory quantity cannot be negative

should remain protected by:

inventory.decrease(...)

and:

inventory.setAvailableQuantity(...)

Database can later reinforce this with a constraint।

But domain behaviour remains explicit।


Database Constraints Are Reinforcement

Suppose Inventory has:

available_quantity >= 0

We may later add a database constraint enforcing that too।

Why?

Because it protects persisted integrity even if application code has a bug or another code path writes invalid data।

This creates layered protection:

request validation
    ↓
domain invariant
    ↓
database constraint

Each layer protects a different boundary।


Database Is the Last Integrity Boundary

Imagine a bug accidentally tries to persist:

availableQuantity = -3

Even if application validation failed somewhere, database constraint can reject the invalid row।

This is valuable।

But we don't say:

Since the database has a CHECK constraint, the domain no longer needs validation.

Both remain useful।


What Should Not Be Persisted?

Not every Java object needs a database table।

Examples:

CreateOrderRequest

ProductResponse

ApiProblem

PageResponse

These are transport objects।

They are temporary representations around a request/response boundary।

They do not represent durable business state।


DTOs Do Not Need Tables

Avoid creating tables like:

create_order_requests

api_problem_responses

product_response

simply because those Java classes exist।

Persistence is driven by durable business state, not class count।


UseCases Do Not Need Tables

Likewise:

CreateOrderUseCase

CancelOrderUseCase

are application behaviour।

They are not data entities।

No database table is needed for a UseCase।


Handlers Do Not Need Persistence

OrderHandler should not contain:

database state

or request history as instance fields।

Handler is an HTTP adapter।

Persistence remains behind Repository boundaries।


Payment Is More Nuanced

Our Payment capability will eventually integrate with an external provider।

Do we need a:

payments

table?

Maybe।

But we explicitly deferred that decision until the integration contract requires it।

We might need to persist:

provider payment reference

attempt state

idempotency data

or perhaps less।

Do not create a Payment table merely because Payment exists as a capability।


Customer Is Also Important

We have a:

CustomerId

but no local Customer aggregate in v1।

Therefore we should not create:

customers

table automatically।

Our Order can persist:

external CustomerId

directly।

Again:

A domain noun does not automatically require a local table.


Product and Inventory Are Separate

We deliberately modeled:

Product

and:

Inventory

as separate responsibilities।

Persistence should respect that conceptual separation।

That likely means distinct persisted structures for:

Product catalog state

and:

available quantity

rather than stuffing Inventory into Product simply because SQL can store both in one row।


Why Separation Matters

Product price changes because:

commercial/catalog decision

Inventory changes because:

orders

cancellations

admin stock adjustments

These have different change patterns and invariants।

Keeping persistence aligned with responsibility boundaries improves clarity।


Persistence Does Not Mean Microservices

We may later have tables such as:

products

inventory

orders

order_items

That does not imply:

Product Service

Inventory Service

Order Service

as separate deployments।

We still have:

one modular monolith

one Spring Boot application

one PostgreSQL database

Logical separation is not network separation।


One Database Is Fine

For our current architecture:

one PostgreSQL database

is a deliberate pragmatic choice।

It supports local transactions across:

Inventory

Order

which is valuable for Order creation and cancellation।

We do not need separate databases per capability।


Local Transaction Example

Successful Order creation requires:

Inventory decreases

Order is saved

These should behave as one logical operation।

We don't want:

Inventory decreased

but:

Order failed to persist

leaving inconsistent state।

PostgreSQL transactions will help us make these local changes atomic।

We cover this deeply later in Module 7।


Persistence Failure Is Possible

Once a database exists, new failure modes appear:

database unavailable

connection failure

transaction failure

constraint violation

deadlock

timeout

Backend engineers need to think about these failures explicitly।

Persistence is not an infallible object store।


Repository Boundary

Our architecture already has:

Handler
    ↓
UseCase
    ↓
Repository

Repository represents the application's persistence/query needs।

For Product:

ProductRepository

may conceptually support operations such as:

findById

save

plus required product queries।


Repository Is Not Automatically Generic CRUD

Avoid starting with:

interface Repository<T, ID> {

    T save(T entity);

    T findById(ID id);

    List<T> findAll();

    void delete(ID id);
}

for every domain object simply because CRUD is easy to generalize।

Our domain does not even support physical Product deletion।

A generic repository can accidentally suggest operations that the business doesn't allow।


Repository Should Reflect Application Needs

For example, Product application needs:

find Product by ID

persist Product

browse orderable Products

Inventory needs:

find by ProductId

persist changed quantity

list Inventory for admin

Order needs:

save Order

find accessible Order

find customer Order history

Actual interfaces should emerge from these workflows।


Don't Put SQL in UseCases

Avoid:

createOrderUseCase.execute() {
    entityManager.createQuery(...);
}

or:

jdbcTemplate.query(...)

inside UseCase।

UseCase coordinates business operation।

Repository/persistence layer owns database mechanics।


Don't Put HTTP in Repository

Likewise Repository should not:

return ResponseEntity

throw HTTP 404

build ApiProblem

Persistence reports data/result/failure in application terms।

HTTP mapping remains at the edge।


Persistence Representation May Differ From Domain

Suppose domain Order stores:

List<OrderItem>

Persistence may use:

orders row
+
multiple order_items rows

That's fine।

Domain representation and storage representation solve different problems।

Mapping connects them।


Object Graph vs Relational Tables

In Java we think:

Order
    ↓
List<OrderItem>

In relational storage we may think:

orders
    ↑
order_items.order_id

This difference is often called:

object-relational impedance mismatch

We don't need to memorize the term।

The practical point is:

Java object relationships and relational data relationships are not identical representations.

JPA helps bridge them, but we still need deliberate mapping।


Persistence Needs Stable Identity

To retrieve state later, records need identity।

Our domain already has:

ProductId

OrderId

CustomerId

Inventory is identified by:

ProductId

These concepts will influence schema keys later।

The exact database key representation will be decided during schema design।


Do Not Choose IDs Accidentally From JPA

A common mistake is:

@Id
@GeneratedValue
private Long id;

then deciding:

Our domain uses Long IDs because Hibernate generated that for us.

Wrong direction।

We should decide identifier semantics deliberately, then map them to persistence।

The ORM should not define our domain identity strategy accidentally।


Persistence and Historical Data

Persistence is not only about current state।

Order represents history।

For example:

current Product price

and:

OrderItem unitPrice

may intentionally differ।

Database schema must preserve both concepts։

Never "normalize away" historical data if doing so destroys business meaning।


Not Every Duplication Is Bad

In relational design, engineers often try to avoid duplicate values।

But storing:

OrderItem.unitPrice

even though Product also has:

Product.price

is intentional historical duplication।

Because they mean different things:

Product.price
→ current price
OrderItem.unitPrice
→ price used when Order was created

Same numeric type, different business meaning।


Persistence and Deactivation

Product deactivation means:

active = false

or equivalent persisted lifecycle state।

We do not physically delete the Product because historical Orders may refer to it।

This is another example where business semantics determine persistence behaviour।


Why Physical Delete Can Be Dangerous

Suppose OrderItem references:

Product P-100

Admin deactivates Product।

If implementation does:

DELETE FROM products
WHERE id = 'P-100';

we may:

break foreign-key relationships

lose useful historical catalog reference

or need awkward cascading behaviour।

Our requirement is deactivation, not deletion।

Persistence should implement the actual lifecycle։


Order Cancellation Also Persists Two Changes

Cancellation requires:

Order status → CANCELLED

and:

restore Inventory

Both must survive restart।

If only Order status persists but Inventory restoration does not, data becomes inconsistent।

Persistence design must support the complete business workflow।


Payment State Must Survive Too

Once an Order is successfully paid:

status = PAID

must persist।

Otherwise a restart could make the application think the Order is still:

UNPAID

and allow another payment attempt।

This becomes especially important when we discuss external-service idempotency later।


Persistence Is Not a Cache

PostgreSQL is the authoritative durable state for our application-owned business data।

It is not merely:

a performance cache

If application memory disagrees with PostgreSQL after restart, PostgreSQL wins for persisted application state।


We Don't Need Redis Yet

Could Redis store some of this state?

Technically many systems use Redis for various purposes।

But our requirements do not need it।

Adding:

Redis

would introduce:

another datastore

consistency questions

operational complexity

without solving a current problem better than PostgreSQL।

Our architecture remains intentionally simple।


We Don't Need Event Sourcing

Another possible persistence style is storing every domain event and reconstructing state।

That would introduce:

event store

replay

event schemas

versioning

projection logic

Our system does not require this complexity।

We persist current business state relationally।


We Don't Need CQRS

Read and write operations can use the same relational persistence foundation।

We may use efficient read projections later where useful।

That does not require a separate:

CQRS architecture

or separate databases।


PostgreSQL as a Shared Transaction Boundary

One major advantage of our modular monolith is that related local state lives in one database।

For:

CreateOrderUseCase

we can eventually perform:

decrease Inventory

insert Order

insert OrderItems

within one database transaction।

This is far simpler than coordinating multiple remote services/databases।


This Is an Architectural Benefit

Sometimes engineers split systems into microservices early and later discover they need distributed transactions for workflows that were naturally local։

Our current system avoids that problem।

We can preserve:

strong local consistency

for Order and Inventory without distributed coordination।


Database State vs External Payment State

However Payment Provider is external।

We cannot put:

our PostgreSQL transaction

around:

external provider's database

That means payment introduces a different consistency problem।

We already recognized:

local DB changes
+
external payment execution

cannot be one normal PostgreSQL transaction।

That complexity belongs in the external-services module।


Persistence Boundaries Help Reveal This

Local operations:

Create Order

Cancel Order

can be atomic within PostgreSQL।

Cross-system operation:

Pay Order

cannot rely on one local transaction for the entire external interaction։

Understanding persistence boundaries helps us distinguish these cases early।


Data Must Be Queryable

Persistence isn't useful if we can only save data but cannot efficiently retrieve what the API needs।

Our known query patterns include:

find Product by ID

browse orderable Products

find Inventory by ProductId

list Inventory

find Order by ID

find Orders by CustomerId

paginate Orders

These query patterns will influence:

schema

indexes

Repository methods

later।


Design Schema From Access Patterns Too

A relational schema should preserve domain meaning, but we also need to understand how the application accesses the data।

For example:

GET /orders

needs:

CustomerId scope

bounded pagination

stable ordering

This will later influence indexing choices।

Schema design is not isolated from API/usecase behaviour।


Don't Index Everything

Once we learn about indexes, it may be tempting to add them to every column।

Indexes have costs:

disk usage

write overhead

maintenance

We will add them based on actual query patterns।

Our current API design gives us those patterns।


Persistence Has Operational Responsibility

Once PostgreSQL becomes part of the application, engineers also need to care about:

database migrations

connection configuration

backups

monitoring

query performance

Not all of these are covered deeply in this course, but production backend engineering must recognize them।


Schema Must Evolve Safely

Production databases contain real state।

We cannot simply:

drop everything

recreate tables

for every model change।

That's why we'll use:

Flyway

for versioned database migrations।

Application code evolves։

Database schema must evolve in a controlled way with it।


ddl-auto=create Is Not Our Migration Strategy

Spring/JPA can be configured to generate schema automatically।

That can look convenient during experiments।

But for our production-style application, we want database changes to be:

explicit

reviewable

versioned

repeatable

Flyway will own schema migrations։

JPA mappings will map to the schema, not silently replace migration discipline।


Persistence Is Part of Definition of Done

When a ticket changes durable data requirements, Definition of Done may include:

database migration

Repository changes

integration tests

backward-safe schema evolution

A feature is not complete if Java code expects a column that production database does not have।


Local Development

During local development, PostgreSQL will run as a supporting dependency।

Our established workflow:

docker compose up -d

starts dependencies such as PostgreSQL।

Spring Boot application runs normally via:

./gradlew bootRun

or IDE/JAR।

We don't need to containerize the application itself yet।


Automated Tests

Integration tests should not depend on the developer manually running local Compose।

Later we'll use:

Testcontainers

to start controlled PostgreSQL instances for automated tests।

That makes tests:

repeatable

isolated

CI-friendly

Why Not H2 for Everything?

Some Spring applications use an in-memory database such as H2 for tests।

But relational databases differ in:

SQL behaviour

data types

locking

constraints

query planner behaviour

For PostgreSQL-specific integration confidence, testing against real PostgreSQL through Testcontainers is stronger।

Unit tests still do not need a database।


Not Every Test Needs PostgreSQL

We should not turn every domain test into an integration test।

Example:

order.cancel();

can be tested with plain Java।

No database needed।

Similarly:

inventory.decrease(...)

is domain behaviour।

Use PostgreSQL integration tests for persistence/query/transaction behaviour where the database actually matters।


Persistence Testing Layers

A balanced strategy later:

Domain test
→ no DB

UseCase unit test
→ test doubles where appropriate

Repository integration test
→ PostgreSQL Testcontainer

API integration test
→ Spring + PostgreSQL for key workflows

Each test type proves a different concern।


Avoid Mocking PostgreSQL Behaviour

A mock Repository cannot prove:

JPA mapping works

foreign keys work

query returns correct rows

transaction behaves correctly

migration is valid

That's why persistence needs real integration testing eventually।


Repository Is a Boundary Worth Testing

For example:

OrderRepository

may have an integration test ensuring:

save Order with items

reload Order

historical prices preserved

CustomerId preserved

status preserved

That verifies mapping between:

domain/application

and:

PostgreSQL

works correctly।


Persistence Should Be Boring

A good persistence layer usually should not be the most clever part of the system।

We want:

clear schema

clear mappings

predictable queries

explicit migrations

measured indexes

not:

magic triggers

generic repositories everywhere

reflection-heavy persistence abstractions

clever dynamic SQL frameworks without need

Simple persistence is easier to operate։


Example State Before Restart

Suppose PostgreSQL contains:

Product
P-100
Keyboard
100.00
active

Inventory
P-100
4

Order
O-1001
Customer C-10
UNPAID

OrderItem
P-100
quantity 2
unitPrice 100.00

Application restarts।

After restart, Repository loads:

same business state

That's the core value of persistence।


Example State Evolution

Admin changes Product price:

100 → 120

Now persistence contains:

Product.currentPrice = 120

But historical OrderItem remains:

unitPrice = 100

Application can now correctly answer both:

What does this Product cost now?

What did this customer order it for?

Persistence preserves business history and current state simultaneously।


Example Cancellation

Before cancellation:

Order O-1001
status = UNPAID

Inventory P-100
quantity = 4

Order has:

2 units of P-100

Cancellation workflow:

Order.status
UNPAID → CANCELLED

Inventory
4 → 6

Both changes persist together।

After restart:

Order remains CANCELLED

Inventory remains 6

That's durable consistency।


Persistence Review Questions

Before storing something, ask:

Does this state need to survive application restart?

Does the application own this state?

Is it current state or historical state?

What identifies it?

What workflows modify it?

What queries need it?

What integrity rules should the database reinforce?

Does it really need its own table?

These questions prevent both under-modeling and over-modeling।


What We Currently Expect to Persist

Our current likely durable model:

Product

Inventory

Order

OrderItem

with Order storing:

external CustomerId

No local full Customer aggregate/table required for v1।

Payment persistence will be introduced only if the integration workflow requires it।


What We Do Not Persist as Business Tables

Current examples:

CreateProductRequest

CreateOrderRequest

OrderResponse

ApiProblem

PageResponse

Handler

UseCase

These are application/transport constructs, not durable business records।


Common Mistake 1 — Everything Becomes a Table

Java classes and domain nouns do not automatically correspond one-to-one with database tables।


Common Mistake 2 — Schema Drives the Entire Domain

Business behaviour should still shape our domain model and workflows।


Common Mistake 3 — Generic CRUD Repository Everywhere

Repository APIs should reflect real application operations։


Common Mistake 4 — SQL Inside UseCase

Persistence mechanics belong behind Repository/persistence boundaries।


Common Mistake 5 — Database Owns Business Workflow

Triggers and stored logic should not invisibly replace application/domain behaviour without a real reason।


Common Mistake 6 — Domain Validation Removed Because Database Has Constraints

Database constraints reinforce invariants; they don't replace domain correctness।


Common Mistake 7 — Local Customer Table Added Automatically

External identity is authoritative; v1 only needs CustomerId for ownership।


Common Mistake 8 — Payment Table Added Before Integration Design

Persist only what the real payment workflow needs।


Common Mistake 9 — Product Deactivation Implemented as Physical Delete

Historical Orders must remain valid and referentially meaningful।


Common Mistake 10 — In-Memory Database Assumed Equivalent to PostgreSQL

Important persistence behaviour should eventually be integration-tested against PostgreSQL itself।


Responsibility Map

Handler

Understands:

HTTP

No direct SQL/JPA persistence mechanics।


UseCase

Coordinates:

application workflow

transaction intent

Uses Repositories।


Domain

Owns:

business state

local invariants

behaviour

No SQL/JPA requirements should drive its meaning।


Repository

Defines:

application persistence/query needs

and separates those needs from database implementation details।


PostgreSQL / Persistence Layer

Owns:

durable storage

relational mapping

queries

database constraints

transaction mechanics

indexes

Engineering Principle

The core principle:

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

Another:

Use the database to reinforce integrity and coordinate durable state, 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, or noun in the codebase.


Summary

In this lesson, we learned that:

  • Java memory is temporary and cannot preserve production business state across application restarts.
  • Product, Inventory, Order, and OrderItem contain durable state that must be persisted.
  • OrderItem must preserve purchase-time price independently from current Product price.
  • PostgreSQL is the durable relational datastore for our modular monolith.
  • Persistence provides durability, querying, transactions, integrity, and coordinated concurrent access.
  • Persistence should support the domain rather than drive domain modeling.
  • Domain Entities and JPA entities are related concepts but not the same thing.
  • Product and Inventory remain separate responsibilities in persistence as well as domain design.
  • External Customer identity does not justify a local Customer table in v1.
  • Payment persistence remains deferred until the actual integration contract requires it.
  • Database constraints reinforce domain invariants but do not replace them.
  • Handlers, UseCases, DTOs, API Problems, and pagination responses are not business tables.
  • Repositories should reflect actual application persistence needs rather than expose generic CRUD by default.
  • SQL/JPA mechanics belong behind Repository/persistence boundaries, not inside UseCases.
  • Product deactivation must preserve historical references rather than physically deleting Product data.
  • Local Order creation and cancellation benefit from one PostgreSQL transaction boundary.
  • External Payment cannot be made atomic with PostgreSQL through one ordinary local transaction.
  • API query patterns will later influence schema and index design.
  • Flyway will manage explicit versioned database migrations.
  • Automated persistence tests will use PostgreSQL with Testcontainers where appropriate.
  • Domain and UseCase tests should not require PostgreSQL when persistence behaviour is irrelevant.
  • 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 sees our data—as relations, rows, keys, constraints, and joins—before designing the actual schema.