Tutorials

Spring Data JPA Tutorial

Key Takeaways

  • Spring Data JPA generates repository implementations; Hibernate remains the persistence provider that maps entities and executes database operations.
  • In the Author–Book relationship, Book.author owns the foreign key. mappedBy identifies Author.books as the inverse side.
  • Derived queries such as findByTitle need deliberate return types, database constraints, and query-specific fetch plans.
  • Use service-level transactions and versioned migrations, then test against MySQL rather than assuming an in-memory database behaves identically.

Spring Data JPA reduces repetitive database-access code by creating implementations of repository interfaces. This Spring Data JPA tutorial builds a runnable Spring Boot application that saves authors and books in MySQL, finds books by title, and retrieves their authors intentionally.

Jakarta Persistence, commonly called JPA, defines the persistence API and mapping rules. Hibernate implements those rules; Spring Data JPA adds repositories above that implementation, while Spring Boot configures the pieces. The Spring Boot SQL documentation describes that integration; our Spring Data JPA vs Hibernate comparison explains the division of responsibilities.

What You'll Build and Need

You'll create one author with multiple possible books, save George Orwell's 1984, and print 1984 by George Orwell. This is a command-line application, not a REST API.

The example uses Java 25, Spring Boot 4.1.1, and MySQL 8.4. Boot 4.1.1 supports Java 25 and requires at least Java 17 and Maven 3.6.3; see its system requirements. You'll also need Docker for the local database and Bash for the setup commands.

Boot 4.1 is a supported release line for this 2026 baseline. Follow the Spring Boot support policy and keep the parent version patched. If Boot itself is new to you, start with our Spring Boot tutorial.

Advertisement

1. Create the Spring Boot Project

Create an empty directory with this pom.xml. The Boot parent manages dependency versions, so don't independently pin Hibernate or Spring Data.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>
    <groupId>example</groupId>
    <artifactId>bookservice</artifactId>
    <version>1.0.0</version>
    <properties>
        <java.version>25</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

The JPA starter supplies Spring Data JPA and Hibernate. The JDBC driver is com.mysql:mysql-connector-j, not the old mysql-connector-java artifact. Flyway will manage the schema through SQL migration files.

2. Configure MySQL and Schema Migrations

Start a Disposable Local Database

Run these commands in Bash. Enter nonempty passwords when prompted, then keep this shell open for the Maven commands.

read -r -s -p 'Application database password: ' DB_PASSWORD
printf '\n'
read -r -s -p 'MySQL root password: ' MYSQL_ROOT_PASSWORD
printf '\n'
export DB_PASSWORD MYSQL_ROOT_PASSWORD
export DB_USERNAME=books_app
export MYSQL_DATABASE=books_service
export MYSQL_USER="$DB_USERNAME"
export MYSQL_PASSWORD="$DB_PASSWORD"

docker run --name books-mysql --rm -d \
  -p 127.0.0.1:3306:3306 \
  -e MYSQL_DATABASE -e MYSQL_USER \
  -e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD \
  mysql:8.4

docker logs -f books-mysql

On a fresh database, the official MySQL image creates books_service and the application user from these variables. Wait until initialization finishes and MySQL reports that it's ready for connections; press Ctrl+C to leave the log viewer.

The prompts keep passwords out of source files and literal shell commands. Environment variables are convenient locally, but use your deployment's secret-management mechanism in production.

Configure the Datasource

Create src/main/resources/application.properties:

spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/books_service}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false

These placeholders use Spring Boot's externalized configuration. Boot can infer the JDBC driver from the URL, and Hibernate normally detects the SQL dialect from database metadata. Set spring.jpa.database-platform only when you need an explicit override, such as a custom dialect; the data-access configuration guide covers these settings.

Create the Initial Migration

Create src/main/resources/db/migration/V1__create_author_and_book.sql:

