Persistence with PostgreSQL
Entity Mapping
আপনি একটি 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.
@Entityidentifies a persistent entity and@Tableexplicitly maps its table.- Persistence entities remain infrastructure representations rather than automatically becoming domain entities.
ProductEntitymaps toproducts.- Product uses a database-generated
BIGINTidentity mapped through@Idand@GeneratedValue. - Nullable
Longis appropriate for generated persistence identity before insertion rather than using0as a fake ID. BigDecimalrepresents persisted Product and OrderItem prices.- JPA mappings should not introduce arbitrary Product name lengths or monetary precision limits that our schema does not contain.
InventoryEntityuses 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.
OrderEntitymaps 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.
OrderItemEntityuses an@EmbeddedIdrepresenting(order_id, product_id).- Jakarta Persistence supports composite identifiers through
@EmbeddedIdand@IdClass. - Composite key classes require meaningful value equality.
OrderItemEntityIdis a persistence construct, not a new domainOrderItemId.- The persistence representation may contain
orderIdeven when domain OrderItem doesn't need to store its parent ID explicitly. - We do not add
@ManyToOne,@OneToOne, or@OneToManyautomatically 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.