Sitemap
Java Fusion

Java Fusion

Java enthusiast crafting code with passion. enthusiastic about creating robust, scalable applications. Let's code, innovate, and build the future together!

Spring Boot Data JPA

15 min readDec 27, 2025

--

Spring Boot Data JPA = Java Objects ⇄ Hibernate ⇄ Database (automatic CRUD, no SQL needed)

Press enter or click to view image in full size

Working with databases can be challenging, but Spring Data JPA makes it surprisingly simple. In this comprehensive guide, I’ll walk you through everything you need to know about advanced database operations in Spring Boot — from complex relationships to performance optimization.

Whether you’re building your first Spring Boot application or looking to level up your database skills, this guide has got you covered. Let’s dive in!

What You’ll Learn

By the end of this article, you’ll master:

  • Entity Relationships — One-to-Many, Many-to-Many patterns
  • Custom Queries — JPQL vs Native SQL with practical examples
  • Pagination & Sorting — Handle thousands of records efficiently
  • Transaction Management — Keep your data consistent
  • Database Migrations — Version control your database schema
  • Best Practices — Avoid common pitfalls that slow down your app

Prerequisites

Before we start, make sure you have:

  • Basic understanding of Spring Boot
  • Java 17 or higher installed
  • Knowledge of relational databases (MySQL, PostgreSQL, etc.)
  • Your favorite IDE (IntelliJ IDEA, VS Code, Eclipse)

Don’t worry if you’re not an expert — I’ll explain everything step by step! 😊

Entity Relationships Made Simple

Let’s start with the foundation — entity relationships. These are the building blocks of any database-driven application.

Understanding One-to-Many Relationships

Imagine a real-world scenario: One author writes multiple books. This is a classic One-to-Many relationship.

Here’s how we model this in Spring Boot:

The Author Entity

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

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(unique = true, nullable = false)
private String email;

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();

// Helper method to maintain bidirectional relationship
public void addBook(Book book) {
books.add(book);
book.setAuthor(this);
}

public void removeBook(Book book) {
books.remove(book);
book.setAuthor(null);
}

// Constructors, Getters, and Setters
}

The Book Entity

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

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String title;

@Column(unique = true)
private String isbn;

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

@Column(name = "published_date")
private LocalDate publishedDate;

// Constructors, Getters, and Setters
}

Key Concepts Explained

Let me break down what’s happening here:

  • @OneToMany(mappedBy = “author”) This tells JPA that the Book entity owns the relationship. The author refers to the field name in the Book class.
  • cascade = CascadeType.ALL When you save, update, or delete an Author, those operations automatically apply to their Books. Neat, right?
  • orphanRemoval = true If you remove a book from an author’s list, it gets deleted from the database automatically.
  • @ManyToOne(fetch = FetchType.LAZY) This is crucial for performance! It means books don’t load their author data until you actually access it.
  • Helper methods — addBook() and removeBook() maintain both sides of the relationship. Always use these instead of manipulating the list directly!

Understanding Many-to-Many Relationships

Now let’s tackle something more complex Students enrolling in multiple courses, and courses having multiple students.

The Student Entity

@Entity
@Table(name = "students")
public class Student {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(unique = true, nullable = false)
private String email;

@Column(name = "enrollment_date")
private LocalDate enrollmentDate;

@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();

// Helper methods for maintaining relationships
public void enrollCourse(Course course) {
this.courses.add(course);
course.getStudents().add(this);
}

public void dropCourse(Course course) {
this.courses.remove(course);
course.getStudents().remove(this);
}

// Constructors, Getters, and Setters
}

The Course Entity

@Entity
@Table(name = "courses")
public class Course {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(unique = true, nullable = false)
private String code;

private Integer credits;

@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();

// Constructors, Getters, and Setters
}

Important Points About Many-to-Many

  • Use Set instead of List This prevents duplicate entries and makes operations more efficient.
  • @JoinTable This creates a junction table (student_course) that links students and courses.
  • Avoid CascadeType.REMOVE If you delete a student, you don’t want to delete all their courses! Other students might be enrolled in them.
  • Bidirectional consistency The helper methods ensure both sides of the relationship stay in sync.

Understanding One-to-One Relationship

Example: One User has One Profile

