# Jpa Patterns

> When to activate: JPA, Hibernate, entity, relationship, JPQL, Criteria API, N+1 problem, caching, transaction, fetch type, lazy loading

- Skill: `mattakushi432/jpa-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/jpa-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/jpa-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/jpa-patterns

---

# JPA / Hibernate Patterns

## Entity Design

```java
@Entity
@Table(name = "orders",
    indexes = @Index(name = "idx_orders_customer", columnList = "customer_id,created_at"))
@EntityListeners(AuditingEntityListener.class)
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
    @SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status = OrderStatus.PENDING;

    @CreatedDate
    @Column(updatable = false)
    private Instant createdAt;

    @Version
    private Long version;  // optimistic locking

    // Bidirectional helper methods
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }
}
```

## Solving N+1 with JOIN FETCH

```java
// BAD — triggers N+1: one query for orders, N for each customer
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class).getResultList();

// GOOD — single JOIN query
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items WHERE o.status = :status")
List<Order> findWithDetails(@Param("status") OrderStatus status);

// GOOD — @BatchSize for collections
@OneToMany(mappedBy = "order")
@BatchSize(size = 20)
private List<OrderItem> items;

// GOOD — @EntityGraph
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<Order> findById(Long id);
```

## Projections & DTOs

```java
// Interface projection — no entity loaded
public interface OrderSummary {
    Long getId();
    String getCustomerName();
    BigDecimal getTotalAmount();
    OrderStatus getStatus();
}

@Query("SELECT o.id AS id, c.name AS customerName, o.totalAmount AS totalAmount, o.status AS status " +
       "FROM Order o JOIN o.customer c WHERE o.createdAt > :since")
List<OrderSummary> findSummariesSince(@Param("since") Instant since);

// Class projection (constructor expression)
@Query("SELECT new com.example.dto.OrderDto(o.id, c.name, o.totalAmount) FROM Order o JOIN o.customer c")
List<OrderDto> findOrderDtos();
```

## Criteria API (Type-Safe Queries)

```java
public List<Order> search(OrderSearchRequest req) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> query = cb.createQuery(Order.class);
    Root<Order> root = query.from(Order.class);
    root.fetch("customer", JoinType.LEFT);

    List<Predicate> predicates = new ArrayList<>();
    if (req.status() != null) predicates.add(cb.equal(root.get("status"), req.status()));
    if (req.since() != null) predicates.add(cb.greaterThan(root.get("createdAt"), req.since()));
    if (req.minAmount() != null) predicates.add(cb.ge(root.get("totalAmount"), req.minAmount()));

    query.where(predicates.toArray(Predicate[]::new))
         .orderBy(cb.desc(root.get("createdAt")));

    return em.createQuery(query).setMaxResults(req.limit()).getResultList();
}
```

## Transactions

```java
@Service
@Transactional(readOnly = true)
public class OrderService {

    @Transactional  // overrides class-level readOnly
    public Order placeOrder(PlaceOrderRequest req) {
        Order order = new Order();
        order.setCustomer(customerRepo.getReferenceById(req.customerId()));
        req.items().forEach(item -> order.addItem(buildItem(item)));
        return orderRepo.save(order);
    }

    // Requires existing transaction — throws if none
    @Transactional(propagation = Propagation.MANDATORY)
    public void recalculateTotals(Order order) { ... }

    // New independent transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void auditLog(Long orderId, String action) { ... }
}
```

## Second-Level Cache

```java
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product { ... }

@OneToMany
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private Set<Tag> tags;
```

```yaml
spring.jpa.properties:
  hibernate.cache.use_second_level_cache: true
  hibernate.cache.use_query_cache: true
  hibernate.cache.region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
```

## Auditing

```java
@Configuration
@EnableJpaAuditing
public class JpaConfig {}

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
    @CreatedDate @Column(updatable = false) protected Instant createdAt;
    @LastModifiedDate protected Instant updatedAt;
    @CreatedBy @Column(updatable = false) protected String createdBy;
    @LastModifiedBy protected String updatedBy;
}
```

## Key Rules
- Always use `FetchType.LAZY` for associations; eager loading is almost always wrong
- Use `JOIN FETCH` or `@EntityGraph` to load associations when needed — not `FetchType.EAGER`
- Use `getReferenceById` instead of `findById` when you only need a proxy for a foreign key
- `@Version` for optimistic locking — prevents lost updates in concurrent scenarios
- Never call `save()` on an entity already managed by the current session — it's a no-op at best, confusing at worst
- Use sequence generators with `allocationSize > 1` to reduce DB roundtrips for ID generation

