Conventions and best practices for JPA/Hibernate entities and Spring Data JPA repositories in NUS Java applications. Covers entity design, associations, fetching strategy, OSIV, N+1 avoidance, not-found conventions, Lombok pitfalls, and cascade safety.
Scope: JPA/Hibernate entity design, Spring Data JPA repositories, association mapping, fetching strategy, OSIV, N+1 avoidance, not-found conventions, and transaction boundaries.
See also:
- Coding skill (boundary-safe errors + sensitive data):
.github/skills/coding/SKILL.md- Java umbrella skill:
.github/skills/java/SKILL.md- Java REST API skill (API boundary, DTO rules, error mapping):
.github/skills/java/rest-api/SKILL.md- Java Spring Service skill (service-layer transaction boundaries, entity-to-DTO mapping, N+1 orchestration):
.github/skills/java/spring-service/SKILL.md
LAZY vs EAGER).Optional).Optional<T>, @EntityGraph, or @BatchSize wholesale into a codebase that does not already use them.null for not-found results.Account account = accountRepository.findByAccountId(accountId);
if (account == null) {
throw new AccountNotFoundException("Account not found: " + accountId);
}
Optional<T> returns wholesale into a codebase that uses null; follow the existing pattern unless the change is explicitly requested.Optional<T>, continue using it consistently.@Entity.@Id.@GeneratedValue only if the DB generates the PK; if the application assigns the PK, omit it.@Table name SHOULD reflect the DB table; specify @Table(name = "...") explicitly when names diverge from JPA defaults.@Data or @EqualsAndHashCode on JPA entities unless the repo already uses it — @Data generates equals/hashCode based on all fields and a toString that traverses associations, triggering lazy loading and risking StackOverflowError or LazyInitializationException.@ToString on entities unless the repo already uses it; if it is already used, MUST add @ToString.Exclude on all association fields (@OneToMany, @ManyToOne, @ManyToMany, @OneToOne).equals/hashCode manually:
toString manually or via Lombok, MUST exclude association fields to prevent lazy loading and recursion.Example (safe manual pattern):
@Entity
@Table(name = "account")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "display_name", nullable = false)
private String displayName;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
// equals/hashCode based on id only; toString excludes associations
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Account)) return false;
Account other = (Account) o;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Account{id=" + id + ", displayName='" + displayName + "'}";
}
}
JPA specification defaults:
@OneToMany and @ManyToMany default to LAZY.@ManyToOne and @OneToOne default to EAGER.Guidelines:
@OneToMany and @ManyToMany SHOULD declare fetch = FetchType.LAZY explicitly (it is already the default, but explicit is clearer and prevents surprises if the annotation is copied):
@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
private List<Order> orders;
@ManyToOne SHOULD override the EAGER default with fetch = FetchType.LAZY unless the association is always needed alongside the owning entity:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
@OneToOne SHOULD also be declared LAZY where possible; note that Hibernate cannot always lazily load the inverse (mappedBy) side without bytecode enhancement — prefer the owning side or accept EAGER only when the association is small and always used.@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
optional = false tells Hibernate the FK is never null; nullable = false on @JoinColumn tells the DDL generator to create a NOT NULL column.optional = true) and omit nullable = false.CascadeType.REMOVE or CascadeType.ALL on @ManyToOne or @ManyToMany associations, or on any association to a shared/non-owned entity.
@ManyToOne would delete the shared parent when the child is deleted — almost always wrong.@ManyToMany is similarly dangerous for shared entities.CascadeType.PERSIST and CascadeType.MERGE on @OneToMany (owned collection) are generally safe.CascadeType.ALL is only appropriate on @OneToMany / @OneToOne where the child is fully owned by the parent and has no independent lifecycle.// In Account entity
public void addOrder(Order order) {
orders.add(order);
order.setAccount(this);
}
mappedBy side MUST NOT be the owning side; the @JoinColumn side owns the FK.XxxRepository extending JpaRepository<Xxx, ID> or CrudRepository<Xxx, ID>....repository package.Account findByEmail(String email); // returns null if not found (no Optional)
List<Account> findByDepartmentId(Long departmentId);
@Query with JPQL or native SQL:
@Query("SELECT a FROM Account a WHERE a.status = :status AND a.createdAt >= :since")
List<Account> findActiveAccountsSince(@Param("status") AccountStatus status,
@Param("since") Date since);
java.util.Date is common in this org — do not introduce java.time.* types (e.g. LocalDateTime) unless the codebase already uses them.Optional).JOIN FETCH or @EntityGraph to avoid N+1:
@Query("SELECT a FROM Account a JOIN FETCH a.department WHERE a.status = 'ACTIVE'")
List<Account> findActiveWithDepartment();
JOIN FETCH on collections that can return more than one row per root entity without de-duplication (DISTINCT or LinkedHashSet); this causes result set multiplication.@EntityGraph only if the codebase already uses it.spring.jpa.open-in-view=true).spring.jpa.open-in-view=false) and load all required associations in the service layer before returning.| Situation | Recommended approach |
|---|---|
| Single entity by PK | Custom finder (returns null per org convention) or findById (returns Optional) — match repo convention |
| Entity + required association (always needed) | JOIN FETCH in JPQL query |
| Entity + optional association (rarely needed) | Load separately only when needed |
| List of entities + association | JOIN FETCH with DISTINCT, or batch loading |
| Large collections | Use pagination (Pageable) |
@Transactional (or the service class itself).@Transactional(readOnly = true) to signal intent and allow Hibernate optimizations.@Transactional methods from within the same class (bypasses proxy); extract to a separate bean if needed.@Transactional to repository interfaces unless you need to change the propagation or readOnly flag.Pageable and Page<T> for large result sets:
Page<Account> findByStatus(AccountStatus status, Pageable pageable);
When reviewing or generating JPA/Spring Data JPA code, check:
LazyInitializationException risk in the controller or view layer@Transactional is applied at the service layer; repositories do not own transaction boundaries for business operationsOptional, List, Page)JOIN FETCH or @EntityGraph used when associations are always needed together; N+1 queries avoidedPageable; Page<T> or Slice<T> returned@OneToMany / @ManyToMany cascade operations are deliberate; CascadeType.ALL and REMOVE are justified and reviewed@Modifying + @Transactional is present on bulk JPQL update/delete repository methodstoString() methods do not include sensitive fields (NRIC, tokens, passwords); entities are not inadvertently logged in full