User Entity (Owner Side)

@Entity
@Table(name = "users")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String username;

@Column(nullable = false)
private String password;

// One user has one profile
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;

// Helper methods
public void setProfile(UserProfile profile) {
this.profile = profile;
profile.setUser(this);
}

public void removeProfile() {
if (this.profile != null) {
this.profile.setUser(null);
this.profile = null;
}
}

// Constructors, Getters, and Setters
}

UserProfile Entity (Inverse Side)

@Entity
@Table(name = "user_profiles")
public class UserProfile {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "full_name")
private String fullName;

private String bio;

@Column(name = "phone_number")
private String phoneNumber;

private String address;

// One profile belongs to one user
@OneToOne(mappedBy = "profile")
private User user;

// Constructors, Getters, and Setters
}

Important Points for One-to-One

  • @JoinColumn Specifies which side owns the foreign key
  • mappedBy Used on the inverse side (non-owning side)
  • CascadeType.ALL When you save/delete user, profile is also saved/deleted
  • orphanRemoval = true If profile is removed from user, it gets deleted from database
  1. One-to-One: One user, one profile (use @JoinColumn on owner side)
  2. One-to-Many: One author, many books (use @ManyToOne on “many” side)
  3. Many-to-Many: Many students, many courses (creates junction table)
  4. Always use helper methods to keep relationships synchronized
  5. Choose cascade types carefully — don’t accidentally delete important data!

You now understand all three JPA relationships! 🚀

Writing Custom Queries

Spring Data JPA gives you multiple ways to query your database. Let’s explore the most powerful ones!

Method 1: Query Methods (The Easiest Way)

Spring can generate queries from method names. It’s like magic! ✨

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

// Finds books by exact title
List<Book> findByTitle(String title);

// Finds books by author name
List<Book> findByAuthorName(String authorName);

// Finds books with title containing keyword
List<Book> findByTitleContaining(String keyword);

// Finds books within price range
List<Book> findByPriceBetween(Double minPrice, Double maxPrice);

// Finds books published after a date
List<Book> findByPublishedDateAfter(LocalDate date);

// Complex query with multiple conditions
List<Book> findByAuthorNameAndPriceGreaterThan(String authorName, Double price);
}

When to use: Simple queries with straightforward conditions.

Method 2: JPQL Queries (Database-Independent)

JPQL works with your entity objects, not database tables. This means your queries work across different databases!

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

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

// JPQL with JOIN
@Query("SELECT b FROM Book b JOIN b.author a WHERE a.email = :email")
List<Book> findBooksByAuthorEmail(@Param("email") String email);

// JPQL with aggregation
@Query("SELECT a.name, COUNT(b) FROM Book b JOIN b.author a GROUP BY a.name")
List<Object[]> countBooksByAuthor();

// JPQL with multiple conditions
@Query("""
SELECT b FROM Book b
WHERE b.author.name = :authorName
AND b.price BETWEEN :minPrice AND :maxPrice
ORDER BY b.publishedDate DESC
""")
List<Book> findBooksByAuthorAndPriceRange(
@Param("authorName") String authorName,
@Param("minPrice") Double minPrice,
@Param("maxPrice") Double maxPrice
);

// Fetch specific fields only (DTO projection)
@Query("SELECT new com.example.dto.BookDTO(b.id, b.title, b.price) FROM Book b WHERE b.author.id = :authorId")
List<BookDTO> findBookDTOsByAuthor(@Param("authorId") Long authorId);
}

JPQL Pro Tips

  • Use entity names (Book), not table names (books)
  • Reference entity fields (b.author.name), not column names
  • Perfect for queries that need to work on MySQL, PostgreSQL, Oracle, etc.

Method 3: Native SQL Queries (Maximum Control)

When you need database-specific features or maximum performance, use native SQL.

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

// Simple native query
@Query(value = "SELECT * FROM books WHERE isbn = :isbn", nativeQuery = true)
Book findByIsbnNative(@Param("isbn") String isbn);

// Complex native query with joins
@Query(value = """
SELECT b.* FROM books b
INNER JOIN authors a ON b.author_id = a.id
WHERE a.name = :authorName
AND b.published_date > :date
ORDER BY b.published_date DESC
LIMIT :limit
""", nativeQuery = true)
List<Book> findRecentBooksByAuthor(
@Param("authorName") String authorName,
@Param("date") LocalDate date,
@Param("limit") int limit
);

