Persistence with PostgreSQL

Primary Keys and Foreign Keys

ReadingPreview

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

আগের lesson-এ আমরা initial PostgreSQL schema design করেছি:

products

inventory

orders

order_items

এখন সেই schema-এর সবচেয়ে গুরুত্বপূর্ণ structural guarantees-এর দুইটি নিয়ে গভীরে যাব:

Primary Key

Foreign Key

এগুলো শুধু SQL syntax নয়।

এগুলো database-কে বলে:

কোন row uniquely identifiable

কোন relationship valid

কোন duplicate state invalid

কোন referenced data অবশ্যই exist করতে হবে

আমাদের initial schema-তে:

products.id
→ Primary Key

inventory.product_id
→ Primary Key + Foreign Key

orders.id
→ Primary Key

order_items(order_id, product_id)
→ Composite Primary Key

এবং:

inventory.product_id
→ products.id

order_items.order_id
→ orders.id

order_items.product_id
→ products.id

foreign-key relationships আছে।

এই lesson-এর goal:

Primary keys, composite keys, foreign keys, uniqueness, এবং delete behaviour ব্যবহার করে persisted data-এর structural integrity কীভাবে protect করা যায়—এবং এই constraints কী করতে পারে না—সেটা পরিষ্কারভাবে বোঝা।


What Problem Does a Primary Key Solve?

Suppose products table contains:

id   | name
-----+----------
101  | Keyboard
102  | Mouse
103  | Monitor

আমরা যদি বলি:

Product 102

database precisely জানে কোন row-এর কথা বলা হচ্ছে।

এই identity guarantee আসে:

PRIMARY KEY

থেকে।


Primary Key Means Unique Row Identity

For Product:

CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY
        PRIMARY KEY,
    ...
);

This guarantees:

id cannot be NULL

id must be unique

So this cannot happen:

id   | name
-----+----------
101  | Keyboard
101  | Mouse

PostgreSQL rejects the second row।


Why Identity Matters Beyond Lookup

Primary keys support:

reliable updates

reliable references

foreign keys

joins

ORM identity mapping

For example:

UPDATE products
SET active = FALSE
WHERE id = 101;

works because id = 101 identifies exactly one Product।


Domain Identity and Database Identity

Our domain already has:

ProductId

and:

OrderId

Database primary keys persist those identities।

Conceptually:

ProductId(101)
    ↕
products.id = 101

This is a clean alignment।


Primary Key Is Not Just an Auto-Increment Column

A common beginner model is:

Every table needs an auto-increment id.

That's not the actual rule।

The real question is:

What uniquely identifies one row representing this business fact?

Sometimes that is a generated ID।

Sometimes it is an existing meaningful key।


Inventory Is the Best Example

Our Inventory model says:

one Inventory state per Product

So:

ProductId

already uniquely identifies Inventory।

Schema:

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

We do not need:

id BIGINT GENERATED ALWAYS AS IDENTITY

as an additional key।


What Would an Extra Inventory ID Add?

Suppose we design:

inventory

id   | product_id | available_quantity
-----+------------+-------------------
5001 | 101        | 12

Now which identifier does the application actually care about?

InventoryId 5001?

or:

ProductId 101?

Our domain says ProductId।

The extra ID would mostly exist because of database habit।

That's unnecessary complexity।


Primary Key Should Match Row Identity

For Inventory:

product_id

is both:

the relation to Product

and

the identity of the Inventory row

This is a strong relational design।


Composite Primary Keys

A Primary Key does not have to contain one column।

Our order_items table uses:

PRIMARY KEY (
    order_id,
    product_id
)

This is a:

composite primary key

Why (order_id, product_id)?

Our business rule says:

One Order cannot contain the same Product twice.

So for a specific Order:

order_id = 1001

there can only be one row where:

product_id = 101

This combination:

(1001, 101)

uniquely identifies that OrderItem।


Example

Valid:

order_id | product_id | quantity
---------+------------+---------
1001     | 101        | 2
1001     | 102        | 1
1002     | 101        | 5

Invalid:

order_id | product_id | quantity
---------+------------+---------
1001     | 101        | 2
1001     | 101        | 3

The composite primary key rejects the second 1001 + 101 row।


