Skip to main content

Spring GraphQL with JPA - Entity Mapping Best Practices

· 8 min read
GraphQL Guy

JPA Integration

JPA entities and GraphQL types serve different purposes. Learn how to map between them efficiently while avoiding common pitfalls like lazy loading exceptions.

The Impedance Mismatch

JPA entities are designed for:

  • Persistence and transactions
  • Bidirectional relationships
  • Lazy loading optimization

GraphQL types are designed for:

  • Client-driven data fetching
  • Read-only views of data
  • Hierarchical responses

Mixing them carelessly leads to problems. Let's explore best practices.

Project Setup

Dependencies

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<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>
</dependencies>

Configuration

spring:
jpa:
open-in-view: false # Important! Disable OSIV
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100

logging:
level:
org.hibernate.SQL: DEBUG

Why disable OSIV? Open Session In View keeps the Hibernate session open during view rendering, masking N+1 problems and lazy loading issues. Disable it to catch problems early.

Entity Design

JPA Entities

@Entity
@Table(name = "books")
public class BookEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;

@Column(nullable = false)
private String title;

private String description;

@Column(name = "published_year")
private Integer publishedYear;

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

@OneToMany(mappedBy = "book", cascade = CascadeType.ALL)
private List<ReviewEntity> reviews = new ArrayList<>();

@Column(name = "created_at", nullable = false)
private Instant createdAt;

@Column(name = "updated_at")
private Instant updatedAt;

// Getters, setters, equals, hashCode...
}

@Entity
@Table(name = "authors")
public class AuthorEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;

@Column(nullable = false)
private String name;

private String bio;

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

// ...
}

GraphQL Schema

type Book {
id: ID!
title: String!
description: String
publishedYear: Int
author: Author!
reviews: [Review!]!
reviewCount: Int!
averageRating: Float
createdAt: DateTime!
}

type Author {
id: ID!
name: String!
bio: String
books: [Book!]!
bookCount: Int!
}

type Review {
id: ID!
rating: Int!
content: String
reviewer: User!
book: Book!
createdAt: DateTime!
}

Pattern 1: DTOs for GraphQL Responses

Don't expose JPA entities directly:

// GraphQL DTO
public record Book(
String id,
String title,
String description,
Integer publishedYear,
String authorId, // Not the full author - resolve separately
Instant createdAt
) {
public static Book from(BookEntity entity) {
return new Book(
entity.getId(),
entity.getTitle(),
entity.getDescription(),
entity.getPublishedYear(),
entity.getAuthor().getId(), // Just the ID
entity.getCreatedAt()
);
}
}

Why DTOs?

  1. Decouples API from persistence layer
  2. Prevents lazy loading exceptions
  3. Allows schema evolution independent of entities
  4. Controls what data is exposed

Pattern 2: Repository Layer

Basic Repository

@Repository
public interface BookRepository extends JpaRepository<BookEntity, String> {

// For list queries
@Query("SELECT b FROM BookEntity b")
List<BookEntity> findAllBooks(Pageable pageable);

// With eager fetch for single item
@Query("SELECT b FROM BookEntity b LEFT JOIN FETCH b.author WHERE b.id = :id")
Optional<BookEntity> findByIdWithAuthor(@Param("id") String id);

// For batch loading
@Query("SELECT b FROM BookEntity b WHERE b.author.id IN :authorIds")
List<BookEntity> findByAuthorIdIn(@Param("authorIds") Collection<String> authorIds);
}

Projection Interface for Specific Fields

public interface BookSummary {
String getId();
String getTitle();
Integer getPublishedYear();
}

@Repository
public interface BookRepository extends JpaRepository<BookEntity, String> {

@Query("SELECT b.id as id, b.title as title, b.publishedYear as publishedYear " +
"FROM BookEntity b")
List<BookSummary> findAllSummaries();
}

Pattern 3: Service Layer with Transactions

