
Introduction
From my experience, most Java-based enterprise applications rely on JPA (Java Persistence API) for database access because of the ease of use and portability it offers. Spring Data JPA further simplifies this process by reducing boilerplate code and offering powerful abstractions. In this guide, we’ll explore essential concepts, key annotations, and best practices, along with practical examples to help you build efficient and maintainable data access layers. We will learn this by going through entities in an event booking application.
1. Entity Basics
@Entity and @Table
The @Entity
annotation marks a class as a JPA entity, and @Table
specifies the table name.
@Id
marks the primary key field, and @GeneratedValue
configures automatic key generation.
@Entity @Table(name = "events") public class Event { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @Column(name = "event_date") private LocalDate eventDate; }
Common Column Annotations
@Column
: Customizes column mapping@Enumerated
: For enum mappings@Lob
: For large objects
@Entity public class Event { @Column(nullable = false, length = 100) private String name; @Column(columnDefinition = "TEXT") private String description; @Enumerated(EnumType.STRING) private EventCategory category; @Column(name = "created_at") private LocalDateTime createdAt; }
2. Relationships
One-to-Many and Many-to-One
The most common relationship types:
// Parent entity @Entity public class Venue { @OneToMany(mappedBy = "venue") private Set<Event> events; } // Child entity @Entity public class Event { @ManyToOne @JoinColumn(name = "venue_id") private Venue venue; }
Many-to-Many
Using join tables:
@Entity public class Event { @ManyToMany @JoinTable( name = "event_artists", joinColumns = @JoinColumn(name = "event_id"), inverseJoinColumns = @JoinColumn(name = "artist_id") ) private Set<Artist> artists; } @Entity public class Artist { @ManyToMany(mappedBy = "artists") private Set<Event> events; }
One-to-One
For unique relationships:
@Entity public class User { @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "profile_id") private UserProfile profile; }
3. JPA Repositories
Basic Repository
The simplest form:
public interface EventRepository extends JpaRepository<Event, Long> { // Gets all basic CRUD operations for free }
Custom Queries
Different ways to define queries:
public interface EventRepository extends JpaRepository<Event, Long> { // Method name query List<Event> findByVenueIdAndEventDateAfter(Long venueId, LocalDate date); // JPQL Query @Query("SELECT e FROM Event e WHERE e.venue.name = :venueName") List<Event> findByVenueName(@Param("venueName") String venueName); // Native Query @Query(value = "SELECT * FROM events WHERE date_part('year', event_date) = :year", nativeQuery = true) List<Event> findEventsInYear(@Param("year") int year); }
Pagination and Sorting
Built-in support for paged results:
public interface EventRepository extends JpaRepository<Event, Long> { // Returns a Page object Page<Event> findByCategory(String category, Pageable pageable); // Returns a sorted list List<Event> findByVenueId(Long venueId, Sort sort); } // Usage example: PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("eventDate").descending()); Page<Event> events = eventRepository.findByCategory("CONCERT", pageRequest);
This uses Offset Pagination. Results are fetched from the database using LIMIT and OFFSET.
Transactions
Use @Transactional
to manage transactions:
@Service public class BookingService { @Transactional public void bookEvent(Long eventId, Long userId) { Event event = eventRepository.findById(eventId) .orElseThrow(() -> new EntityNotFoundException("Event not found")); User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("User not found")); Booking booking = new Booking(user, event); bookingRepository.save(booking); ticketService.generateTickets(event, user); } }
Auditing
Track creation and modification timestamps:
@EntityListeners(AuditingEntityListener.class) @MappedSuperclass public abstract class Auditable { @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; } @Entity public class Event extends Auditable { // Entity fields }
Enable auditing in configuration:
@Configuration @EnableJpaAuditing public class JpaConfig { }
Composite Keys
For entities with multiple fields in the primary key:
@Embeddable public class BookingId implements Serializable { private Long userId; private Long eventId; } @Entity public class Booking { @EmbeddedId private BookingId id; // Other fields }
Soft Deletes
Implement soft deletion:
@Entity @SQLDelete(sql = "UPDATE events SET deleted = true WHERE id = ?") @Where(clause = "deleted = false") public class Event { private boolean deleted = false; }
Lazy Loading
Lazy loading can be enabled by setting the fetch type to LAZY
which means that the related entities are not loaded until they are accessed. If you want to load the related entities eagerly, set the fetch type to EAGER
.
By default, OneToOne
and ManyToOne
relationships are fetched eagerly and OneToMany
and ManyToMany
relationships are fetched lazily.
@Entity public class Event { @ManyToOne(fetch = FetchType.EAGER) private Venue venue; @OneToMany(mappedBy = "event", fetch = FetchType.LAZY) private Set<Booking> bookings; }
Fetch Modes
FetchMode is used to control how related entities are retrieved from the database. While FetchType (LAZY or EAGER) determines whether the association is loaded immediately or on demand, FetchMode fine-tunes how Hibernate fetches related entities when loading an entity.
- FetchMode.JOIN – Uses SQL JOIN to fetch related entities in a single query.
- FetchMode.SELECT – Executes a separate SQL query for each related entity (default for @OneToMany). Causes the N+1 query problem.
- FetchMode.SUBSELECT – Uses a single additional query to fetch all related entities at once instead of multiple queries.
@Entity public class Event { @ManyToOne(fetch = FetchType.EAGER) private Venue venue; @OneToMany(mappedBy = "event", fetch = FetchType.LAZY) @Fetch(FetchMode.SUBSELECT) private Set<Booking> bookings; }
Batch Operations
@Modifying @Query("UPDATE Event e SET e.status = :status WHERE e.eventDate < :date") int updatePastEvents(@Param("status") String status, @Param("date") LocalDate date);
Cascade Types
Cascade operations to related entities:
- PERSIST: Save the related entity when the parent is saved.
- MERGE: Update the related entity when the parent is updated.
- REMOVE: Delete the related entity when the parent is deleted.
- REFRESH: Refresh the related entity when the parent is refreshed.
- DETACH: Detach the related entity when the parent is detached.
- ALL: All of the above.
@Entity public class Event { @OneToMany( mappedBy = "event", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true ) private Set<Ticket> tickets; }
Validate Entities
Ensure data integrity using Bean Validation:
@Entity public class Event { @NotBlank(message = "Event name is required") private String name; @Future(message = "Event date must be in the future") private LocalDate eventDate; @Min(value = 0, message = "Price cannot be negative") private BigDecimal price; }
Criteria API
The JPA Criteria API provides a type-safe way to construct queries programmatically. This is useful when you need to build dynamic queries based on user input:
@Service public class EventService { @PersistenceContext private EntityManager em; public List<Event> findEventsByCriteria(String name, LocalDate startDate, String category) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Event> cq = cb.createQuery(Event.class); Root<Event> event = cq.from(Event.class); List<Predicate> predicates = new ArrayList<>(); if (name != null) { predicates.add(cb.like(event.get("name"), "%" + name + "%")); } if (startDate != null) { predicates.add(cb.greaterThanOrEqualTo(event.get("eventDate"), startDate)); } if (category != null) { predicates.add(cb.equal(event.get("category"), category)); } cq.where(predicates.toArray(new Predicate[0])); return em.createQuery(cq).getResultList(); } }
Projections
Projections allow you to retrieve only the fields you need. This can improve query performance and reduce data transfer:
Interface-based Projection
public interface EventSummary { String getName(); LocalDate getEventDate(); String getVenueName(); } public interface EventRepository extends JpaRepository<Event, Long> { List<EventSummary> findByCategory(String category); }
Class or record based Projection
public record EventView(String name, String venueName) {} public interface EventRepository extends JpaRepository<Event, Long> { @Query("SELECT new com.example.EventView(e.name, e.venue.name) FROM Event e") List<EventView> findEventViews(); }
Dynamic Projections
public interface EventRepository extends JpaRepository<Event, Long> { <T> List<T> findByVenueId(Long venueId, Class<T> type); } // Usage: List<EventSummary> summaries = repository.findByVenueId(1L, EventSummary.class); List<EventView> views = repository.findByVenueId(1L, EventView.class);
Specifications
Specifications provide a way to create reusable, composable query predicates:
@Component public class EventSpecifications { public static Specification<Event> hasCategory(String category) { return (root, query, cb) -> { if (category == null) { return null; } return cb.equal(root.get("category"), category); }; } public static Specification<Event> afterDate(LocalDate date) { return (root, query, cb) -> { if (date == null) { return null; } return cb.greaterThan(root.get("eventDate"), date); }; } public static Specification<Event> inVenue(Long venueId) { return (root, query, cb) -> { if (venueId == null) { return null; } return cb.equal(root.get("venue").get("id"), venueId); }; } } // Repository public interface EventRepository extends JpaRepository<Event, Long>, JpaSpecificationExecutor<Event> { } // Usage @Service public class EventService { public List<Event> findEvents(String category, LocalDate date, Long venueId) { return eventRepository.findAll( Specification .where(hasCategory(category)) .and(afterDate(date)) .and(inVenue(venueId)) ); } }
Query By Example
Query by Example (QBE) is a flexible query mechanism in Spring Data JPA that allows you to build dynamic queries without writing explicit JPQL or SQL. It works by providing an example entity and letting Spring generate a query automatically.
Spring uses the fields set in an example entity to generate a query dynamically. It’s useful when you need dynamic filtering without complex specifications.
public List<Event> findSimilarEvents(Event probe) { ExampleMatcher matcher = ExampleMatcher.matching() .withIgnoreCase() .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING) .withIgnorePaths("id", "createdAt"); Example<Event> example = Example.of(probe, matcher); return eventRepository.findAll(example); } // Usage Event probe = new Event(); probe.setCategory("CONCERT"); probe.setVenue(someVenue); List<Event> matches = findSimilarEvents(probe);
Above code will search for all events that have the category as "CONCERT" and the venue as "someVenue".
Stored Procedures
Execute database stored procedures using JPA:
@Entity @NamedStoredProcedureQueries({ @NamedStoredProcedureQuery( name = "calculateEventRevenue", procedureName = "CALC_EVENT_REVENUE", parameters = { @StoredProcedureParameter(mode = ParameterMode.IN, name = "eventId", type = Long.class), @StoredProcedureParameter(mode = ParameterMode.OUT, name = "revenue", type = BigDecimal.class) } ) }) public class Event { // ...entity fields... } public interface EventRepository extends JpaRepository<Event, Long> { @Procedure("calculateEventRevenue") BigDecimal calculateRevenue(Long eventId); // Or use EntityManager directly @PersistenceContext private EntityManager em; public BigDecimal getRevenueUsingEM(Long eventId) { StoredProcedureQuery query = em .createStoredProcedureQuery("CALC_EVENT_REVENUE") .registerStoredProcedureParameter("eventId", Long.class, ParameterMode.IN) .registerStoredProcedureParameter("revenue", BigDecimal.class, ParameterMode.OUT) .setParameter("eventId", eventId); query.execute(); return (BigDecimal) query.getOutputParameterValue("revenue"); } }
Query Hints
Optimize query performance using JPA hints:
public interface EventRepository extends JpaRepository<Event, Long> { // Read-only hint for better performance @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true")) List<Event> findByCategory(String category); // Cache query results @QueryHints(value = { @QueryHint(name = "org.hibernate.cacheable", value = "true"), @QueryHint(name = "org.hibernate.cacheRegion", value = "event-cache") }) Optional<Event> findWithCacheById(Long id); // Load all data in a single query (avoid N+1 problem) @QueryHints(@QueryHint(name = "javax.persistence.fetchgraph", value = "event-with-venue-graph")) List<Event> findAllWithVenue(); } // Entity with named entity graph @Entity @NamedEntityGraph( name = "event-with-venue-graph", attributeNodes = { @NamedAttributeNode("venue"), @NamedAttributeNode("category") } ) public class Event { // ...entity fields... }
Locking Strategies
Implement optimistic and pessimistic locking:
@Entity public class Event { @Version private Long version; // Optimistic locking // ...other fields... } public interface EventRepository extends JpaRepository<Event, Long> { // Pessimistic read lock @Lock(LockModeType.PESSIMISTIC_READ) Optional<Event> findByIdWithPessimisticRead(Long id); // Pessimistic write lock @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT e FROM Event e WHERE e.id = :id") Optional<Event> findByIdWithPessimisticWrite(@Param("id") Long id); // Optimistic lock forcing @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) Optional<Event> findByIdWithForceIncrement(Long id); } @Service public class EventService { // Handle optimistic locking exception @Transactional public Event updateEvent(Event event) { try { return eventRepository.save(event); } catch (OptimisticLockingFailureException e) { // Handle concurrent modification throw new ConflictException("Event was modified by another user"); } } }
Entity Graphs
Entity Graphs provide fine-grained control over which related entities to fetch eagerly:
Named Entity Graphs
@Entity @NamedEntityGraphs({ @NamedEntityGraph( name = "event.minimal", attributeNodes = { @NamedAttributeNode("name"), @NamedAttributeNode("eventDate") } ), @NamedEntityGraph( name = "event.with.venue", attributeNodes = { @NamedAttributeNode("name"), @NamedAttributeNode("eventDate"), @NamedAttributeNode("venue") } ), @NamedEntityGraph( name = "event.complete", attributeNodes = { @NamedAttributeNode("name"), @NamedAttributeNode("eventDate"), @NamedAttributeNode("venue"), @NamedAttributeNode(value = "tickets", subgraph = "tickets") }, subgraphs = { @NamedSubgraph( name = "tickets", attributeNodes = { @NamedAttributeNode("price"), @NamedAttributeNode("status") } ) } ) }) public class Event { // ...entity fields... }
Using Entity Graphs in Repositories
public interface EventRepository extends JpaRepository<Event, Long> { // Using named entity graph @EntityGraph(value = "event.with.venue") List<Event> findByCategory(String category); // Dynamic entity graph @EntityGraph(attributePaths = {"venue", "tickets"}) Optional<Event> findById(Long id); // With query and entity graph @EntityGraph(value = "event.complete") @Query("SELECT e FROM Event e WHERE e.eventDate > :date") List<Event> findUpcomingEvents(@Param("date") LocalDate date); }
Programmatic Entity Graphs
@Service public class EventService { @PersistenceContext private EntityManager em; public Event loadEventWithGraph(Long id) { EntityGraph<?> graph = em.createEntityGraph(Event.class); graph.addAttributeNodes("name", "eventDate"); graph.addSubgraph("venue") .addAttributeNodes("name", "address"); Map<String, Object> hints = new HashMap<>(); hints.put("javax.persistence.fetchgraph", graph); return em.find(Event.class, id, hints); } }
Custom Type Converters
Implement custom conversion logic for entity attributes:
@Converter(autoApply = true) public class BooleanToStringConverter implements AttributeConverter<Boolean, String> { @Override public String convertToDatabaseColumn(Boolean attribute) { return (attribute != null && attribute) ? "Y" : "N"; } @Override public Boolean convertToEntityAttribute(String dbData) { return "Y".equals(dbData); } } @Entity public class Event { @Convert(converter = BooleanToStringConverter.class) private Boolean isVirtual; // Disable auto-applied converter for specific field @Convert(disableConversion = true) private Boolean isCancelled; }
JSON Conversion
Store complex objects as JSON:
@Converter public class JsonToMapConverter implements AttributeConverter<Map<String, Object>, String> { private final ObjectMapper mapper = new ObjectMapper(); @Override public String convertToDatabaseColumn(Map<String, Object> attribute) { try { return mapper.writeValueAsString(attribute); } catch (Exception e) { throw new IllegalArgumentException("Error converting map to JSON", e); } } @Override public Map<String, Object> convertToEntityAttribute(String dbData) { try { return mapper.readValue(dbData, new TypeReference<Map<String, Object>>() {}); } catch (Exception e) { throw new IllegalArgumentException("Error converting JSON to map", e); } } } @Entity public class Event { @Convert(converter = JsonToMapConverter.class) @Column(columnDefinition = "TEXT") private Map<String, Object> metadata; }
Inheritance Strategies
Table Per Class Hierarchy (Single Table)
Best for performance and simple hierarchies:
@Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "ticket_type") public abstract class Ticket { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private BigDecimal basePrice; } @Entity @DiscriminatorValue("VIP") public class VIPTicket extends Ticket { private String benefitsPackage; private Boolean backstageAccess; } @Entity @DiscriminatorValue("STANDARD") public class StandardTicket extends Ticket { private String seatNumber; private String section; }
For the above example, there will be a single table named ticket
with columns for all fields of the subclasses. The ticket_type
column will store the discriminator value.
Table Per Subclass (Joined)
Best for normalized schema design:
@Entity @Inheritance(strategy = InheritanceType.JOINED) public abstract class Payment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private BigDecimal amount; private LocalDateTime timestamp; } @Entity @PrimaryKeyJoinColumn(name = "payment_id") public class CreditCardPayment extends Payment { private String cardNumber; private String cardHolderName; private LocalDate expiryDate; } @Entity @PrimaryKeyJoinColumn(name = "payment_id") public class BankTransferPayment extends Payment { private String accountNumber; private String bankName; private String transferReference; }
In this strategy, there will be separate tables for each subclass with a foreign key to the parent table. So in the above example, there will be tables named payment
, credit_card_payment
, and bank_transfer_payment
.
Table Per Concrete Class
Best for independent tables:
@Entity @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) public abstract class Performer { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private String contactEmail; } @Entity public class Artist extends Performer { private String genre; private Integer yearsActive; } @Entity public class Band extends Performer { private Integer memberCount; private String manager; } // Usage example @Service public class PerformerService { public List<Performer> findAllPerformers() { // Generates UNION query automatically return performerRepository.findAll(); } }
For the above example, there will be separate tables for each concrete class (Artist and Band) with all fields from the superclass. So in this case, there will be tables named artist
and band
.
Best Practices
Below are some best practices to follow when working with Spring Data JPA:
- Use Fetch Strategies Wisely – Prefer LAZY loading to avoid unnecessary data fetching. Use @EntityGraph or JOIN FETCH when eager loading is needed.
- Prevent the N+1 Query Problem – Avoid calling related entities in loops. Use @EntityGraph, JOIN FETCH, or batch fetching strategies.
- Use Pagination for Large Data Sets – Never use findAll() on large tables. Instead, use Pageable and Slice for efficient data retrieval.
- Use Transactions – Always use @Transactional for service methods which involve modifying database state.
- Use Projections for Read-Only Operations – Fetch only the required fields using projections for better performance.
- Use @Modifying for Update/Delete Queries – Ensure modifying queries are annotated properly to avoid unexpected behavior.
- Enable Auditing for Tracking Changes – Use @CreatedDate and @LastModifiedDate for auditing entity changes.
- Optimize Transactions and Locking Mechanisms – Use @Transactional carefully to avoid unnecessary long-running transactions. Consider Optimistic Locking (@Version) for concurrency control.
- Entity Validation - Validate entities before persisting them to ensure data integrity.
- Utilize specifications or query methods for complex queries to keep code clean.
Conclusion
Spring Data JPA significantly simplifies database operations in Spring applications. By following these patterns and best practices, you can build efficient and maintainable database access layers.
Related Posts
Spring Web MVC Basics and Cheat Sheet
Spring Web MVC basics and key annotations cheat sheet. Learn how to build web applications using Spring Web MVC with this handy reference guide.
Master Integration Testing Spring Boot API using Testcontainers
Learn how to create efficient integration tests for your Spring Boot APIs using Testcontainers and the Rest Assured library. This blog will guide you through building fluent integration tests for an API that interacts with MongoDB and AWS S3.