Persistence with PostgreSQL

Relational Database Thinking

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

In the previous lesson, we established why our application's durable state must be persisted in PostgreSQL.

Now we need a mindset shift.

In Java, we naturally think in objects:

Product

Inventory

Order

OrderItem

and relationships often look like object graphs:

Order
    ↓
List<OrderItem>

PostgreSQL sees the same business data differently:

tables

rows

columns

keys

constraints

relationships

A backend engineer needs to understand both representations because the persistence layer effectively translates between:

domain/application model
↔
relational model

The goal of this lesson is:

Shift from Java object thinking to relational thinking so we can model our Domain correctly using tables, rows, keys, relationships, constraints, and joins.


Objects and Rows Are Different Models

In Java, we might have:

Product product =
        Product.create(
                productId,
                "Keyboard",
                10000
        );

This is an object.

It can have:

identity

state

methods

business behaviour

For example:

product.changePrice(
        12000
);

product.deactivate();

In PostgreSQL, the same Product may be represented as a row:

products

id                                   | name     | price_cents | active
-------------------------------------+----------+-------------+-------
59ba787c-6d83-4a21-9bae-19c3d0469bd5 | Keyboard | 10000       | true

The values are familiar.

The model is different.


Java Thinks in Behaviour-Rich Objects

A Product can contain:

ProductId

name

priceCents

active

plus behaviour such as:

product.changePrice(
        12000
);

product.deactivate();

A PostgreSQL row does not execute:

product.deactivate();

It stores durable state.

When Domain behaviour changes:

active = true

to:

active = false

persistence eventually translates that into something conceptually like:

UPDATE products
SET active = false
WHERE id = :productId;

The Domain operation and SQL mutation are connected.

They are not the same responsibility.


Think in Relations

In relational database theory, a relation is conceptually a set of rows with defined attributes.

In practical PostgreSQL work, we usually think in:

tables

rows

columns

For our application, the main relational structures will be:

products

inventory

orders

order_items

Each table represents a coherent category of persisted business facts.


A Table Is Not a Java Class Dump

A table should not simply contain every field from every related object.

For example, this would be a poor Order representation:

orders

order_id
customer_id
status

product_1_id
product_1_quantity
product_1_price_cents

product_2_id
product_2_quantity
product_2_price_cents

product_3_id
...

Why?

Because:

number of OrderItems is variable

The relational model gives us a better structure:

orders

order_items

connected through keys.


Rows Represent Persisted Records

Example Product rows:

products

id                                   | name      | price_cents | active
-------------------------------------+-----------+-------------+-------
59ba787c-6d83-4a21-9bae-19c3d0469bd5 | Keyboard  | 10000       | true
364ec915-01ab-4cf0-b19f-327ed160f42b | Mouse     | 5000        | true
73c68220-b7bf-4ac0-8499-38bdc20755ae | Monitor   | 30000       | false

Each row represents one persisted Product state.


Columns Represent Stored Attributes

For Product:

id

name

price_cents

active

For Inventory:

product_id

available_quantity

For Order:

id

customer_id

status

created_at

For OrderItem:

order_id

product_id

quantity

unit_price_cents

The next lesson will define the exact PostgreSQL schema.

For now, focus on the relational concepts behind it.


Primary Keys

A relational table needs a way to uniquely identify each row.

That is normally the:

PRIMARY KEY

For Product:

products.id

is the natural primary key.

That guarantees:

Two Product rows cannot have the same ProductId.


Domain Identity and Relational Identity

This fits our Domain naturally.

In Java:

ProductId

identifies Product.

In PostgreSQL:

products.id

identifies the persisted Product row.

Our canonical mapping is:

ProductId
→ java.util.UUID
→ PostgreSQL UUID

The database supports an identity we already chose in the Domain.

It does not invent that identity for us.


Order Identity

Likewise:

OrderId

identifies Order in the Domain.

The relational representation will use:

orders.id

as its primary key.

Again:

OrderId
→ UUID

is application-defined.

We do not wait for Hibernate to give us a generated numeric ID.


Inventory Is Different

Our Domain does not have:

InventoryId

Inventory is identified by:

ProductId

because v1 has one Inventory state per Product.

Relationally, that means:

inventory.product_id

can itself identify the row.

We do not need:

inventory.id

merely because many tables happen to have an id column.


Do Not Add Surrogate IDs Automatically

