Blog

Quick Start | Spring Data JPA

Key Takeaways

  • Use Spring Boot’s managed dependencies: the JPA starter supplies repository integration and Hibernate, while PostgreSQL needs its own JDBC driver.
  • Model Author–Book as one-to-many: Book.author owns the relationship and stores the author_id foreign key.
  • Put multi-step persistence work in a transactional service, and load the relationships you need before leaving that transaction.
  • Use Flyway migrations with Hibernate schema validation, then test the mappings against disposable PostgreSQL with @DataJpaTest and Testcontainers.

A minimal Maven-based Spring Boot application can use Spring Data JPA to map Java entities, define repositories, connect to PostgreSQL, and save and reload data. This Spring Data JPA quick start builds that application with an Author–Book example, then explains how one-to-one and many-to-many mappings differ.

If the terminology is new, start with our Spring Data JPA introduction. JPA is the persistence specification, Hibernate is the provider used here, and Spring Data JPA supplies the repository abstraction—not three names for the same library. See Spring Data JPA versus Hibernate for that distinction.

Quick Start Using Maven

1) Create the project and configure Maven dependencies

The example baseline is Spring Boot 4.1.1, which manages Spring Data JPA 4.1.1 and Jakarta Persistence 3.2.0. Use JDK 25 for the POM below and PostgreSQL 18; Boot itself requires at least Java 17. These are explicit example versions, not instructions to skip later maintenance releases.

Generate a Maven/Java JAR project with Spring Initializr. Set the group to com.example, artifact to jpa-demo, package to com.example.jpa, and Java to 25. Keep the generated mvnw, mvnw.cmd, and .mvn/wrapper files, then replace pom.xml with:

<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>com.example</groupId>
    <artifactId>jpa-demo</artifactId>
    <version>0.0.1-SNAPSHOT</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>org.postgresql</groupId>
            <artifactId>postgresql</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-database-postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-postgresql</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 brings in Spring Data JPA, Hibernate, and Spring ORM. The PostgreSQL driver is separate; PostgreSQL is simply this tutorial’s database choice. Flyway also needs its PostgreSQL-specific module, and the Boot 4 JPA test starter includes the general test starter plus JPA testing support. Let Boot manage dependency versions instead of pinning these libraries independently.

2) Define the Author and Book entities

Put all application Java files below in src/main/java/com/example/jpa/. These complete classes use explicit table names and column constraints rather than relying entirely on naming defaults.

Author.java

package com.example.jpa;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

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

    @Column(name = "name", nullable = false, length = 120)
    private String name;

    @OneToMany(mappedBy = "author")
    private List<Book> books = new ArrayList<>();

    protected Author() {}

    public Author(String name) {
        this.name = name;
    }

    public Book addBook(String title) {
        Book book = new Book(title, this);
        books.add(book);
        return book;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Book> getBooks() {
        return Collections.unmodifiableList(books);
    }
}

Book.java

package com.example.jpa;

import jakarta.persistence.*;

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

    @Column(name = "title", nullable = false, length = 200)
    private String title;

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

    protected Book() {}

    Book(String title, Author author) {
        this.title = title;
        this.author = author;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public Author getAuthor() { return author; }
}

JPA entities require a public or protected no-argument constructor. Here, @Id identifies each entity independently of its ordinary fields: two authors can have the same name, and two books can have the same title. Field annotations let the provider access persistent state without public setters.

3) Add Spring Data JPA repositories

AuthorRepository.java

package com.example.jpa;

import org.springframework.data.jpa.repository.JpaRepository;

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

BookRepository.java

package com.example.jpa;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}

Spring Data creates the implementations; you don’t write classes implementing these interfaces. The inherited methods cover saving, finding, and deleting entities.

InterfaceUse it when
CrudRepositoryYou need the basic create, read, update, and delete abstraction.
JpaRepositoryYou also want pagination, sorting, list-returning CRUD methods, and JPA operations such as flush() and saveAndFlush().

We use JpaRepository because the demonstration explicitly flushes changes before reloading. Continue with our Spring Data JPA CRUD example when you’re ready to expand the repository operations.

4) Create PostgreSQL and configure the datasource

Have Docker running. Set DB_PASSWORD in your shell to a nonempty local-development password, then run the following from Bash. The official PostgreSQL image creates the specified user and database on first initialization.

: "${DB_PASSWORD:?Set DB_PASSWORD before continuing}"
export DB_PASSWORD

docker run --name jpa-postgres --rm -d \
  -p 127.0.0.1:5432:5432 \
  -e POSTGRES_DB=jpa_demo \
  -e POSTGRES_USER=jpa_demo \
  -e POSTGRES_PASSWORD="$DB_PASSWORD" \
  postgres:18

docker exec jpa-postgres pg_isready -U jpa_demo -d jpa_demo

Repeat the readiness check if PostgreSQL is still starting. It checks server readiness, not whether your application credentials work; the save-and-reload operation below verifies application connectivity. This disposable container binds the database port to localhost, and --rm removes the container when it exits. The image-created user is a superuser: don’t reuse that privilege model in production.

Create src/main/resources/application.properties:

spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/jpa_demo}
spring.datasource.username=${DB_USER:jpa_demo}
spring.datasource.password=${DB_PASSWORD:}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false

The configuration placeholders let each environment supply its own connection details without committing passwords. Boot can infer the driver from the JDBC URL when the driver dependency is present.

5) Create the schema with a Flyway migration

Create src/main/resources/db/migration/V1__create_library.sql. Flyway discovers versioned migrations in this default location; Boot runs migrations before initializing the dependent JPA infrastructure. Hibernate’s validate setting checks the mapped schema without changing it.

CREATE TABLE authors (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(120) NOT NULL
);

CREATE TABLE books (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    author_id BIGINT NOT NULL,
    CONSTRAINT fk_books_author
        FOREIGN KEY (author_id) REFERENCES authors(id)
);

CREATE INDEX idx_books_author_id ON books(author_id);

The identity columns supply generated identifiers, while the foreign key prevents books from referring to nonexistent authors. Names and titles intentionally aren’t unique. If your domain introduces a genuinely unique business key, enforce it in a migration rather than relying only on an application-side check.

Optional application-local-scratch.properties:

spring.flyway.enabled=false
spring.jpa.hibernate.ddl-auto=update

Activate local-scratch only against that separate database. Don’t point the migration-based workflow at an already populated scratch schema and expect Flyway to reconstruct its history.

6) Save, reload, and run the application

LibraryService.java

package com.example.jpa;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

    @PersistenceContext
    private EntityManager entityManager;

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

    @Transactional
    public String createAndRead() {
        Author author = authors.save(new Author("Sam Erickson"));
        Book saved = books.saveAndFlush(author.addBook("Persistence Notes"));
        Long bookId = saved.getId();

        entityManager.clear();
        Book reloaded = books.findById(bookId)
            .orElseThrow(() -> new IllegalStateException("Saved book not found"));

        return reloaded.getTitle() + " by " + reloaded.getAuthor().getName();
    }
}

The persistence context tracks managed entity instances. saveAndFlush() synchronizes pending changes with the database; clear() detaches managed instances, so this reload doesn’t merely return the instance already in that context. Flushing is not committing—the service transaction still surrounds the entire operation.

Put @Transactional on the service operation that must succeed as a unit. Inherited repository CRUD methods already have transactional configuration, but separate calls don’t automatically make one business transaction. The runner calls a separate Spring-managed service, so transaction interception applies; calling an annotated method from another method on the same instance normally bypasses that interception.

JpaDemoApplication.java, in the same package:

package com.example.jpa;

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 JpaDemoApplication {
    private static final Logger log = LoggerFactory.getLogger(JpaDemoApplication.class);

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

    @Bean
    @Profile("demo")
    CommandLineRunner demo(LibraryService library) {
        return args -> log.info("Reloaded: {}", library.createAndRead());
    }
}

Keeping the main class above your entities, repositories, and services in the package hierarchy lets Boot discover them. The demo profile prevents sample writes during ordinary startup; each demo run intentionally creates another author and book.

After adding the repository test below, replace Initializr’s generated context-load test with it and build from the project root:

./mvnw clean package
java -jar target/jpa-demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=demo

On Windows, use mvnw.cmd. Maven resolves dependencies during the build; install is unnecessary here because it also installs the artifact into your local Maven repository. Running package immediately after install repeats lifecycle work.

The expected application message is:

Reloaded: Persistence Notes by Sam Erickson

Test Persistence Against Disposable PostgreSQL

Create src/test/java/com/example/jpa/LibraryRepositoryTest.java. @DataJpaTest loads the JPA slice and rolls back each test by default. The container is a Spring-managed bean, so its lifecycle follows the test application context; @ServiceConnection supplies its connection details instead of your local datasource settings.

package com.example.jpa;

import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.testcontainers.postgresql.PostgreSQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(LibraryRepositoryTest.Database.class)
class LibraryRepositoryTest {
    @Autowired AuthorRepository authors;
    @Autowired BookRepository books;
    @Autowired EntityManager entityManager;

    @Test
    void savesAndReloadsBothSides() {
        Author author = authors.save(new Author("Sam Erickson"));
        Book book = books.saveAndFlush(author.addBook("Persistence Notes"));
        Long bookId = book.getId();
        Long authorId = author.getId();
        entityManager.clear();

        Book reloaded = books.findById(bookId).orElseThrow();
        assertThat(reloaded.getTitle()).isEqualTo("Persistence Notes");
        assertThat(reloaded.getAuthor().getId()).isEqualTo(authorId);
        assertThat(reloaded.getAuthor().getName()).isEqualTo("Sam Erickson");
        assertThat(authors.findById(authorId).orElseThrow().getBooks()).hasSize(1);
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class Database {
        @Bean
        @ServiceConnection
        PostgreSQLContainer postgres() {
            return new PostgreSQLContainer("postgres:18");
        }
    }
}

Run ./mvnw test with Docker available. Spring Boot 4.1.1’s JPA test slice does not import FlywayAutoConfiguration; including the Flyway starter alone does not enable migrations within this slice. The explicit @ImportAutoConfiguration(FlywayAutoConfiguration.class) enables the migration before Hibernate validates the schema in the separate PostgreSQL container, not the manually started container. Without that import, validation fails against the empty database before the test executes. The test checks both the foreign-key association and the inverse collection after clearing the persistence context. The PostgreSQL container class is org.testcontainers.postgresql.PostgreSQLContainer for the managed Testcontainers generation used here.

Spring Data JPA Relationships Explained

One-to-many: ownership is on Book

authors                         books
-------                         -----
id <--------------------------- author_id  NOT NULL
name                            id
                                title

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

mappedBy = "author" names the Java field on Book, not a database column. That makes Author.books the inverse side; changing only that collection isn’t enough to establish the owning association. addBook() sets both references for each new book.

@ManyToOne defaults to eager fetching, while @OneToMany defaults to lazy fetching. We explicitly request lazy loading for Book.author; JPA defines lazy loading as a provider hint, not a universal guarantee about emitted SQL. Fetching and cascading are separate: these mappings declare no cascades, so the service saves each entity explicitly.

One-to-one: a different business rule

A one-to-one Author–Book model means each author has at most one book and each book at most one author. That usually isn’t a publishing model, but it demonstrates the mapping. Here Author.book owns a unique authors.book_id foreign key pointing to books.id.

The following are alternative mapping fragments, not additions to the runnable example. Remove the one-to-many/many-to-one associations and replace their affected constructors, helpers, service code, and migration. Retain the entity identifiers and protected no-argument constructors; these annotations use the existing Jakarta imports.

// Fragment inside Author: replaces books and its relationship helpers.
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", unique = true)
private Book book;
// Fragment inside Book: replaces its previous author association.
@OneToOne(mappedBy = "book")
private Author author;
authors.book_id  UNIQUE  ---->  books.id
Author.book: owner              Book.author: inverse

Put the uniqueness constraint in the migration too; with schema validation, annotation changes don’t alter the database. Keep both Java references synchronized. For a dedicated walkthrough, see our Spring Data JPA one-to-one relationship example.

Many-to-many: use a join table for coauthors

If books can have multiple authors, a join table records each book–author pair. These alternative fragments replace the original association fields, getters, and helper methods; retain the identifiers, scalar fields, and JPA constructors, and update the migration and application code separately.

// Author.java fragment: imports, then members inside Author.
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;

// Inside Author:
@ManyToMany(mappedBy = "authors")
private Set<Book> books = new HashSet<>();

public Set<Book> getBooks() { return books; }
// Book.java fragment: imports, then members inside Book.
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

// Inside Book:
@ManyToMany
@JoinTable(name = "book_author",
    joinColumns = @JoinColumn(name = "book_id"),
    inverseJoinColumns = @JoinColumn(name = "author_id"))
private Set<Author> authors = new HashSet<>();

public Set<Author> getAuthors() { return authors; }

public void addAuthor(Author author) {
    authors.add(author);
    author.getBooks().add(this);
}
books.id <---- book_author ----> authors.id
               book_id
               author_id
               PRIMARY KEY (book_id, author_id)

Book.authors: owner             Author.books: inverse

Book.authors owns the join-table mapping; Author.books uses mappedBy = "authors". Give the join table foreign keys to both entity tables and a composite primary key to prevent duplicate pairs. If the relationship needs data such as author order or contribution type, model a join entity instead of a bare many-to-many collection.

Common Mistakes and Production Boundaries

