04-persistence-data-access

Spring Data JPA: Entities, Relationships, and Repositories

A staff-engineer guide to entity mapping, relationship modeling, and repository design with Spring Data JPA and Hibernate.

August 14, 2026
backend-engineerjpahibernateentitiesrepositoriesorm

Spring Data JPA

Spring Data JPA is the layer most backend engineers spend the most hours in — and the layer where sloppy modeling decisions compound fastest. A single mismapped @OneToMany, a poorly chosen ID generation strategy, or a repository method that silently loads an entire table can turn into a production incident months after the code was written. This guide covers entity mapping, relationship modeling, and repository design the way you actually need to reason about them: with an eye on the SQL Hibernate generates, not just the annotations you type.


1. Where Spring Data JPA Sits

JPA (Jakarta Persistence API) is a specification — a set of interfaces and annotations. Hibernate is the most common implementation of that specification. Spring Data JPA is a abstraction layer on top of JPA that eliminates boilerplate DAO code by generating repository implementations at runtime.

💡

Terminology check: "JPA" is the contract, "Hibernate" is the engine, "Spring Data JPA" is the productivity layer that writes your repository implementations for you at startup via dynamic proxies. You can swap Hibernate for EclipseLink and Spring Data JPA code mostly still compiles — in practice, almost nobody does, because Hibernate-specific behavior (fetch strategies, dirty checking, second-level cache) leaks through everywhere.


2. Entity Mapping Fundamentals

An entity is a Java class mapped to a database table. Hibernate manages the lifecycle of entity instances and translates field changes into SQL.

java
@Entity
@Table(name = "orders", indexes = {
    @Index(name = "idx_orders_customer_id", columnList = "customer_id"),
    @Index(name = "idx_orders_status", columnList = "status")
})
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(name = "customer_id", nullable = false)
    private Long customerId;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status;
 
    @Column(name = "total_amount", precision = 19, scale = 4, nullable = false)
    private BigDecimal totalAmount;
 
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
 
    @Version
    private Long version;
 
    protected Order() {
        // required by JPA — never call directly
    }
 
    public Order(Long customerId, BigDecimal totalAmount) {
        this.customerId = customerId;
        this.totalAmount = totalAmount;
        this.status = OrderStatus.PENDING;
        this.createdAt = Instant.now();
    }
 
    // getters, no public setters for immutable fields
}
⚠️

JPA requires a no-arg constructor because Hibernate instantiates entities via reflection (or bytecode enhancement) before populating fields — it does not call your business constructors. Keep it protected, not public, so application code can't accidentally bypass your validated constructors.

Core mapping annotations

AnnotationPurposeNotes
@EntityMarks a class as JPA-managedMust have a no-arg constructor and an @Id
@TableCustomizes table name, schema, indexes, unique constraintsOptional — defaults to class name
@IdMarks the primary key fieldRequired on every entity
@GeneratedValueConfigures PK generation strategySee generation strategies below
@ColumnCustomizes column name, nullability, length, precisionOptional — defaults from field name/type
@EnumeratedMaps a Java enumAlways use EnumType.STRING, never ORDINAL
@TransientExcludes a field from persistenceComputed/derived fields only
@VersionEnables optimistic lockingCovered in depth in the transactions guide

ID generation strategies

StrategyMechanismProsCons
IDENTITYDB auto-increment columnSimple, no extra tableDisables JDBC batch inserts (Hibernate must flush per row to get the ID)
SEQUENCEDB sequence objectBatch-insert friendly, can pre-allocate rangesRequires DB support (Postgres, Oracle — not MySQL until 8.0+)
TABLEA dedicated table simulates a sequencePortable across all DBsSlow — extra row locking per allocation, avoid in production
AUTOProvider picks based on dialectConvenientUnpredictable across DB migrations — be explicit instead
UUID (custom generator or @UuidGenerator)Client- or DB-generated UUIDNo coordination needed, good for distributed writes16 bytes vs 8, worse index locality unless using UUIDv7/ULID-style ordering

Production default: prefer SEQUENCE with @SequenceGenerator(allocationSize = 50) on Postgres/Oracle. It lets Hibernate pre-fetch a block of IDs in memory and batch your inserts, which matters enormously for bulk-write throughput. IDENTITY is the worst choice for write-heavy services because it defeats Hibernate's JDBC batching entirely.

java
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_id_seq", allocationSize = 50)
private Long id;
sql
CREATE SEQUENCE order_id_seq START WITH 1 INCREMENT BY 50;

3. Relationship Modeling