A common habit is:

CREATE TABLE inventory (
    id BIGINT PRIMARY KEY,
    product_id UUID NOT NULL,
    available_quantity INTEGER NOT NULL
);

But if:

ProductId

already uniquely identifies Inventory, then a second generated identity adds no useful business meaning.

Our canonical design is simpler:

inventory.product_id
→ primary key
→ foreign key to products.id

Schema design should follow:

business identity

relationships

query patterns

rather than boilerplate.


Foreign Keys

A foreign key expresses a relationship between rows in different tables.

For example:

inventory.product_id

references:

products.id

Conceptually:

Product
   ↑
   │
Inventory

PostgreSQL can then enforce:

Inventory cannot reference a Product that does not exist.


Why Foreign Keys Matter

Without a foreign key, a bug could persist:

inventory

product_id =
9df2d34b-1554-4103-a85c-7f7ea4869240

even though no Product with that ID exists.

Now the database contains an orphaned relationship.

A foreign key prevents that structural inconsistency.


Order and OrderItem

Our Domain says:

Order
    ↓
many OrderItems

Relationally:

orders
    ↑
    │ order_id
    │
order_items

An Order might look like:

orders

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

with related OrderItems:

order_items

order_id                              | product_id                            | quantity | unit_price_cents
--------------------------------------+---------------------------------------+----------+-----------------
d2e7b44e-463f-4bbc-b16f-b1c80bc80442 | 59ba787c-6d83-4a21-9bae-19c3d0469bd5  | 2        | 10000
d2e7b44e-463f-4bbc-b16f-b1c80bc80442 | 364ec915-01ab-4cf0-b19f-327ed160f42b  | 1        | 5000

One Order row can therefore relate to many OrderItem rows.


One-to-Many Relationship

This is a classic:

one-to-many

relationship.

One:

Order

has many:

OrderItems

The foreign key lives on the many side:

order_items.order_id

and references:

orders.id

Product and Inventory

Our Product/Inventory relationship is effectively:

one Product
→ zero or one Inventory row

Why zero or one rather than always exactly one?

Because we have already established:

missing Inventory
≠
Inventory with quantity 0

Relationally:

products.id
    ↑
    │
inventory.product_id

Making inventory.product_id the primary key ensures:

at most one Inventory row
per Product

without requiring every Product to have one.


OrderItem and Product

Each OrderItem stores:

ProductId

So:

order_items.product_id

can reference:

products.id

This preserves the relationship to Product identity.

But OrderItem must still persist:

unit_price_cents

We never reconstruct historical price using current:

products.price_cents

Relationships Do Not Mean Joining Everything

A foreign key means:

a relationship exists

It does not mean every query must join every related table.

For Order history, we may query primarily:

orders

and return summary fields.

For Order detail, we need:

orders
+
order_items

Different application operations require different relational queries.


Joining Tables

SQL:

JOIN

lets us combine related rows.

Conceptually:

SELECT ...
FROM orders
JOIN order_items
    ON order_items.order_id = orders.id
WHERE orders.id = :orderId;

This lets the persistence layer reconstruct:

Order
+
OrderItems

from relational data.


Product Browsing Also Needs a Join

Customer Product browsing means:

Product.active = true

AND

Inventory.availableQuantity > 0

Relationally, that combines:

products

inventory

Conceptually:

SELECT
    p.id,
    p.name,
    p.price_cents
FROM products p
JOIN inventory i
    ON i.product_id = p.id
WHERE p.active = true
  AND i.available_quantity > 0
ORDER BY
    p.name ASC,
    p.id ASC;

This is a strong example of why relational thinking matters.

The public API sees:

GET /api/v1/products

while persistence can combine multiple tables efficiently.


Logical Boundaries Do Not Ban Joins

We modeled:

Product

and:

Inventory

as separate responsibilities.

That does not mean PostgreSQL should avoid joining them.

These are different concerns:

Domain ownership

and:

query execution

In one PostgreSQL database, joining related tables is normal.


Normalization

Relational design generally tries to store each changing fact in an appropriate place.

For example:

Product name

current Product price

Product active state

belong to Product.

available quantity

belongs to Inventory.

This avoids multiple unrelated copies of the same current state.


Historical Facts Are Different

Suppose current Product price is:

12000

while an old OrderItem records:

10000

Keeping both values is correct because they answer different questions:

What does the Product cost now?

What price did this Order use?

Historical snapshots are not necessarily bad duplication.

The meaning matters.


Duplication Is About Semantics

Duplicating current Product price into many unrelated current-state rows would be dangerous because those copies are supposed to stay synchronized.

But:

Product.priceCents

and:

OrderItem.unitPriceCents

are not two copies of the same changing fact.

They represent:

current commercial price

and:

historical purchase-time price

respectively.

Their divergence is correct.


Store Facts, Not Accidental Derivations

Our canonical Order total is:

sum(
    quantity × unitPriceCents
)

across its OrderItems.

We have already decided:

Order total
→ derived
→ not authoritative persisted state

Therefore our v1 schema does not need:

orders.total_cents

as another mutable source of truth.

Persistence stores:

quantity

unit_price_cents

and the application derives:

totalCents

with exact arithmetic.

This avoids possible drift between:

orders.total_cents

and:

order_items

Think About Functional Relationships

A useful relational question is:

Given this key, what facts should it determine?

For Product:

ProductId
→ name
→ priceCents
→ active

For Inventory:

ProductId
→ availableQuantity

For Order:

OrderId
→ CustomerId
→ status
→ createdAt

For OrderItem:

OrderId + ProductId
→ quantity
→ unitPriceCents

This way of thinking helps identify:

primary keys

uniqueness rules

table boundaries

Duplicate Products in an Order

Our application rejects duplicate Product IDs in one Order.

For example, one Order cannot contain:

Product A × 1

Product A × 2

as two separate lines.

Relationally, we can reinforce this by requiring:

(order_id, product_id)

to be unique.

Our canonical schema goes one step further:

PRIMARY KEY (
    order_id,
    product_id
)

for order_items.


Why the Composite Key Fits

The pair:

order_id
+
product_id

naturally identifies one Product line within an Order.

That aligns with our business rule:

one Product line per Order

So we do not introduce:

OrderItemId

merely for ORM convenience.


Composite Keys

A key made from multiple columns is called a:

composite key

Our canonical example is:

(order_id, product_id)

for:

order_items

This simultaneously expresses:

row identity

and:

duplicate Product prevention

for one Order.


Application Rules and Database Integrity Work Together

CreateOrderUseCase already rejects duplicate Product IDs before any Inventory mutation.

Why also protect this in PostgreSQL?

Because the database constraint protects against:

buggy persistence code

unexpected write path

migration mistake

manual invalid write

So we deliberately have:

application rule
+
database constraint

Both reinforce the same persisted invariant from different boundaries.


Nullability

Relational columns can allow or reject:

NULL

The design question is:

Is absence a valid business state?

For our persisted v1 model:

Product.id
Product.name
Product.price_cents
Product.active

Inventory.product_id
Inventory.available_quantity

Order.id
Order.customer_id
Order.status
Order.created_at

OrderItem.order_id
OrderItem.product_id
OrderItem.quantity
OrderItem.unit_price_cents

are all required.

So these columns should be:

NOT NULL

where the primary-key definition does not already imply it.


Java null and SQL NULL

Java null and SQL NULL are related concepts but belong to different systems.

Our general rule is:

required business state
→ required in Domain/application
→ required in persisted schema

We should not make a database column nullable merely because doing so makes ORM mapping convenient.


Data Types Matter

Java and PostgreSQL need compatible representations.

Our canonical mappings include:

java.util.UUID
↔
UUID
String CustomerId
↔
TEXT
boolean
↔
BOOLEAN
long cents
↔
BIGINT
int quantity
↔
INTEGER
Instant
↔
TIMESTAMPTZ

These choices communicate business meaning and define important storage characteristics.


Money Uses Integer Minor Units

Earlier versions of the material used:

BigDecimal

with decimal database types.

Our canonical course design has since standardized money as:

Java
→ long cents

PostgreSQL
→ BIGINT

For example:

10000
=
100.00

So Product uses:

price_cents BIGINT

and OrderItem uses:

unit_price_cents BIGINT

with:

value >= 0

constraints.

We do not use:

REAL

DOUBLE PRECISION

for authoritative money.


Quantities Use Integers

Order quantity is:

positive whole number

Inventory quantity is:

non-negative whole number

They naturally map to:

INTEGER

rather than decimal types.

Storage type should reflect business meaning.


Product Active State Is Boolean