This Reinforces Our API Contract

Remember Create Order rejects:

{
  "items": [
    {
      "productId": "101",
      "quantity": 2
    },
    {
      "productId": "101",
      "quantity": 1
    }
  ]
}

Application rejects this early।

Database also prevents it from being persisted accidentally।

That is:

defense in depth

Could We Use a Generated OrderItem ID Instead?

Yes।

For example:

order_items

id
order_id
product_id
quantity
unit_price

with:

id PRIMARY KEY

and separately:

UNIQUE(order_id, product_id)

This would also preserve the duplicate-line rule।


Which Design Is Better?

Both can be valid।

Generated OrderItem ID may make some ORM mappings simpler।

Composite key better reflects our current domain:

OrderItem has no independent identity

and avoids inventing one।

For this course we keep:

(order_id, product_id)

as the composite primary key unless implementation evidence gives us a good reason to change it।


Persistence Convenience Should Not Automatically Win

If JPA mapping a composite key requires a little more code, that alone is not enough reason to change the relational model।

But neither should we suffer large complexity merely for theoretical purity।

When we reach JPA relationships, we'll evaluate the actual trade-off।

The rule is:

Choose deliberately, not mechanically.


Primary Key and NULL

A Primary Key cannot be null।

This matters because identity cannot be:

unknown

for a persisted row।

For example:

orders.id

must always identify the Order once it is persisted।


Generated Identity Before Persistence

A new Order may temporarily exist in application memory before PostgreSQL assigns:

OrderId

depending on our persistence construction strategy।

This creates a design consideration:

Does the domain require ID before persistence?

Or can persistence assign it?

We will deal with this when mapping JPA entities and repositories।

Don't distort the database key strategy before seeing the actual implementation need।


What Problem Does a Foreign Key Solve?

Suppose Inventory contains:

product_id = 999

but no Product 999 exists।

Without a foreign key, PostgreSQL may happily store this orphan row।

Foreign key tells PostgreSQL:

This value must reference an existing row in another table.


Inventory Foreign Key

Schema:

CONSTRAINT inventory_product_fk
    FOREIGN KEY (product_id)
    REFERENCES products(id)

Now:

inventory.product_id = 101

is valid only if:

products.id = 101

exists।


Example Failure

Products:

id
---
101
102

Attempt:

INSERT INTO inventory (
    product_id,
    available_quantity
)
VALUES (
    999,
    10
);

PostgreSQL rejects it because Product 999 does not exist।

That is:

referential integrity

Why Application Validation Is Not Enough

We could have application code:

check Product exists

then insert Inventory

But bugs happen।

Other code paths may appear।

Migrations or operational scripts may write data։

A database foreign key protects the relationship regardless of which application path performs the write।


Foreign Keys Are Especially Valuable in a Modular Monolith

Our modules share one PostgreSQL database।

That means the database knows relationships such as:

Inventory → Product

OrderItem → Order

OrderItem → Product

Using foreign keys lets us get strong local referential integrity without network coordination।

This is an advantage of our current architecture।


OrderItem to Order

Constraint:

CONSTRAINT order_items_order_fk
    FOREIGN KEY (order_id)
    REFERENCES orders(id)

This guarantees:

An OrderItem cannot exist without an Order.

So PostgreSQL rejects:

order_id = 9999

if no such Order exists।


This Matches Aggregate Ownership

Our domain says:

OrderItem belongs to Order

Relationally:

order_items.order_id
→ orders.id

captures that ownership relationship structurally।


OrderItem to Product

Constraint:

CONSTRAINT order_items_product_fk
    FOREIGN KEY (product_id)
    REFERENCES products(id)

guarantees every OrderItem references a locally known Product।

Because Products are deactivated instead of deleted, historical OrderItem references remain valid।


Why Preserve the Product Reference?

OrderItem already stores:

unit_price

for historical pricing।

So why keep:

product_id

too?

Because the OrderItem still represents:

This quantity of this Product was ordered at this purchase-time price.

Historical price snapshot does not erase Product identity।


Foreign Key Does Not Mean Historical Fields Are Derived

Even with:

order_items.product_id
→ products.id

we must not calculate historical unit price using:

