Persistence with PostgreSQL

Entity Mapping

ReadingPreview

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

আমাদের relational schema এখন concrete:

products

inventory

orders

order_items

Database জানে:

tables

columns

primary keys

foreign keys

constraints

কিন্তু Java application কাজ করে objects দিয়ে।

এখন আমাদের bridge তৈরি করতে হবে:

PostgreSQL row
        ↕
JPA Entity
        ↕
Domain object

Jakarta Persistence-এ @Entity persistent entity type declare করে, @Table primary table mapping define করে, এবং @Id/@EmbeddedId entity identity map করে। Composite identifier-এর জন্য @EmbeddedId একটি supported standard mechanism।

এই lesson-এর goal:

আমাদের accepted PostgreSQL schema-কে explicit JPA mappings-এ represent করা—without changing the domain model or allowing Hibernate defaults to redesign the database.


Our Mapping Direction

আমাদের direction:

Business requirements
        ↓
Domain model
        ↓
PostgreSQL schema
        ↓
JPA mapping

Not:

@Entity annotations
        ↓
Hibernate guesses schema
        ↓
we accept whatever appears

Schema already exists conceptually।

JPA-এর job:

Map Java persistence objects to that schema accurately.


Persistence Entity Is an Infrastructure Model

আমরা domain-এ already have concepts such as:

Product

Inventory

Order

OrderItem

Persistence layer may contain:

ProductEntity

InventoryEntity

OrderEntity

OrderItemEntity

These objects exist because Hibernate needs a Java representation of database rows।

For example:

Product
→ business behaviour

while:

ProductEntity
→ products row mapping

Those responsibilities are related, but different।


Why Keep Them Separate?

Our domain Product may expose behaviour:

product.changePrice(...);

product.rename(...);

product.deactivate();

Our persistence representation needs:

id column

name column

price column

active column

A separate persistence entity lets JPA-specific requirements remain inside:

persistence/

instead of shaping the domain।


Field Access

In our examples, JPA annotations will be placed directly on fields:

@Id
private Long id;

That gives us a field-oriented mapping style।

We therefore don't need public setters for every persistence property merely so Hibernate can map them।

This keeps persistence entities reasonably encapsulated।


ProductEntity

Our table:

CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC NOT NULL,
    active BOOLEAN NOT NULL,

    CONSTRAINT products_price_non_negative
        CHECK (price >= 0)
);

A JPA representation can look like:

package io.liveklass.ordermanagement.product.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class ProductEntity {

    @Id
    @GeneratedValue(
            strategy = GenerationType.IDENTITY
    )
    private Long id;

    @Column(
            name = "name",
            nullable = false
    )
    private String name;

    @Column(
            name = "price",
            nullable = false
    )
    private BigDecimal price;

    @Column(
            name = "active",
            nullable = false
    )
    private boolean active;

    protected ProductEntity() {
    }

    public ProductEntity(
            String name,
            BigDecimal price,
            boolean active
    ) {
        this.name = name;
        this.price = price;
        this.active = active;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public boolean isActive() {
        return active;
    }

    public void setName(
            String name
    ) {
        this.name = name;
    }

    public void setPrice(
            BigDecimal price
    ) {
        this.price = price;
    }

    public void setActive(
            boolean active
    ) {
        this.active = active;
    }
}

The exact mapping methods can evolve when Repository implementation is written।


@Entity

@Entity

declares the class as a persistent entity।

It tells JPA:

instances of this class
can represent persistent entity state

It does not mean:

this is automatically our domain model

Those are separate decisions।


@Table

@Table(name = "products")

explicitly maps:

ProductEntity
→ products

We prefer explicit table names because our schema is already deliberately designed।

We don't want correctness to depend on whichever implicit naming strategy happens to be configured।


@Id

@Id
private Long id;

maps:

ProductEntity.id
→ products.id

as the entity identifier।


@GeneratedValue

Our PostgreSQL schema uses:

GENERATED ALWAYS AS IDENTITY

So the persistence entity uses an identity-generation mapping:

@GeneratedValue(
        strategy = GenerationType.IDENTITY
)

Hibernate supports identity-based generated identifiers as part of its identifier generation support.


Why Long, Not long?

Before persistence, a new ProductEntity has no database-generated ID yet।

Using:

Long id;

allows:

id = null

to mean:

not persisted / identity not assigned yet

After insertion, persistence assigns the generated value।

Using:

long id;

would start with:

0

which looks like a real numeric value even though no Product with ID 0 was assigned।

Wrapper type expresses persistence lifecycle more clearly।


Generated Identity Creates an Important Domain Boundary

A Product API request does not provide ProductId:

{
  "name": "Keyboard",
  "price": 100.00
}

PostgreSQL generates the ID when the Product is persisted।

Therefore:

CreateProductUseCase

must not invent:

ProductId(0)

before persistence։

A cleaner conceptual flow is:

validated Product creation data
        ↓
persistence creates Product row
        ↓
database assigns ID
        ↓
Repository returns persisted Product
        ↓
Product now has ProductId

Exactly how the constructor/repository contract expresses this lifecycle should remain deliberate।


Do Not Use Fake IDs

Avoid:

new ProductId(0);

or:

new ProductId(-1);

to mean:

not persisted yet

A ProductId should represent an actual Product identity।

Persistence lifecycle should be modeled separately from fake business identities।


@Column

Example:

@Column(
        name = "price",
        nullable = false
)
private BigDecimal price;

makes the column mapping explicit।

Notice:

nullable = false

matches our Flyway schema intention।

But this annotation is not our migration strategy

The real database guarantee remains:

price NUMERIC NOT NULL

in the migration।


Don't Duplicate Every SQL Detail in columnDefinition

Avoid:

@Column(
        columnDefinition =
                "NUMERIC CHECK (price >= 0)"
)

Now database design is duplicated inside Java annotations।

We already decided:

Flyway
→ owns schema definition

JPA should map the schema rather than reproduce the migration SQL in annotations।


Product Price Mapping

Java:

BigDecimal price;

maps naturally to our exact decimal database value:

NUMERIC

We intentionally do not specify:

precision = 10,
scale = 2

because our schema intentionally does not invent those restrictions।

The JPA mapping should not silently make the contract narrower than the database design।


Product Validation Does Not Move Into ProductEntity

We do not need:

if (price.signum() < 0) {
    ...
}

inside ProductEntity as our primary business protection।

Our layers already have:

Request validation

Product domain invariant

Database CHECK

ProductEntity is primarily persistence representation।

Do not recreate the entire domain model inside it।


Mapping Between ProductEntity and Product

Conceptually:

ProductEntity
    ↓
Product

may involve:

Product toDomain() {
    return Product.reconstitute(
            new ProductId(id),
            name,
            price,
            active
    );
}

And converting an existing Product back:

static ProductEntity from(
        Product product
) {
    ...
}

The exact factory names depend on our domain implementation।

The important distinction is:

new business object creation

versus:

reconstructing already-valid persisted state

Reconstitution Is Different From User Input

Loading:

price = 100

from PostgreSQL is not the same operation as accepting:

price = 100

from an untrusted HTTP request।

We still want domain objects reconstructed into valid state, but we shouldn't pretend every database load is a new API command।

Well-designed domain factories can distinguish these responsibilities if necessary।


InventoryEntity

Our schema:

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

    ...
);

Inventory uses:

ProductId

as its own row identity।

Initial JPA mapping:

package io.liveklass.ordermanagement.inventory.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "inventory")
public class InventoryEntity {

    @Id
    @Column(name = "product_id")
    private Long productId;

    @Column(
            name = "available_quantity",
            nullable = false
    )
    private int availableQuantity;

    protected InventoryEntity() {
    }

    public InventoryEntity(
            Long productId,
            int availableQuantity
    ) {
        this.productId = productId;
        this.availableQuantity =
                availableQuantity;
    }

    public Long getProductId() {
        return productId;
    }

    public int getAvailableQuantity() {
        return availableQuantity;
    }

    public void setAvailableQuantity(
            int availableQuantity
    ) {
        this.availableQuantity =
                availableQuantity;
    }
}

Notice something important।

There is no:

@GeneratedValue

because:

Inventory identity
=
Product identity

The Product ID already exists।


Where Is @OneToOne?

Our database has:

inventory.product_id
→ products.id

foreign key।

But this first mapping intentionally uses:

Long productId;