Relationships are where most entity-modeling bugs originate. Get the owning side and fetch type wrong and you'll spend a debugging session staring at unexpected UPDATE statements or a stack trace full of proxy objects.

@ManyToOne — the most common, cheapest association

java
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;
}

@ManyToOne is the owning side by definition — it holds the foreign key column. It defaults to FetchType.EAGER in the JPA spec, which is almost always the wrong default for production code (more on this in the fetch-strategy guide).

@OneToMany — model with care

java
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
    private Long id;
 
    @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Order> orders = new ArrayList<>();
 
    public void addOrder(Order order) {
        orders.add(order);
        order.setCustomer(this);   // keep both sides in sync
    }
 
    public void removeOrder(Order order) {
        orders.remove(order);
        order.setCustomer(null);
    }
}

mappedBy = "customer" tells Hibernate: "the foreign key lives on the Order.customer field — I am the inverse side, don't manage the FK from here." Omitting mappedBy creates a unidirectional @OneToMany, which forces Hibernate to use an ugly join table or extra UPDATE statements to maintain the relationship — almost never what you want.

🚨

Never map a bidirectional relationship on both sides without a helper method pair (addOrder/removeOrder above). If you only append to the collection without setting the owning-side reference, Hibernate silently persists nothing — the FK column stays NULL because the owning side (Order.customer) was never set.

@ManyToMany — prefer explicit join entities

java
@Entity
public class Product {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    private Long id;
 
    @ManyToMany
    @JoinTable(
        name = "product_category",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "category_id")
    )
    private Set<Category> categories = new HashSet<>();
}

This works fine while the join table has no extra columns. The moment you need metadata on the relationship (e.g., added_at, added_by), replace @ManyToMany with an explicit join entity:

java
@Entity
@Table(name = "product_category")
public class ProductCategory {
    @EmbeddedId
    private ProductCategoryId id;
 
    @ManyToOne @MapsId("productId")
    @JoinColumn(name = "product_id")
    private Product product;
 
    @ManyToOne @MapsId("categoryId")
    @JoinColumn(name = "category_id")
    private Category category;
 
    @Column(name = "added_at")
    private Instant addedAt;
}
 
@Embeddable
public class ProductCategoryId implements Serializable {
    private Long productId;
    private Long categoryId;
    // equals/hashCode required for composite keys
}

Rule of thumb: avoid @ManyToMany in any relationship you expect to grow metadata on later — which is most of them. Modeling the join table explicitly from day one costs a little more code up front and saves you a painful migration later.

Relationship annotation summary

AnnotationFK locationOwning sideDefault fetchCommon pitfall
@ManyToOneThis entity's tableAlways owningEAGERLeft at default EAGER, triggers unwanted joins
@OneToManyOther entity's tableOwning only if no mappedByLAZYUnidirectional mapping causes extra UPDATE statements
@OneToOneEither side (owning side has @JoinColumn)Side with @JoinColumnEAGERNon-owning side triggers an extra SELECT to check existence
@ManyToManyJoin tableSide with @JoinTableLAZYCan't add metadata columns without converting to an entity

4. Embeddables and Value Objects

Not everything needs its own table. @Embeddable types let you model value objects — like Address or Money — that are stored inline in the owning entity's table.

java
@Embeddable
public class Address {
    @Column(name = "street") private String street;
    @Column(name = "city") private String city;
    @Column(name = "postal_code") private String postalCode;
    @Column(name = "country") private String country;
    // equals/hashCode by value, no @Id
}
 
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
    private Long id;
 
    @Embedded
    private Address billingAddress;
 
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "shipping_street")),
        @AttributeOverride(name = "city", column = @Column(name = "shipping_city"))
    })
    private Address shippingAddress;
}
💡

@AttributeOverrides is required when the same @Embeddable type is used more than once in the same entity — otherwise Hibernate tries to map both Address fields to the same street/city columns and fails at startup with a duplicate column mapping error.


5. Repositories: CRUD Without Boilerplate

Spring Data JPA generates a full implementation of your repository interface at application startup, using a dynamic proxy backed by SimpleJpaRepository.

java
public interface OrderRepository extends JpaRepository<Order, Long> {
}

That single line gives you save(), findById(), findAll(), delete(), count(), plus paging and sorting variants — with zero implementation code. JpaRepository<Order, Long> extends PagingAndSortingRepository which extends CrudRepository.

Repository hierarchy behavior

