JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
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 indomain/model/and contain zero JPA annotations. AMarketMappertranslates between the two. Never exposeMarketEntity(JPA) outside the persistence adapter.
@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 {}
// 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;
@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
@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 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);
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);
}
// Column projections — only fetch what you need
public interface MarketSummary {
Long getId();
String getName();
MarketStatus getStatus();
Instant getCreatedAt();
}
Page<MarketSummary> findAllProjectedBy(Pageable pageable);
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();
// 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);
}
}
// 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)
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
}
@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
// 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 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);
}
// 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
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
hibernate.ddl-auto=create in productionV{version}__{description}.sql (e.g., V002__add_market_slug_index.sql)spring.flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true # For existing databases
@DataJpaTest with Testcontainers (@ServiceConnection)TestEntityManager.flush() to flush before queryingRemember: 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.