jpa-patterns

star 10

JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot.

marvinrichter By marvinrichter schedule Updated 3/10/2026

name: jpa-patterns description: JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot.

JPA/Hibernate Patterns

Use for persistence adapter implementation and performance tuning in Spring Boot.

DDD/Hexagonal context: JPA entities in this skill live in adapter/out/persistence/ — they are persistence models, not domain entities. Domain entities live in domain/model/ and contain zero JPA annotations. A MarketMapper translates between the two. Never expose MarketEntity (JPA) outside the persistence adapter.

When to Activate

  • Designing JPA entities and table mappings
  • Defining relationships (@OneToMany, @ManyToOne, @ManyToMany)
  • Optimizing queries (N+1 prevention, fetch strategies, projections)
  • Configuring transactions, auditing, or soft deletes
  • Setting up pagination, sorting, or custom repository methods
  • Tuning connection pooling (HikariCP) or second-level caching
  • Debugging LazyInitializationException or slow query performance issues

Entity Design

@Entity
@Table(name = "markets", indexes = {
  @Index(name = "idx_markets_slug",       columnList = "slug",             unique = true),
  @Index(name = "idx_markets_status_ts",  columnList = "status, created_at")
})
@EntityListeners(AuditingEntityListener.class)
public class MarketEntity {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 200)
  private String name;

  @Column(nullable = false, unique = true, length = 120)
  private String slug;

  @Enumerated(EnumType.STRING)
  @Column(nullable = false, length = 20)
  private MarketStatus status = MarketStatus.DRAFT;

  @CreatedDate  private Instant createdAt;
  @LastModifiedDate private Instant updatedAt;

  // Always initialize collection fields — prevents NullPointerException
  @OneToMany(mappedBy = "market", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<PositionEntity> positions = new ArrayList<>();
}

Enable auditing:

@Configuration
@EnableJpaAuditing
class JpaConfig {}

Relationships

@OneToMany / @ManyToOne (Bidirectional)