@Service
@Transactional(readOnly = true)
public class BookService {

private final BookRepository bookRepository;
private final AuthorRepository authorRepository;

public List<Book> findAll(int page, int size) {
return bookRepository.findAllBooks(PageRequest.of(page, size))
.stream()
.map(Book::from)
.toList();
}

public Optional<Book> findById(String id) {
return bookRepository.findById(id)
.map(Book::from);
}

@Transactional
public Book create(CreateBookInput input) {
AuthorEntity author = authorRepository.findById(input.authorId())
.orElseThrow(() -> new AuthorNotFoundException(input.authorId()));

BookEntity entity = new BookEntity();
entity.setTitle(input.title());
entity.setDescription(input.description());
entity.setPublishedYear(input.publishedYear());
entity.setAuthor(author);
entity.setCreatedAt(Instant.now());

return Book.from(bookRepository.save(entity));
}
}

Pattern 4: GraphQL Controllers with Batch Loading

@Controller
public class BookController {

private final BookService bookService;

@QueryMapping
public List<Book> books(@Argument int page, @Argument int size) {
return bookService.findAll(page, size);
}

@QueryMapping
public Book bookById(@Argument String id) {
return bookService.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}

// Batch load authors for books
@BatchMapping
public Map<Book, Author> author(List<Book> books) {
Set<String> authorIds = books.stream()
.map(Book::authorId)
.collect(Collectors.toSet());

Map<String, Author> authorsById = authorRepository
.findAllById(authorIds)
.stream()
.map(Author::from)
.collect(Collectors.toMap(Author::id, Function.identity()));

return books.stream()
.collect(Collectors.toMap(
Function.identity(),
book -> authorsById.get(book.authorId())
));
}

// Batch load reviews for books
@BatchMapping
public Map<Book, List<Review>> reviews(List<Book> books) {
Set<String> bookIds = books.stream()
.map(Book::id)
.collect(Collectors.toSet());

Map<String, List<Review>> reviewsByBookId = reviewRepository
.findByBookIdIn(bookIds)
.stream()
.map(Review::from)
.collect(Collectors.groupingBy(Review::bookId));

return books.stream()
.collect(Collectors.toMap(
Function.identity(),
book -> reviewsByBookId.getOrDefault(book.id(), List.of())
));
}
}

Pattern 5: Computed Fields

For fields that don't map directly to entity fields:

@Controller
public class BookController {

@SchemaMapping(typeName = "Book", field = "reviewCount")
public int reviewCount(Book book) {
return reviewRepository.countByBookId(book.id());
}

@SchemaMapping(typeName = "Book", field = "averageRating")
public Double averageRating(Book book) {
return reviewRepository.averageRatingByBookId(book.id());
}
}

Batch Computed Fields

@BatchMapping(typeName = "Book", field = "reviewCount")
public Map<Book, Integer> reviewCounts(List<Book> books) {
Set<String> bookIds = books.stream()
.map(Book::id)
.collect(Collectors.toSet());

// Single query for all counts
Map<String, Long> counts = reviewRepository.countByBookIdIn(bookIds);

return books.stream()
.collect(Collectors.toMap(
Function.identity(),
book -> counts.getOrDefault(book.id(), 0L).intValue()
));
}

Repository method:

@Query("SELECT r.book.id, COUNT(r) FROM ReviewEntity r " +
"WHERE r.book.id IN :bookIds GROUP BY r.book.id")
List<Object[]> countGroupedByBookId(@Param("bookIds") Collection<String> bookIds);

default Map<String, Long> countByBookIdIn(Collection<String> bookIds) {
return countGroupedByBookId(bookIds).stream()
.collect(Collectors.toMap(
row -> (String) row[0],
row -> (Long) row[1]
));
}

Pattern 6: Handling Lazy Loading

The Problem

// This WILL throw LazyInitializationException
@QueryMapping
public List<Book> books() {
List<BookEntity> entities = bookRepository.findAll();
// Session closed, can't access author
return entities.stream()
.map(e -> new Book(e.getId(), e.getTitle(), e.getAuthor().getName()))
.toList();
}

Solution: Don't Access Lazy Fields in Mapping

// Good - only access loaded fields
public record Book(
String id,
String title,
String authorId // Just the ID, not the full entity
) {
public static Book from(BookEntity entity) {
return new Book(
entity.getId(),
entity.getTitle(),
entity.getAuthor().getId() // ID is loaded with the foreign key
);
}
}

Solution: Fetch Join When Needed