products.price

Foreign key preserves identity relationship।

unit_price preserves historical price।

They solve different problems।


CustomerId Has No Foreign Key

Our orders table contains:

customer_id TEXT NOT NULL

but no:

customers

table।

Therefore there is intentionally no local foreign key।


Does That Mean CustomerId Has No Integrity?

Not exactly।

Its integrity comes from a different boundary:

external authentication system
    ↓
authenticated identity
    ↓
CustomerId
    ↓
Order

PostgreSQL guarantees:

customer_id is present

but cannot verify a relationship to a table the application does not own।


Don't Create a Fake Customer Table for Foreign-Key Comfort

Bad reasoning:

Every foreign identifier should reference a table, so let's create customers.

That would invent local Customer ownership we intentionally rejected।

Database modeling must respect system boundaries।


Referential Integrity Stops at System Boundaries

Inside our PostgreSQL-owned data:

Product

Inventory

Order

OrderItem

foreign keys can be strong।

Across an external identity system:

CustomerId

we rely on application/integration guarantees instead।

This is normal in real systems।


Foreign Keys and Insert Order

Relationships influence persistence ordering।

For example, to create an Order with OrderItems:

Order row must exist
before OrderItem rows can reference its ID

So persistence conceptually performs:

INSERT Order
    ↓
obtain OrderId
    ↓
INSERT OrderItems

within one transaction।


Product Must Exist Before Inventory

Similarly:

Product
    ↓
Inventory

means Inventory row cannot be inserted before the referenced Product exists।

This is okay because that relationship is real।


Foreign Key and Transaction Visibility

Within one PostgreSQL transaction, we can insert:

parent row

then:

child row

before committing।

Other transactions do not need to observe an inconsistent final state।

Transactions and foreign keys work together।


What Happens When a Referenced Row Is Deleted?

This is where foreign-key delete behaviour matters।

Suppose:

order_items.product_id
→ products.id

and someone tries:

DELETE FROM products
WHERE id = 101;

while OrderItems still reference Product 101।

What should happen?


Our Desired Behaviour: Reject the Delete

For current requirements:

Product is deactivated

not physically deleted

Therefore historical references should block accidental physical deletion।

We intentionally do not configure:

ON DELETE CASCADE

for Product relationships।


Default Restrictive Behaviour

Without a cascade action, PostgreSQL will generally prevent deleting the referenced Product while dependent rows exist।

That's exactly what we want।

It protects history from accidental destructive operations।


Why ON DELETE CASCADE Would Be Dangerous

Suppose:

Product 101

has OrderItems in 10,000 historical Orders।

If Product foreign key were:

ON DELETE CASCADE

then deleting Product could automatically delete related OrderItems।

Suddenly historical Orders are corrupted or emptied।

Database convenience has destroyed business data।


Cascade Is Not Inherently Bad

ON DELETE CASCADE is useful when child data has no independent meaning once parent is deleted।

For example, some temporary child records may genuinely need cascading deletion।

But the key question is:

Does deletion of the parent mean these child facts should cease to exist?

For historical OrderItems:

No.

So no cascade।


What About Order → OrderItems?

Our domain also does not support physical Order deletion।

So there is no current business need for:

ON DELETE CASCADE

there either।

If a future operational purge requirement appears, it deserves its own design rather than preconfiguring destructive cascades now।


ON DELETE SET NULL

Another option is:

ON DELETE SET NULL

That would turn:

order_items.product_id

into:

NULL

when Product is deleted।

But our OrderItem requires Product identity।

Losing it would damage historical meaning।

So this also doesn't fit।


Referential Integrity and Soft Deletion

Our Product deactivation approach:

active = false

keeps the row intact।

That means foreign keys remain valid automatically।

This is one reason lifecycle state is often better than physical deletion for referenced business entities։


Primary Key vs Unique Constraint

Primary key guarantees unique row identity।

But sometimes we need additional uniqueness that is not the table's primary identity।

Example:

If OrderItem had generated ID:

id PRIMARY KEY

we would still need:

UNIQUE (
    order_id,
    product_id
)

to enforce:

one Product line per Order

So:

Primary Key

and:

Unique Constraint

solve related but distinct problems।


