Spring Data JPA
·Cheat SheetJPA / JakartaHibernate ORMSpring Data
Drop this file into your Next.js app/ directory as page.js — uses Tailwind + JetBrains Mono from Google Fonts.
History
Raw JDBCManual SQL, connection mgmt, field mapping
HibernateORM — auto-map tables; write Java not SQL
JPA SpecStandardised ORM API (Jakarta Persistence)
Spring Data JPAConvenience layer; Repository interfaces auto-generate CRUD
Architecture Stack
·Your CodeEntity classes, Repository calls
↳Spring Data JPARepository interfaces, auto-generated CRUD
↳JPA / JakartaEntityManager, Persistence Context, JPQL
↳HibernateJPA impl, Session, HQL, caching
↳JDBC / DBRaw SQL, connection pool
JPA vs Hibernate
JPA
Portability · Standard API · Vendor-independent · EntityManager · JPQL
Hibernate
Advanced caching · Multi-tenancy · Batch · Session · HQL · more features
Entity lifecycle: New → Managed → Detached → Removed
Entity Annotations
@Entity // class = DB table
@Table(name="employees",
uniqueConstraints=@UniqueConstraint(
columnNames={"email"}))
public class Employee {
@Id // PK
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
@Column(name="full_name", nullable=false, length=100)
private String name;
@Column(unique=true)
private String email;
@Enumerated(EnumType.STRING) // "SENIOR" not 2
private Level level;
@Embedded // inline Address cols
private Address address;
@Column(updatable=false) // written once only
private LocalDateTime createdAt;
@Transient // NOT stored in DB
private String displayLabel;
@Lob @Column(columnDefinition="TEXT")
private String bio; // CLOB/BLOB
}@GeneratedValue Strategies
| IDENTITY | DB auto-increment (MySQL) |
| SEQUENCE | Uses a DB sequence object |
| TABLE | Dedicated table tracks next val |
| AUTO | JPA picks per DB |
@Embeddable / @Embedded
@Embeddable
public class Address {
private String street, city, zip;
} // cols inlined into parent table
// Embed twice → rename cols with:
@Embedded
@AttributeOverrides({
@AttributeOverride(name="street",
column=@Column(name="home_street")),
@AttributeOverride(name="city",
column=@Column(name="home_city"))
})
private Address homeAddress;Validation Annotations
@NotNull@NotBlank@Email@Positive@Min(0)@Size(max=255)@Pattern
Relationships
@OneToOne
class User {
@OneToOne(mappedBy="user", cascade=CascadeType.ALL)
private Profile profile; // no FK here
}
class Profile {
@OneToOne
@JoinColumn(name="user_id") // FK lives here
private User user;
}@OneToMany / @ManyToOne
class User {
@OneToMany(mappedBy="user")
private List<Order> orders;
}
class Order {
@ManyToOne // owns FK
@JoinColumn(name="user_id")
private User user;
}@ManyToMany — pivot table
class Student {
@ManyToMany // Owner — controls join table
@JoinTable(name="student_course",
joinColumns=@JoinColumn(name="student_id"),
inverseJoinColumns=@JoinColumn(name="course_id"))
private Set<Course> courses = new HashSet<>();
}
class Course {
@ManyToMany(mappedBy="courses") // Observer
private Set<Student> students;
}mappedBy — "I don't own FK — look at the other side"
@JoinColumn — This side owns the FK column
No mappedBy — JPA creates two FK columns (bad!)
Bidirectional — Always update BOTH sides in Java code
Cascade & Fetch
| PERSIST | Save parent → auto-save children |
| MERGE | Update parent → auto-update children |
| REMOVE | Delete parent → auto-delete children |
| REFRESH | Resync parent → resync children from DB |
| DETACH | Detach parent → detach children |
| ALL | All above — strong parent-child |
orphanRemoval=true → removed from collection = DELETE
LAZYdefault collections. Load on access.
EAGERdefault single refs. Always loaded.
N+1 Problem
1 query fetches N parents → then N separate queries fire to load each parent's children (LAZY access in a loop). Total: N+1 queries instead of 1 or 2. Destroys performance at scale.
Fixes ↓
// Fix 1: JOIN FETCH in JPQL
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
// Fix 2: EntityGraph (Spring Data)
@EntityGraph(attributePaths={"books"})
List<Author> findAll();
// Fix 3: Batch for large sets
@BatchSize(size=50) private List<Book> books;Rule: default all LAZY, JOIN FETCH only when needed
Repository Hierarchy
·RepositoryMarker interface
↳CrudRepositoryBasic CRUD
↳PagingAndSortingRepository+ Pagination & Sorting
↳JpaRepository+ JPA-specific (most used)
↳JpaSpecificationExecutor+ Dynamic filtering
All JpaRepository Methods
// SAVE
save(entity) // INSERT if id null, else UPDATE
saveAll(iterable) // bulk
saveAndFlush(entity) // save + immediate DB sync
// FIND
findById(id) // Optional<T> — hits DB now
getReferenceById(id) // lazy proxy, SELECT on access
findAll() // ⚠️ no Pageable = full table scan
findAllById(ids) // WHERE id IN (...)
findAll(pageable) // paginated
findAll(sort) // sorted
// CHECK
existsById(id) // boolean
count() // SELECT COUNT(*)
// DELETE
deleteById(id)
delete(entity)
deleteAll() // ⚠️ clears whole table
deleteAllInBatch() // single DELETE — faster
flush() // sync context to DB
getReferenceById → throws EntityNotFoundException on access if missing
Derived Query Methods
// Numeric
List<P> findByPriceLessThan(double p);
List<P> findByPriceBetween(double min, double max);
List<P> findByStockGreaterThanAndCategoryIn(
int s, Collection<String> cats);
// String
List<P> findByNameIgnoreCase(String name);
List<P> findByNameContainingIgnoreCase(String kw);
List<P> findByNameStartingWith(String prefix);
// Null / Boolean / Enum
List<P> findByDescriptionIsNull();
List<P> findByActiveTrue();
List<P> findByStatusIn(Collection<Status> s);
// Date
List<P> findByCreatedAtAfter(LocalDateTime d);
List<P> findByCreatedAtBetween(LocalDateTime f, LocalDateTime t);
// Limiting
Optional<P> findFirstByOrderByPriceAsc();
List<P> findTop5ByOrderByRatingDesc();
// Count / Exists / Delete
long countByCategory(String cat);
boolean existsByEmail(String email);
long deleteByCreatedAtBefore(LocalDateTime d);@Query — JPQL & Native
JPQL (entity class + field names, DB-agnostic)
@Query("SELECT u FROM User u WHERE u.email=:e")
Optional<User> byEmail(@Param("e") String e);
// JOIN FETCH — solves N+1
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.author.id=:id")
List<Post> withComments(@Param("id") Long id);
// Aggregate
@Query("SELECT AVG(p.price) FROM Product p WHERE p.category=:c")
Double avgPrice(@Param("c") String c);
// UPDATE/DELETE — @Modifying required
@Modifying @Transactional
@Query("UPDATE User u SET u.active=false WHERE u.lastLogin<:d")
int deactivateOld(@Param("d") LocalDate d);Native SQL (DB-specific features)
@Query(value="SELECT * FROM users WHERE email LIKE %:d",
nativeQuery=true)
List<User> byDomain(@Param("d") String d);
// Native + Pagination needs countQuery
@Query(value="SELECT * FROM products ORDER BY price DESC",
countQuery="SELECT COUNT(*) FROM products",
nativeQuery=true)
Page<Product> allNative(Pageable p);Projections
Interface Projection
public interface UserSummary {
String getName();
String getEmail();
@Value("#{target.firstName+' '+target.lastName}")
String getFullName(); // computed field
}
List<UserSummary> findByActive(boolean a);DTO / Record Projection
public record UserDTO(String name, String email, long cnt) {}
@Query("""
SELECT new com.example.UserDTO(u.name, u.email, COUNT(p))
FROM User u LEFT JOIN u.posts p GROUP BY u.id""")
List<UserDTO> findUserSummaries();Dynamic — caller picks shape
<T> List<T> findByActive(boolean a, Class<T> type);
repo.findByActive(true, User.class); // full
repo.findByActive(true, UserSummary.class); // slim
repo.findByActive(true, UserDTO.class); // dto
Native + Interface Projection
// alias must match getter name (camelCase)
@Query(value="SELECT p.name, p.category_id AS categoryId FROM products p",
nativeQuery=true)
List<ProductSummary> findActiveSummaries();Pagination & Sorting
// Repository — add Pageable param
Page<User> findByActive(boolean a, Pageable p);
Slice<User> findByDept(String d, Pageable p);
// Build Pageable (0-based pages!)
PageRequest.of(0, 10);
PageRequest.of(2, 10, Sort.by("name"));
// Page<T> metadata
page.getContent() // List<T>
page.getNumber() // current page (0-based)
page.getTotalElements() // total DB rows
page.getTotalPages() // ceil(total/size)
page.isFirst() / isLast() / hasNext()
page.map(UserDTO::fromEntity) // transform in-place
// Sort patterns
Sort.by("name") // ASC default
Sort.by(Direction.DESC, "name")
Sort.by("dept").ascending()
.and(Sort.by("salary").descending()) // chained
// Fine-grained
Sort.by(
Sort.Order.asc("lastName").nullsLast(),
Sort.Order.desc("salary").nullsFirst(),
Sort.Order.asc("name").ignoreCase()
)
// REST controller pattern
@GetMapping("/users")
public Page<UserDTO> list(
@RequestParam(defaultValue="0") int page,
@RequestParam(defaultValue="10") int size,
@RequestParam(defaultValue="id") String sort) {
return repo.findAll(PageRequest.of(page, size,
Sort.by(sort))).map(UserDTO::fromEntity);
}Page2 queries (data + COUNT)
Slice1 query, hasNext only
⚠️ Always whitelist sort fields from HTTP params
Specifications — Dynamic Filtering
Repo extends JpaSpecificationExecutor<Employee>
// 1. Specification class
public class EmployeeSpec {
public static Specification<Employee>
hasDepartment(String dept) {
return (root, query, cb) ->
dept == null ? null
: cb.equal(root.get("department"), dept);
}
public static Specification<Employee>
salaryBetween(Double min, Double max) {
return (root, query, cb) ->
cb.between(root.get("salary"), min, max);
}
}
// 2. Service — compose specs
Specification<Employee> spec =
Specification.where(EmployeeSpec.hasDepartment(dept))
.and(EmployeeSpec.salaryBetween(min, max));
return repo.findAll(spec, pageable);(root, query, cb)
| root | Entry point. root.get("field"), root.join("rel") |
| query | Structure. query.distinct(true), orderBy(…) |
| cb | Predicates. cb.equal, cb.like, cb.between, cb.and, cb.or |
Enables: /employees?department=IT&minSalary=50000
@Transactional — How it Works
Spring AOP proxy intercepts the call:
try {
txManager.begin(); // 1. START TX
yourMethod(); // 2. run code
persistenceCtx.flush(); // 3. sync dirty entities
txManager.commit(); // 4. COMMIT
} catch (RuntimeException e) {
txManager.rollback(); // 5. ROLLBACK
throw e;
}
// ⚠️ Checked exceptions do NOT rollback by default!
@Transactional(rollbackFor=Exception.class)
// ⚠️ Self-invocation BREAKS @Transactional
public void outer() {
inner(); // this.inner() — bypasses proxy!
} // Fix: inject self, or split to two beans@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
readOnly = false,
timeout = 30,
rollbackFor = {CustomException.class},
noRollbackFor= {BusinessWarning.class}
)Propagation Types
| REQUIRED | Join existing TX; create if none (default) |
| REQUIRES_NEW | Always new TX; commits independently — audit logs |
| MANDATORY | Must have existing TX; throws if not |
| NEVER | Throws if inside an existing TX |
| SUPPORTS | Uses TX if present; runs without if not |
| NOT_SUPPORTED | Suspends active TX; runs without one |
readOnly = true Optimisations
| Dirty Checking | Disabled — no snapshots or field comparisons |
| Flush Mode | Set to NEVER — no flush overhead |
| JDBC Driver | Can auto-route to read replicas (MySQL) |
| DB Undo Log | Skipped in PostgreSQL/Oracle — lower lock contention |
Best Practice — readOnly Pattern
@Service
@Transactional(readOnly=true) // class default: reads
public class ProductService {
// Inherits readOnly=true ✅
public Page<Product> findAll(Pageable p) {
return repo.findAll(p);
}
// Override for writes ✅
@Transactional // readOnly=false
public Product save(Product p) {
return repo.save(p);
}
@Transactional
public Product update(Long id, ProductDTO dto) {
Product p = repo.findById(id).orElseThrow();
p.setName(dto.getName());
return p; // no save()! dirty check auto-flushes
}
}Dirty Checking
Managed entities inside a TX are tracked. Field changes auto-flush on commit — no explicit save() needed for updates.
Spring Data JPA · Always LAZY by default, JOIN FETCH when needed · readOnly=true on class, override for writes · Never findAll() without Pageable in production