// Native update query
@Modifying
@Transactional
@Query(value = "UPDATE books SET price = price * 1.1 WHERE author_id = :authorId", nativeQuery = true)
int increasePriceByAuthor(@Param("authorId") Long authorId);

// Native query with custom result mapping
@Query(value = """
SELECT a.name as authorName, COUNT(b.id) as bookCount, AVG(b.price) as avgPrice
FROM authors a
LEFT JOIN books b ON a.id = b.author_id
GROUP BY a.id, a.name
HAVING COUNT(b.id) > :minBooks
""", nativeQuery = true)
List<Map<String, Object>> getAuthorStatistics(@Param("minBooks") int minBooks);
}

Pagination & Sorting

Imagine you have 10,000 books in your database. Loading them all at once would crash your application!

Get Java Fusion’s stories in your inbox

Join Medium for free to get updates from this writer.

Pagination is the solution. Let’s learn how to implement it properly.

Basic Pagination Setup

Repository Layer

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

// Pagination with category filter
Page<Product> findByCategory(String category, Pageable pageable);

// Pagination with custom query
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :minPrice AND :maxPrice")
Page<Product> findByPriceRange(
@Param("minPrice") Double minPrice,
@Param("maxPrice") Double maxPrice,
Pageable pageable
);

// Pagination with search
@Query("SELECT p FROM Product p WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%'))")
Page<Product> searchProducts(@Param("keyword") String keyword, Pageable pageable);
}

Service Layer

@Service
public class ProductService {

@Autowired
private ProductRepository productRepository;

// Get products with simple sorting
public Page<Product> getProducts(int page, int size, String sortBy) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).descending());
return productRepository.findAll(pageable);
}

// Get products with multiple sort criteria
public Page<Product> getProductsWithComplexSort(int page, int size) {
Sort sort = Sort.by(
Sort.Order.desc("price"), // First sort by price (high to low)
Sort.Order.asc("name") // Then by name (A to Z)
);

Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(pageable);
}

// Search with pagination
public Page<Product> searchProducts(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return productRepository.searchProducts(keyword, pageable);
}
}

Controller Layer

@RestController
@RequestMapping("/api/products")
public class ProductController {

@Autowired
private ProductService productService;

@GetMapping
public ResponseEntity<Page<Product>> getAllProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id") String sortBy
) {
Page<Product> products = productService.getProducts(page, size, sortBy);
return ResponseEntity.ok(products);
}

@GetMapping("/search")
public ResponseEntity<Page<Product>> searchProducts(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Page<Product> products = productService.searchProducts(keyword, page, size);
return ResponseEntity.ok(products);
}
}

Understanding the Response

When you call a paginated endpoint, you get this structure:

