Spring Data JPA

Project: A Spring Data JPA Layer

18 min Lesson 10 of 13

Project: A Spring Data JPA Layer

You have spent nine lessons learning the individual pieces of Spring Data JPA — entities, repositories, derived queries, JPQL, projections, pagination, auditing, and transactions. This final lesson puts all of them together in one coherent, production-quality repository layer for a small e-commerce domain. By the end you will have a reference architecture you can adapt to any real project.

The Domain: Order Management

The domain consists of four entities: Customer, Product, Order, and OrderItem. The relationships are straightforward but touch every JPA concept you have learned.

// Customer.java package com.example.shop.domain; import jakarta.persistence.*; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.Instant; import java.util.ArrayList; import java.util.List; @Entity @Table(name = "customers") @EntityListeners(AuditingEntityListener.class) public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; @Column(nullable = false, unique = true, length = 150) private String email; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true) private List<Order> orders = new ArrayList<>(); @CreatedDate @Column(updatable = false) private Instant createdAt; @LastModifiedDate private Instant updatedAt; // standard getters/setters omitted for brevity }
// Order.java @Entity @Table(name = "orders") @EntityListeners(AuditingEntityListener.class) public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id") private Customer customer; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private OrderStatus status; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>(); @CreatedDate @Column(updatable = false) private Instant placedAt; @LastModifiedDate private Instant updatedAt; }
Why FetchType.LAZY on every @ManyToOne? The default for @ManyToOne is EAGER, which silently loads the parent every time you load a child — even when you never use it. Switching to LAZY on all associations means Hibernate only hits the database when you actually traverse the relationship. This one change can cut query counts in half in a busy application.

Repository Interfaces

Each aggregate root gets its own repository. Notice how the interfaces combine techniques from across this tutorial: derived queries, @Query, projections, and Pageable.

// CustomerRepository.java package com.example.shop.repository; import com.example.shop.domain.Customer; import com.example.shop.dto.CustomerSummary; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Optional; public interface CustomerRepository extends JpaRepository<Customer, Long> { Optional<Customer> findByEmail(String email); boolean existsByEmail(String email); // Projection-based search — only fetches id + name + email from DB Page<CustomerSummary> findBy(Pageable pageable); // Fetch customer together with all orders in a single query (avoids N+1) @Query("SELECT c FROM Customer c LEFT JOIN FETCH c.orders WHERE c.id = :id") Optional<Customer> findByIdWithOrders(@Param("id") Long id); }
// CustomerSummary.java (closed projection interface) package com.example.shop.dto; public interface CustomerSummary { Long getId(); String getName(); String getEmail(); }
// OrderRepository.java package com.example.shop.repository; import com.example.shop.domain.Order; import com.example.shop.domain.OrderStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.Instant; import java.util.List; public interface OrderRepository extends JpaRepository<Order, Long> { // Derived: all orders for a customer, newest first List<Order> findByCustomerIdOrderByPlacedAtDesc(Long customerId); // Paginated orders by status Page<Order> findByStatus(OrderStatus status, Pageable pageable); // JPQL aggregate query @Query("SELECT COUNT(o) FROM Order o WHERE o.customer.id = :customerId AND o.status = :status") long countByCustomerAndStatus(@Param("customerId") Long customerId, @Param("status") OrderStatus status); // Bulk status update using a modifying query — avoids loading entities into memory @Modifying @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus AND o.placedAt < :before") int cancelStaleDraftOrders(@Param("oldStatus") OrderStatus oldStatus, @Param("newStatus") OrderStatus newStatus, @Param("before") Instant before); }
@Modifying queries bypass the first-level cache. After you call cancelStaleDraftOrders, any Order entities already loaded in the same EntityManager session still show their old status. Either clear the cache (@Modifying(clearAutomatically = true)) or reload entities from the database before relying on the updated values.

The Service Layer and Transaction Boundaries

Repositories are infrastructure; business logic belongs in a service. The service is also where you set the correct transaction boundary for multi-step operations.

// OrderService.java package com.example.shop.service; import com.example.shop.domain.*; import com.example.shop.repository.*; import jakarta.persistence.EntityNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service @Transactional(readOnly = true) // default: all methods read-only public class OrderService { private final OrderRepository orderRepository; private final CustomerRepository customerRepository; private final ProductRepository productRepository; public OrderService(OrderRepository orderRepository, CustomerRepository customerRepository, ProductRepository productRepository) { this.orderRepository = orderRepository; this.customerRepository = customerRepository; this.productRepository = productRepository; } public Order findById(Long id) { return orderRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found")); } @Transactional // overrides class-level readOnly=true — this method writes public Order placeOrder(Long customerId, List<OrderLineRequest> lines) { Customer customer = customerRepository.findById(customerId) .orElseThrow(() -> new EntityNotFoundException("Customer not found")); Order order = new Order(); order.setCustomer(customer); order.setStatus(OrderStatus.PENDING); for (OrderLineRequest line : lines) { Product product = productRepository.findById(line.productId()) .orElseThrow(() -> new EntityNotFoundException("Product not found")); OrderItem item = new OrderItem(); item.setOrder(order); item.setProduct(product); item.setQuantity(line.quantity()); item.setUnitPrice(product.getPrice()); order.getItems().add(item); } return orderRepository.save(order); // Hibernate inserts Order and all OrderItems in one transaction } }
Annotate the class with @Transactional(readOnly = true) and override individual write methods with @Transactional. The readOnly hint tells Hibernate to skip dirty checking during the flush phase and tells many JDBC drivers and connection pools to route the connection to a read replica. You get this performance benefit for free on all query-only methods without writing any extra code.

Enabling Auditing

All four entities use @CreatedDate and @LastModifiedDate. One annotation on your main application class activates the mechanism:

@SpringBootApplication @EnableJpaAuditing public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }

Integration Test: Verifying the Repository Layer

A repository layer without tests is incomplete. Use @DataJpaTest to spin up a slice context with an embedded H2 database — fast, isolated, and accurate.

@DataJpaTest class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Autowired private CustomerRepository customerRepository; @Test void findByCustomerId_returnsMostRecentFirst() { Customer c = new Customer(); c.setName("Alice"); c.setEmail("alice@example.com"); customerRepository.save(c); Order o1 = new Order(); o1.setCustomer(c); o1.setStatus(OrderStatus.PENDING); orderRepository.save(o1); Order o2 = new Order(); o2.setCustomer(c); o2.setStatus(OrderStatus.COMPLETED); orderRepository.save(o2); List<Order> results = orderRepository .findByCustomerIdOrderByPlacedAtDesc(c.getId()); assertThat(results).hasSize(2); assertThat(results.get(0).getStatus()).isEqualTo(OrderStatus.COMPLETED); } }

Architecture Summary

The complete repository layer follows a clear separation of concerns:

  • Entities — pure domain objects annotated with JPA mappings and auditing. No Spring dependencies except @EntityListeners.
  • Repository interfaces — extend JpaRepository; declare derived queries, @Query methods, projections, and modifying updates. No implementation code to write.
  • Service classes — own the transaction boundary, orchestrate multiple repositories, enforce business rules.
  • DTOs and projections — decouple the database shape from the API shape and prevent over-fetching.

Every pattern in this lesson — lazy loading, JOIN FETCH to avoid N+1, readOnly transactions, @Modifying bulk updates, closed projections, and @DataJpaTest slices — is something you will reach for every day in production Spring Boot applications. Keep this project as a reference and adapt the structure to any domain you build.