Skip to content
C Codeloom
Java

Spring Data JPA Tutorial: Repositories, Queries, and Transactions

Learn Spring Data JPA from the ground up. Understand repositories, derived queries, JPQL, pagination, and transaction boundaries in real Java apps.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How Spring Data JPA reduces boilerplate
  • Defining entities and repositories
  • Derived query methods and JPQL
  • Pagination, sorting, and projections
  • Transaction management essentials

Prerequisites

  • Basic Java
  • SQL fundamentals
  • Spring Boot basics

What and Why

Spring Data JPA is a thin, opinionated layer on top of JPA (typically Hibernate) that eliminates the repetitive DAO code most teams write by hand. Instead of writing CRUD operations for every entity, you declare an interface and Spring generates the implementation at runtime.

The “why” is leverage. A startup writing a payments service does not benefit from hand-rolling EntityManager calls. Spring Data lets you focus on the domain model and business logic while still dropping down to raw JPQL or native SQL when needed.

Mental Model

Think of Spring Data JPA as three concentric layers. At the core sits JDBC, talking to the database. Wrapping it is the JPA specification, which defines entities, persistence contexts, and JPQL. Hibernate implements that spec. Spring Data wraps Hibernate with repositories, query derivation, and integration with Spring’s transaction manager.

When you call a repository method, Spring routes the call through a proxy. The proxy starts a transaction (if needed), delegates to Hibernate, flushes changes at commit, and translates exceptions into Spring’s DataAccessException hierarchy.

Hands-on Example

Let’s model a simple Book entity and repository.

@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String author;
    private int yearPublished;
    // getters and setters
}

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByAuthor(String author);
    List<Book> findByYearPublishedGreaterThan(int year);

    @Query("SELECT b FROM Book b WHERE b.title LIKE %:keyword%")
    List<Book> searchByTitle(@Param("keyword") String keyword);

    Page<Book> findByAuthor(String author, Pageable pageable);
}

Using it in a service:

@Service
public class LibraryService {
    private final BookRepository repo;

    public LibraryService(BookRepository repo) { this.repo = repo; }

    @Transactional
    public Book addBook(String title, String author, int year) {
        Book b = new Book();
        b.setTitle(title);
        b.setAuthor(author);
        b.setYearPublished(year);
        return repo.save(b);
    }
}
Controller
  |
  v
Service (@Transactional)
  |
  v
Repository Proxy ----> Transaction Manager
  |                       |
  v                       v
Hibernate Session       Connection Pool
  |                       |
  +---------+-------------+
            v
         Database
Call flow from controller through repository proxy to database

The flow shows why annotations matter: the proxy is what intercepts your call and ensures a transaction is open before Hibernate touches the database.

Common Pitfalls

The N+1 query problem is the classic trap. Calling book.getAuthor() inside a loop on lazily loaded associations triggers a query per row. Use JOIN FETCH or an @EntityGraph to load related data eagerly when needed.

Another mistake is misunderstanding save(). It does not always issue an INSERT; for a managed entity, it just merges state and lets the persistence context flush later. Returning the same object you passed in can mask whether the database actually saw your change.

Forgetting @Transactional on write methods is a quieter bug. Without it, every repository call runs in its own short transaction, which breaks atomicity across multiple saves.

Finally, do not return entities directly from controllers. Lazy fields will fail to serialize when the session closes. Use DTOs or projections instead.

Practical Tips

Prefer constructor injection for repositories. It makes dependencies explicit and tests easier. Use Slice instead of Page when you do not need a total count; the COUNT query is expensive on large tables.

Enable SQL logging in development with spring.jpa.show-sql=true and spring.jpa.properties.hibernate.format_sql=true. Read the queries Hibernate generates; you will catch surprises early.

For complex reads, use projection interfaces. They produce smaller selects and avoid materializing whole entities:

public interface BookSummary {
    String getTitle();
    String getAuthor();
}

Keep transactions short. Long transactions hold database locks and connection pool slots, which becomes a production problem fast.

Wrap-up

Spring Data JPA trades a small amount of magic for a large reduction in boilerplate. The key to using it well is understanding what the proxy does, where transactions begin and end, and how the persistence context tracks changes. Once those concepts click, the rest is just naming conventions and JPQL.

Start with derived queries, reach for @Query when expressivity matters, and drop to native SQL only when JPQL cannot do the job.