Spring Boot Skills
GitHub
Data & Persistence

Spring Data JPA

Use when generating or refactoring Spring Boot 4 JPA entities, repositories, queries, projections, persistence tests, entity relationships, embeddables, IDs, or Hibernate mappings. Covers Jakarta Persistence 3.2 imports, Hibernate 7 entity modeling, new-state detection, N+1 prevention, projections, keyset pagination, batch writes, and common agent mistakes.

View SKILL.md

Spring Boot 4 manages Jakarta Persistence 3.2, Jakarta Validation 3.1, and Hibernate ORM 7.x. Use Boot dependency management and import jakarta.persistence.* / jakarta.validation.*. Do not add explicit Hibernate, JPA, or Validator versions unless the project has a deliberate override policy.

Entity Model Rules

Use an @Entity only for persistent state with identity and lifecycle. Use records for DTOs, commands, and read models. Use @Embeddable for values stored inside an entity table.

@Entity
@Table(name = "orders", indexes = {
    @Index(name = "idx_orders_customer_id", columnList = "customer_id"),
    @Index(name = "idx_orders_status_created", columnList = "status, created_at")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(nullable = false, updatable = false)
    private UUID id;

    @Version
    private Long version;

    @Column(name = "customer_id", nullable = false, updatable = false)
    private UUID customerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private OrderStatus status;

    @Embedded
    private Money total;

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

    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    public static Order create(UUID customerId) {
        Order order = new Order();
        order.customerId = Objects.requireNonNull(customerId);
        order.status = OrderStatus.DRAFT;
        order.total = Money.zero("EUR");
        return order;
    }

    public void addItem(UUID productId, int quantity, Money unitPrice) {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Cannot edit submitted order");
        }
        items.add(OrderItem.create(this, productId, quantity, unitPrice));
        recalculateTotal();
    }

    private void recalculateTotal() {
        total = items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.zero("EUR"), Money::add);
    }
}

Rules:

  • Use jakarta.persistence.*, never javax.persistence.*.
  • Keep entities non-final with a protected no-arg constructor so Hibernate can instantiate/proxy them.
  • Do not use Java records for ordinary entities. Records are good DTOs and sometimes embeddables.
  • Use targeted Lombok (@Getter, protected @NoArgsConstructor), not @Data or broad @Setter.
  • Prefer behavior methods and static factories over public setters/constructors.
  • Initialize collections inline. JPA collection fields should not be null.
  • Use @Enumerated(EnumType.STRING) with explicit column length. Never use ORDINAL.
  • Add @Version Long version for user-editable aggregates. Use wrapper Long, not primitive long.
  • Prefer UUID or pooled sequence IDs. Avoid GenerationType.IDENTITY on high-write tables because it disables insert batching.
  • Validate request DTOs at the boundary; enforce entity invariants inside behavior methods.

Embeddables and DTOs

@Embeddable
public record Money(
    @Column(name = "amount", nullable = false, precision = 19, scale = 2)
    BigDecimal amount,

    @Column(name = "currency", nullable = false, length = 3)
    String currency
) {
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
    }

    public static Money zero(String currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(amount.add(other.amount), currency);
    }

    public Money multiply(int quantity) {
        if (quantity < 1) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
}

Never expose entities from controllers. Map entities to response records:

public record OrderResponse(UUID id, String status, BigDecimal total, Instant createdAt) {
    static OrderResponse from(Order order) {
        return new OrderResponse(
            order.getId(),
            order.getStatus().name(),
            order.getTotal().amount(),
            order.getCreatedAt());
    }
}

Relationships

