Skip to main content

Data Persistence with Spring Data JPA

Most microservices need to store state. Spring Data JPA provides a repository abstraction over JPA (Hibernate), significantly reducing boilerplate code.

1. Dependencies

In pom.xml (or build.gradle):
<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>
<!-- For Production -->
<!-- <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
</dependency> -->

2. Defining Entities

An Entity represents a table in your database.
import jakarta.persistence.*;
import lombok.Data;

@Entity
@Table(name = "products")
@Data // Lombok for getters/setters/toString
public class Product {

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

    @Column(nullable = false)
    private String name;

    private Double price;

    private boolean inStock;
}

3. The Repository Interface

This is where the magic happens. You don’t need to write implementation classes.
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Magic Method: Spring generates the SQL automatically!
    // SELECT * FROM products WHERE in_stock = ?
    List<Product> findByInStock(boolean inStock);

    // SELECT * FROM products WHERE price < ?
    List<Product> findByPriceLessThan(Double price);
}

4. Service Layer & Transactions

Business logic lives in the Service layer, not the controller.
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional // Database Transaction
    public Product updatePrice(Long id, Double newPrice) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Product not found"));
        
        product.setPrice(newPrice);
        // No need to call save()! 
        // Hibernate 'Dirty Checking' detects the change and issues an UPDATE at the end of the transaction.
        return product;
    }
}

@Transactional Explained

  • Atomicity: Either all operations in the method succeed, or none do.
  • Rollback: If a RuntimeException is thrown, the transaction rolls back automatically.
  • Propagation: If one transactional method calls another, how do they relate? (Default is data joining the existing transaction).

5. H2 Console

When using H2 (in-memory DB), you can view the data in a browser. Add to application.properties:
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:mem:testdb
Access at http://localhost:8080/h2-console.

6. Projections

Sometimes you don’t want the full Entity. You just want a slice of data.
// Interface based projection
public interface ProductNameOnly {
    String getName();
}

// In Repository
List<ProductNameOnly> findByNameStartingWith(String prefix);
Spring Data is smart enough to select only the required columns.

7. The N+1 Query Problem

This is the most common performance killer in Hibernate. Imagine: 1 Author has N Books.
List<Author> authors = authorRepository.findAll(); // 1 Query
for (Author a : authors) {
    System.out.println(a.getBooks().size()); // N Queries (One per author)
}
If you have 1000 authors, you run 1001 queries. Solution: JOIN FETCH Tell Hibernate to fetch everything in ONE query.
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();

8. Concurrency Control (Locking)

What if two users update the same product price at the exact same millisecond? “Lost Update” problem. Add a @Version field.
@Version
private Long version;
Hibernate checks: UPDATE product SET price = 10, version = 2 WHERE id = 1 AND version = 1. If the version doesn’t match (someone else updated it), it throws OptimisticLockException.

Pessimistic Locking

Lock the database row.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdLocked(Long id);

9. Auditing

Keep track of “Who changed what and when” automatically.
  1. Add @EnableJpaAuditing to main class.
  2. Add fields to Entity:
@EntityListeners(AuditingEntityListener.class)
public class Product {
    
    @CreatedDate
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    @CreatedBy
    private String createdBy; // Needs AuditorAware implementation
}

10. Testing with @DataJpaTest

Don’t use the full @SpringBootTest for DB tests (too slow). Use Slice Testing.
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository repo;

    @Test
    void shouldFindInStockProducts() {
        Product p = new Product("Phone", 100.0, true);
        repo.save(p);

        List<Product> found = repo.findByInStock(true);
        assertThat(found).hasSize(1);
    }
}
Note: This usually uses H2 by default. For real Postgres testing, look into Testcontainers.

11. JPA Architecture

12. Deep Dive: Transaction Management

Handling transactions correctly is what separates seniors from juniors.

Propagation Levels (@Transactional(propagation = ...))

LevelDescriptionUse Case
REQUIRED (Default)Join existing transaction. If none, create new.Most business logic.
REQUIRES_NEWSuspend current transaction. Create a brand new independent one.Audit logging (save log even if main logic fails).
MANDATORYMust be called inside a transaction. Else throw Exception.Helper methods that shouldn’t run standalone.
SUPPORTSRun in transaction if exists. Else run non-transactional.Read-only operations.
NOT_SUPPORTEDSuspend current transaction. Run non-transactional.Sending emails/long processes (don’t hold DB lock).
NESTEDCreate a Savepoint within the existing transaction.Complex rollbacks (try sub-task, if fail, rollback only sub-task).

Isolation Levels (@Transactional(isolation = ...))

Defines “how much” one transaction sees of another.
  1. READ_UNCOMMITTED: Dirty Reads allowed. (Dangerous).
  2. READ_COMMITTED: PostgreSQL Default. No Dirty Reads.
  3. REPEATABLE_READ: No Non-Repeatable Reads. (MySQL Default).
  4. SERIALIZABLE: Full locking. Slowest but safest.

Rollback Rules

By default, Spring ONLY rolls back on RuntimeException (Unchecked). It does NOT rollback on CheckedException (e.g., IOException). Fix:
@Transactional(rollbackFor = Exception.class) // Rollback for everything
public void dangerousMethod() throws IOException { ... }

13. High-Performance Caching

Caching is the easiest way to improve performance. Spring provides an abstraction over multiple caching providers.

Enable Caching

@SpringBootApplication
@EnableCaching
public class DemoApplication {}

Basic Usage

@Service
public class ProductService {

    @Cacheable("products") // Cache the result using key = id
    public Product getProduct(Long id) {
        // Expensive DB call
        return productRepository.findById(id).orElseThrow();
    }

    @CacheEvict(value = "products", key = "#id")
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }

    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
}
Annotations:
  • @Cacheable: If key exists in cache, return cached value. Else, execute method and cache the result.
  • @CacheEvict: Remove from cache.
  • @CachePut: Always execute method AND update cache.

Using Redis (Production)

By default, Spring uses ConcurrentHashMap (in-memory). For distributed systems, use Redis. Dependency:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Config:
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379
Spring automatically switches to Redis.

Pitfalls

  1. Serialization Issues: Your cached objects must be Serializable. Use Jackson for JSON serialization.
  2. Cache Stampede: If cache expires, 1000 requests hit DB at once. Use @Cacheable(sync = true) (locks during computation).
  3. Stale Data: Always define a TTL (Time to Live).
@Configuration
public class CacheConfig {
    @Bean
    public RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)); // Expire after 10 min
    }
}