Unique Constraint Means "No Duplicate Fact"

Suppose we had:

email

inside an application-owned User table and email were globally unique।

A UNIQUE constraint could represent that fact even if primary identity were numeric UserId।

Our current schema doesn't need that example, but the distinction matters।


Inventory Uses Primary Key Instead of Separate Unique Constraint

We could write:

id BIGINT PRIMARY KEY,
product_id BIGINT UNIQUE

But since ProductId itself is Inventory identity:

product_id BIGINT PRIMARY KEY

is cleaner।

One constraint expresses both identity and uniqueness।


OrderItem Composite PK Is Also a Business Constraint

Our composite Primary Key simultaneously means:

this combination identifies the row

and:

duplicate Product lines are impossible

This is a strong example of relational identity and business structure aligning nicely।


Foreign Key Data Types Must Be Compatible

If:

products.id

is:

BIGINT

then:

inventory.product_id

and:

order_items.product_id

should use compatible types।

Do not create:

products.id BIGINT

but:

order_items.product_id TEXT

without a strong reason।

Consistent types make joins and integrity straightforward।


Indexing and Keys

Primary keys are backed by indexes in PostgreSQL so uniqueness and lookup can be enforced efficiently।

Unique constraints also create supporting uniqueness structures।

But foreign keys are different:

The referencing columns are not automatically guaranteed to have every index we may need for our application queries.

We'll cover indexes deliberately in the index lesson।


Don't Add Duplicate Indexes Blindly

Because Primary Key already supports lookup on its key, creating:

CREATE INDEX ON products(id);

would typically duplicate an index already implied by the primary key।

Understand existing constraints before adding indexes।


Composite Key Ordering Matters

Our OrderItem Primary Key:

(order_id, product_id)

naturally supports operations beginning with:

order_id

which is useful because a common query is:

load all items for Order 1001

This key order aligns well with our access pattern।


Why Not (product_id, order_id)?

That would still uniquely identify rows।

But our most important relationship/query is:

Order
→ its OrderItems

So putting:

order_id

first is more natural for both domain ownership and likely lookup patterns।

Schema details should reflect access patterns where possible।


Product-to-OrderItem Queries

What if later we need:

find all Orders containing Product 101

The composite key (order_id, product_id) may not optimally serve that query by Product alone।

If that becomes an important query, we can add an index on:

product_id

later।

Don't redesign the primary key for a query we don't currently need।


Foreign Keys Do Not Enforce Every Domain Rule

Suppose OrderItem references:

Product 101

Foreign key verifies Product exists।

It cannot tell us:

Product was active when Order was created

That's a temporal business rule।

UseCase checks Product state during Create Order।


Foreign Key Does Not Check Inventory

OrderItem → Product foreign key cannot ensure:

Inventory was sufficient

when the Order was created।

Again:

CreateOrderUseCase

coordinates that operation।


Primary Key Does Not Enforce Order Lifecycle

orders.id being unique tells us nothing about whether:

UNPAID → PAID

or:

PAID → CANCELLED

is valid।

Keys protect identity and relationships, not workflow semantics।


Constraints Are Powerful but Narrow

A useful mental model:

Primary Key
→ Who is this row?
Unique Constraint
→ Can this fact appear twice?
Foreign Key
→ Does this referenced row exist?
CHECK
→ Does this row satisfy a local condition?
Domain / UseCase
→ Is this business operation valid?

Each has a different role।


Don't Push Workflow Into Foreign-Key Tricks

Suppose someone proposes separate tables:

unpaid_orders

paid_orders

cancelled_orders

just so foreign keys can somehow encode transitions।

That would massively complicate persistence for a simple lifecycle।

Our current:

orders.status

plus domain transitions is clearer।

Use relational constraints where they naturally fit։


Referential Integrity During Product Deactivation

When Product becomes inactive:

active = false

nothing happens to:

inventory.product_id

order_items.product_id

Their foreign keys remain valid।

This is correct because Product still exists historically।

Only orderability changes।


Inventory Can Exist for Inactive Product

Should deactivating Product delete Inventory?

No such requirement exists।

Product may remain:

inactive

with:

Inventory quantity = 5

Customer browse filters it out because Product isn't orderable।

