JPA & Spring Data
Jakarta Persistence (formerly Java Persistence API, still abbreviated JPA) is a Java API for managing relational data. It maps Java objects to database tables and tracks changes automatically. Hibernate is the JPA implementation supported by Spring Boot and recommended for Vaadin applications.
Spring Data JPA builds on JPA to reduce boilerplate. It generates repository implementations at runtime based on interface definitions.
JPA is well suited for aggregate-oriented persistence—loading object graphs, working with entities that have lifecycles and business rules, and saving them back. If you need fine-grained control over SQL, complex reporting queries, or bulk operations, consider combining JPA with jOOQ or using jOOQ alone.
|
Note
| This page assumes you’ve already added Spring Data JPA to your project and are familiar with JPA concepts. If you’re new to JPA, read the Accessing Data with JPA guide first. |
Entities
JPA entities represent your domain objects and their lifecycles. An Order entity might transition through states, enforce pricing rules, and maintain consistency with its line items.
Constraints
JPA imposes some restrictions on entity classes:
-
Classes cannot be
final -
Fields cannot be
final -
A default (parameter-less) constructor is required, but can be
protectedor package-private
Naming
JPA deduces table and column names from class and field names. To make Flyway migrations easier to write, explicitly declare names:
-
@Tableon entity classes -
@Columnon fields -
@JoinColumnand@JoinTableon associations
Identity
Spring Data checks the @Id field to decide whether an entity is new. If the ID is null, the entity is new; otherwise, it’s persistent.
|
Note
|
If you assign IDs before persisting, implement the Spring Data Persistable interface.
|
Don’t use Spring Data’s AbstractPersistable base class. Declare the @Id field directly in each entity, or create your own base class. This gives you better control over ID generation.
Equality
Override equals and hashCode so that an entity equals itself or another entity of the same type with the same ID. Handle the case where the ID is null (before persistence):
Source code
Java
import jakarta.persistence.*;
import org.springframework.data.util.ProxyUtils;
@Entity
@Table(name = "customer")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "customer_id")
private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !getClass().equals(ProxyUtils.getUserClass(o))) return false; 1
Customer other = (Customer) o;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}-
Use
ProxyUtils.getUserClassbecause Hibernate may return proxied entities whose classes don’t match directly.
Domain Primitives
Domain primitives can be integrated with JPA entities in several ways.
Accessor Methods
Use the unwrapped value in the field, converting in accessor methods:
Source code
Java
@Entity
@Table(name = "customer")
public class Customer {
@Column(name = "customer_email")
private String email;
public EmailAddress getEmail() {
return email == null ? null : new EmailAddress(email);
}
public void setEmail(EmailAddress email) {
this.email = email == null ? null : email.value();
}
}This also works for multi-value domain primitives:
Source code
Java
@Entity
@Table(name = "offer")
public class Offer {
@Enumerated(EnumType.STRING)
@Column(name = "currency")
private CurrencyUnit currency;
@Column(name = "price")
private BigDecimal price;
public MonetaryAmount getPrice() {
return new MonetaryAmount(currency, price);
}
public void setPrice(MonetaryAmount amount) {
this.currency = amount.currency();
this.price = amount.value();
}
}This approach makes query specifications easier to write since you work with unwrapped types.
Attribute Converters
For single-value domain primitives, write an attribute converter:
Source code
Java
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter
public class EmailAddressConverter implements AttributeConverter<EmailAddress, String> {
@Override
public String convertToDatabaseColumn(EmailAddress attribute) {
return attribute == null ? null : attribute.value();
}
@Override
public EmailAddress convertToEntityAttribute(String dbData) {
return dbData == null ? null : new EmailAddress(dbData);
}
}Then apply it:
Source code
Java
@Column(name = "customer_email")
@Convert(converter = EmailAddressConverter.class)
private EmailAddress email;This is cleaner but makes non-equality queries harder—LIKE queries require strings, not domain primitives. Attribute converters also don’t work with primary keys.
Use converters for domain primitives that are only queried by equality and aren’t identifiers.
Embeddable Records
Multi-value domain primitives can be @Embeddable. Hibernate 6.2+ supports Java records:
Source code
Java
@Embeddable
public record MonetaryAmount(
@Enumerated(EnumType.STRING) CurrencyUnit currency,
BigDecimal value
) {
public MonetaryAmount {
requireNonNull(currency);
requireNonNull(value);
}
}Used in an entity:
Source code
Java
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "currency", column = @Column(name = "unit_price_currency")),
@AttributeOverride(name = "value", column = @Column(name = "unit_price"))
})
private MonetaryAmount unitPrice;Non-record embeddables must be non-final with a parameter-less constructor, which conflicts with domain primitive immutability requirements.
Repositories
Repository interfaces extend JpaRepository:
Source code
Java
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}Spring Data implements the interface at runtime. Inject it like any Spring bean:
Source code
Java
@Service
public class OrderService {
private final OrderRepository orderRepository;
OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}Entity State and Saving
JPA entities can be managed or detached. While managed (within a transaction), changes are automatically saved when the transaction commits—even without calling save.
Once the transaction completes, entities become detached. Further changes aren’t persisted without an explicit save.
Source code
Java
@Transactional
public void updateCustomer(Long id, String newName) {
var customer = customerRepository.findById(id).orElseThrow();
customer.setName(newName);
// Changes persist automatically at transaction commit
}For clarity, call save explicitly when you intend to persist changes:
Source code
Java
customer.setName(newName);
customerRepository.save(customer);|
Caution
| Don’t modify managed entities if you don’t intend to save the changes. Roll back the transaction to discard modifications. |
See Transactions for more on transaction management.
Locking
JPA supports both optimistic and pessimistic locking.
Optimistic Locking
Add a version field to detect concurrent modifications:
Source code
Java
@Entity
@Table(name = "customer")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "customer_id")
private Long id;
@Version
@Column(name = "_version")
private Long version;
}If two transactions load the same entity and both try to save, the second fails with an optimistic locking exception.
Pessimistic Locking
Use @Lock on query methods to acquire database locks:
Source code
Java
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :accountId")
Account lockForWriting(Long accountId);
}See the Hibernate documentation for details.
Query Methods
Spring Data derives queries from method names:
Source code
Java
public interface CustomerRepository extends JpaRepository<Customer, Long> {
List<Customer> findByNameContaining(String name);
}Always limit results to avoid memory issues:
Source code
Java
List<Customer> findTop100ByNameContainingOrderByNameAsc(String name);Or use parameters:
Source code
Java
List<Customer> findByNameContaining(String name, Limit limit, Sort sort);Pagination
For large datasets, use pagination:
Source code
Java
Page<Customer> findByNameContaining(String name, Pageable pageable);A Page includes total count (requires an extra query). Use Slice if you only need to know whether more results exist:
Source code
Java
Slice<Customer> findByNameContaining(String name, Pageable pageable);Query Specifications
For dynamic queries, extend JpaSpecificationExecutor:
Source code
Java
public interface CustomerRepository extends JpaRepository<Customer, Long>,
JpaSpecificationExecutor<Customer> {
}Create specification factory methods:
Source code
Java
public final class CustomerSpecifications {
public static Specification<Customer> emailContaining(String term) {
return (root, query, cb) -> cb.like(root.get(Customer_.EMAIL), "%" + term + "%");
}
public static Specification<Customer> createdBetween(LocalDate from, LocalDate to) {
return (root, query, cb) -> cb.between(root.get(Customer_.CREATED_DATE), from, to);
}
private CustomerSpecifications() {}
}Combine them:
Source code
Java
var customers = customerRepository.findAll(
emailContaining("acme.com").and(createdBetween(startDate, endDate)),
PageRequest.ofSize(20)
);Query Classes
Not every query returns complete entities. List views often need only a few fields. Reports aggregate across tables. These are table-oriented operations—data transformations rather than entity lifecycle management.
For such queries, create separate query interfaces:
Source code
Java
public interface OrderSummaryQuery extends Repository<Order, Long> {
@Query("""
SELECT new com.example.OrderSummary(o.id, o.status, o.total, c.name)
FROM Order o JOIN o.customer c
WHERE o.createdDate >= :since
""")
Page<OrderSummary> findRecentOrders(@Param("since") LocalDate since, Pageable pageable);
record OrderSummary(Long orderId, OrderStatus status, BigDecimal total, String customerName) {}
}For complex reporting queries, consider jOOQ instead. JPA and jOOQ work well together—use JPA for entity persistence and jOOQ for queries.