Spring Data & Persistence
Data Access in Spring
Spring offers multiple persistence options:
| Technology | Use case |
|---|---|
| Spring Data JPA | Relational databases with Hibernate ORM |
| Spring Data JDBC | Simple JDBC without ORM overhead |
| Spring Data MongoDB | Document store |
| Spring Data Redis | Caching, key-value |
This page focuses on Spring Data JPA — the most common choice for relational backends.
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>
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=app
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
Use ddl-auto=validate in production — schema changes go through migrations.
Entity Mapping
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String customerEmail;
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Column(name = "created_at")
private Instant createdAt = Instant.now();
protected Order() {} // JPA requires no-arg constructor
public Order(String customerEmail) {
this.customerEmail = customerEmail;
}
public void addItem(String product, int quantity, BigDecimal price) {
items.add(new OrderItem(this, product, quantity, price));
}
// getters
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private String productName;
private int quantity;
private BigDecimal unitPrice;
protected OrderItem() {}
public OrderItem(Order order, String productName, int quantity, BigDecimal unitPrice) {
this.order = order;
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
}
Prefer FetchType.LAZY on associations to avoid loading entire object graphs.
Repository Interface
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerEmail(String email);
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, Instant since);
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
@Query(value = "SELECT COUNT(*) FROM orders WHERE status = :status", nativeQuery = true)
long countByStatusNative(@Param("status") String status);
}
Spring Data generates implementations from method names — no boilerplate CRUD code.
Using Repositories in Services
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public Order createOrder(String email, List<LineItem> lineItems) {
Order order = new Order(email);
lineItems.forEach(li -> order.addItem(li.product(), li.qty(), li.price()));
return orderRepository.save(order);
}
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findByIdWithItems(id)
.orElseThrow(() -> new ResourceNotFoundException("Order", id));
}
}
@Transactional on service methods — not repositories or controllers.
Pagination and Sorting
Page<Order> page = orderRepository.findByStatus(
OrderStatus.PENDING,
PageRequest.of(0, 20, Sort.by("createdAt").descending())
);
// Response
return Map.of(
"content", page.getContent(),
"totalPages", page.getTotalPages(),
"totalElements", page.getTotalElements()
);
DTO Projection
Avoid exposing entities directly — map to DTOs:
public record OrderSummary(Long id, String email, OrderStatus status, Instant createdAt) {
public static OrderSummary from(Order order) {
return new OrderSummary(order.getId(), order.getCustomerEmail(),
order.getStatus(), order.getCreatedAt());
}
}
Or use interface projections:
public interface OrderSummaryProjection {
Long getId();
String getCustomerEmail();
OrderStatus getStatus();
}
List<OrderSummaryProjection> findByStatus(OrderStatus status);
Database Migrations with Flyway
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
-- V1__create_orders.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_email VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_name VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL
);
Flyway runs migrations on startup. Never use ddl-auto=create in production.
N+1 Query Problem
// BAD — N+1: one query for orders, N queries for items
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> o.getItems().size());
// GOOD — fetch join
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
Enable query logging in dev to catch N+1:
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
Caching
@Cacheable("orders")
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Order", id));
}
@CacheEvict(value = "orders", key = "#id")
public void cancelOrder(Long id) { /* ... */ }
Enable with @EnableCaching and a cache provider (Caffeine for local, Redis for distributed).
Testing Data Layer
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository repository;
@Test
void findsByEmail() {
repository.save(new Order("[email protected]"));
List<Order> found = repository.findByCustomerEmail("[email protected]");
assertEquals(1, found.size());
}
}
@DataJpaTest uses an in-memory H2 database by default.
Best Practices
- Use
validateornoneforddl-autoin production - Flyway/Liquibase for all schema changes
- Service-layer
@Transactionalboundaries - DTOs at API boundaries — never expose lazy-loaded entities
- Index columns used in
findBy*queries - Monitor slow queries with datasource proxy or p6spy
Spring Data eliminates boilerplate persistence code while keeping full access to JPQL and native SQL when needed.