Keeping Inventory does not violate relational integrity।


Cancellation Restores Inventory Regardless of Product Active State

Our accepted behaviour says:

cancel Order
→ restore consumed Inventory

even if Product has since been deactivated।

Foreign-key design supports that:

Product row still exists
Inventory row still exists

The cancellation workflow can restore quantity normally।

This is another reason not to destroy Inventory during Product deactivation।


What If Inventory Row Is Missing?

Our relational model expects:

one Inventory state per Product used for ordering

But foreign key only says:

if Inventory exists, Product must exist

It does not guarantee:

every Product has an Inventory row

This is important।


Foreign Keys Are Directional

This constraint:

inventory.product_id
→ products.id

means:

Inventory requires Product.

It does not mean:

Product requires Inventory.

Enforcing that every Product has Inventory would be a different cross-table rule।


Do We Need to Enforce Product → Inventory Existence?

Our requirements don't yet say that Product creation must atomically create Inventory state।

Therefore we should not introduce complex database enforcement for it।

Orderability already requires:

Product active

AND

Inventory > 0

A Product without Inventory simply won't satisfy that browse/order path until Inventory is configured appropriately।


Order Must Have at Least One OrderItem

Similar directionality exists for:

order_items.order_id
→ orders.id

It guarantees every OrderItem has an Order।

It does not guarantee every Order has at least one OrderItem।

That rule remains protected by:

Order domain invariant

CreateOrderUseCase transaction

Parent-Existence Rules Can Be Harder Relationally

Rules like:

every Order must have at least one child row

span multiple rows/tables and are not naturally represented by a simple foreign key।

This is why not every invariant belongs in a declarative relational constraint।


Temporary Integrity Inside a Transaction

During Create Order transaction:

1. insert Order
2. insert OrderItems
3. commit

there may be a moment inside the transaction where the Order row exists before its items are inserted।

That's fine।

The important invariant is:

no incomplete Order becomes committed visible state

Transaction atomicity handles this।


Generated Keys and OrderItems

Because orders.id is generated by PostgreSQL, persistence needs the generated ID before inserting:

order_items.order_id

JPA can manage this mapping for us later।

But conceptually it's important to understand what the ORM is doing।

The database relationship still requires a concrete parent key।


Could Application Generate IDs Before Insert?

Yes।

If we used UUID or another application-generated identifier, we could know OrderId before persistence।

Then parent/child inserts can both use the already-known value।

But our modular-monolith requirements do not justify changing identity strategy solely for that convenience।

Generated numeric IDs remain reasonable।


Foreign-Key Violations Should Not Become Raw API Errors

Suppose buggy code attempts to persist OrderItem for a missing Product।

PostgreSQL might raise a foreign-key constraint violation।

We must not send:

order_items_product_fk violation

to the API client।

That's an internal persistence failure signal।


Translate Meaning at the Right Boundary

If missing Product was an expected business case:

CreateOrderUseCase

should normally detect:

Product not found

before persistence and produce:

PRODUCT_NOT_FOUND

at the API boundary।

A later foreign-key violation for the same condition usually indicates:

race

bug

unexpected persistence problem

and needs appropriate handling rather than blindly mapping constraint names to client codes।


Do Not Build API Logic Around Constraint Names

Avoid:

if (
    exception.getMessage()
            .contains(
                "order_items_product_fk"
            )
) {
    return PRODUCT_NOT_FOUND;
}

This tightly couples HTTP behaviour to database exception text।

Application should detect expected business conditions intentionally।

Database constraints remain last-line integrity protection।


When Constraint Mapping Can Be Useful

There are cases where a database uniqueness constraint is the authoritative concurrency-safe way to detect a race।

For example, creating a value that must be globally unique।

Then persistence layer may translate a specific constraint violation into a meaningful application conflict।

But that translation should be deliberate and based on stable persistence semantics—not string parsing scattered across Handlers।


Inventory Concurrency Is Another Example

Later we may use database mechanics to prevent concurrent overselling।

A database failure/zero-row update may become:

INSUFFICIENT_INVENTORY

at application level।

Again:

Repository/persistence
→ interprets DB mechanics

UseCase
→ understands operation outcome