CREATE TABLE author (
    id BIGINT NOT NULL AUTO_INCREMENT,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE book (
    id BIGINT NOT NULL AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    author_id BIGINT NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT uk_book_title UNIQUE (title),
    INDEX idx_book_author (author_id),
    CONSTRAINT fk_book_author
        FOREIGN KEY (author_id) REFERENCES author (id)
) ENGINE=InnoDB;

The Flyway starter plus org.flywaydb:flyway-mysql enables MySQL migrations. Boot discovers migrations in classpath:db/migration by default and runs them before dependent persistence infrastructure is used. Here, Flyway creates the tables and ddl-auto=validate checks the mappings rather than changing the schema; see Boot's database initialization guidance.

3. Create the Author and Book Entities

Use jakarta.persistence imports, not javax.persistence. The mappings below name tables and columns explicitly, use identity-generated IDs to match MySQL's AUTO_INCREMENT, and require every persisted book to have an author.

Author.java

Create src/main/java/bookservice/model/Author.java:

package bookservice.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;

@Entity
@Table(name = "author")
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "first_name", nullable = false, length = 100)
    private String firstName;

    @Column(name = "last_name", nullable = false, length = 100)
    private String lastName;

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    private Set<Book> books = new LinkedHashSet<>();

    protected Author() {}

    public Author(String firstName, String lastName) {
        this.firstName = Objects.requireNonNull(firstName);
        this.lastName = Objects.requireNonNull(lastName);
    }

    public void addBook(Book book) {
        Objects.requireNonNull(book);
        if (book.getAuthor() != null && book.getAuthor() != this) {
            throw new IllegalArgumentException("Book already has an author");
        }
        books.add(book);
        book.setAuthor(this);
    }

    public Set<Book> getBooks() {
        return Collections.unmodifiableSet(books);
    }

    public String getFullName() {
        return firstName + " " + lastName;
    }
}

Book.java

Create src/main/java/bookservice/model/Book.java:

package bookservice.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.Objects;

@Entity
@Table(name = "book", indexes = @Index(
        name = "idx_book_author", columnList = "author_id"))
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "title", nullable = false, unique = true, length = 255)
    private String title;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    protected Book() {}

    public Book(String title) {
        this.title = Objects.requireNonNull(title);
    }

    public String getTitle() {
        return title;
    }

    public Author getAuthor() {
        return author;
    }

    void setAuthor(Author author) {
        this.author = author;
    }
}

@Entity marks a persistent entity, @Id identifies its primary key, and @Table and @Column define mappings. Both entities provide the protected no-argument constructor required by Jakarta Persistence. The SQL migration remains responsible for creating the actual database constraints and index.

Relationship Ownership and mappedBy

author                         book
------                         ----
id (PK) <--------------------- author_id (FK, NOT NULL)
first_name                     id (PK)
last_name                      title (UNIQUE in this demo)

Author.books: inverse side     Book.author: owning side
mappedBy = "author"             @JoinColumn(name = "author_id")

mappedBy = "author" names the Java field Book.author, not a database column. That owning field controls the foreign-key relationship; adding a book only to the inverse collection doesn't set book.author_id.

addBook updates both Java-side references. Its guard deliberately rejects reassignment to another author; a full reassignment operation would also need to maintain the former author's collection. No cascade is configured, so the application explicitly saves both entities.

Last names aren't unique. Titles are unique here only to keep the original single-result finder meaningful—not because real books always have distinct titles. For a real catalog, choose an appropriate identifier and allow multiple title matches. Our JPA one-to-many relationship example explores the mapping further.

4. Create the Spring Data JPA Repositories

AuthorRepository.java

Create src/main/java/bookservice/repository/AuthorRepository.java:

package bookservice.repository;

import bookservice.model.Author;
import org.springframework.data.repository.CrudRepository;

public interface AuthorRepository extends CrudRepository<Author, Long> {
}

BookRepository.java

Create src/main/java/bookservice/repository/BookRepository.java:

package bookservice.repository;

import bookservice.model.Book;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.CrudRepository;

