A JPA entity is a plain Java class mapped to a database table, where each instance
corresponds to a row. You mark it with @Entity, give it an identity field with @Id, and
the persistence provider (Hibernate in Spring Boot) handles the SQL.
@Entity // managed by the persistence context
class Customer {
@Id @GeneratedValue
Long id; // maps to the primary key column
String name; // maps to a "name" column by convention
protected Customer() {} // JPA requires a no-arg constructor
}
An entity must have a no-arg constructor (any visibility), must not be final, and needs an identity field. Hibernate proxies and instantiates entities reflectively, which is why those rules exist.
Rule of thumb: @Entity + @Id + a no-arg constructor turns a POJO into a table-backed
object Hibernate can manage.
@GeneratedValue tells JPA how the primary key is produced. There are four strategies:
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) // DB auto-increment column
Long id;
IDENTITY— relies on an auto-increment column. Simple, but disables JDBC batch inserts because Hibernate needs the key back immediately after each insert.SEQUENCE— uses a database sequence; Hibernate can pre-fetch a block of ids, so it batches well. Preferred on PostgreSQL/Oracle.TABLE— emulates a sequence with a table. Portable but slow; avoid.AUTO(default) — lets the provider pick based on the dialect.
Rule of thumb: Prefer SEQUENCE (with an allocation size) on databases that support it;
use IDENTITY only when you must, knowing it costs batch inserts.
@Column customizes the column mapping for a field. Without it, Hibernate infers the
column name and sensible defaults, so you only add it to override something.
@Column(name = "email_address", // column name differs from the field
nullable = false, // adds NOT NULL to the generated DDL
unique = true, // adds a unique constraint
length = 320) // VARCHAR(320) instead of the default 255
String email;
Note nullable/unique/length only affect schema generation (ddl-auto). They are not
runtime validation — for that use Bean Validation (@NotNull, @Size). If you manage schema
with Flyway/Liquibase, these attributes are documentation, not enforcement.
Rule of thumb: Add @Column only to override the default name, nullability, length, or
precision — and remember it shapes DDL, not runtime checks.
Use @Table to set the table name, schema, or table-level constraints when the default
(the entity's simple class name) isn't what you want.
@Entity
@Table(name = "app_users", // override the default "Customer" → "app_users"
schema = "sales",
uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_id", "email"}),
indexes = @Index(name = "idx_email", columnList = "email"))
class User { ... }
@Table is optional. The composite @UniqueConstraint is the way to express a multi-column
unique rule (you can't do that with @Column(unique=true)). Naming strategies can also map
CamelCase fields to snake_case columns automatically.
Rule of thumb: Use @Table for a non-default table/schema name or for composite unique
constraints and indexes.
JPA reads and writes entity state either through fields (direct reflection) or
properties (getters/setters). The placement of @Id decides which Hibernate uses for the
whole entity.
@Entity
class Order {
@Id Long id; // annotation on the FIELD → field access for all mappings
String status;
}
@Entity
class Invoice {
private Long id;
@Id Long getId() { return id; } // annotation on the GETTER → property access
}
Field access is the common default: Hibernate sets values directly, bypassing your getters,
so accessor logic never fires unexpectedly during loading. Property access runs your
getters/setters, useful for derived or transformed values. Mixing both needs @Access.
Rule of thumb: Put annotations on fields (field access) unless you specifically need getter/setter logic to run during persistence.
Mark it @Transient. JPA then ignores the field entirely — no column, no read, no write.
@Entity
class Employee {
LocalDate birthDate;
@Transient // computed, never stored
int getAge() {
return Period.between(birthDate, LocalDate.now()).getYears();
}
}
Use it for derived values, caches, or helper state that lives only in memory. Don't confuse
JPA's javax.persistence.Transient/jakarta.persistence.Transient with Java's transient
keyword — the keyword affects Java serialization, the annotation affects JPA mapping (though
Hibernate also honors the keyword).
Rule of thumb: Use @Transient for any field you compute at runtime and never want
persisted.
They model a value object whose fields live in the same table as the owning entity — grouping related columns into a reusable class without a separate table or id.
@Embeddable
class Address {
String street;
String city;
String zip;
}
@Entity
class Customer {
@Id Long id;
@Embedded
Address address; // street/city/zip become columns on the customer table
}
The embeddable has no identity of its own — it's part of the entity. Use @AttributeOverride
to remap column names when you embed the same type twice (e.g. billingAddress and
shippingAddress). It's the JPA way to keep cohesive columns together cleanly.
Rule of thumb: Use @Embeddable/@Embedded for cohesive groups of columns that have no
independent identity, like an address or a money amount.
Annotate the field with @Enumerated and choose between ORDINAL (stores the position) and
STRING (stores the name). The default is ORDINAL, which is dangerous.
enum Status { NEW, PAID, SHIPPED }
@Enumerated(EnumType.STRING) // stores "PAID" — stable and readable
Status status;
// @Enumerated(EnumType.ORDINAL) would store 0/1/2 — reordering the enum corrupts data!
With ORDINAL, inserting a new constant in the middle (or reordering) silently shifts every
stored value to a different meaning. STRING is self-describing and reorder-safe; the only cost
is a few bytes per row.
Rule of thumb: Almost always use @Enumerated(EnumType.STRING) — never let enum ordering
become a hidden data dependency.
An entity instance is always in one of four states relative to the persistence context:
Customer c = new Customer(); // TRANSIENT — new object, no id, not tracked
em.persist(c); // MANAGED — tracked; changes auto-flush to DB
em.detach(c); // DETACHED — has an id but no longer tracked
em.remove(managed); // REMOVED — scheduled for DELETE on flush
- Transient — a plain new object Hibernate knows nothing about.
- Managed (persistent) — attached to the persistence context; Hibernate dirty-checks it and writes changes automatically at flush time.
- Detached — was managed, but the context closed or it was evicted; edits are not
tracked until you
mergeit back. - Removed — marked for deletion.
The key consequence: on a managed entity you don't call save() after changing a field —
the change is flushed automatically within the transaction.
Rule of thumb: Know that managed entities are auto-flushed via dirty checking; detached ones
need merge to reattach.
Dirty checking is how Hibernate detects modifications to managed entities and issues
UPDATEs automatically — you change a field, you don't call any save method.
@Transactional
void raise(Long id) {
Order o = repo.findById(id).orElseThrow(); // o is now MANAGED
o.setStatus(SHIPPED); // just mutate it
// no repo.save(o) needed — Hibernate flushes the UPDATE at commit
}
At flush, Hibernate compares each managed entity against the snapshot it took at load time;
changed properties generate an UPDATE. This is why a missing @Transactional (no open
context) means your changes silently never persist, and why huge persistence contexts are slow
— every managed entity is compared on flush.
Rule of thumb: Inside a transaction, mutating a loaded entity is enough; Hibernate's dirty checking writes the UPDATE for you.
Carefully — the generated id is null before persist, so naive id-based or all-field
implementations break when entities sit in a HashSet across the persist boundary.
@Entity
class Product {
@Id @GeneratedValue Long id;
@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 have the same non-null id
}
@Override public int hashCode() {
return getClass().hashCode(); // constant — stable across id assignment
}
}
A constant hashCode (or one based on a business/natural key) keeps the object findable in a
set even after Hibernate assigns its id. Avoid Lombok's @Data on entities — it generates
all-field equals/hashCode that trigger lazy loads and break this contract.
Rule of thumb: Base equals on a stable business key or a null-safe id check, and use a
constant hashCode — never auto-generate them from all fields.
It controls whether Hibernate generates or alters the schema from your entities at startup.
# application.properties
spring.jpa.hibernate.ddl-auto=update # values: none | validate | update | create | create-drop
none— do nothing (use a real migration tool).validate— check that the schema matches the entities, fail fast otherwise.update— add missing tables/columns (never drops). Handy in dev, risky in prod.create/create-drop— drop and recreate on startup/shutdown; for tests only.
With an embedded DB Spring Boot defaults to create-drop; with a real datasource it defaults to
none. In production you should manage schema with Flyway or Liquibase and set validate.
Rule of thumb: update/create-drop for local dev and tests; validate plus a migration
tool in production — never update against a real database.
Modern JPA maps java.time types automatically — no annotation needed. The legacy
@Temporal is only for old java.util.Date/Calendar. Large content uses @Lob.
LocalDate orderedOn; // → DATE, mapped automatically
LocalDateTime createdAt; // → TIMESTAMP
Instant updatedAt; // → TIMESTAMP (UTC)
@Lob String description; // → CLOB / TEXT for large text
@Lob byte[] document; // → BLOB for binary
@Temporal(TemporalType.DATE) // only needed for the legacy java.util.Date
Date legacyDate;
Prefer the java.time types over Date/Calendar; they're immutable, unambiguous, and need no
@Temporal. Reach for @Lob only when the value is genuinely large.
Rule of thumb: Use java.time types directly (no @Temporal) and @Lob for large
text/binary; @Temporal is legacy-only.
@MappedSuperclass defines shared mappings that subclasses inherit, without the
superclass being an entity or a table itself. It's the standard home for audit columns.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
abstract class Auditable {
@CreatedDate LocalDateTime createdAt;
@LastModifiedDate LocalDateTime updatedAt;
}
@Entity
class Article extends Auditable { // inherits createdAt/updatedAt columns
@Id @GeneratedValue Long id;
}
Combined with @EnableJpaAuditing on a config class, Spring fills @CreatedDate/
@LastModifiedDate automatically. Unlike @Inheritance, there's no shared table or
polymorphic query — each subclass just gets the columns copied into its own table.
Rule of thumb: Use @MappedSuperclass to share columns/mappings (like audit fields) across
entities without modeling the parent as its own table.
Open Session in View (OSIV) keeps the Hibernate session/persistence context open for the whole HTTP request — including view rendering — so lazy associations can still be loaded after the service returns. Spring Boot enables it by default.
spring.jpa.open-in-view=true # default; set false to disable
It's convenient — it prevents LazyInitializationException when serializing lazy fields — but
controversial because it holds a database connection for the entire request, hides N+1
queries behind the controller, and blurs the transaction boundary. Lazy loads then happen
outside any explicit transaction during serialization.
The disciplined approach: set open-in-view=false and fetch exactly what you need in the
service layer (join fetch, entity graphs, or DTO projections).
Rule of thumb: Understand OSIV is on by default; for a clean architecture disable it and load associations explicitly in the service layer.
More Data Access interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.