// When you know you need the author
@Query("SELECT b FROM BookEntity b JOIN FETCH b.author")
List<BookEntity> findAllWithAuthors();

Pattern 7: Entity Graphs for Selective Loading

@Entity
@NamedEntityGraph(
name = "Book.withAuthorAndReviews",
attributeNodes = {
@NamedAttributeNode("author"),
@NamedAttributeNode("reviews")
}
)
public class BookEntity { ... }

@Repository
public interface BookRepository extends JpaRepository<BookEntity, String> {

@EntityGraph(value = "Book.withAuthorAndReviews")
Optional<BookEntity> findWithDetailsById(String id);
}

Use selectively based on query needs:

@QueryMapping
public Book bookById(@Argument String id, DataFetchingEnvironment env) {
// Check what fields are requested
boolean needsAuthor = hasField(env, "author");
boolean needsReviews = hasField(env, "reviews");

BookEntity entity;
if (needsAuthor && needsReviews) {
entity = bookRepository.findWithDetailsById(id).orElseThrow();
} else if (needsAuthor) {
entity = bookRepository.findWithAuthorById(id).orElseThrow();
} else {
entity = bookRepository.findById(id).orElseThrow();
}

return Book.from(entity);
}

private boolean hasField(DataFetchingEnvironment env, String fieldName) {
return env.getSelectionSet().contains(fieldName);
}

Pattern 8: Specifications for Dynamic Queries

@Service
public class BookQueryService {

public List<Book> search(BookFilter filter, Pageable pageable) {
Specification<BookEntity> spec = buildSpec(filter);
return bookRepository.findAll(spec, pageable)
.map(Book::from)
.toList();
}

private Specification<BookEntity> buildSpec(BookFilter filter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();

if (filter.title() != null) {
predicates.add(cb.like(
cb.lower(root.get("title")),
"%" + filter.title().toLowerCase() + "%"
));
}

if (filter.authorId() != null) {
predicates.add(cb.equal(
root.get("author").get("id"),
filter.authorId()
));
}

if (filter.publishedAfter() != null) {
predicates.add(cb.greaterThan(
root.get("publishedYear"),
filter.publishedAfter()
));
}

return cb.and(predicates.toArray(new Predicate[0]));
};
}
}

Common Pitfalls

1. N+1 Without Batch Loading

// BAD - causes N+1
@SchemaMapping
public Author author(Book book) {
return authorRepository.findById(book.authorId()).orElse(null);
}

// GOOD - batch loading
@BatchMapping
public Map<Book, Author> author(List<Book> books) {
// Single query for all authors
}

2. Exposing Entity Directly

// BAD - exposes JPA entity
@QueryMapping
public BookEntity bookById(@Argument String id) {
return bookRepository.findById(id).orElse(null);
}

// GOOD - use DTO
@QueryMapping
public Book bookById(@Argument String id) {
return bookRepository.findById(id).map(Book::from).orElse(null);
}

3. Circular References in DTOs

// BAD - infinite recursion
public record Book(String id, Author author) {
public static Book from(BookEntity e) {
return new Book(e.getId(), Author.from(e.getAuthor())); // Author creates Books...
}
}

// GOOD - use IDs for references
public record Book(String id, String authorId) { }
public record Author(String id, String name) { }
// Resolve relationships via @BatchMapping

Performance Monitoring

SQL Logging

logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE

Query Statistics

@Component
public class HibernateStatsInterceptor implements WebGraphQlInterceptor {

@Autowired
private EntityManagerFactory emf;

@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.clear();

return chain.next(request)
.doOnSuccess(response -> {
log.info("Query count: {}", stats.getQueryExecutionCount());
log.info("Entity loads: {}", stats.getEntityLoadCount());
});
}
}

Summary

PatternPurpose
DTOsDecouple GraphQL from JPA
@BatchMappingSolve N+1 problem
ProjectionsFetch only needed columns
Entity GraphsSelective eager loading
SpecificationsDynamic query building
Service LayerTransaction management

JPA and GraphQL work well together when you respect their boundaries. Use DTOs for the API layer, batch loading for relationships, and let each tool do what it does best.

Next: Deploying Spring GraphQL - production configuration and monitoring.