rather than:

ProductEntity product;

Why?

Because:

A database foreign key does not force us to model a navigable JPA association.

This is an extremely useful distinction।


Foreign Key and JPA Association Are Different Things

PostgreSQL can guarantee:

inventory.product_id
references products.id

while Java persistence representation simply stores:

productId

The relationship still exists in the database।

We only need:

@OneToOne

if object navigation such as:

inventoryEntity.getProduct()

actually helps our persistence model।

We will evaluate relationship mappings in the next lesson।


Why Scalar Foreign-Key Mapping Can Be Useful

Keeping:

Long productId;

means reading Inventory does not automatically imply:

load Product

create Product proxy

manage a JPA association

Our application already has separate:

ProductRepository

InventoryRepository

boundaries।

A scalar reference often keeps that separation clearer।


Don't Build Object Graphs Just Because SQL Has Foreign Keys

A common JPA mistake:

Every foreign key
→ @ManyToOne or @OneToOne

Then a simple entity can turn into a large navigable graph:

Order
→ items
→ products
→ inventory
→ ...

This increases the chance of:

unexpected queries

lazy-loading surprises

N+1 queries

persistence coupling

Associations should be added intentionally।


OrderEntity

Our schema:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY
        PRIMARY KEY,

    customer_id TEXT NOT NULL,

    status TEXT NOT NULL,

    created_at TIMESTAMPTZ NOT NULL
        DEFAULT CURRENT_TIMESTAMP,

    ...
);

Mapping:

package io.liveklass.ordermanagement.order.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.time.Instant;

@Entity
@Table(name = "orders")
public class OrderEntity {

    @Id
    @GeneratedValue(
            strategy = GenerationType.IDENTITY
    )
    private Long id;

    @Column(
            name = "customer_id",
            nullable = false
    )
    private String customerId;

    @Enumerated(EnumType.STRING)
    @Column(
            name = "status",
            nullable = false
    )
    private OrderStatusEntity status;

    @Column(
            name = "created_at",
            nullable = false
    )
    private Instant createdAt;

    protected OrderEntity() {
    }

    public OrderEntity(
            String customerId,
            OrderStatusEntity status,
            Instant createdAt
    ) {
        this.customerId = customerId;
        this.status = status;
        this.createdAt = createdAt;
    }

    public Long getId() {
        return id;
    }

    public String getCustomerId() {
        return customerId;
    }

    public OrderStatusEntity getStatus() {
        return status;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }

    public void setStatus(
            OrderStatusEntity status
    ) {
        this.status = status;
    }
}

Why Separate OrderStatusEntity?

We have a domain:

OrderStatus

with:

UNPAID

PAID

CANCELLED

We could map the domain enum directly if keeping JPA annotations off the enum requires no annotation there।

For example:

@Enumerated(EnumType.STRING)
private OrderStatus status;

That may be perfectly pragmatic।

A separate:

OrderStatusEntity

is only useful if persistence representation needs to evolve independently।

We should not create duplicate enums simply because entities are separate।

So our preferred simple direction can be:

@Enumerated(EnumType.STRING)
private OrderStatus status;

provided OrderStatus itself remains framework-independent।


Explicit EnumType.STRING

This is important।

Use:

@Enumerated(EnumType.STRING)

not implicit ordinal mapping।

Jakarta Persistence supports explicit string enum persistence, while ordinal mapping remains relevant when no explicit string representation is selected.

Our database stores:

UNPAID

PAID

CANCELLED

so string mapping aligns directly with the schema।


Why Ordinal Mapping Is Dangerous

Imagine:

public enum OrderStatus {
    UNPAID,
    PAID,
    CANCELLED
}

Ordinal values conceptually:

UNPAID    → 0

PAID      → 1

CANCELLED → 2

Later someone inserts:

PROCESSING

in the middle:

UNPAID,
PROCESSING,
PAID,
CANCELLED

Now numeric positions change।

Persisted database values could represent the wrong meaning if ordinal mapping is used carelessly।

String persistence is much safer for our readable lifecycle values।


Enum Names Become Persisted Contract

Once database contains:

PAID

renaming the Java constant:

PAID
→ PAYMENT_COMPLETED

is no longer a simple Java refactor।