public interface BookRepository extends CrudRepository<Book, Long> {
    @EntityGraph(attributePaths = "author")
    Optional<Book> findByTitle(String title);
}

How Repository Implementations and Derived Queries Work

Spring creates repository proxy objects for these interfaces. Standard operations such as save, findAll, findById, and delete delegate to the JPA repository implementation. With this package layout, Boot discovers entities and repositories beneath bookservice; you don't need an explicit @EnableJpaRepositories.

For findByTitle, Spring Data parses a supported method-name pattern and binds Title to the entity's title property. This isn't arbitrary natural-language interpretation: invalid property paths or unsupported names can fail repository initialization. Use derived queries or an explicit @Query as appropriate.

Optional<Book> means zero or one result: no match returns Optional.empty(), while multiple matches raise IncorrectResultSizeDataAccessException. It doesn't silently choose the first row. If titles aren't unique, remove that constraint and return List<Book> instead; consult the repository return-type rules.

CrudRepository vs JpaRepository

InterfaceWhat You GetWhen to Use It
CrudRepositoryBasic create, read, update, and delete operations; findAll returns Iterable.A small repository like this example.
JpaRepositoryCRUD plus list-returning methods, paging, sorting, and JPA operations such as flush and saveAndFlush.You need its broader repository API.

JpaRepository's API shows its inherited interfaces and JPA-specific operations. Either interface can declare derived queries.

FetchType.LAZY, FetchType.EAGER, and N+1 Queries

JPA defaults @ManyToOne to eager fetching and @OneToMany to lazy fetching. Eager means the association must be loaded, not that it must use a SQL join; lazy is a provider hint to defer loading. This example explicitly requests lazy associations.

Lazy loading alone doesn't prevent N+1 queries: reading a list of books and then accessing each author can trigger additional selects. The finder uses @EntityGraph(attributePaths = "author") to request the author for that query. A fetch join is another option; inspect generated SQL rather than assuming a global fetch setting guarantees efficient loading. The Hibernate fetching guide explains these trade-offs.

Advertisement

5. Run the Application Inside Explicit Transactions

DemoService.java

Create src/main/java/bookservice/DemoService.java:

package bookservice;

import bookservice.model.Author;
import bookservice.model.Book;
import bookservice.repository.AuthorRepository;
import bookservice.repository.BookRepository;
import java.util.NoSuchElementException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DemoService {
    private final AuthorRepository authors;
    private final BookRepository books;

    public DemoService(AuthorRepository authors, BookRepository books) {
        this.authors = authors;
        this.books = books;
    }

    @Transactional
    public void seed() {
        if (books.findByTitle("1984").isPresent()) {
            return;
        }
        Author author = authors.save(new Author("George", "Orwell"));
        Book book = new Book("1984");
        author.addBook(book);
        books.save(book);
    }

    @Transactional(readOnly = true)
    public String describe(String title) {
        Book book = books.findByTitle(title)
                .orElseThrow(() -> new NoSuchElementException(title));
        return book.getTitle() + " by " + book.getAuthor().getFullName();
    }
}

The write transaction makes the author and book saves one unit of work. The persistence context tracks managed entity instances during that work; pending changes are synchronized with the database during flushing. Repository CRUD methods have transaction defaults, but separate calls don't automatically form one shared application transaction, and declared query methods don't receive transaction configuration by default. See Spring Data JPA transactionality.

Read needed associations inside the service boundary and return a scalar result or a data transfer object. Accessing an uninitialized lazy association after its Hibernate session closes can fail. Keep transaction methods on a separate injected bean as shown: default proxy-based @Transactional interception doesn't apply to self-invocation.

Application.java

Create src/main/java/bookservice/Application.java:

package bookservice;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;

@SpringBootApplication
public class Application {
    private static final Logger log = LoggerFactory.getLogger(Application.class);

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    @Profile("demo")
    CommandLineRunner demo(DemoService service) {
        return args -> {
            service.seed();
            log.info("{}", service.describe("1984"));
        };
    }
}