Product lifecycle currently has:

active

inactive

So:

BOOLEAN

is appropriate.

We do not need:

ACTIVE

INACTIVE

as a separate status vocabulary because Product currently has only this binary state.


OrderStatus Is Different

Order lifecycle has three states:

UNPAID

PAID

CANCELLED

This cannot be represented meaningfully by a boolean.

Our canonical relational design stores readable text:

status TEXT NOT NULL

with a constraint:

CHECK (
    status IN (
        'UNPAID',
        'PAID',
        'CANCELLED'
    )
)

This keeps raw data understandable while constraining accepted values.


Avoid Magic Numeric Status Codes

A persisted value such as:

status = 2

requiring engineers to remember:

1 = UNPAID
2 = PAID
3 = CANCELLED

saves little and harms readability.

For our system, clear persisted values matter more than tiny storage optimization.

A production schema should be understandable when inspected directly.


Referential Integrity

Foreign keys protect relationships between persisted records.

Our canonical relationships are:

inventory.product_id
→ products.id
order_items.order_id
→ orders.id
order_items.product_id
→ products.id

These constraints make invalid relationships harder to persist.


CustomerId Has No Local Foreign Key

Order stores:

customer_id

from our external identity boundary.

There is no local:

customers

table in v1.

Therefore:

orders.customer_id

cannot and should not reference a local Customer table that does not exist.

This is correct.

Relational integrity can reference only state our database actually owns.


External Identity Still Requires Durable Representation

Even without a foreign key:

customer_id

must be:

NOT NULL

and capable of storing the stable external identifier.

Our canonical type is:

TEXT

The authenticated identity boundary determines that CustomerId is valid.

PostgreSQL stores it durably.


Foreign Keys Do Not Enforce Workflow

Suppose an Order is:

PAID

A foreign key cannot decide whether:

order.cancel();

is allowed.

That is Domain behaviour.

Relational constraints are best at:

structure

relationships

simple persisted invariants

not arbitrary workflow orchestration.


CHECK Constraints

PostgreSQL can reinforce simple row-level invariants.

For example:

CHECK (
    price_cents >= 0
)
CHECK (
    available_quantity >= 0
)
CHECK (
    quantity > 0
)
CHECK (
    unit_price_cents >= 0
)

These constraints complement:

request validation

Domain validation

UseCase rules

Do Not Put Every Business Rule in SQL

Could PostgreSQL somehow encode:

PAID Order cannot be cancelled

through increasingly complicated mechanisms?

Possibly.

But that would move lifecycle behaviour away from:

Order.cancel()

where it is easier to understand and test.

Use database constraints for clear persisted integrity rules.

Do not turn PostgreSQL into the whole business layer.


Unique Constraints

Uniqueness expresses facts such as:

one Inventory row per Product

Our design accomplishes this through:

inventory.product_id
→ PRIMARY KEY

Likewise:

one Product line per Order

is enforced by:

PRIMARY KEY (
    order_id,
    product_id
)

on order_items.


Primary Key vs Unique Constraint

Both can enforce uniqueness.

But:

PRIMARY KEY

also defines the table's principal row identity.

A table has one primary key and may have additional unique constraints.

For Inventory:

product_id

is naturally the row identity.

For OrderItem:

(order_id, product_id)

is naturally the row identity.


Relational Design Is About Trade-Offs

Good schema design considers:

business identity

relationships

constraints

query patterns

historical requirements

concurrency

mapping complexity

future change cost

There is rarely one universally perfect schema.

We choose the simplest design that preserves our current requirements correctly.


Avoid Over-Normalization

We do not need separate tables such as:

product_names

product_prices

product_active_states

merely because those facts could theoretically be decomposed further.

That would create unnecessary joins and complexity.

Normalization is a tool.

It is not a contest to maximize the number of tables.


Avoid Under-Normalization

The opposite extreme is one giant table containing:

Order

OrderItems

Product current state

Inventory

with repeated data.

That produces:

duplication

awkward variable-length structures

update anomalies

poor queryability

Good relational design finds a practical middle ground.


Update Anomalies

Imagine current Product name is copied into thousands of unrelated current-state records.

An Admin renames Product.

Now every copy must change consistently.

If one is missed, the database contains contradictory versions of the same current fact.

That is an:

update anomaly

Keeping current Product information in the Product row avoids it.


