From POJO to table row
The whole promise of JPA is that you write Java classes and Hibernate writes the SQL. Get the mapping
right and persistence fades into the background; get it subtly wrong — a defaulted enum strategy, a
Lombok @Data on an entity, an eager @ManyToOne you forgot about — and you ship data corruption or a
query storm. This article walks through how an entity is mapped and the decisions that actually matter.
@Entity, @Id, and the rules Hibernate imposes
An entity is a class marked @Entity with an identity field marked @Id. Each instance is a row.
@Entity
class Customer {
@Id @GeneratedValue
Long id;
String name;
protected Customer() {} // JPA needs a no-arg constructor
}
Hibernate instantiates entities reflectively and may subclass them for proxies, which is why the rules exist: a no-arg constructor (any visibility), the class must not be final, and it needs an identity field. These aren't arbitrary — they're what lets Hibernate build lazy proxies and hydrate objects from a result set.
Choosing a key generation strategy
@GeneratedValue decides how the primary key is produced, and the choice has real performance
consequences:
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE) // pre-fetches a block of ids → batches well
Long id;
IDENTITY leans on an auto-increment column but disables JDBC batch inserts, because Hibernate must
ask the database for each generated key immediately. SEQUENCE lets Hibernate grab ids in blocks, so
inserts batch — prefer it on PostgreSQL and Oracle. TABLE is portable but slow; avoid it. AUTO lets
the dialect decide. On a database that supports sequences, SEQUENCE with a sensible allocation size is
the default worth reaching for.
Mapping columns without over-annotating
You only need @Column to override a default — name, nullability, length, precision:
@Column(name = "email_address", nullable = false, unique = true, length = 320)
String email;
A crucial caveat: nullable, unique, and length shape the generated DDL, not runtime behavior.
They are not validation. If you manage your schema with Flyway or Liquibase (you should, in production),
those attributes are documentation. For actual input validation, use Bean Validation — @NotNull,
@Size — which runs on every save regardless of how the schema was built.
Value objects with @Embeddable
When a cluster of columns belongs together but has no identity of its own — an address, a money amount — model it as an embeddable and inline it into the table:
@Embeddable
class Address { String street; String city; String zip; }
@Entity
class Customer {
@Id Long id;
@Embedded Address address; // street/city/zip live on the customer table
}
Embed the same type twice (billing and shipping) by remapping the columns with @AttributeOverride.
The embeddable keeps cohesive columns together without the overhead of a separate table or relationship.
The enum trap
Map enums with @Enumerated, and almost always choose STRING:
@Enumerated(EnumType.STRING) // stores "PAID"
Status status;
The default is ORDINAL, which stores the enum's position (0, 1, 2…). The moment someone reorders
the enum or inserts a constant in the middle, every stored value silently changes meaning — a paid order
becomes a shipped one in the database with no error anywhere. STRING costs a few bytes and is immune to
reordering. This is one of the easiest data-corruption bugs to prevent and one of the most painful to
discover after the fact.
The lifecycle and why you rarely call save()
An entity instance is always in one of four states: transient (a new object Hibernate doesn't know), managed (attached to the persistence context), detached (was managed, context closed), or removed (marked for deletion). The consequence that trips people up:
@Transactional
void ship(Long id) {
Order o = repo.findById(id).orElseThrow(); // MANAGED
o.setStatus(SHIPPED); // just mutate it
// no save() call — Hibernate flushes the UPDATE at commit
}
This is dirty checking: at flush, Hibernate compares each managed entity to the snapshot it took at
load time and issues UPDATEs for what changed. Inside a transaction, mutating a loaded entity is
enough. The flip side — no open transaction means no context, so your change silently never persists —
is why a missing @Transactional is such a common "my update didn't save" bug.
equals() and hashCode(): handle with care
The generated id is null before persist, which breaks naive implementations the moment an entity goes
into a HashSet and is then saved:
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product p)) return false;
return id != null && id.equals(p.id); // equal only when both ids are non-null and match
}
@Override public int hashCode() {
return getClass().hashCode(); // constant — stable across id assignment
}
A constant hashCode keeps the object findable in a set even after Hibernate assigns its id. The
biggest practical takeaway: do not put Lombok's @Data on entities — its all-field equals/hashCode
trigger lazy loads and break this contract.
Schema management and ddl-auto
spring.jpa.hibernate.ddl-auto decides whether Hibernate touches your schema at startup: none,
validate, update, create, create-drop. update and create-drop are fine for local dev and
tests, but in production you manage schema with a real migration tool and set validate so the app fails
fast when entities and schema drift apart. Never point update at a production database.
One default worth knowing: Open Session in View
Spring Boot leaves spring.jpa.open-in-view=true on by default, keeping the persistence context open for
the entire HTTP request so lazy fields can be serialized without a LazyInitializationException. It's
convenient and controversial: it holds a connection for the whole request and hides N+1 queries behind the
controller. A clean architecture sets it false and loads exactly what's needed in the service layer.
Recap
@Entity + @Id + a no-arg constructor maps a class to a table; pick SEQUENCE for keys that batch;
add @Column only to override defaults (and remember it shapes DDL, not validation); use @Embeddable
for cohesive value objects and @Enumerated(STRING) always; understand that managed entities auto-flush
via dirty checking so you rarely call save(); implement equals/hashCode with a null-safe id and a
constant hash; and treat ddl-auto and open-in-view as deliberate decisions, not defaults to ignore.
Get the mapping right and Hibernate quietly does the rest.