Project: A Transactional, Tuned Data Layer
Throughout this tutorial you learned each technique in isolation — @Transactional propagation, isolation levels, optimistic and pessimistic locking, second-level caching, query caching, and profiling. In this capstone lesson you assemble those techniques into a single, cohesive persistence layer for a realistic e-commerce order-processing service. You will see how the pieces interact, why each choice was made, and where the common pitfalls hide when everything runs together under load.
Domain Overview
The service manages two core aggregates: Product (with an inventory count) and Order (with line items). The critical operation is place an order: reserve stock, persist the order, and publish an event — all atomically. Secondary operations include browsing the catalogue and checking order status, which must be fast and must not lock rows unnecessarily.
Design principle: Separate read paths from write paths at the service layer. Reads get @Transactional(readOnly = true); writes get the appropriate propagation and isolation. This single discipline eliminates dozens of accidental performance and locking bugs.
The Entity Layer
Both aggregates use optimistic locking via @Version to handle concurrent orders for the same product without escalating to database-level locks on every read.
// Product.java
package com.example.shop.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
@org.hibernate.annotations.Cache(
usage = org.hibernate.annotations.CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private String name;
private BigDecimal price;
private int stockQuantity;
@Version
private int version; // optimistic locking token
// getters / setters omitted for brevity
public void reserveStock(int qty) {
if (this.stockQuantity < qty) {
throw new InsufficientStockException(sku, qty, stockQuantity);
}
this.stockQuantity -= qty;
}
}
// Order.java
package com.example.shop.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private Instant createdAt;
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<OrderLine> lines = new ArrayList<>();
// factory + domain methods
public static Order create(String customerId) {
Order o = new Order();
o.customerId = customerId;
o.status = OrderStatus.PENDING;
o.createdAt = Instant.now();
return o;
}
public void addLine(Product p, int qty) {
lines.add(OrderLine.of(this, p, qty));
}
}
Keep the @Version field on the aggregate root (Product), not on every child entity. Hibernate increments the version on any dirty state within the aggregate, so you get conflict detection without scattering version columns everywhere.
The Repository Layer
Repositories are Spring Data JPA interfaces. The key design decision is declaring JPQL queries that fetch join the collections needed by a given use case — one query instead of N+1.
// ProductRepository.java
package com.example.shop.repository;
import com.example.shop.domain.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import java.util.List;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Cache-backed catalogue browse — read-only, no lock
@QueryHints(@QueryHint(
name = org.hibernate.jpa.HibernateHints.HINT_CACHEABLE,
value = "true"))
@Query("SELECT p FROM Product p WHERE p.stockQuantity > 0 ORDER BY p.name")
List<Product> findAvailable();
// Optimistic — default; used when placing an order
Optional<Product> findBySku(String sku);
// Pessimistic write — used by the inventory recount job
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(Long id);
}
The Service Layer
The service ties everything together. Note the separation of concerns: the catalogue method is read-only and query-cached; the order placement method is a full read-write transaction with the default REQUIRED propagation.
// OrderService.java
package com.example.shop.service;
import com.example.shop.domain.*;
import com.example.shop.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import java.util.List;
@Service
public class OrderService {
private final ProductRepository productRepo;
private final OrderRepository orderRepo;
private final OrderEventPublisher eventPublisher;
public OrderService(ProductRepository productRepo,
OrderRepository orderRepo,
OrderEventPublisher eventPublisher) {
this.productRepo = productRepo;
this.orderRepo = orderRepo;
this.eventPublisher = eventPublisher;
}
// ---- READ PATH ----------------------------------------
@Transactional(readOnly = true)
public List<Product> getAvailableProducts() {
return productRepo.findAvailable(); // hits L2 + query cache
}
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepo.findWithLines(id) // fetch-join avoids N+1
.orElseThrow(() -> new OrderNotFoundException(id));
}
// ---- WRITE PATH ---------------------------------------
@Transactional(
isolation = Isolation.READ_COMMITTED, // default for MySQL/Postgres
rollbackFor = Exception.class) // roll back on checked too
public Order placeOrder(String customerId, List<OrderRequest> requests) {
Order order = Order.create(customerId);
for (OrderRequest req : requests) {
Product p = productRepo.findBySku(req.sku())
.orElseThrow(() -> new ProductNotFoundException(req.sku()));
p.reserveStock(req.quantity()); // mutates; @Version guards concurrency
order.addLine(p, req.quantity());
}
Order saved = orderRepo.save(order);
// publish AFTER the flush but BEFORE the commit
// if the publish throws, the transaction rolls back
eventPublisher.orderPlaced(saved);
return saved;
}
}
OptimisticLockException under contention: If two requests try to reserve stock for the same SKU concurrently, Hibernate raises OptimisticLockException for the loser. Handle this at the controller or a retry wrapper — never swallow it silently. A common pattern is to catch the exception and return HTTP 409 Conflict so the client can retry.
Wiring Up the Second-Level Cache
Add Caffeine as the Hibernate L2 provider and enable query caching in application.properties:
# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=\
org.hibernate.cache.jcache.JCacheRegionFactory
spring.jpa.properties.hibernate.javax.cache.provider=\
com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider
# TTL and size per region (Caffeine config file or inline)
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=5m
The @Cache annotation on Product registers it with the READ_WRITE strategy — Hibernate acquires a soft lock during writes so readers never see uncommitted data from the cache.
Observability: Detecting Problems Before Users Do
Enable Hibernate statistics and expose them through Actuator so you can monitor cache hit rates and query counts in production:
# application.properties
spring.jpa.properties.hibernate.generate_statistics=true
management.endpoints.web.exposure.include=health,metrics,info
Then query /actuator/metrics/hibernate.second.level.cache.hit.ratio or wire Micrometer to Prometheus. A healthy service shows a cache hit ratio above 0.8 for catalogue reads and zero N+1 queries (query count per request equals a small constant regardless of result-set size).
Add an integration test that asserts the query count. Use the StatisticsService or Datasource-Proxy in your test setup to count SQL statements. If a later refactor accidentally removes a fetch join and introduces an N+1 regression, the test catches it before it reaches production.
Putting It All Together: A Performance Checklist
- Every write path has an explicit
@Transactional with rollbackFor = Exception.class.
- Every read path uses
@Transactional(readOnly = true) — this tells both Hibernate and the database driver to skip dirty-checking and use a read-only connection hint.
- Fetch strategy: all collections are
LAZY by default; use a named JPQL JOIN FETCH in the repository method that needs the collection.
- Concurrency control: optimistic locking (
@Version) for high-read, low-write contention; pessimistic locking only for inventory jobs where serialised access is required.
- Caching: L2 cache for read-heavy reference data (products, categories); query cache only for stable parameterised queries; never cache mutable transactional data.
- Statistics in production: Hibernate stats + Actuator metrics give early warning of cache thrashing or query count spikes.
Summary
A well-designed persistence layer is not a single clever trick — it is a disciplined combination of transactional boundaries, correct isolation, optimistic locking, targeted caching, and continuous observability. The patterns in this project — separate read/write service methods, fetch-join repositories, @Version-guarded aggregates, and L2/query caching backed by statistics — are the baseline that production Spring Boot services are built on. With this foundation in place, you have the tools to build data layers that are both correct under concurrency and fast under load.