Historical Snapshots Are an Exception

Historical data has different semantics.

If OrderItem stores:

unit_price_cents = 10000

and Product later changes to:

price_cents = 12000

the OrderItem should not update.

That is correct.

A useful principle is:

Duplication is problematic when multiple values are intended to represent the same changing fact.

Historical purchase-time price is a different fact.


Delete Behaviour Matters

Foreign keys can define what happens if a referenced row is physically deleted.

Common choices include:

RESTRICT

CASCADE

SET NULL

But deletion semantics must match the business lifecycle.

Our Product lifecycle says:

deactivate

not:

delete

and Order cancellation says:

change status

not:

erase history

So ordinary v1 workflows do not require physical Product or Order deletion.


Be Careful With Cascade Delete

Imagine Product deletion automatically cascades into:

Inventory

OrderItems

Deleting historical OrderItems would destroy Order history.

That would be unacceptable.

We therefore do not choose cascade rules simply because an ORM makes them convenient.

Persistence lifecycle must match business lifecycle.


Orders Are Not Deleted Either

Our Customer operation is:

cancel Order

Cancellation means:

UNPAID
→
CANCELLED

The Order remains part of history.

There is no Customer:

DELETE /api/v1/orders/{orderId}

operation in v1.

Relational lifecycle should preserve that history.


Rows Are Shared State

Multiple application requests may modify the same rows concurrently.

Suppose:

available_quantity = 1

and two Customers simultaneously request the final unit.

Both requests operate on shared durable state.

That means relational thinking must include:

transactions

row locking

atomic updates

isolation

not only tables and columns.


Read-Then-Write Is Not Automatically Safe

A naive flow is:

SELECT available_quantity
→ 1

check 1 >= 1

UPDATE available_quantity = 0

Two concurrent requests can both read:

1

before either commits.

Without a concurrency-safe strategy, both may believe they succeeded.


Our Inventory Strategy Is Already Decided

Earlier course design established ADR-005:

Use Atomic Conditional Updates for Inventory Consumption.

So Order creation uses:

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

Then:

affected rows = 1
→ consumption succeeded

affected rows = 0
→ insufficient Inventory

This avoids a race-prone application-level:

load
check
decrease
save

workflow.


Constraints and Concurrency Solve Different Problems

This constraint:

CHECK (
    available_quantity >= 0
)

protects persisted validity.

The atomic conditional update determines which competing request is allowed to consume available stock.

So:

constraint

and:

concurrency strategy

are related but not interchangeable.


Think in Queries, Not Object Traversal

Java encourages expressions such as:

order.items();

A relational database operates through queries.

For Customer Order history, we need something conceptually like:

SELECT ...
FROM orders
WHERE customer_id = :customerId
ORDER BY
    created_at DESC,
    id DESC
LIMIT :size
OFFSET :offset;

The database should perform:

ownership filtering

ordering

pagination

before the results reach Java.


Do Not Load Everything Into Java

Bad:

load all Orders
    ↓
filter CustomerId in Java
    ↓
sort in Java
    ↓
take first 20

PostgreSQL is designed to perform:

filtering

sorting

joining

aggregation

pagination

close to the data.

Use it.


Pagination Must Reach PostgreSQL

For:

GET /api/v1/orders?page=0&size=20

the correct direction is:

PostgreSQL
    ↓
customer scope
    ↓
ORDER BY created_at DESC, id DESC
    ↓
LIMIT / OFFSET
    ↓
bounded result

not:

PostgreSQL
    ↓
all Orders
    ↓
Java filters and slices

Do Not Move the Whole Application Into SQL

There is an important balance.

PostgreSQL should perform:

filtering

joins

aggregation where useful

constraints

atomic persistence

concurrency-safe updates

The application still owns:

Create Order workflow

Order lifecycle

Customer ownership semantics

Payment orchestration

business decisions

Using PostgreSQL effectively does not mean turning SQL into the entire backend.


Reads Can Be More Relational

Mutation workflows often need Domain objects because business behaviour must execute.

Read workflows can sometimes use direct projections.

For example, Order history may need only:

OrderId

status

createdAt

totalCents

rather than every OrderItem.

A relational read query may therefore return an efficient summary projection.

This is pragmatic database usage.


No CQRS Ceremony Required

Using:

rich Domain objects for writes

and:

efficient relational projections for reads

does not require:

Event Sourcing

separate read database

separate write database

CQRS framework

We are simply choosing an appropriate query shape for each operation.


Constraints Are Documentation

A schema containing:

PRIMARY KEY

FOREIGN KEY

NOT NULL

CHECK

UNIQUE

communicates important assumptions.

For example:

available_quantity INTEGER NOT NULL

CHECK (
    available_quantity >= 0
)

tells another engineer exactly what persisted Inventory state is considered valid.

A good schema communicates intent.


Naming Matters

Prefer names that express business meaning.

Good:

available_quantity

customer_id

unit_price_cents

created_at

Poor:

qty1

owner_ref_value

price2

date_value

Database naming should be boring and understandable.


Java Naming vs SQL Naming

Java commonly uses:

availableQuantity

PostgreSQL commonly uses:

available_quantity

That is fine.

Persistence mapping translates between conventions.

We do not need camelCase database columns merely because Java uses camelCase.


Table Names

Our canonical table names are:

products

inventory

orders

order_items

They are simple and readable.

There is no need for prefixes such as:

tarkev_products

because the database already belongs to this application context.

Unnecessary prefixes add noise.


Schema Is an Internal Contract

The PostgreSQL schema is not a public HTTP contract.

But it is still a contract between:

application versions

Flyway migrations

production data

operations

Once real data exists, schema changes require care.

This is why our schema will evolve through explicit Flyway migrations.


Relational View of Our Current Domain

Before writing the migration, our current shape is conceptually:

products
--------
id
name
price_cents
active
inventory
---------
product_id
available_quantity
orders
------
id
customer_id
status
created_at
order_items
-----------
order_id
product_id
quantity
unit_price_cents

This is not yet the migration file.

It is the relational shape we can reason about before adding all concrete PostgreSQL constraints.


Relationship Diagram

Product and Inventory:

products
   │
   │ 1
   │
   │ 0..1
inventory

Order and OrderItems:

orders
   │
   │ 1
   │
   │ many
order_items

Product and historical OrderItems:

products
   │
   │ 1
   │
   │ many
order_items

Order additionally contains:

customer_id

as an external identity reference rather than a foreign key to a local Customer table.


Why This Shape Fits Our Workflows

Customer Product browsing:

products
+
inventory

Create Order:

products
+
inventory
+
orders
+
order_items

Order history:

orders

with additional OrderItems for detail when needed.

Cancel Order:

orders
+
inventory

Pay Order:

orders
+
external PaymentService

The relational model supports our confirmed behaviour without inventing extra business subsystems.


Relational Thinking Checklist

When designing a table, ask:

What business fact does one row represent?

What uniquely identifies that row?

Which columns are required?

Which simple constraints protect valid values?

Which other rows does this row reference?

Should those relationships use foreign keys?

Can duplicates represent invalid business state?

What queries need this table?

What data changes concurrently?

Which values represent current state?

Which values represent historical state?

These questions produce stronger schemas than:

Which JPA annotations do I remember?


Common Mistakes

Every Table Gets id BIGINT

Use the real identity where appropriate.

Inventory naturally uses ProductId.

OrderItem naturally uses (order_id, product_id).


Every Java Object Becomes a Table

DTOs, Handlers, UseCases, Commands, and many Value Objects do not independently require tables.

Persist durable facts.


One Huge Table

Variable relationships such as OrderItems belong naturally in related rows.


No Foreign Keys

Application checks alone do not protect persisted referential integrity from every possible write.


Cascade Delete Everywhere

Deletion behaviour must follow business lifecycle and preserve Order history.


Recalculate Historical Price From Product

OrderItem must persist historical:

unit_price_cents

independently of current Product price.


Duplicate Current Facts Everywhere

Mutable Product state should have a clear authoritative location.


Magic Numeric Status Values

Readable persisted lifecycle values are easier to operate and evolve.


Pagination in Java

Filtering, ordering, joining, and pagination should happen in PostgreSQL whenever appropriate.


SQL Becomes the Whole Business Layer

Use PostgreSQL for relational integrity and efficient data operations while keeping workflows and Domain behaviour explicit in Java.


Responsibility Map

Domain Thinking

Asks:

What does this business concept mean?

What behaviour is valid?

What invariants must hold?

Relational Thinking

Asks:

What facts must be stored?

How are they related?

What identifies each row?

What constraints protect them?