{
"content": [
{
"id": 1,
"name": "Laptop",
"price": 999.99
},
{
"id": 2,
"name": "Mouse",
"price": 29.99
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 10,
"sort": {
"sorted": true,
"unsorted": false
},
"offset": 0
},
"totalPages": 5,
"totalElements": 47,
"last": false,
"first": true,
"size": 10,
"number": 0,
"numberOfElements": 10
}

Pagination Best Practices

  1. Always set a max page size — Limit to 100 items to prevent memory issues
  2. Index your sort columns — Makes sorting much faster
  3. Use Slice<T> instead of Page<T> when you don't need the total count (better performance)
  4. Cache paginated results for frequently accessed data

Transaction Management

Transactions are like safety nets for your data. They ensure that either all operations succeed, or none of them do. No half-done operations!

Understanding Transactions with a Real Example

Imagine transferring money between bank accounts. You need to:

  1. Deduct money from Account A
  2. Add money to Account B
  3. Log the transaction

If step 2 fails, step 1 should rollback. Otherwise, money disappears!

Basic Transaction Example

@Service
public class BankingService {

@Autowired
private AccountRepository accountRepository;

@Autowired
private TransactionRepository transactionRepository;

@Transactional
public void transferMoney(Long fromAccountId, Long toAccountId, Double amount) {
// Step 1: Find accounts
Account fromAccount = accountRepository.findById(fromAccountId)
.orElseThrow(() -> new AccountNotFoundException("Source account not found"));

Account toAccount = accountRepository.findById(toAccountId)
.orElseThrow(() -> new AccountNotFoundException("Destination account not found"));

// Step 2: Validate
if (fromAccount.getBalance() < amount) {
throw new InsufficientFundsException("Not enough balance");
}

// Step 3: Perform transfer
fromAccount.setBalance(fromAccount.getBalance() - amount);
toAccount.setBalance(toAccount.getBalance() + amount);

// Step 4: Save changes
accountRepository.save(fromAccount);
accountRepository.save(toAccount);

// Step 5: Log transaction
Transaction transaction = new Transaction();
transaction.setFromAccountId(fromAccountId);
transaction.setToAccountId(toAccountId);
transaction.setAmount(amount);
transaction.setTimestamp(LocalDateTime.now());
transactionRepository.save(transaction);

// If ANY step fails, EVERYTHING rolls back!
}
}

Advanced Transaction Configuration

@Service
public class OrderService {

@Autowired
private OrderRepository orderRepository;

@Autowired
private InventoryService inventoryService;

@Autowired
private PaymentService paymentService;

// Read-only transaction (faster for queries)
@Transactional(readOnly = true)
public List<Order> getOrdersByCustomer(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}

// Transaction with timeout
@Transactional(timeout = 30) // 30 seconds max
public Order createOrder(OrderRequest request) {
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setItems(request.getItems());
order.setStatus(OrderStatus.PENDING);

return orderRepository.save(order);
}

// Transaction with custom isolation level
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processHighValuePayment(Long orderId, PaymentDetails details) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found"));

// Process payment
paymentService.charge(details);

// Update order
order.setStatus(OrderStatus.PAID);
order.setPaymentDate(LocalDateTime.now());
orderRepository.save(order);
}

// Rollback for specific exceptions
@Transactional(rollbackFor = {BusinessException.class, ValidationException.class})
public Order placeOrder(OrderRequest request) {
// Validate inventory
boolean available = inventoryService.checkAvailability(request.getItems());
if (!available) {
throw new ValidationException("Some items are out of stock");
}

// Create order
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setItems(request.getItems());
Order savedOrder = orderRepository.save(order);

// Reserve inventory
inventoryService.reserve(request.getItems());

// Send confirmation email (non-critical - don't rollback if this fails)
try {
emailService.sendOrderConfirmation(savedOrder);
} catch (Exception e) {
log.error("Failed to send email, but order was created successfully", e);
}

return savedOrder;
}
}

Transaction Anti-Patterns to Avoid

// ❌ BAD: Transaction in controller
@RestController
public class BadController {

@Transactional // DON'T DO THIS!
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
// Transactions should be in service layer, not controllers!
}
}

// ❌ BAD: Calling external API inside transaction
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);

// DON'T call external APIs inside transactions!
// This holds database connections while waiting for external response
externalPaymentAPI.charge(order.getAmount());
}
// ✅ GOOD: Call external API outside transaction
public void processOrder(Order order) {
// First call external API
PaymentResult result = externalPaymentAPI.charge(order.getAmount());

// Then save to database in transaction
saveOrderWithPayment(order, result);
}
@Transactional
private void saveOrderWithPayment(Order order, PaymentResult result) {
order.setPaymentResult(result);
orderRepository.save(order);
}

Database Migration with Flyway

Managing database changes across environments (dev, staging, production) is challenging. Flyway makes it simple and automated!

Why Use Flyway?

Imagine this scenario

  • Developer A adds a new column to users table
  • Developer B creates a new orders table
  • How do you keep all environments in sync? 🤔

Flyway solves this by version-controlling your database schema!

Setting Up Flyway

Step 1: Add Dependency

Maven (pom.xml):

<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>

<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId> <!-- Or flyway-postgresql -->
</dependency>

Gradle (build.gradle):

implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql' // Or flyway-postgresql

Step 2: Configure Flyway

application.yml:

spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: dbuser
password: dbpass
driver-class-name: org.postgresql.Driver

