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 validate or none for ddl-auto in production
  • Flyway/Liquibase for all schema changes
  • Service-layer @Transactional boundaries
  • 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.