InterfaceAddsWatch out for
Repository<T, ID>Marker interface onlyNo methods — use as a base for tightly scoped custom repos
CrudRepository<T, ID>save, findById, existsById, deleteById, findAllfindAll() loads the entire table — fine for lookup tables, dangerous for large ones
PagingAndSortingRepository<T, ID>findAll(Pageable), findAll(Sort)Always prefer this over unpaged findAll() in APIs
JpaRepository<T, ID>flush(), saveAndFlush(), deleteAllInBatch(), getReferenceById()getReferenceById() returns a lazy proxy — accessing fields outside a transaction throws LazyInitializationException
⚠️

CrudRepository.save() does an existence check under the hood — if the entity's @Id is non-null, Hibernate issues a SELECT to decide whether to INSERT or UPDATE (unless you implement Persistable to short-circuit this). For high-throughput bulk inserts with client-assigned IDs (e.g., UUIDs), implement Persistable<ID> to avoid the extra round trip per row.


6. Derived Query Methods

Spring Data JPA parses method names and generates JPQL automatically — no @Query needed for straightforward lookups.

java
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    List<Order> findByCustomerId(Long customerId);
 
    List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, Instant since);
 
    Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(Long customerId);
 
    boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status);
 
    long countByStatus(OrderStatus status);
 
    List<Order> findByTotalAmountGreaterThanEqual(BigDecimal amount);
 
    List<Order> findByCustomerIdIn(Collection<Long> customerIds);
 
    @EntityGraph(attributePaths = {"lineItems", "customer"})
    List<Order> findByStatus(OrderStatus status);
}

Keyword reference

KeywordExample methodGenerated clause
And / OrfindByStatusAndCustomerIdWHERE status = ?1 AND customer_id = ?2
BetweenfindByCreatedAtBetweenWHERE created_at BETWEEN ?1 AND ?2
LessThan / GreaterThanfindByTotalAmountGreaterThanWHERE total_amount > ?1
Like / ContainingfindByEmailContainingWHERE email LIKE %?1%
InfindByStatusInWHERE status IN (?1)
OrderByfindByCustomerIdOrderByCreatedAtDescORDER BY created_at DESC
First / TopfindFirst5ByStatusLIMIT 5
IsNull / IsNotNullfindByShippedAtIsNullWHERE shipped_at IS NULL

Derived query methods are great until the method name gets unwieldy (4+ conditions). At that point, switch to @Query with JPQL — it's more readable and easier to review in a PR than findByStatusAndCustomerIdAndCreatedAtBetweenAndTotalAmountGreaterThan.


7. @Query: JPQL and Native SQL

java
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("""
        SELECT o FROM Order o
        WHERE o.customer.id = :customerId
          AND o.status = :status
        ORDER BY o.createdAt DESC
        """)
    List<Order> findRecentOrders(@Param("customerId") Long customerId,
                                  @Param("status") OrderStatus status);
 
    @Query(value = """
        SELECT * FROM orders
        WHERE created_at >= :since
        ORDER BY created_at DESC
        LIMIT :limit
        """, nativeQuery = true)
    List<Order> findRecentOrdersNative(@Param("since") Instant since,
                                        @Param("limit") int limit);
 
    @Modifying
    @Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
    int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);
 
    @Query("""
        SELECT new com.example.orders.dto.OrderSummary(o.id, o.status, o.totalAmount)
        FROM Order o WHERE o.customer.id = :customerId
        """)
    List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);
}
⚠️

@Modifying queries bypass the persistence context entirely — they issue a bulk UPDATE/DELETE directly against the database. Any already-loaded entities in the current session become stale and won't reflect the change unless you set clearAutomatically = true on @Modifying or manually call entityManager.clear().

JPQL vs native SQL

AspectJPQLNative SQL
PortabilityDatabase-agnosticTied to a specific dialect
Type safetyOperates on entities/fieldsOperates on raw columns
DB-specific featuresNot supported (window functions, CTEs, JSONB ops)Fully supported
Result mappingAutomatic entity/DTO mappingNeeds @SqlResultSetMapping or projections
Use whenStandard CRUD-adjacent queriesPerformance-critical or dialect-specific queries

DTO projections (constructor expressions like new com.example...OrderSummary(...) above, or Spring Data interface projections) are the right tool whenever you don't need a full managed entity — they skip persistence-context tracking and fetch only the columns you asked for.

java
public interface OrderSummaryView {
    Long getId();
    OrderStatus getStatus();
    BigDecimal getTotalAmount();
}
 
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<OrderSummaryView> findByCustomerId(Long customerId);
}

8. Auditing Fields