Map the database shape first. Prefer normal foreign keys: @ManyToOne on the owning side and @OneToMany(mappedBy = ...) only when parent-to-child navigation is actually needed.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false, foreignKey = @ForeignKey(name = "fk_order_item_order"))
    private Order order;

    @Column(name = "product_id", nullable = false, updatable = false)
    private UUID productId;

    private int quantity;
    private Money unitPrice;

    static OrderItem create(Order order, UUID productId, int quantity, Money unitPrice) {
        OrderItem item = new OrderItem();
        item.order = Objects.requireNonNull(order);
        item.productId = Objects.requireNonNull(productId);
        item.quantity = quantity;
        item.unitPrice = Objects.requireNonNull(unitPrice);
        return item;
    }

    Money subtotal() {
        return unitPrice.multiply(quantity);
    }
}
  • Put fetch = FetchType.LAZY on @ManyToOne and @OneToOne; to-one mappings are eager by default.
  • Avoid unbounded bidirectional graphs. Add back-references only when required.
  • Use orphanRemoval = true only when the parent truly owns the child's lifecycle.
  • Avoid @ManyToMany for business relationships with attributes; model the join row as an entity.
  • Do not serialize lazy relationships to JSON. Map to DTOs inside a transaction.

equals and hashCode

Do not generate entity equality with Lombok @Data. It includes mutable fields and associations, which can trigger lazy loading, recursion, and hash changes.

Preferred options:

  • If the entity has a stable natural key, base equality on that key and enforce a unique database constraint.
  • If it only has a generated ID, keep default object identity unless the project already has a proxy-safe generated-ID pattern.
  • Never include collections, mutable fields, or associations in equals, hashCode, or toString.
  • Use instanceof, not getClass(), when equality must work with Hibernate proxies.
@Override
public boolean equals(Object other) {
    return other instanceof Customer that
        && email != null
        && email.equals(that.getEmail());
}

@Override
public int hashCode() {
    return email == null ? 0 : email.hashCode();
}

Repositories and Query Patterns

public interface OrderRepository extends JpaRepository<Order, UUID> {

    boolean existsByCustomerIdAndStatus(UUID customerId, OrderStatus status);

    Optional<Order> findByIdAndCustomerId(UUID id, UUID customerId);

    @EntityGraph(attributePaths = {"items"})
    Optional<Order> findById(UUID id);

    @Query("""
        select o
        from Order o
        where o.status = :status
        order by o.createdAt desc, o.id desc
        """)
    List<Order> findRecentByStatus(OrderStatus status, Limit limit);
}

Use:

  • Derived queries for simple filters.
  • @Query for explicit joins, keyset pagination, and complex predicates.
  • @EntityGraph for bounded graph loading.
  • Projections for read-only API views.
  • exists... queries instead of find...().isPresent() checks.

Avoid:

  • findAll() in endpoints.
  • Native SQL unless JPQL cannot express the query or the database-specific feature is intentional.
  • Returning entities for read-only list views when a projection is enough.

N+1 Prevention

Identify N+1 by looking for lazy association access inside loops or JSON serialization of entities.

@EntityGraph(attributePaths = {"items"}) // OrderItem holds a productId, not a Product association
Optional<Order> findWithItemsById(UUID id);

public interface OrderSummary {
    UUID getId();
    UUID getCustomerId();
    OrderStatus getStatus();
    Instant getCreatedAt();
}

List<OrderSummary> findByStatus(OrderStatus status);

Use fetch joins and entity graphs only for bounded relationships. For list endpoints, prefer projections to avoid loading entire aggregate graphs.

Pagination

Use Pageable for normal list screens:

Page<Order> findByStatus(OrderStatus status, Pageable pageable);

Use keyset pagination for deep or infinite-scroll lists. OFFSET pagination scans and discards skipped rows.

@Query("""
    select o
    from Order o
    where o.status = :status
      and (o.createdAt < :lastCreatedAt
           or (o.createdAt = :lastCreatedAt and o.id < :lastId))
    order by o.createdAt desc, o.id desc
    """)
List<Order> findNextPage(OrderStatus status, Instant lastCreatedAt, UUID lastId, Limit limit);

The (createdAt, id) tuple keeps the cursor stable when timestamps collide. Back it with an index like (status, created_at desc, id desc).

Batch Writes

Enable JDBC batching for write-heavy workloads:

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

GenerationType.IDENTITY disables insert batching because Hibernate needs the generated key after each row. Use UUIDs or pooled sequences when batch insert throughput matters.

New-State Detection

Spring Data JPA detects new entities by nullable wrapper @Version first, then nullable ID. A primitive version cannot be used because JPA treats 0 as the first persisted version.