// Parent side
@OneToMany(mappedBy = "market", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PositionEntity> positions = new ArrayList<>();

// Convenience methods to keep both sides in sync
public void addPosition(PositionEntity pos) {
    positions.add(pos);
    pos.setMarket(this);
}

public void removePosition(PositionEntity pos) {
    positions.remove(pos);
    pos.setMarket(null);
}

// Child side
@ManyToOne(fetch = FetchType.LAZY)   // Always LAZY on @ManyToOne
@JoinColumn(name = "market_id", nullable = false)
private MarketEntity market;

@ManyToMany

@Entity
public class UserEntity {
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<RoleEntity> roles = new HashSet<>();
}

// Use Set for @ManyToMany to avoid duplicate rows and better performance

@OneToOne (Shared Primary Key)

@Entity
public class UserProfileEntity {
    @Id
    private Long id;  // Same as UserEntity.id

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private UserEntity user;
}

N+1 Prevention

// N+1 BAD: separate query per market to load positions
List<MarketEntity> markets = repo.findAll();
markets.forEach(m -> m.getPositions().size());  // N additional queries!

// GOOD: fetch join in JPQL
@Query("select m from MarketEntity m left join fetch m.positions where m.status = :status")
List<MarketEntity> findActiveWithPositions(@Param("status") MarketStatus status);

// GOOD: @EntityGraph for selective loading
@EntityGraph(attributePaths = {"positions", "categories"})
List<MarketEntity> findByStatus(MarketStatus status);

// GOOD: DTO projection — no entity loading at all
@Query("""
    select new com.example.MarketSummaryDto(m.id, m.name, m.status, count(p))
    from MarketEntity m left join m.positions p
    where m.status = :status
    group by m.id, m.name, m.status
""")
List<MarketSummaryDto> findSummaries(@Param("status") MarketStatus status);

Repository Patterns

Spring Data Query Methods

public interface MarketRepository extends JpaRepository<MarketEntity, Long> {
    // Derived queries
    Optional<MarketEntity> findBySlug(String slug);
    List<MarketEntity> findByStatusOrderByCreatedAtDesc(MarketStatus status);
    long countByStatus(MarketStatus status);
    boolean existsBySlug(String slug);

    // JPQL — prefer over native SQL for portability
    @Query("select m from MarketEntity m where m.status = :status")
    Page<MarketEntity> findByStatus(@Param("status") MarketStatus status, Pageable pageable);

    // Bulk update — bypass entity loading
    @Modifying
    @Query("update MarketEntity m set m.status = :status where m.id in :ids")
    int updateStatusForIds(@Param("status") MarketStatus status, @Param("ids") List<Long> ids);
}

Interface-Based Projections

// Column projections — only fetch what you need
public interface MarketSummary {
    Long getId();
    String getName();
    MarketStatus getStatus();
    Instant getCreatedAt();
}

Page<MarketSummary> findAllProjectedBy(Pageable pageable);

Record-Based Projections (Java 16+)

public record MarketDto(Long id, String name, MarketStatus status) {}

@Query("select new com.example.MarketDto(m.id, m.name, m.status) from MarketEntity m")
List<MarketDto> findAllAsDto();

Transactions

// Service layer: @Transactional on methods
@Service
@Transactional(readOnly = true)  // Default readOnly for all methods
public class MarketQueryService {

    public Page<MarketSummary> findAll(Pageable pageable) {
        return repo.findAllProjectedBy(pageable);
    }

    @Transactional  // Override for write methods
    public Market updateStatus(Long id, MarketStatus newStatus) {
        MarketEntity entity = repo.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Market " + id));
        entity.setStatus(newStatus);  // Dirty checking — no explicit save() needed
        return mapper.toDomain(entity);
    }

    @Transactional
    public void deleteById(Long id) {
        if (!repo.existsById(id)) throw new EntityNotFoundException("Market " + id);
        repo.deleteById(id);
    }
}

Transaction Propagation

// REQUIRED (default): join existing or create new
@Transactional(propagation = Propagation.REQUIRED)

// REQUIRES_NEW: always create new (independent transaction)
// Use for audit logging that must persist even if outer tx rolls back
@Transactional(propagation = Propagation.REQUIRES_NEW)

// NOT_SUPPORTED: suspend outer transaction
@Transactional(propagation = Propagation.NOT_SUPPORTED)

Optimistic Locking

Prevent lost updates in concurrent writes:

@Entity
public class MarketEntity {
    @Version
    private Long version;  // Hibernate increments on every update
    // Throws OptimisticLockException if stale version detected
}

Soft Deletes

@Entity
@SQLDelete(sql = "UPDATE markets SET deleted_at = now() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")  // Hibernate 6.3+; was @Where in older versions
public class MarketEntity {
    @Column(name = "deleted_at")
    private Instant deletedAt;
}

// repo.delete(entity)  → sets deleted_at, doesn't physically delete
// repo.findAll()       → automatically filters out deleted rows

Pagination

// Offset pagination
PageRequest page = PageRequest.of(pageNumber, pageSize, Sort.by("createdAt").descending());
Page<MarketEntity> result = repo.findByStatus(MarketStatus.ACTIVE, page);

// Page response metadata
result.getTotalElements();
result.getTotalPages();
result.hasNext();

// Keyset/cursor pagination for deep pages (more efficient)
@Query("""
    select m from MarketEntity m
    where m.status = :status
      and (m.createdAt < :cursor or (m.createdAt = :cursor and m.id < :lastId))
    order by m.createdAt desc, m.id desc
""")
List<MarketEntity> findPage(
    @Param("status") MarketStatus status,
    @Param("cursor") Instant cursor,
    @Param("lastId") Long lastId,
    Pageable pageable
);

Batch Operations

// Batch insert
@Transactional
public void bulkCreate(List<MarketEntity> entities) {
    repo.saveAll(entities);  // With hibernate.jdbc.batch_size=50
}

// Efficient bulk update — no entity loading
@Transactional
public void deactivateExpired(Instant cutoff) {
    int updated = repo.updateStatusForIds(MarketStatus.CLOSED, findExpiredIds(cutoff));
    log.info("Deactivated {} expired markets", updated);
}

Indexing and Performance

// Composite index matching common query patterns
@Table(name = "markets", indexes = {
    @Index(columnList = "status"),                     // filter by status
    @Index(columnList = "status, created_at DESC"),    // status + sort by date
    @Index(columnList = "user_id, status"),            // per-user filtered list
    @Index(columnList = "slug", unique = true),        // slug lookup
})

Config for N+1 detection in development:

# application-dev.yml
spring.jpa.properties:
  hibernate.generate_statistics: true
logging.level:
  org.hibernate.SQL: DEBUG
  org.hibernate.orm.jdbc.bind: TRACE

Connection Pooling (HikariCP)

spring.datasource.hikari:
  maximum-pool-size: 20      # cpu_cores * 2 + disk_spindles (rule of thumb)
  minimum-idle: 5
  connection-timeout: 30000  # 30s — fail fast
  idle-timeout: 600000       # 10m
  max-lifetime: 1800000      # 30m — must be < DB timeout
  validation-timeout: 5000
  # PostgreSQL specific
  data-source-properties:
    applicationName: my-service

# PostgreSQL LOB handling
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation: true

Migrations

  • Use Flyway or Liquibase; never rely on hibernate.ddl-auto=create in production
  • Name migrations: V{version}__{description}.sql (e.g., V002__add_market_slug_index.sql)
  • Migrations are additive — never rename/drop columns without a plan
  • Test migrations in CI against a real database (Testcontainers)
spring.flyway:
  enabled: true
  locations: classpath:db/migration
  baseline-on-migrate: true   # For existing databases

Testing Data Access

  • Use @DataJpaTest with Testcontainers (@ServiceConnection)
  • Assert N+1 with Hibernate statistics in tests
  • Use TestEntityManager.flush() to flush before querying

Remember: JPA entities are persistence adapters — keep them separate from domain entities. Keep queries intentional, transactions short, and N+1 eliminated. Index for your actual read/write paths.

For domain entity modeling (Aggregates, Value Objects), see skill: ddd-java.

Install via CLI
npx skills add https://github.com/marvinrichter/clarc --skill jpa-patterns
Repository Details
star Stars 10
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
marvinrichter
marvinrichter Explore all skills →