How will they be queried?

What can change concurrently?

Persistence Mapping

Connects:

Domain/application representation
↔
relational representation

PostgreSQL

Protects and provides:

durable relational state

primary keys

foreign keys

constraints

joins

queries

transactional writes

concurrency-safe persistence operations

Engineering Principle

The core principle is:

Objects model identity and behaviour in the application; relational tables model durable facts and relationships in the database. Good persistence design understands both without pretending they are the same thing.

Another:

Primary keys, foreign keys, uniqueness, nullability, and CHECK constraints are not database decoration—they encode important assumptions about valid persisted state.

And:

Use PostgreSQL for what it is good at: filtering, joining, constraining, ordering, paginating, and atomically storing shared data while keeping business workflows explicit in the application.


Summary

In this lesson, we established that:

  • Java object models and relational models represent the same business state differently.
  • Java emphasizes objects, identity, and behaviour.
  • PostgreSQL emphasizes tables, rows, columns, keys, constraints, and relationships.
  • Persistence mapping translates between those representations.
  • A database table should model durable facts rather than simply mirror a Java class.
  • Product rows store UUID identity, name, current price_cents, and active state.
  • Inventory rows store ProductId and available_quantity.
  • Order rows store UUID OrderId, external CustomerId, status, and created_at.
  • OrderItem rows store OrderId, ProductId, quantity, and historical unit_price_cents.
  • ProductId and OrderId use PostgreSQL UUID.
  • CustomerId remains an external String and maps naturally to textual storage.
  • Monetary values use BIGINT integer cents.
  • Quantities use integer storage.
  • createdAt maps to TIMESTAMPTZ.
  • Product active state maps naturally to BOOLEAN.
  • OrderStatus uses readable UNPAID, PAID, and CANCELLED values.
  • Product identity maps naturally to products.id.
  • Order identity maps naturally to orders.id.
  • Inventory does not need a separate InventoryId.
  • inventory.product_id is both its primary key and Product foreign key.
  • Order-to-OrderItem is a one-to-many relationship.
  • Product-to-Inventory allows at most one Inventory row per Product.
  • Missing Inventory remains distinct from zero Inventory.
  • OrderItem references both its parent Order and Product.
  • Historical OrderItem price is persisted independently of current Product price.
  • Order.totalCents remains derived and is not stored as an independent authoritative column.
  • (order_id, product_id) is the canonical composite primary key for OrderItems.
  • That composite key reinforces the rule that a Product appears at most once per Order.
  • Foreign keys protect referential integrity.
  • CustomerId has no local foreign key because Customer lifecycle is externally owned.
  • Required business state should generally use NOT NULL.
  • CHECK constraints reinforce simple persisted invariants.
  • Price and Inventory values must be non-negative.
  • OrderItem quantity must be positive.
  • Database constraints complement Domain/application validation rather than replacing it.
  • Domain lifecycle rules such as cancellation remain in Java rather than being hidden in SQL.
  • Normalization helps prevent accidental duplication of mutable current facts.
  • Historical snapshots can intentionally duplicate values when they represent different facts.
  • Physical deletion and cascade behaviour must follow business lifecycle.
  • Product is deactivated rather than deleted.
  • Order is cancelled rather than deleted.
  • Order history must not disappear through cascade rules.
  • Rows are shared mutable state across concurrent requests.
  • Read-check-write Inventory consumption is unsafe under concurrency.
  • ADR-005 already established atomic conditional Inventory consumption.
  • Database constraints and concurrency control solve different problems.
  • PostgreSQL should perform filtering, ordering, joins, and pagination close to the data.
  • Customer Order history must be scoped and paginated in PostgreSQL.
  • Efficient read projections do not require CQRS.
  • Schema constraints also serve as documentation.
  • SQL names use clear snake_case conventions.
  • Our canonical table names are products, inventory, orders, and order_items.
  • The schema is an internal contract that must evolve carefully through Flyway.
  • Relational design should be driven by business identity, history, constraints, concurrency, and real query patterns rather than ORM convenience.

Next lesson:

Designing the Initial Schema

There, we will turn these relational concepts into the first concrete PostgreSQL schema for products, inventory, orders, and order_items, including the exact PostgreSQL types, primary keys, foreign keys, NOT NULL rules, CHECK constraints, composite OrderItem key, and the Order-history index already justified by our application requirements.