HTTP layer
→ maps to 409 Problem

not Controller parsing PostgreSQL messages।


Foreign-Key Cycles

Complex schemas can create:

A references B

B references A

which complicates inserts/deletes।

Our current model is intentionally simple and mostly directional:

Product
    ↑
Inventory

Product
    ↑
OrderItem
    ↓
Order

No unnecessary circular relational ownership is introduced।


Keep Ownership Clear

Order owns OrderItems conceptually।

Product does not own Orders।

Inventory references Product but isn't nested inside Product persistence automatically।

Clear relationship direction reduces mapping confusion।


Constraint Names Should Be Descriptive

We currently use names such as:

inventory_product_fk

order_items_order_fk

order_items_product_fk

order_items_pk

These are useful when:

reading schema

debugging migrations

inspecting production errors

Prefer names that reveal the relationship।


Avoid Names Like fk_1

This:

fk_1

provides almost no operational context।

During an incident, seeing:

order_items_product_fk

is much more useful।


Primary Key Names

PostgreSQL can generate primary-key constraint names automatically।

For some tables we explicitly name composite constraints such as:

order_items_pk

because readability matters।

We do not need to manually name every trivial constraint if conventions already make them clear, but explicit names can be useful in migrations and operations।


Referential Actions Should Be Explicit Decisions

Whenever we create a foreign key, ask:

What should happen if parent is deleted?

Should child deletion cascade?

Should deletion be rejected?

Can reference become null?

Never add:

ON DELETE CASCADE

because a framework tutorial used it।


Our Current Referential Actions

For:

inventory.product_id
→ products.id

we want Product physical delete blocked while Inventory references it।

For:

order_items.product_id
→ products.id

we definitely want history protected।

For:

order_items.order_id
→ orders.id

Order physical deletion is not a business operation।

Therefore restrictive/default behaviour fits all three current relationships।


What About ON UPDATE CASCADE?

Our generated Product/Order primary keys are immutable identities।

We do not support:

change ProductId 101 → 200

or:

change OrderId

So cascading primary-key updates solve no real requirement।

Identity should remain stable।


Primary Keys Are Immutable Business References

Once Product is:

ProductId 101

renaming Product or changing price should not change its ID।

Likewise Order lifecycle transitions do not change:

OrderId

Identity remains stable while state changes।


Never Use Mutable Business Attributes as Primary Keys Casually

Suppose Product name were used as primary key:

"Keyboard"

Then renaming to:

"Mechanical Keyboard"

would become an identity migration across all relationships।

That's unnecessary coupling।

Stable generated IDs avoid this।


External CustomerId Is Different

CustomerId is externally controlled but expected to be stable。

We store it as ownership reference because that external system defines the identity contract।

We do not derive ownership from:

customer email

display name

which may change।

Stable identifiers are critical for relational references even when no local FK exists।


Primary Keys and API URLs

Our API might expose:

GET /api/v1/products/101

and:

GET /api/v1/orders/1001

These IDs ultimately map to database primary keys through domain IDs।

But HTTP code should still operate with:

ProductId

OrderId

rather than direct SQL assumptions।


Do Not Expose Composite Persistence Keys Accidentally

OrderItem does not have its own public endpoint:

/order-items/{orderId}/{productId}

just because its database key is composite।

The API design already decided OrderItems are nested parts of Order।

Persistence identity does not automatically define HTTP resources।


API Resource Design and DB Keys Are Related but Separate

Database asks:

How is this row uniquely identified?

HTTP API asks:

What operations/resources should clients interact with?

Sometimes they align directly।

Sometimes they don't।

Do not derive API design mechanically from table keys।


Constraints Also Protect Operational Scripts

Even in a well-designed application, production systems may eventually have:

migration scripts

data repair scripts

manual administrative SQL

Database constraints still apply there।

That's one reason structural integrity should not live only in Java।


But Constraints Can Also Block Bad Migrations

Suppose a migration accidentally attempts to create an invalid foreign reference।

PostgreSQL rejects it instead of silently corrupting data।

This makes strong constraints valuable during system evolution too।


Adding Foreign Keys to Existing Data

In a brand-new schema, foreign keys are straightforward।

