Skip to content

Spring Boot · Data Access

Mapping JPA Entities in Spring Boot, Explained

6 min read Updated 2026-06-26 Share:

Practice JPA Entities interview questions

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.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel