Key Takeaways
Book.authorowns the relationship and controlsbook.author_id;Author.booksis the inverse side.- Maintain both sides with
Author.addBookbefore saving the author. Cascades only follow references present in the object graph. - Use lazy association mappings and request related data deliberately with a repository fetch plan.
- The project below includes Maven dependencies, H2 configuration, a transactional runner, and an integration test that reloads the relationship from the database.
This Spring Data JPA one-to-many example maps an author to multiple books, saves the relationship through a repository, and queries books together with their author. The important distinction is between relationship ownership, which determines the foreign key, and cascading, which determines whether an operation propagates to related entities.
We'll keep entities, repositories, and the runner separate. For background on the layers involved, see Spring Data JPA vs Hibernate.
Project Setup: Spring Boot and H2
This project targets Spring Boot 4.1.1 and Java 25, with Maven 3.6.3 or later and an in-memory H2 database. Java 25 falls within the documented Java compatibility range for this Boot release. Use jakarta.persistence imports with this baseline, not legacy javax.persistence imports.
Maven Dependencies
Create pom.xml at the project root. The Boot parent manages dependency versions; don't independently pin Spring Data JPA, Hibernate, or the H2 driver. Both JPA starters below appear in Boot's managed dependency coordinates.
<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.stackchief</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.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-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>Datasource Configuration
Create src/main/resources/application.yaml:
spring:
datasource:
url: jdbc:h2:mem:books;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password: ""
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
show-sql: trueThis uses a named, in-memory database, so you don't need a separate database server. H2's DB_CLOSE_DELAY=-1 keeps it alive between connections; it does not make the data survive a JVM restart. The H2 connection documentation explains these URL settings.
With the JPA starter and database driver available, Boot configures the datasource and JPA infrastructure. The empty password is only for this local, in-process database. open-in-view: false prevents view-layer lazy loading if you later turn this into a web application.
Understand the Author–Book Relationship
For this simplified domain, each book has one author, and an author can have many books. The foreign key belongs in the book table:
Author Book
id id
books: Set<Book> author: Author
inverse side owning side
mappedBy = "author" @JoinColumn(name = "author_id")
Database:
author.id <-------------------------- book.author_id
one author, many booksmappedBy = "author" names the Java relationship attribute in Book, not a database column. Book.author owns the relationship and controls the foreign key; this mapping needs no join table. The Jakarta Persistence relationship tutorial explains owning and inverse sides.
Entities
An entity represents persistent data, but not every field maps to a standalone column. Basic attributes map to columns, while associations describe relationships. These classes place their mapping annotations on fields, so JPA uses field access rather than getter-based access, as defined by the Jakarta Persistence specification.
Author Entity
src/main/java/bookservice/model/Author.java
package bookservice.model;
import jakarta.persistence.*;
import java.util.Collections;
import java.util.HashSet;
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)
private String firstName;
@Column(name = "last_name", nullable = false)
private String lastName;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY,
cascade = CascadeType.PERSIST)
private Set<Book> books = new HashSet<>();
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 another author");
}
books.add(book);
book.setAuthor(this);
}
public Long getId() {
return id;
}
public Set<Book> getBooks() {
return Collections.unmodifiableSet(books);
}
public String getFullName() {
return firstName + " " + lastName;
}
}addBook synchronizes both ends: it adds the book to the author's initialized collection and sets the book's author. This helper attaches books; it deliberately rejects moving a book from another author. The unmodifiable collection view prevents callers from bypassing it.
Book Entity
src/main/java/bookservice/model/Book.java
package bookservice.model;
import jakarta.persistence.*;
import java.util.Objects;
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id", nullable = false)
private Author author;
@Column(name = "title", nullable = false)
private String title;
protected Book() {}
public Book(String title) {
this.title = Objects.requireNonNull(title);
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void rename(String title) {
this.title = Objects.requireNonNull(title);
}
void setAuthor(Author author) {
this.author = Objects.requireNonNull(author);
}
public Author getAuthor() {
return author;
}
}@Entityidentifies a persistent entity;@Tablemakes the table name explicit.@Ididentifies the primary key, and@GeneratedValuerequests generated identifiers.@Columnconfigures basic-column mappings;@JoinColumnconfigures the association's foreign-key column.optional = falserequires an author, whilenullable = falsemakes the generated foreign-key column non-nullable.
Neither surname nor title is globally unique in this example. Repeated surnames and book titles are allowed; a real uniqueness constraint should express an actual business rule.
IDENTITY is suitable for this small H2 example because H2 supports identity columns. It isn't a universally better strategy: Hibernate must insert the row to obtain its identity value and disables JDBC insert batching for identity-generated inserts. See the Hibernate User Guide before choosing a strategy for a write-heavy application.
Cascades Follow the Object Graph
CascadeType.PERSIST propagates persistence from a new author to books present in Author.books. The inverse side can cascade operations even though it doesn't own the foreign key.
Don't replace this with CascadeType.ALL automatically: ALL includes REMOVE, which would propagate author deletion to books. This mapping also leaves orphan removal disabled; removing a relationship is not automatically a request to delete a book.
Repositories and Derived Queries
Extending CrudRepository<Entity, IdType> gives you a repository implementation without writing the persistence implementation yourself.
Author Repository
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> {
}Book Repository
src/main/java/bookservice/repository/BookRepository.java
package bookservice.repository;
import bookservice.model.Book;
import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.CrudRepository;
public interface BookRepository extends CrudRepository<Book, Long> {
@EntityGraph(attributePaths = "author")
List<Book> findByTitle(String title);
}Spring Data derives a title-equality query from findByTitle. Returning a list accommodates repeated titles, and @EntityGraph(attributePaths = "author") requests the author for this query without changing the association's general lazy mapping. See the official query-method and entity-graph documentation.
The CRUD repository API covers these operations:
- Create:
authors.save(author)saves the new author graph using the configured cascade. - Read:
books.findById(id)returns anOptional<Book>;findAll()retrieves all books. - Update: load a book inside a transaction and call
rename. JPA dirty checking tracks changes to managed entities and writes them when the persistence context flushes. - Delete: use
delete(book)ordeleteById(id). With this mapping, delete an author's books before deleting the author.
save uses EntityManager.persist for entities considered new and merge otherwise. Our cascade covers new-graph persistence, not detached-graph merging. When merging detached entities, use the instance returned by save and design merge cascading deliberately; the entity-persistence documentation explains state detection.
For a broader walkthrough of these operations, continue with the Spring Data JPA CRUD example.
Run and Verify the Example
Application Entry Point
src/main/java/bookservice/Application.java
package bookservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}Transactional Runner
src/main/java/bookservice/DemoRunner.java
package bookservice;
import bookservice.model.Author;
import bookservice.model.Book;
import bookservice.repository.AuthorRepository;
import bookservice.repository.BookRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class DemoRunner implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DemoRunner.class);
private final AuthorRepository authors;
private final BookRepository books;
public DemoRunner(AuthorRepository authors, BookRepository books) {
this.authors = authors;
this.books = books;
}
@Override
@Transactional
public void run(String... args) {
Author author = new Author("JK", "Rowling");
author.addBook(new Book("Harry Potter"));
authors.save(author);
Book savedBook = books.findByTitle("Harry Potter").stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"Demo book was not persisted"));
log.info("{} by {}", savedBook.getTitle(),
savedBook.getAuthor().getFullName());
}
}The runner's transaction covers saving, querying, and reading the relationship. In application code, put the same unit-of-work boundary on a service method. Repository CRUD transactions alone don't provide a transaction around an entire business workflow; see Spring Data's transaction-boundary guidance.
Build Commands and Expected Output
After adding all files, including the test below, run these commands from the project root. The second command uses the Spring Boot Maven plugin's run goal.
mvn test
mvn spring-boot:runThe runner should log this message, alongside SQL and startup output; logger prefixes are omitted:
Harry Potter by JK RowlingIntegration Test: Reload the Persisted Relationship
Create src/test/java/bookservice/AuthorBookRepositoryTest.java. @DataJpaTest configures a JPA test slice, excludes ordinary components such as our runner, and rolls back each test by default. Its Boot 4 package is shown below; see the JPA testing documentation.
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.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class AuthorBookRepositoryTest {
@Autowired
private AuthorRepository authors;
@Autowired
private BookRepository books;
@PersistenceContext
private EntityManager entityManager;
@Test
void savesBothSidesAndFetchesTheAuthor() {
Author author = new Author("JK", "Rowling");
author.addBook(new Book("Harry Potter"));
Long authorId = authors.save(author).getId();
entityManager.flush();
entityManager.clear();
List<Book> matches = books.findByTitle("Harry Potter");
assertThat(matches).hasSize(1);
Book savedBook = matches.get(0);
// Detach the results: the entity graph must have loaded the author.
entityManager.clear();
assertThat(savedBook.getAuthor().getId()).isEqualTo(authorId);
assertThat(savedBook.getAuthor().getFullName()).isEqualTo("JK Rowling");
Author reloaded = authors.findById(authorId).orElseThrow();
assertThat(reloaded.getBooks())
.extracting(Book::getTitle)
.containsExactly("Harry Potter");
}
}The first flush and clear force the query to read persisted state rather than reuse the original objects. The second clear detaches the query results, so reading the author's name also checks that the entity graph loaded it.
Lazy vs Eager Association Fetching
In the Jakarta Persistence version used by this baseline, @OneToMany defaults to lazy fetching and @ManyToOne defaults to eager fetching. Lazy fetching is a provider hint; eager fetching is a requirement to load the association. Neither setting alone guarantees a particular SQL join.
| Fetch choice | Behavior | Practical tradeoff |
|---|---|---|
LAZY mapping | Defers association loading when supported. | Avoids unnecessary loading, but later access can trigger extra queries. |
EAGER mapping | Requires the association to be loaded. | Can load unwanted data and can still produce secondary queries. |
| Query-specific entity graph | Requests selected associations for that repository query. | Makes the use case's data requirements explicit; inspect the resulting SQL. |
Lazy loading isn't automatically faster. Loading books and then accessing each unloaded author can cause the N+1 query problem: one query for the books followed by additional author queries. Accessing an uninitialized lazy association after its Hibernate session closes can throw LazyInitializationException.
Keep associations lazy as a starting point, then fetch what a use case needs inside its transaction. Our title query requests authors through an entity graph. A JPQL join fetch is another option; Hibernate's fetching guidance in the User Guide covers both approaches.
Production Checklist
- Replace disposable schema generation with reviewed migrations. Boot's database-initialization guide covers schema-generation settings and migration tools.
- Keep production database credentials outside source control, and don't reuse this demo's empty password.
- Review cascade and deletion rules explicitly. Here, a parent with existing books cannot simply be deleted without addressing the foreign-key references.
- Inspect SQL for representative list queries and test against your production database engine, not only H2.
For another relationship shape, compare the Spring Data JPA one-to-one relationship example.
FAQ
How do I save a parent and its children with Spring Data JPA?
For a new graph, populate the parent's collection and each child's owning-side reference, configure CascadeType.PERSIST, and save the parent. Author.addBook performs both relationship updates in this example.
Which side owns a JPA one-to-many relationship, and what does mappedBy mean?
The many-to-one side owns a bidirectional one-to-many relationship. Here, Book.author controls book.author_id, and mappedBy = "author" tells JPA that Author.books is the inverse side of that Java attribute.
Should @ManyToOne be lazy or eager?
Prefer an explicit lazy mapping as a starting point, then request related data with an entity graph or fetch join when needed. Eager fetching can load unwanted data and does not guarantee a single SQL query.
Why does saving my JPA parent not save its child?
A cascade only reaches children referenced by the association on the saved parent. If you set only Book.author and leave Author.books empty, saving the author won't cascade persistence to the book.
What does GenerationType.IDENTITY mean in this example?
It delegates identifier generation to H2 identity columns. Hibernate obtains the identifier through insertion, and identity-generated inserts do not use Hibernate's JDBC insert batching.
Further Reading
- Jakarta Persistence introduction — entities, ownership, and relationship mappings.
- Jakarta Persistence specification — access strategies, annotation rules, and fetch defaults.
- Spring Boot SQL databases — datasource and JPA autoconfiguration.
- Spring Data JPA query methods — derived queries and entity graphs.
- Persisting entities — how save chooses persist or merge.
- Spring Data JPA transactionality — repository and service transaction boundaries.
- Hibernate ORM User Guide — identifier strategies, fetching, and performance pitfalls.
- Spring Boot application testing — JPA test slices and transactional tests.