In an existing production database with legacy bad data, adding a new foreign key may fail because orphan rows already exist।

Then engineers need:

data cleanup

migration planning

safe rollout

Our course starts from a new schema, but later change requests should remember this operational reality।


Constraint Changes Are Schema Changes

Changing:

Primary Key

Foreign Key

Unique Constraint

requires a database migration।

These are not JPA-only changes।

That's another reason schema changes belong to Flyway।


JPA Must Respect the Schema

Later when mapping:

ProductEntity

InventoryEntity

OrderEntity

OrderItemEntity

the ORM should map our accepted keys and relationships accurately।

We should not allow a convenient JPA default to silently:

add extra IDs

remove uniqueness

change nullability

invent cascades

that contradict the schema design।


JPA Cascade Is Not the Same as Database Cascade

This distinction becomes important later।

JPA can have concepts such as:

CascadeType.PERSIST

CascadeType.REMOVE

Database has:

ON DELETE CASCADE

These are different mechanisms at different layers।

Never assume configuring one means the other automatically behaves the same way।

We will revisit this during JPA relationships।


Example: Creating an Order

Let's see how the keys cooperate।

Application creates:

Order
CustomerId = C-10
items:
    Product 101 × 2
    Product 102 × 1

Persistence transaction:

INSERT orders
    ↓
PostgreSQL generates OrderId 1001

Then:

INSERT order_items
(1001, 101, 2, 100.00)

INSERT order_items
(1001, 102, 1, 50.00)

Foreign keys verify:

Order 1001 exists

Product 101 exists

Product 102 exists

Composite PK verifies:

Product 101 appears only once in Order 1001

Product 102 appears only once

Everything commits together।


Example: Duplicate Product Bug

Suppose application bug tries:

(1001, 101, 2)

(1001, 101, 1)

Second row violates:

PRIMARY KEY(order_id, product_id)

Database refuses to persist invalid duplicated lines।

The transaction should roll back।


Example: Missing Product Bug

Suppose application tries:

order_id = 1001

product_id = 999

but Product 999 doesn't exist।

Foreign key rejects the row।

Again, invalid relational state never commits।


Example: Delete Product Accident

Suppose Product 101 has historical OrderItems।

Someone runs:

DELETE FROM products
WHERE id = 101;

Foreign key from order_items prevents deletion।

Historical references survive।

The correct business action remains:

deactivate Product

Example: Delete Order Accident

Suppose Order 1001 has two OrderItems।

Someone tries:

DELETE FROM orders
WHERE id = 1001;

Foreign key prevents deleting the parent while children reference it।

Again, historical business state is protected।


Example: Inventory Without Product

Attempt:

Inventory(ProductId = 999, quantity = 10)

without Product 999।

Foreign key rejects it।

This protects the structural rule:

Inventory belongs to a real Product

Database Integrity Is Necessary but Not Sufficient

After all these constraints, database can still contain a structurally valid but business-invalid state if application workflows are wrong।

For example:

Order status = PAID

while payment provider never actually confirmed payment।

All keys and foreign keys are valid।

But business truth is wrong।

That's why:

correct UseCases

domain rules

integration handling

remain essential।


Another Example

Database might contain:

UNPAID Order

with valid OrderItems and valid Product foreign keys।

But maybe Inventory was not decreased due application bug।

All relational constraints could still pass।

Cross-aggregate workflow correctness requires transaction/application logic।

Keys are not enough।


Think in Layers of Protection

Our final system will protect data through several levels:

HTTP validation
    ↓
UseCase workflow
    ↓
Domain invariants
    ↓
Repository persistence strategy
    ↓
Primary Keys
Foreign Keys
Unique Constraints
CHECK constraints
    ↓
PostgreSQL transaction

No single layer solves everything।

Together they create a resilient system।


Primary and Foreign Key Review Checklist

For every table, ask:

What uniquely identifies one row?

Is that identity stable?

Do I really need a generated surrogate ID?

Is there a natural/composite identity already?

Which columns reference other persisted concepts?

Can PostgreSQL enforce those relationships?

What should happen if the parent is deleted?

Would cascade destroy historical information?

Does a uniqueness constraint reinforce a real business rule?