java
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
 
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
 
    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
 
    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
 
    @CreatedBy
    @Column(name = "created_by", updatable = false)
    private String createdBy;
 
    @LastModifiedBy
    @Column(name = "updated_by")
    private String updatedBy;
}
 
@Entity
public class Order extends Auditable {
    // inherits createdAt, updatedAt, createdBy, updatedBy
}

@CreatedBy/@LastModifiedBy require an AuditorAware<String> bean that resolves the current user, typically from the security context:

java
@Bean
public AuditorAware<String> auditorAware() {
    return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
        .map(Authentication::getName);
}

9. Production Observations

🚨

Never expose entities directly from @RestController endpoints. Lazy-loaded associations serialize unpredictably (or throw LazyInitializationException outside the transaction), and entity classes leak internal fields (e.g., @Version, audit columns) into your public API contract. Always map to a DTO/record at the controller boundary.

  • Schema-first vs entity-first: in real production systems with a DBA-owned schema and Flyway/Liquibase migrations, entities are a reflection of the schema, not the source of truth. Never rely on ddl-auto: update outside local development.
  • Bytecode enhancement: Hibernate can use build-time bytecode enhancement (hibernate-enhance-maven-plugin or the Gradle plugin) for lazy field-level fetching and dirty-checking optimizations — worth enabling once your entity graphs get large.
  • Composite keys: prefer @EmbeddedId over @IdClass for readability; both require correct equals()/hashCode() on the key class.
  • Repository custom implementations: for logic too complex for derived queries or JPQL (dynamic sorting, native window functions), implement a OrderRepositoryCustom interface backed by EntityManager directly, and have OrderRepository extends JpaRepository<...>, OrderRepositoryCustom.
java
public interface OrderRepositoryCustom {
    List<Order> findWithDynamicFilters(OrderFilterCriteria criteria);
}
 
public class OrderRepositoryImpl implements OrderRepositoryCustom {
    private final EntityManager entityManager;
 
    public OrderRepositoryImpl(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
 
    @Override
    public List<Order> findWithDynamicFilters(OrderFilterCriteria criteria) {
        // CriteriaBuilder logic here — see the Pagination & Specifications guide
        return List.of();
    }
}

Name the implementation class <RepositoryName>Impl exactly — Spring Data JPA's component scanning wires it up by naming convention alone, no @Component annotation required.

Key takeaways

  • Choose SEQUENCE generation over IDENTITY for any write-heavy entity — IDENTITY disables Hibernate's JDBC insert batching.
  • Bidirectional relationships need a synchronized helper method pair (addX/removeX); setting only the collection side silently drops the foreign key.
  • Prefer explicit join entities over @ManyToMany the moment you anticipate needing metadata on the relationship.
  • Derived query methods are great for simple lookups; switch to @Query once the method name needs more than 3-4 conditions.
  • @Modifying bulk updates bypass the persistence context — clear or refresh stale entities afterward.
  • Never return JPA entities directly from REST controllers; map to DTOs to control the wire contract and avoid lazy-loading surprises.
  • getReferenceById() returns an uninitialized proxy — only safe to use inside the owning transaction, or when you only need the ID for a foreign key assignment.
  • Treat ddl-auto: update as a local-dev convenience only; production schema changes belong in versioned migrations (Flyway/Liquibase).

Interview Questions

  • What is the difference between JPA, Hibernate, and Spring Data JPA?
  • Why does JPA require a no-arg constructor on every entity?
  • Compare GenerationType.IDENTITY, SEQUENCE, and TABLE. Which would you pick for a high-throughput write service and why?
  • What does mappedBy do in a @OneToMany mapping, and what breaks if you omit it?
  • Why should bidirectional associations be updated through paired helper methods instead of setting one side directly?
  • When would you replace a @ManyToMany mapping with an explicit join entity?
  • What's the difference between @Embeddable/@Embedded and a full @Entity?
  • How does CrudRepository.save() decide whether to INSERT or UPDATE? How can you optimize this for bulk inserts with client-assigned IDs?
  • What SQL does a derived query method like findByStatusAndCreatedAtAfter generate?
  • What is the risk of using @Modifying queries without clearAutomatically = true?
  • Why is it dangerous to return JPA entities directly from a REST controller?
  • How would you implement a custom repository method that needs dynamic, non-derivable query logic?
  • What does @Version do, and where does optimistic locking actually get enforced?
  • How does Spring Data JPA auditing (@CreatedDate, @CreatedBy) get wired up, and what bean is required for @CreatedBy to work?