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.
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.
@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
| Annotation | Purpose | Notes |
|---|---|---|
@Entity | Marks a class as JPA-managed | Must have a no-arg constructor and an @Id |
@Table | Customizes table name, schema, indexes, unique constraints | Optional — defaults to class name |
@Id | Marks the primary key field | Required on every entity |
@GeneratedValue | Configures PK generation strategy | See generation strategies below |
@Column | Customizes column name, nullability, length, precision | Optional — defaults from field name/type |
@Enumerated | Maps a Java enum | Always use EnumType.STRING, never ORDINAL |
@Transient | Excludes a field from persistence | Computed/derived fields only |
@Version | Enables optimistic locking | Covered in depth in the transactions guide |
ID generation strategies
| Strategy | Mechanism | Pros | Cons |
|---|---|---|---|
IDENTITY | DB auto-increment column | Simple, no extra table | Disables JDBC batch inserts (Hibernate must flush per row to get the ID) |
SEQUENCE | DB sequence object | Batch-insert friendly, can pre-allocate ranges | Requires DB support (Postgres, Oracle — not MySQL until 8.0+) |
TABLE | A dedicated table simulates a sequence | Portable across all DBs | Slow — extra row locking per allocation, avoid in production |
AUTO | Provider picks based on dialect | Convenient | Unpredictable across DB migrations — be explicit instead |
UUID (custom generator or @UuidGenerator) | Client- or DB-generated UUID | No coordination needed, good for distributed writes | 16 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.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_id_seq", allocationSize = 50)
private Long id;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
@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
@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
@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:
@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
| Annotation | FK location | Owning side | Default fetch | Common pitfall |
|---|---|---|---|---|
@ManyToOne | This entity's table | Always owning | EAGER | Left at default EAGER, triggers unwanted joins |
@OneToMany | Other entity's table | Owning only if no mappedBy | LAZY | Unidirectional mapping causes extra UPDATE statements |
@OneToOne | Either side (owning side has @JoinColumn) | Side with @JoinColumn | EAGER | Non-owning side triggers an extra SELECT to check existence |
@ManyToMany | Join table | Side with @JoinTable | LAZY | Can'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.
@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.
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
| Interface | Adds | Watch out for |
|---|---|---|
Repository<T, ID> | Marker interface only | No methods — use as a base for tightly scoped custom repos |
CrudRepository<T, ID> | save, findById, existsById, deleteById, findAll | findAll() 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.
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
| Keyword | Example method | Generated clause |
|---|---|---|
And / Or | findByStatusAndCustomerId | WHERE status = ?1 AND customer_id = ?2 |
Between | findByCreatedAtBetween | WHERE created_at BETWEEN ?1 AND ?2 |
LessThan / GreaterThan | findByTotalAmountGreaterThan | WHERE total_amount > ?1 |
Like / Containing | findByEmailContaining | WHERE email LIKE %?1% |
In | findByStatusIn | WHERE status IN (?1) |
OrderBy | findByCustomerIdOrderByCreatedAtDesc | ORDER BY created_at DESC |
First / Top | findFirst5ByStatus | LIMIT 5 |
IsNull / IsNotNull | findByShippedAtIsNull | WHERE 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
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
| Aspect | JPQL | Native SQL |
|---|---|---|
| Portability | Database-agnostic | Tied to a specific dialect |
| Type safety | Operates on entities/fields | Operates on raw columns |
| DB-specific features | Not supported (window functions, CTEs, JSONB ops) | Fully supported |
| Result mapping | Automatic entity/DTO mapping | Needs @SqlResultSetMapping or projections |
| Use when | Standard CRUD-adjacent queries | Performance-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.
public interface OrderSummaryView {
Long getId();
OrderStatus getStatus();
BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummaryView> findByCustomerId(Long customerId);
}8. Auditing Fields
@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:
@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: updateoutside local development. - Bytecode enhancement: Hibernate can use build-time bytecode enhancement (
hibernate-enhance-maven-pluginor the Gradle plugin) for lazy field-level fetching and dirty-checking optimizations — worth enabling once your entity graphs get large. - Composite keys: prefer
@EmbeddedIdover@IdClassfor readability; both require correctequals()/hashCode()on the key class. - Repository custom implementations: for logic too complex for derived queries or JPQL (dynamic sorting, native window functions), implement a
OrderRepositoryCustominterface backed byEntityManagerdirectly, and haveOrderRepository extends JpaRepository<...>, OrderRepositoryCustom.
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
SEQUENCEgeneration overIDENTITYfor any write-heavy entity —IDENTITYdisables 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
@ManyToManythe moment you anticipate needing metadata on the relationship. - Derived query methods are great for simple lookups; switch to
@Queryonce the method name needs more than 3-4 conditions. @Modifyingbulk 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: updateas 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, andTABLE. Which would you pick for a high-throughput write service and why? - What does
mappedBydo in a@OneToManymapping, 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
@ManyToManymapping with an explicit join entity? - What's the difference between
@Embeddable/@Embeddedand a full@Entity? - How does
CrudRepository.save()decide whether toINSERTorUPDATE? How can you optimize this for bulk inserts with client-assigned IDs? - What SQL does a derived query method like
findByStatusAndCreatedAtAftergenerate? - What is the risk of using
@Modifyingqueries withoutclearAutomatically = 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
@Versiondo, 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@CreatedByto work?