CodeWiz Logo

    CodeWiz

    Spring Data JPA: Complete Guide and Cheat Sheet

    Spring Data JPA: Complete Guide and Cheat Sheet

    20/02/2025

    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.

    Follow us on YouTube, LinkedIn, and Medium.