  • Using eager loading to fix every lazy-loading error. Traversing associations can produce N+1 queries—one initial query followed by additional relationship queries. Fetch what a use case needs with a fetch join, an @EntityGraph, or a DTO projection, and inspect the SQL.
  • Returning bidirectional entities directly from REST controllers. Walking both references can recurse; unloaded relationships can also trigger queries or fail after the persistence context closes. Build DTOs—plain response objects containing only the required fields—inside your service boundary. The explicit spring.jpa.open-in-view=false setting avoids relying on web-view lazy loading.
  • Adding broad cascades without lifecycle rules. CascadeType.ALL includes removal. Don’t cascade removal from a book to shared authors; propagate only operations your ownership model actually requires.
  • Treating validation as a complete schema audit. Keep database constraints and indexes in reviewed migrations, test those migrations, and use a restricted application role rather than the tutorial container’s superuser.

FAQ

Which Maven dependency do I need for Spring Data JPA?

Use org.springframework.boot:spring-boot-starter-data-jpa in a Spring Boot application and let Boot manage its version. For PostgreSQL, also add org.postgresql:postgresql as a runtime dependency.

What does the @Entity annotation do in JPA?

@Entity declares a class as a persistent entity managed by JPA. Its identifier, fields, and relationships are mapped using annotations or XML metadata; an entity isn’t limited to a simplistic one-class-to-one-table mapping.

How do I configure connection pooling with PostgreSQL?

The JPA starter brings in HikariCP, which Boot prefers when available. Configure its pool through properties such as spring.datasource.hikari.maximum-pool-size=8; eight is an example, not a recommended default, and you should size the pool against measured concurrency and the database connection budget.

Do I need a repository for every entity?

No. An aggregate is a group of related objects whose consistency you manage through one entry point, called the aggregate root; repositories are usually designed around those roots rather than every mapped table. This tutorial uses both repositories to make the saves explicit, but a model that persists books through an author aggregate could omit a separate BookRepository.

How do I test a Spring Data JPA repository?

Use @DataJpaTest to load the persistence slice and Testcontainers to supply disposable PostgreSQL. Save and flush data, clear the persistence context, then reload and assert the stored relationships; these tests roll back by default.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me