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.