AgentSkillsCN

jpa-rules

JPA实体映射规则:默认不建立关联关系,仅采用单向关联;Join操作应优先使用QueryDSL

SKILL.md
--- frontmatter
name: jpa-rules
description: JPA entity mapping rules - no associations by default, unidirectional only, use QueryDSL for joins
triggers:
  - jpa
  - entity
  - hibernate
  - mapping
argument-hint: ""

JPA & Hibernate Rules

Overview

JPA entity mapping, fetch strategies, and persistence context management rules for this project.

Core principles

Key Principle: Keep entities simple. Extend BaseEntity or BaseTimeEntity. Use LAZY fetching. Avoid associations.

GuidelineDescription
Extend BaseEntity or BaseTimeEntityInherit audit columns
Enum as STRINGAlways @Enumerated(EnumType.STRING), never ORDINAL
LAZY by defaultUse FetchType.LAZY for all associations
No associationsDo not use entity associations by default

BaseEntity vs BaseTimeEntity

ClassFieldsUse Case
BaseTimeEntitycreatedAt, modifiedAtEntities that only need timestamp auditing
BaseEntitycreatedAt, modifiedAt, createdBy, modifiedByEntities that need full auditing (who + when)

Association policy

IMPORTANT: Do not use entity associations by default.

RuleDescription
DefaultDo not map entity associations
ExceptionUnidirectional only, when absolutely necessary
ProhibitedBidirectional associations are strictly forbidden
QueryingUse QueryDSL for joining related data

Correct: No association (default)

kotlin
@Entity
@Table(name = "orders")
class Order(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @Column(nullable = false)
    val userId: Long,  // Store FK as plain ID value

    @Column(nullable = false)
    var totalAmount: BigDecimal,
) : BaseEntity()

Correct: QueryDSL for joins

kotlin
// Use QueryDSL to join related data
fun findOrderWithUser(orderId: Long): OrderWithUserDto? {
    return queryFactory
        .select(
            QOrderWithUserDto(
                order.id,
                order.totalAmount,
                user.name,
                user.email,
            )
        )
        .from(order)
        .join(user).on(order.userId.eq(user.id))
        .where(order.id.eq(orderId))
        .fetchOne()
}

Exception: Unidirectional only (when absolutely necessary)

kotlin
// Unidirectional allowed only when strictly necessary
@Entity
@Table(name = "order_items")
class OrderItem(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    val order: Order,

    @Column(nullable = false)
    var quantity: Int,
) : BaseEntity()

Incorrect: Bidirectional (prohibited)

kotlin
// Bad: Bidirectional associations are prohibited
@Entity
class Order(
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
    val items: MutableList<OrderItem> = mutableListOf(),  // PROHIBITED
)

@Entity
class OrderItem(
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    val order: Order,  // Opposite side of bidirectional
)

Entity structure

Standard entity pattern

kotlin
@Entity
@Table(name = "users")
class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @Column(nullable = false, length = 100)
    var name: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    var status: UserStatus = UserStatus.ACTIVE,
) : BaseEntity()

Entity checklist

AnnotationPurposeRequired
@EntityMark as JPA entityYes
@Table(name = "xxx")Specify table nameYes
extends BaseEntity or BaseTimeEntityInherit audit columnsYes

BaseTimeEntity (timestamps only)

kotlin
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseTimeEntity {

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    lateinit var createdAt: LocalDateTime
        protected set

    @LastModifiedDate
    @Column(name = "modified_at", nullable = false)
    lateinit var modifiedAt: LocalDateTime
        protected set
}

BaseEntity (full auditing: timestamps + author)

kotlin
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity : BaseTimeEntity() {

    @CreatedBy
    @Column(name = "created_by", nullable = false, updatable = false, length = 50)
    lateinit var createdBy: String
        protected set

    @LastModifiedBy
    @Column(name = "modified_by", nullable = false, length = 50)
    lateinit var modifiedBy: String
        protected set
}