jpa:
hibernate:
ddl-auto: validate # Important: Let Flyway manage schema
show-sql: true

flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
schemas: public
baseline-version: 0
validate-on-migrate: true

Creating Migration Files

Create migrations in: src/main/resources/db/migration/

V1__Create_Authors_Table.sql

-- First migration: Create authors table
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
bio TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);


-- Create indexes for better performance
CREATE INDEX idx_authors_email ON authors(email);
CREATE INDEX idx_authors_name ON authors(name);

-- Insert some sample data
INSERT INTO authors (name, email, bio) VALUES
('James Clear', 'james@example.com', 'Author of Atomic Habits'),
('Robert Martin', 'uncle.bob@example.com', 'Author of Clean Code');

V2__Create_Books_Table.sql

-- Second migration: Create books table
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
isbn VARCHAR(13) UNIQUE,
price DECIMAL(10, 2) DEFAULT 0.00,
author_id BIGINT NOT NULL,
published_date DATE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

-- Foreign key constraint
CONSTRAINT fk_books_author
FOREIGN KEY (author_id)
REFERENCES authors(id)
ON DELETE CASCADE
);

-- Create indexes
CREATE INDEX idx_books_author_id ON books(author_id);
CREATE INDEX idx_books_isbn ON books(isbn);
CREATE INDEX idx_books_title ON books(title);
CREATE INDEX idx_books_price ON books(price);

V3__Add_Categories_And_Tags.sql

-- Third migration: Add category field to books
ALTER TABLE books
ADD COLUMN category VARCHAR(100);

-- Create tags table for many-to-many relationship
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create junction table
CREATE TABLE book_tags (
book_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (book_id, tag_id),
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- Insert some tags
INSERT INTO tags (name) VALUES
('Programming'),
('Self-Help'),
('Business'),
('Technology'),
('Fiction');

V4__Create_Students_And_Courses.sql

-- Fourth migration: Create student and course tables
CREATE TABLE students (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
enrollment_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(20) UNIQUE NOT NULL,
credits INTEGER NOT NULL DEFAULT 3,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE student_course (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
grade VARCHAR(2),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);
-- Create indexes
CREATE INDEX idx_students_email ON students(email);
CREATE INDEX idx_courses_code ON courses(code);

V5__Add_Audit_Columns.sql

-- Fifth migration: Add audit columns to all tables
ALTER TABLE books
ADD COLUMN created_by VARCHAR(255),
ADD COLUMN updated_by VARCHAR(255);

ALTER TABLE authors
ADD COLUMN created_by VARCHAR(255),
ADD COLUMN updated_by VARCHAR(255);
-- Create trigger function for updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers
CREATE TRIGGER update_books_updated_at
BEFORE UPDATE ON books
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_authors_updated_at
BEFORE UPDATE ON authors
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
### Flyway Migration Naming Convention
```

V{version}__{description}.sql
Examples:
V1__Create_Users_Table.sql
V2__Add_Email_Column.sql
V3.1__Add_Index_On_Email.sql
V2024.12.23.1__Holiday_Migration.sql
create_users.sql (missing version)
V1-create-users.sql (wrong separator)

Flyway Best Practices

  1. Never modify executed migrations — Create a new migration instead
  2. Use descriptive names — V5__Add_User_Preferences.sql not V5__Update.sql
  3. Test migrations locally first — Run them on your dev database
  4. Keep migrations small — One logical change per migration
  5. Use transactions — Wrap DDL in BEGIN/COMMIT for rollback support
  6. Version control — Commit migration files to Git

Common Pitfalls & Best Practices

Let me share the most common mistakes I see developers make, and how to avoid them!

1. The Deadly N+1 Query Problem

This is the #1 performance killer in JPA applications!

The Problem

@GetMapping("/authors-with-books")
public List<AuthorDTO> getAuthorsWithBooks() {
List<Author> authors = authorRepository.findAll(); // 1 query

List<AuthorDTO> result = new ArrayList<>();
for (Author author : authors) {
AuthorDTO dto = new AuthorDTO();
dto.setName(author.getName());

// THIS TRIGGERS A NEW QUERY FOR EACH AUTHOR!
List<Book> books = author.getBooks(); // N queries
dto.setBookCount(books.size());

result.add(dto);
}

return result; // Total: 1 + N queries!
}

If you have 100 authors, this executes 101 queries!

Solution 1: Use JOIN FETCH

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> findAllWithBooks(); // Just 1 query!
}

@GetMapping("/authors-with-books")
public List<AuthorDTO> getAuthorsWithBooks() {
return authorRepository.findAllWithBooks()
.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}

Solution 2: Use Entity Graphs

@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a")
List<Author> findAllWithBooks();

Exposing Entities Directly in APIs

The Problem

@RestController
public class BadBookController {

@GetMapping("/books/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
Book book = bookRepository.findById(id).orElseThrow();
return ResponseEntity.ok(book); // ⚠️ DON'T DO THIS!
}
}

Why is this bad?

  • Exposes your internal data structure
  • Can cause lazy loading exceptions
  • May leak sensitive information
  • Makes API changes difficult

Solution: Use DTOs

// Create DTO classes
@Data
@Builder
public class BookDTO {
private Long id;
private String title;
private String isbn;
private Double price;
private AuthorDTO author;
}

@Data
@Builder
public class AuthorDTO {
private Long id;
private String name;
private String email;
}
// Service layer
@Service
public class BookService {

@Autowired
private BookRepository bookRepository;

public BookDTO getBookById(Long id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book not found"));

return convertToDTO(book);
}

private BookDTO convertToDTO(Book book) {
return BookDTO.builder()
.id(book.getId())
.title(book.getTitle())
.isbn(book.getIsbn())
.price(book.getPrice())
.author(AuthorDTO.builder()
.id(book.getAuthor().getId())
.name(book.getAuthor().getName())
.email(book.getAuthor().getEmail())
.build())
.build();
}
}
// Controller
@RestController
@RequestMapping("/api/books")
public class BookController {

@Autowired
private BookService bookService;

@GetMapping("/{id}")
public ResponseEntity<BookDTO> getBook(@PathVariable Long id) {
BookDTO book = bookService.getBookById(id);
return ResponseEntity.ok(book);
}
}

Proper Exception Handling

Create Custom Exceptions

public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}

public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.NOT_FOUND.value())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.path(((ServletWebRequest) RequestContextHolder.currentRequestAttributes())
.getRequest().getRequestURI())
.build();

return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicateResource(DuplicateResourceException ex) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.CONFLICT.value())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.build();

return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST.value())
.message("Database constraint violation")
.timestamp(LocalDateTime.now())
.build();

return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.message("An unexpected error occurred")
.timestamp(LocalDateTime.now())
.build();

return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

@Data
@Builder
class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
private String path;
}

Using Lombok for Cleaner Code

@Entity
@Table(name = "products")
@Data // Generates getters, setters, toString, equals, hashCode
@NoArgsConstructor // Generates no-arg constructor
@AllArgsConstructor // Generates all-args constructor
@Builder // Enables builder pattern
public class Product {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(unique = true)
private String sku;

private Double price;

@Enumerated(EnumType.STRING)
private ProductStatus status;

@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
private LocalDateTime updatedAt;
}

// Usage
Product product = Product.builder()
.name("Laptop")
.sku("LAP-001")
.price(999.99)
.status(ProductStatus.ACTIVE)
.build();

Conclusion

Congratulations! You’ve just learned the essential concepts of Spring Data JPA. Let’s recap what we covered:

Key Takeaways

  1. Entity Relationships — Model complex data with One-to-Many and Many-to-Many relationships
  2. Custom Queries — Write efficient queries using method names, JPQL, and native SQL
  3. Pagination & Sorting — Handle large datasets without performance issues
  4. Transaction Management — Keep your data consistent and safe
  5. Database Migrations — Version control your schema with Flyway
  6. Best Practices — Avoid N+1 queries, use DTOs, handle exceptions properly

Let’s Connect!

I hope you found this guide helpful! If you have any questions or want to share your experience with Spring Data JPA, drop a comment below. 👇

Found this useful? Please give it a clap 👏 and share it with your fellow developers!

Want more Spring Boot content? Follow me for upcoming articles on:

  • Spring Security with JWT
  • Microservices Architecture
  • Performance Optimization
  • Testing Strategies

Happy Coding!

--

--