The schema/data and potentially API contract must evolve too।

This is another example of persistence creating durable compatibility concerns।


CustomerId

Persistence uses:

String customerId;

because database column is:

TEXT

But domain uses:

CustomerId

Mapping boundary converts:

String
↔
CustomerId

We do not need to make HTTP, domain, and persistence use the same raw type everywhere।


createdAt

We use:

Instant createdAt;

for an absolute point in time।

The database uses:

TIMESTAMPTZ

for Order creation time।

The persistence adapter maps the database timestamp into the application's temporal representation।


Who Sets createdAt?

Our schema has:

DEFAULT CURRENT_TIMESTAMP

but a JPA entity may also explicitly provide:

Instant createdAt;

when creating the row।

We should choose one clear creation flow rather than accidentally have:

application clock

and:

database clock

compete as independent sources。

For the application model, explicitly setting creation time before persistence is straightforward, while the database default remains a structural fallback for inserts that omit the value।


Do Not Add @CreationTimestamp Automatically

Hibernate offers convenience annotations for generated timestamps।

But our current model does not require provider-specific timestamp magic।

Using:

Instant.now()

through an appropriate application/persistence time source is easier to reason about and test when the Order is created।

No extra Hibernate-specific abstraction is needed yet।


Order Status Setters Are Persistence Mechanics

OrderEntity may need a method such as:

setStatus(...)

That does not mean domain Order should expose:

setStatus(...)

Domain still uses:

order.markPaid();

order.cancel();

The persistence entity is allowed to be more data-oriented because its responsibility is mapping state।


Persistence Model Does Not Replace Domain Behaviour

Correct:

Order
→ controls valid transition

OrderEntity
→ stores resulting status

Not:

OrderEntity.setStatus(anything)
→ becomes our business API

UseCases should not manipulate persistence entities directly।


Composite Key Mapping for order_items

Our schema:

CREATE TABLE order_items (
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INTEGER NOT NULL,
    unit_price NUMERIC NOT NULL,

    PRIMARY KEY (
        order_id,
        product_id
    )
);

The primary key contains:

order_id

product_id

JPA supports composite identifiers using mechanisms including:

@EmbeddedId

@IdClass

Jakarta Persistence defines @EmbeddedId specifically for persistent attributes representing composite primary keys.

For our model, @EmbeddedId makes the composite identity explicit as one value object։


OrderItemEntityId

package io.liveklass.ordermanagement.order.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;

import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class OrderItemEntityId
        implements Serializable {

    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "product_id")
    private Long productId;

    protected OrderItemEntityId() {
    }

    public OrderItemEntityId(
            Long orderId,
            Long productId
    ) {
        this.orderId = orderId;
        this.productId = productId;
    }

    public Long getOrderId() {
        return orderId;
    }

    public Long getProductId() {
        return productId;
    }

    @Override
    public boolean equals(
            Object other
    ) {
        if (this == other) {
            return true;
        }

        if (
                !(other
                        instanceof
                        OrderItemEntityId that)
        ) {
            return false;
        }

        return Objects.equals(
                        orderId,
                        that.orderId
                ) &&
                Objects.equals(
                        productId,
                        that.productId
                );
    }

    @Override
    public int hashCode() {
        return Objects.hash(
                orderId,
                productId
        );
    }
}

Composite identifier classes need value-based identity semantics consistent with their mapped key values; Jakarta Persistence's composite-key model requires appropriate equality semantics.


Why equals() and hashCode() Matter Here

This class represents:

(order_id, product_id)

So:

new OrderItemEntityId(
        1001L,
        101L
)

and another instance with the same values represent the same persistence identity।

Java equality should reflect that।


OrderItemEntity

package io.liveklass.ordermanagement.order.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

import java.math.BigDecimal;

@Entity
@Table(name = "order_items")
public class OrderItemEntity {

    @EmbeddedId
    private OrderItemEntityId id;

    @Column(
            name = "quantity",
            nullable = false
    )
    private int quantity;

    @Column(
            name = "unit_price",
            nullable = false
    )
    private BigDecimal unitPrice;

    protected OrderItemEntity() {
    }