For manually assigned IDs, add @Version Long version or implement Persistable with an isNew flag cleared by @PostPersist and @PostLoad. Use the template in templates/BaseAssignedIdEntity.java.

Gotchas

  • Agent imports javax.persistence.* - Boot 4 uses jakarta.persistence.*.
  • Agent creates entity records - use records for DTOs/embeddables, not ordinary entities.
  • Agent puts @Data on entities - generates setters and unsafe equality; use targeted @Getter.
  • Agent makes entities final or constructors private - breaks Hibernate proxy/instantiation.
  • Agent uses FetchType.EAGER - use LAZY on to-one and many-to-many relationships.
  • Agent uses @Enumerated(EnumType.ORDINAL) - use STRING.
  • Agent uses primitive long version - use nullable wrapper Long.
  • Agent omits @Version on editable aggregates - lost updates are not detected.
  • Agent returns entities from controllers - map to DTO records.
  • Agent calls findAll() for list endpoints - require Pageable, Limit, or a projection query.
  • Agent uses OFFSET pagination on huge tables - switch to keyset for deep pages.
  • Agent includes lazy associations in equality or toString - causes lazy loads and recursion.
  • Agent maps every relationship bidirectionally - add back-references only when required.
  • Agent uses @ManyToMany for business links with attributes - model the join row as an entity.
  • Agent batches inserts with GenerationType.IDENTITY - batching is silently off; use UUID/sequence.
FROM THE REPOSITORY

Examples & templates

bad-entity.javaexamples
Source on GitHub
// BAD - common Boot 4 JPA mistakes.

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@Data
public final class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private long version;

    private String customerEmail;

    @Enumerated(EnumType.ORDINAL)
    private OrderStatus status;

    @OneToMany
    private List<OrderItem> items;

    @ManyToOne
    private User user;

    public Order() {
    }
}
good-entity.javaexamples
Source on GitHub
// GOOD - Boot 4 / Hibernate 7: jakarta imports, UUID ID, wrapper @Version,
// STRING enum, LAZY to-one relationship, behavior methods, and no public setters.

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

@Entity
@Table(name = "orders", indexes = {
    @Index(name = "idx_orders_customer_id", columnList = "customer_id"),
    @Index(name = "idx_orders_status_created", columnList = "status, created_at")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(nullable = false, updatable = false)
    private UUID id;

    @Version
    private Long version;

    @Column(name = "customer_id", nullable = false, updatable = false)
    private UUID customerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private OrderStatus status;

    @Embedded
    private Money total;

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "account_id", nullable = false, foreignKey = @ForeignKey(name = "fk_orders_account"))
    private Account account;

    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    public static Order create(UUID customerId, Account account) {
        Order order = new Order();
        order.customerId = Objects.requireNonNull(customerId);
        order.account = Objects.requireNonNull(account);
        order.status = OrderStatus.DRAFT;
        order.total = Money.zero("EUR");
        return order;
    }

    public void addItem(UUID productId, int quantity, Money unitPrice) {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Cannot edit submitted order");
        }
        items.add(OrderItem.create(this, productId, quantity, unitPrice));
    }

    public void submit() {
        if (items.isEmpty()) {
            throw new IllegalStateException("Cannot submit an empty order");
        }
        this.status = OrderStatus.SUBMITTED;
    }
}
BaseAssignedIdEntity.javatemplates
Source on GitHub
// Use only for entities with manually assigned IDs.
// Generated-ID entities usually do not need Persistable.

import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Transient;
import org.springframework.data.domain.Persistable;

@MappedSuperclass
public abstract class BaseAssignedIdEntity<ID> implements Persistable<ID> {

    @Transient
    private boolean isNew = true;

    @Override
    public boolean isNew() {
        return isNew;
    }

    @PostPersist
    @PostLoad
    void markNotNew() {
        this.isNew = false;
    }
}

Verification scope

Compiled sources
No
Runtime checks
Fixture entity checks mappings, versioning and rollback; not every shipped snippet

Coverage describes checked-in fixtures, not a guarantee for every snippet or project.

Coverage matrix & agent evidence
ADD TO YOUR AGENT

Install Boot 4 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skills