The runner executes during startup when the demo profile is active. Run it from the project directory using the Boot Maven plugin:

mvn spring-boot:run -Dspring-boot.run.profiles=demo

The application log includes:

1984 by George Orwell

For local SQL inspection, optionally create src/main/resources/application-demo.properties containing spring.jpa.show-sql=true. The seed check makes sequential demo runs repeatable, but isn't a concurrency-safe production initialization strategy.

6. Test the Repository Against MySQL

Use the disposable database above, never a production database. This Spring Boot integration test loads the application and its migrations without enabling the demo runner. It checks found and missing titles, and flushes and clears the persistence context before reading so the test exercises database retrieval.

Create src/test/java/bookservice/BookRepositoryTest.java:

package bookservice;

import bookservice.model.Author;
import bookservice.model.Book;
import bookservice.repository.AuthorRepository;
import bookservice.repository.BookRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Transactional
class BookRepositoryTest {
    @Autowired AuthorRepository authors;
    @Autowired BookRepository books;
    @PersistenceContext EntityManager entityManager;

    @Test
    void findsBookAndAuthor() {
        String title = "Test-" + UUID.randomUUID();
        Author author = authors.save(new Author("Test", "Author"));
        Book book = new Book(title);
        author.addBook(book);
        assertThat(author.getBooks()).contains(book);
        books.save(book);
        entityManager.flush();
        entityManager.clear();

        Book loaded = books.findByTitle(title).orElseThrow();
        assertThat(loaded.getTitle()).isEqualTo(title);
        assertThat(loaded.getAuthor().getFullName()).isEqualTo("Test Author");
    }

    @Test
    void returnsEmptyForMissingTitle() {
        assertThat(books.findByTitle("Missing-" + UUID.randomUUID())).isEmpty();
    }
}
mvn test
# When finished with the disposable database:
docker stop books-mysql

Spring's transactional test support rolls back the test inserts; startup migrations still initialize the schema. In CI, provision an isolated MySQL instance for each test environment. For additional repository operations, continue with our Spring Data JPA CRUD example.

Common Mistakes and Production Checks

  • Changing only the inverse collection: update the owning association and keep both in-memory references consistent.
  • Using eager loading as a performance fix: choose fetch plans for individual queries and measure the SQL they execute.
  • Treating demo constraints as domain rules: validate required text in application code, choose genuine uniqueness rules, and maintain indexes through migrations.
  • Shipping local database privileges: use verified TLS connections and least-privilege runtime credentials; separate migration privileges where appropriate.

FAQ

What is the difference between JPA and Spring Data JPA?

JPA defines persistence APIs and object-to-table mapping rules. Hibernate is a provider that implements those rules; Spring Data JPA builds repository abstractions on top to reduce repetitive data-access code.

How does mappedBy work in a JPA one-to-many relationship?

mappedBy names the owning Java attribute on the other entity. In this example, mappedBy = "author" means Book.author controls the foreign-key relationship, while Author.books is the inverse collection.

Should I use CrudRepository or JpaRepository?

CrudRepository is enough for basic persistence and derived queries. Choose JpaRepository when you want its broader API, including paging, sorting, list-returning methods, and JPA-specific operations such as flush.

How should a Spring Data JPA finder handle a missing result?

Return Optional when the query should produce at most one entity, then handle an empty result explicitly. Optional does not permit multiple matches; use a collection return type when multiple results are valid.

How do I avoid N+1 queries in Spring Data JPA?

Fetch the associations each operation needs through a fetch join or entity graph, and inspect the generated SQL. Neither lazy nor eager mapping alone guarantees that a query avoids additional database round trips.

Further Reading

Advertisement

Written by Sam Erickson

Pen name of a software engineer with 20+ years in the industry, from Fortune 500 companies to startups. Newer articles are researched and drafted with AI tools I direct and fact-checked against their sources. Author of Mastering the Tech Interview. More about me

Advertisement
Spring Data JPA Tutorial: Spring Boot and MySQL | StackChief