Enum mapping

IMPORTANT: Always use @Enumerated(EnumType.STRING) for enum fields. Never use EnumType.ORDINAL — it stores the positional index, which silently corrupts data when enum constants are reordered, inserted, or removed.

kotlin
// Good: STRING — stores the enum constant name
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var status: OrderStatus = OrderStatus.PENDING
// DB stores: "PENDING", "PAID", "SHIPPED" — safe to reorder

// Bad: ORDINAL — stores the positional index
@Enumerated(EnumType.ORDINAL)
@Column(nullable = false)
var status: OrderStatus = OrderStatus.PENDING
// DB stores: 0, 1, 2 — breaks if enum order changes

Note: All categorized domain enums must implement CommonCode. See skill: common-codes for full enum conventions.


Fetch strategies

LAZY vs EAGER

TypeBehaviorRecommendation
LAZYLoad on accessAlways use
EAGERLoad immediatelyNever use

IMPORTANT: Always specify LAZY for @ManyToOne and @OneToOne - the defaults are EAGER.

Solving lazy loading issues

ProblemSolution
LazyInitializationExceptionFetch required data as DTO via QueryDSL
N+1 queriesUse QueryDSL JOIN to fetch in a single query
Need data outside transactionConvert to DTO within the transaction boundary

Locking strategies

TypeUse CaseTrade-off
Optimistic (@Version)Low contention, read-heavyRetries on conflict
Pessimistic (@Lock)High contention, critical sectionsBlocks other transactions

Optimistic locking

kotlin
@Entity
@Table(name = "products")
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    var stock: Int,

    @Version
    var version: Long = 0,
) : BaseEntity()

Pessimistic locking

kotlin
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdForUpdate(id: Long): Product?

Entity state & dirty checking

StateDescriptionTracked by JPA
New/TransientNot yet persistedNo
ManagedIn persistence contextYes (dirty checking)
DetachedOutside transactionNo
RemovedMarked for deletionYes
kotlin
@Transactional
fun updateUserName(userId: Long, newName: String) {
    val user = userRepository.findById(userId)
        ?: throw KnownException(ErrorCode.DATA_NOT_FOUND, "User not found: $userId")
    user.name = newName
    // No save() needed - JPA detects change automatically
}

Configuration

Recommended settings

yaml
spring:
  jpa:
    hibernate:
      ddl-auto: none              # Never auto-generate DDL in production
    properties:
      hibernate:
        default_batch_fetch_size: 500
        order_updates: true
        order_inserts: true
        jdbc:
          batch_size: 500
    open-in-view: false           # Disable OSIV

OSIV (Open Session In View)

OSIVRecommendation
true (default)Prohibited - Holds DB connection too long, hides N+1 issues
falseRecommended - Clear transaction boundaries, fetch only needed data via QueryDSL

Common pitfalls

PitfallProblemSolution
N+1 queries1 + N queries for lazy associationsUse QueryDSL JOIN to fetch in a single query
EAGER anywhereLoads unnecessary dataAlways use LAZY
Bidirectional mappingComplex state managementUnidirectional only; prefer no associations with QueryDSL
@Enumerated(ORDINAL)Breaks if enum order changesAlways use STRING
Missing @VersionLost updates in concurrent scenariosAdd optimistic locking for mutable entities
Large batch without flushOutOfMemoryErrorFlush and clear every N items
OSIV enabledDB connection held during view renderingSet open-in-view: false

Summary checklist

Before submitting code, verify:

  • Entity extends BaseEntity or BaseTimeEntity
  • Enums use @Enumerated(EnumType.STRING)
  • No entity associations used (FK stored as plain ID value)
  • If association is necessary, only unidirectional is used
  • No bidirectional associations exist
  • All associations specify FetchType.LAZY
  • Related data is queried via QueryDSL
  • open-in-view: false is configured