    public OrderItemEntity(
            OrderItemEntityId id,
            int quantity,
            BigDecimal unitPrice
    ) {
        this.id = id;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public OrderItemEntityId getId() {
        return id;
    }

    public int getQuantity() {
        return quantity;
    }

    public BigDecimal getUnitPrice() {
        return unitPrice;
    }
}

Now:

OrderItemEntity.id.orderId
→ order_items.order_id

OrderItemEntity.id.productId
→ order_items.product_id

Again: No @ManyToOne Yet

You might expect:

@ManyToOne
private OrderEntity order;

and:

@ManyToOne
private ProductEntity product;

We intentionally haven't added them yet।

The entity can represent:

order_id

product_id

as scalar identifier values while PostgreSQL still enforces the actual foreign keys।

In the next lesson we'll evaluate whether JPA relationships improve our implementation or create unnecessary object graphs।


@EmbeddedId Matches Our Relational Identity

The database says:

OrderItem identity
=
OrderId + ProductId

JPA says:

@EmbeddedId
private OrderItemEntityId id;

That is a clear mapping।

No persistence-only:

order_item_id

needs to be invented।


Could We Use @IdClass Instead?

Yes।

Conceptually:

@IdClass(OrderItemEntityId.class)
@Entity
public class OrderItemEntity {

    @Id
    private Long orderId;

    @Id
    private Long productId;
}

Jakarta Persistence supports both @IdClass and @EmbeddedId for composite identifiers.

For our model, @EmbeddedId communicates:

these values together form one persistence identity

more directly, so we'll use it।


OrderItemEntityId Is Not a Domain OrderItemId

This distinction is important।

We intentionally said domain OrderItem has no independent:

OrderItemId

OrderItemEntityId exists only because JPA needs a Java representation of the database composite key।

It is:

persistence identity representation

not:

new business identity concept

Don't leak it into the domain।


The Composite Key Already Contains Foreign-Key Values

Our embedded key stores:

orderId

productId

Later, if we decide to add actual JPA relationships, Jakarta Persistence provides mechanisms such as @MapsId for mapping relationships that share columns with an embedded identifier.

We will discuss that in the next lesson instead of mixing association complexity into basic entity mapping।


OrderItem Mapping Back to Domain

Database state:

order_id = 1001

product_id = 101

quantity = 2

unit_price = 100.00

can become domain:

OrderItem(
    ProductId(101),
    quantity = 2,
    unitPrice = 100.00
)

Notice:

order_id

doesn't need to become a property inside domain OrderItem if the item already lives inside:

Order

aggregate।

That's persistence information needed to locate ownership relationally।


Persistence Structure Does Not Have to Mirror Domain Structure Exactly

Database:

order_items.order_id

Domain:

Order
    └── List<OrderItem>

The mapping layer connects those two models।

We should not add:

OrderId orderId

inside domain OrderItem just because SQL needs the foreign key։


Reconstructing an Order

Loading full Order conceptually involves:

load orders row

load related order_items rows

then:

map OrderEntity
+
OrderItemEntity list

into:

Order

with:

OrderId

CustomerId

OrderStatus

List<OrderItem>

createdAt

Repository/persistence layer performs this reconstruction।

Handler should know nothing about these mappings।


Where Should Mapping Code Live?

Possible simple direction:

order/persistence/
├── OrderEntity.java
├── OrderItemEntity.java
├── OrderItemEntityId.java
├── OrderJpaRepository.java
└── JpaOrderRepository.java

Mapping can initially live in:

JpaOrderRepository

or small entity mapping methods।

If mapping becomes substantial, introduce:

OrderPersistenceMapper

then—not beforehand।


Don't Introduce MapStruct Yet

Manual mapping here is:

explicit

small

easy to understand

Adding a mapping framework would introduce:

another dependency

generated code

mapping configuration

without solving a current problem।

If mapping volume later becomes painful, we can reassess।


Mapping Nullability

Schema:

products.name
→ NOT NULL

JPA:

@Column(
        name = "name",
        nullable = false
)

Keeping them aligned is useful।

But remember:

nullable = false

does not replace:

Flyway NOT NULL

constraint।

The database migration remains authoritative for persisted integrity।


nullable = false Is Still Valuable

It communicates mapping intent directly in Java:

this entity expects the column to be required

and can help schema validation/tooling detect mismatches।

So it isn't pointless।

It just isn't our migration mechanism।


Avoid Arbitrary length

We intentionally chose:

TEXT

for:

Product.name

Order.customer_id

because no maximum contract exists।

Don't now write:

@Column(
        length = 255
)

by habit।

That would reintroduce an arbitrary constraint at the mapping layer।


Avoid Arbitrary Money Precision

Likewise don't write:

@Column(
        precision = 10,
        scale = 2
)

because:

NUMERIC(10,2)

was deliberately not part of our accepted schema।

Entity mapping should remain consistent with that decision।


Database CHECK Constraints Don't Need JPA Copies

Our schema already has:

price >= 0

available_quantity >= 0

quantity > 0

unit_price >= 0

status IN (...)

We do not need Hibernate-specific annotations recreating every constraint।

Why?

Because:

Flyway migration

is the database contract।

Application/domain already guards relevant business invariants।

Another annotation layer would mostly duplicate rules।


Don't Add @Version Yet

JPA supports optimistic locking through version attributes, but our Inventory concurrency strategy remains intentionally undecided।

Therefore we do not add:

@Version
private long version;

to Product, Inventory, or Order just because optimistic locking exists।

If we select optimistic concurrency for a real workflow later, then schema and mapping evolve together।


Don't Add Audit Fields Yet

Avoid entity template fields such as:

createdBy

updatedBy

updatedAt

version

unless our schema and requirements actually contain them।

Order.createdAt exists because Order history needs it।

That does not mean every entity must receive timestamps।


Don't Add a BaseEntity

A tempting abstraction:

@MappedSuperclass
public abstract class BaseEntity {

    @Id
    private Long id;

    private Instant createdAt;

    private Instant updatedAt;
}

This immediately conflicts with our actual model:

Inventory
→ ProductId as key

OrderItem
→ composite key

Product
→ no createdAt requirement

A generic BaseEntity would make persistence less accurate rather than more reusable।


Different Tables Have Different Identity Models

This is worth emphasizing:

Product
→ generated simple key

Inventory
→ ProductId key

Order
→ generated simple key

OrderItem
→ composite key

Trying to force all four through:

BaseEntity<Long>

would hide real differences।

A little duplication is preferable to a false abstraction।


Entity Classes Should Not Escape Persistence

Avoid:

public ProductEntity getProduct(...)

from a UseCase।

Application repository returns:

Product

or an application read result।

Why?

Because once ProductEntity escapes, the caller may begin depending on:

JPA lifecycle

lazy loading

Hibernate proxies

persistence setters

Infrastructure has leaked upward।


Handler Should Never Serialize JPA Entities

Avoid:

@GetMapping("/{id}")
public ProductEntity get(...) {
    return repository.findById(...);
}

Problems include:

public API coupled to DB mapping

future lazy relationships

accidental field exposure

persistence implementation becomes contract

We already have:

ProductResponse

OrderResponse

for HTTP।

Keep using them।


JPA Entity Methods Are Not Domain APIs

Persistence entity may expose technical methods:

setPrice(...)

setStatus(...)

because the adapter needs to synchronize state।

UseCase should still manipulate:

Product

Order

through domain methods।

The adapter then maps the resulting state to JPA entities।


Could We Use Public Setters?

Technically we could։

But there's rarely a reason to expose persistence mutation more broadly than necessary।

Prefer:

protected/default constructors where appropriate

focused mutation used by adapter

package-local structure when useful

rather than a giant JavaBean surface।


Mapping Existing Rows vs Creating Rows

JPA entities need to support at least two scenarios:

new row representation

existing row representation

Generated identity makes the difference visible:

new ProductEntity
→ id null

loaded ProductEntity
→ id assigned

Don't confuse these persistence states with domain validity।


JPA Entity Identity and Java Equality

Should every entity automatically implement:

equals()

hashCode()

based on database ID?

Not blindly।

Generated identifiers create awkward behaviour before persistence because:

id = null

for multiple new entities।

Entity equality in Hibernate has real lifecycle implications and should be designed intentionally rather than generated automatically by an IDE across every field.

For this lesson, we only require explicit value equality for our composite key class, where the specification requires meaningful identifier equality.


Don't Use Lombok @Data on JPA Entities

A broad annotation that automatically generates:

setters

equals

hashCode

toString

can create undesirable entity behaviour, especially once relationships exist।

We don't use Lombok here anyway।

Explicit persistence code is easier to reason about।


toString() Can Become Dangerous Later

Once relationships exist, auto-generated:

toString()

might traverse:

Order → items → Order → ...

or trigger lazy loading।

Avoid treating persistence entities like ordinary immutable DTO records।


Why Not Use Java Records for JPA Entities?

Records are excellent for:

Request DTO

Response DTO

Value Objects

because their state is fixed and concise।

Persistence entities have lifecycle/mutation/identity requirements that make ordinary classes a clearer default for this course।

For embedded value representations, modern persistence specifications provide increasing flexibility, but ordinary classes keep the teaching model straightforward and portable।


Entity Mapping and Schema Validation

Once Flyway migrations are active, we want Hibernate to map the schema we created—not silently mutate it।

A useful production-style direction is:

Flyway
→ migrate schema

Hibernate
→ validate/use schema

rather than:

Hibernate
→ update schema automatically

We'll configure this explicitly in the Flyway lesson।


If Mapping and Schema Disagree

Suppose Flyway says:

customer_id TEXT NOT NULL

but entity mapping expects:

customer

or a different type।

We want this mismatch discovered early।

That's much better than allowing production schema to drift silently from code assumptions।


Entity Mapping Is an Internal Contract

At this point we now have three distinct models:

HTTP model
→ CreateOrderRequest / OrderResponse
Domain model
→ Order / OrderItem
Persistence model
→ OrderEntity / OrderItemEntity

These may look repetitive।

But they answer different questions।


Transport Model

Asks:

What may the API client send or receive?

Example:

CreateOrderRequest

contains:

productId

quantity

but not:

customerId

unitPrice

status

Domain Model

Asks:

What business state and behaviour must be valid?

Example:

Order

controls:

markPaid()

cancel()

total()

Persistence Model

Asks:

How is durable state represented in PostgreSQL?

Example:

OrderEntity

maps:

orders.id

orders.customer_id

orders.status

orders.created_at

Different concerns justify different models।


Don't Create Mapping Layers for Everything

Separation should remain proportional।

For example:

OrderStatus

can potentially be reused in domain and persistence because mapping it with:

@Enumerated(EnumType.STRING)

does not require modifying the enum itself।

We don't need:

OrderStatus

OrderStatusEntity

OrderStatusDto

OrderStatusPersistenceValue

unless semantics actually differ।


Our Initial Mapping Shape

Conceptually:

products
    ↕
ProductEntity
    ↕
Product
inventory
    ↕
InventoryEntity
    ↕
Inventory
orders
+
order_items
    ↕
OrderEntity
+
OrderItemEntity
    ↕
Order
+
OrderItem

No JPA Relationship Graph Yet

Current JPA mappings intentionally look mostly like:

scalar columns

IDs

embedded composite ID

not:

@OneToMany everywhere

@ManyToOne everywhere

This gives us a clean baseline।

In the next lesson we can evaluate each relationship individually rather than accepting ORM defaults।


Mapping Review Checklist

For each entity, ask:

Which table does it represent?

What is the actual primary key?

Is the ID database-generated?

Does the Java ID type represent
unpersisted state correctly?

Do column names exactly match the schema?

Am I adding length/precision constraints
that the schema never defined?

Is an enum stored by meaning or ordinal?

Is a foreign key better represented as a scalar ID
or a navigable JPA association?

Does this persistence model leak into the domain?

Am I adding a generic BaseEntity
that doesn't match real table identities?

Am I letting Hibernate generate schema decisions
that Flyway should own?

Common Mistake 1 — JPA Entity Becomes the Domain Entity Automatically

Persistence representation and business model are separate concerns।


Common Mistake 2 — Every Foreign Key Becomes an Association

A scalar identifier may be clearer and cheaper।


Common Mistake 3 — long id = 0 Means Unsaved Entity

Use nullable persistence identifier state instead of fake IDs।


Common Mistake 4 — Enum Uses Ordinal Mapping

Persist stable readable lifecycle meaning explicitly।


Common Mistake 5 — VARCHAR(255) Reintroduced Through @Column(length=255)

Don't invent a limit the schema intentionally doesn't have।


Common Mistake 6 — Money Precision Invented in Annotation

Keep JPA consistent with our unconstrained NUMERIC schema unless requirements change।


Common Mistake 7 — @Version Added to Every Entity

Concurrency strategy should determine versioning needs, not boilerplate templates।


Common Mistake 8 — Generic BaseEntity

Our tables deliberately have different identity and timestamp semantics।


Common Mistake 9 — JPA Entity Returned by API

Persistence representation must not become the public transport contract।


Common Mistake 10 — Hibernate-Specific Schema Definitions Replace Flyway

JPA maps persisted data; Flyway owns schema evolution।


Our Entity Mapping Decisions

Product

Table
→ products

Entity
→ ProductEntity

ID
→ Long

Generation
→ database identity

Price
→ BigDecimal

Active
→ boolean

Inventory

Table
→ inventory

Entity
→ InventoryEntity

ID
→ Product ID itself

Generation
→ none

Quantity
→ int

No separate Inventory ID।


Order

Table
→ orders

Entity
→ OrderEntity

ID
→ Long

Generation
→ database identity

CustomerId persistence
→ String

Status
→ EnumType.STRING

CreatedAt
→ Instant

OrderItem

Table
→ order_items

Entity
→ OrderItemEntity

ID
→ OrderItemEntityId

Composite key
→ orderId + productId

Quantity
→ int

UnitPrice
→ BigDecimal

No generated OrderItem ID।


Engineering Principle

The core principle:

JPA entities should faithfully map the relational schema while remaining persistence infrastructure—not become accidental domain models or API contracts.

Another:

A foreign key in PostgreSQL does not require a navigable Java association. Add object relationships only when they make the application's persistence behaviour clearer.

And:

Explicit mapping is preferable to convenient defaults when those defaults can silently change identity, enum representation, schema constraints, or query behaviour.


Summary

In this lesson, we learned that:

  • JPA entity mapping connects persistence Java objects to relational tables.
  • @Entity identifies a persistent entity and @Table explicitly maps its table.
  • Persistence entities remain infrastructure representations rather than automatically becoming domain entities.
  • ProductEntity maps to products.
  • Product uses a database-generated BIGINT identity mapped through @Id and @GeneratedValue.
  • Nullable Long is appropriate for generated persistence identity before insertion rather than using 0 as a fake ID.
  • BigDecimal represents persisted Product and OrderItem prices.
  • JPA mappings should not introduce arbitrary Product name lengths or monetary precision limits that our schema does not contain.
  • InventoryEntity uses ProductId directly as its @Id; it does not need a generated Inventory ID.
  • A PostgreSQL foreign key does not require a JPA association.
  • Scalar foreign-key identifiers can keep persistence boundaries simple and avoid unnecessary object graphs.
  • OrderEntity maps external CustomerId, OrderStatus, and creation time.
  • Order status should be persisted explicitly as a string rather than relying on ordinal enum positions.
  • Persisted enum values become durable data and should not be renamed casually.
  • OrderItemEntity uses an @EmbeddedId representing (order_id, product_id).
  • Jakarta Persistence supports composite identifiers through @EmbeddedId and @IdClass.
  • Composite key classes require meaningful value equality.
  • OrderItemEntityId is a persistence construct, not a new domain OrderItemId.
  • The persistence representation may contain orderId even when domain OrderItem doesn't need to store its parent ID explicitly.
  • We do not add @ManyToOne, @OneToOne, or @OneToMany automatically just because foreign keys exist.
  • We do not add @Version, audit fields, Lombok, MapStruct, or a generic BaseEntity without a concrete requirement.
  • Flyway remains responsible for schema creation and evolution.
  • Hibernate/JPA should map and validate that schema rather than silently redesign it.
  • Transport, domain, and persistence models remain intentionally separate where their responsibilities differ.

Next lesson:

Relationships

There we will decide how our PostgreSQL relationships should—or should not—be represented in JPA: Product ↔ Inventory, Order ↔ OrderItems, and OrderItem → Product, including @OneToMany, @ManyToOne, @OneToOne, ownership, mappedBy, cascade behaviour, lazy loading, and why we should avoid turning the whole application into one giant Hibernate object graph.