Am I accidentally trying to encode workflow behaviour
with structural keys?

Common Mistake 1 — Every Table Gets an Auto-Increment ID

Inventory already has ProductId as its row identity।


Common Mistake 2 — Composite Key Avoided Only Because ORM Mapping Is Less Familiar

Persistence convenience should be considered, but not automatically override a clean relational model।


Common Mistake 3 — No Foreign Keys Because "The Application Checks It"

Database constraints protect all write paths and provide stronger persisted integrity।


Common Mistake 4 — ON DELETE CASCADE Added by Default

Historical Product and Order references must not disappear automatically।


Common Mistake 5 — Customer Table Created Only to Support a Foreign Key

External identity boundaries should remain external।


Common Mistake 6 — Foreign Key Treated as Business Validation

Product existence is not the same as Product orderability।


Common Mistake 7 — Primary Key Treated as Authorization

Guessable numeric IDs still require proper ownership/permission enforcement।


Common Mistake 8 — Mutable Attribute Used as Identity

Names, prices, and statuses change; identity should remain stable।


Common Mistake 9 — Constraint Violation Message Returned Directly to API Client

Database vocabulary remains internal।


Common Mistake 10 — Assuming Keys Guarantee Workflow Correctness

Keys protect structure; UseCases and transactions protect business workflows।


Our Current Key Design

products

PRIMARY KEY
→ id

Purpose:

stable Product identity

inventory

PRIMARY KEY
→ product_id

FOREIGN KEY
→ product_id references products.id

Purpose:

one Inventory row per Product

Inventory cannot exist for a missing Product

orders

PRIMARY KEY
→ id

Purpose:

stable Order identity

customer_id is an external ownership reference, not a local foreign key।


order_items

PRIMARY KEY
→ (order_id, product_id)

Purpose:

one Product line per Order

Foreign keys:

order_id
→ orders.id

product_id
→ products.id

Purpose:

OrderItem belongs to a real Order

OrderItem references a real Product

Engineering Principle

The core principle:

Primary keys define durable row identity; foreign keys protect relationships between persisted facts. Neither should be chosen as boilerplate.

Another:

Use relational constraints aggressively where the business rule is structural and unambiguous, but don't mistake referential integrity for complete business correctness.

And:

Delete behaviour is part of data integrity. A convenient cascade can be far more dangerous than a missing annotation when historical business data matters.


Summary

In this lesson, we learned that:

  • Primary keys uniquely identify persisted rows.
  • Primary keys guarantee uniqueness and non-null identity.
  • Generated Product and Order IDs align naturally with their domain identities.
  • Not every table needs a generated surrogate ID.
  • Inventory is naturally identified by ProductId.
  • Composite primary keys can represent meaningful relational identity.
  • (order_id, product_id) naturally identifies an OrderItem in our v1 model.
  • The OrderItem composite key also reinforces the no-duplicate-Product-lines rule.
  • A generated OrderItem ID would require an additional uniqueness constraint to preserve the same business rule.
  • Foreign keys protect referential integrity between persisted rows.
  • Inventory cannot reference a Product that does not exist.
  • OrderItems cannot reference missing Orders or Products.
  • CustomerId has no local foreign key because Customer identity is externally owned.
  • Referential integrity ends at system ownership boundaries.
  • Foreign keys are directional: Inventory requiring Product does not automatically mean every Product must have Inventory.
  • OrderItem foreign keys do not guarantee every Order has at least one item.
  • Cross-table business invariants still need domain logic and transaction boundaries.
  • Product and Order physical deletion should be restricted because historical data must remain intact.
  • ON DELETE CASCADE should never be added automatically.
  • Primary keys are not authorization mechanisms; sequential IDs still require ownership and role checks.
  • Foreign keys confirm existence, not business state such as Product orderability or Inventory sufficiency.
  • Constraint violations should not leak directly through API responses.
  • Primary keys, foreign keys, uniqueness, CHECK constraints, application logic, and transactions form complementary layers of data protection.

Next lesson:

Spring Data JPA

There we will connect our Java application to PostgreSQL through JPA, Hibernate, and Spring Data, understand what each one actually does, and define how they fit behind our existing Repository boundary without letting the framework become our domain architecture.