Persistence with Spring Data JPA
@Query for anything more involved.What You Will Learn
- Mapping annotations:
@Entity,@Table,@Id,@GeneratedValue,@Column,@Enumerated. - Associations with
@ManyToOneand the meaning ofFetchType.LAZYvsEAGER. JpaRepository<T, ID>and its built-in methods.- Derived query methods from method names.
@Query(JPQL),@Param, andPageable/PageRequest.- Interface projections for aggregate reports.
- The persistence context, dirty checking, and
ddl-auto: validate.
Chapter Structure
3.1 Entities
@Entity
@Table(name = "patient")
@Getter
@Setter
public class Patient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "patient_code", nullable = false, unique = true, length = 40)
private String patientCode;
@Column(name = "first_name", nullable = false, length = 80)
private String firstName;
@Enumerated(EnumType.STRING)
private Gender gender;
@Column(name = "dob")
private LocalDate dob;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private LocalDateTime createdAt;
}| Annotation | Meaning |
|---|---|
@Entity | This class is managed by JPA / Hibernate. |
@Table(name = "patient") | Maps to that table (otherwise the class name is used). |
@Id | The primary-key field. |
@GeneratedValue(IDENTITY) | The DB assigns the key (MySQL AUTO_INCREMENT); the value is read back after insert. |
@Column(...) | Column name, plus schema hints like nullable, unique, length. |
@Enumerated(EnumType.STRING) | Store the enum's name ("Male"), not its ordinal number. Always prefer STRING. |
insertable = false, updatable = false | JPA reads this column but never writes it — created_at / updated_at are managed by database defaults / triggers. |
@Getter / @Setter only — deliberately not @Data. @Data generates equals/hashCode/toString over all fields, which interacts badly with lazy associations and generated ids. Keep entity equals/hashCode hand-written or absent.3.2 Associations and Fetch Type
From Appointment:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "patient_id", nullable = false)
private Patient patient;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "doctor_id", nullable = false)
private Doctor doctor;Many appointments point at one patient, so Appointment holds the foreign key patient_id (that is what @JoinColumn names).
FetchType.LAZY | FetchType.EAGER | |
|---|---|---|
| When the associated row is loaded | On first access of the field | Immediately, with the owner |
Default for @ManyToOne | — | Yes (JPA spec) |
| Risk | LazyInitializationException if accessed after the session closes | N+1 queries, loading half the graph you did not need |
This project overrides @ManyToOne to LAZY almost everywhere and then does its association access inside the service method (still within the transaction) while building the response DTO. The one place it keeps EAGER is User.role — every authenticated request needs the role immediately to build authorities, so eager loading is the right call there.
LazyInitializationException. Mapping to a DTO in the service avoids it entirely (Chapter 2 §2.5).3.3 Repositories
public interface PatientRepository extends JpaRepository<Patient, Long> {
boolean existsByPatientCode(String patientCode);
boolean existsByUhid(String uhid);
// + a @Query method, see below
}You write an interface. At startup Spring Data creates a proxy implementing it. Extending JpaRepository<Patient, Long> (entity type, id type) gives you, for free:
save(entity) saveAll(entities)
findById(id) findAll() findAllById(ids)
existsById(id) count()
deleteById(id) delete(entity) deleteAll()findById returns Optional<Patient> — the project consistently turns an empty Optional into a 404:
patientRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Patient not found"));3.4 Derived Query Methods
Spring Data parses the method name into a query. No body, no annotation.
| Method (real, from the project) | Generated query |
|---|---|
findByEmail(String email) — UserRepository | … WHERE email = ?, returns Optional<User> |
existsByJti(String jti) — RevokedTokenRepository | SELECT count(*) > 0 … WHERE jti = ? |
existsByPatientCode(String) | … WHERE patient_code = ? |
findByVisitId(Long visitId) | Follows the visit association to its id |
findByPatientIdOrderByVisitDateDesc(Long) | … WHERE patient_id = ? ORDER BY visit_date DESC |
findFirstByAppointmentIdOrderByIdDesc(Long) | … WHERE appointment_id = ? ORDER BY id DESC LIMIT 1 |
Keywords you can combine: findBy, existsBy, countBy, deleteBy, And, Or, OrderBy…Asc/Desc, First/Top, Between, In, IsNull, Containing, IgnoreCase.
@Query.3.5 @Query — JPQL
JPQL looks like SQL but is written against entities and fields, not tables and columns. The patient search:
@Query("SELECT p FROM Patient p WHERE :search IS NULL OR " +
"LOWER(p.firstName) LIKE LOWER(CONCAT('%', :search, '%')) OR " +
"LOWER(p.lastName) LIKE LOWER(CONCAT('%', :search, '%')) OR " +
"LOWER(p.patientCode) LIKE LOWER(CONCAT('%', :search, '%')) OR " +
"p.mobile LIKE CONCAT('%', :search, '%') OR " +
"p.aadhaarNumber LIKE CONCAT('%', :search, '%') " +
"ORDER BY p.createdAt DESC")
List<Patient> search(@Param("search") String search);Patient pis the entity,p.firstNameis a field — Hibernate translates to the real column.:searchis a named parameter, bound to the argument marked@Param("search").- The
:search IS NULL OR …trick makes one query serve both "list all" and "filter" — the service passesnullwhen the box is empty.
AppointmentRepository shows aggregate JPQL returning scalars:
@Query("SELECT a.appointmentDate, COUNT(a) FROM Appointment a " +
"WHERE a.appointmentDate BETWEEN :from AND :to " +
"AND (:branchId IS NULL OR a.branch.id = :branchId) " +
"GROUP BY a.appointmentDate")
List<Object[]> countByAppointmentDateBetween(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("branchId") Long branchId);a.branch.id walks the association without an explicitJOIN. The result is an untyped List<Object[]> — workable, but the next section is nicer.
UPDATE/DELETE JPQL needs @Modifying and a surrounding transaction. This project instead deletes viadeleteAll(findBy…) in VisitService, which is simpler to reason about even if it issues more statements.3.6 Paging and Limiting
// PrescriptionItemRepository
List<TopMedicineProjection> findTopMedicines(Pageable pageable, Long branchId);
// ReportsService
prescriptionItemRepository.findTopMedicines(PageRequest.of(0, limit), effectiveBranchId);Passing a Pageable lets the caller decide page number, page size and sort. PageRequest.of(0, limit) means "first page, limit rows" — a clean way to express "top N". A method returning Page<T> also runs a count query so the client learns the total; returning List<T> (as here) skips that.
3.7 Interface Projections
When a query returns computed columns rather than whole entities, declare an interface with getters matching the select aliases:
public interface DoctorRevenueProjection {
Long getDoctorId();
BigDecimal getRevenue();
}
// BillRepository
@Query("SELECT b.appointment.doctor.id AS doctorId, SUM(b.totalAmount) AS revenue " +
"FROM Bill b WHERE b.paymentStatus = 'Paid' " +
"AND (:branchId IS NULL OR b.branch.id = :branchId) " +
"GROUP BY b.appointment.doctor.id")
List<DoctorRevenueProjection> sumRevenueGroupByDoctor(@Param("branchId") Long branchId);The AS doctorId / AS revenue aliases line up with getDoctorId() / getRevenue(). Spring Data hands back proxy objects implementing the interface. ReportsService then collects them straight into a Map:
Map<Long, BigDecimal> revenueByDoctor =
billRepository.sumRevenueGroupByDoctor(effectiveBranchId).stream()
.collect(Collectors.toMap(
DoctorRevenueProjection::getDoctorId,
DoctorRevenueProjection::getRevenue));Other examples in the codebase: TopMedicineProjection, DoctorPatientCountProjection, DepartmentVisitCountProjection. This is the preferred alternative to List<Object[]>.
3.8 The Persistence Context
Within a transaction, Hibernate keeps a persistence context (a first-level cache) of the entities it has loaded. Two consequences visible in this project:
- Dirty checking / automatic flush. In
PatientService.updatePatient, the code loads the patient, calls setters, and callssave()— but even withoutsave()the changes would be written, because a managed entity that changed is flushed at commit. - Identity.
findById(1)twice in the same transaction returns the same object instance.
PatientService.createPatient uses this on purpose:
Patient patient = new Patient();
applyRequest(patient, request);
patient.setPatientCode("TMP-" + UUID.randomUUID()...);
patientRepository.save(patient); // INSERT → id is now populated
patient.setPatientCode(generatePatientCode(patient.getId())); // needs the id
patient.setUhid(generateUhid(patient.getId()));
patientRepository.save(patient); // UPDATE with the real codeThe first save exists only to obtain the database-generated id, which the final code and UHID are derived from.
3.8.1 ddl-auto: validate
spring:
jpa:
hibernate:
ddl-auto: validate| Value | Behaviour at startup |
|---|---|
none | Do nothing. |
validate (this project) | Check every entity maps to an existing table/column; fail fast otherwise. Schema is owned by migrations, not Hibernate. |
update | Alter the schema to fit the entities. Handy in dev, risky elsewhere. |
create / create-drop | Recreate the schema (and drop it on shutdown). Tests only. |
validate is the professional default: your entities and your real schema are kept honest with each other, but the database structure is changed only by deliberate, reviewed migration scripts.Quick Concept Map
@Entity → class ↔ table · @Column → field ↔ column
@ManyToOne + @JoinColumn → foreign key; make it LAZY
JpaRepository<T,ID> → free CRUD + paging
Derived methods → query from the method name (keep them short)
@Query → JPQL over entities, @Param for named binds
Pageable / PageRequest → page, size, sort; "top N" = page 0
Interface projection → getters match SELECT … AS alias
Persistence context → identity + dirty checking + flush on commit
ddl-auto: validate → entities must match the migrated schema
Common Mistakes
- Leaving
@ManyToOneon itsEAGERdefault and loading half the graph. - Accessing a lazy association after the transaction closed →
LazyInitializationException. @Enumeratedleft asORDINAL; reordering the enum later silently corrupts data.- Putting
@Dataon an entity (brokenequals/hashCodewith lazy proxies and null ids). - Writing an ever-growing derived method name instead of a
@Query. - Forgetting
@Paramnames line up with:placeholders. - Expecting
ddl-auto: validateto create tables — it only checks them. - N+1: iterating a list of entities and touching a lazy field on each; fetch with a join or a projection instead.
Revision Questions
- What does each of
@Entity,@Id,@GeneratedValue,@Columndo? - Why
@Enumerated(EnumType.STRING)and not the default? - Explain LAZY vs EAGER and one risk of each.
- Which entity keeps an EAGER association in this project, and why is that justified?
- List five methods you get from
JpaRepositoryfor free. - Translate
findFirstByAppointmentIdOrderByIdDescinto words. - What is JPQL written against, and how does
:searchget its value? - What problem do interface projections solve versus
List<Object[]>? - Why does
createPatientcallsave()twice? - What does
ddl-auto: validateguarantee, and what does it deliberately not do?
Practice
countByStatus(String status) to AppointmentRepository and use it somewhere sensible.@Query + interface projection that returns each doctor's name and their number of completed visits.Pageable and return a Page<…>; expose page and size query params in the controller.Chapter Wrap-Up
Entities map classes to tables; associations map foreign keys, and you should almost always make them LAZY. Repositories are interfaces Spring Data implements — CRUD is free, simple queries come from method names, and @Query with JPQL plus interface projections handles reporting. The persistence context gives you identity and dirty checking, and ddl-auto: validate keeps your mappings aligned with a migration-owned schema.
@Transactional.Teaching note: JPQL snippets for the reports repositories are reconstructed to match the method signatures and projections in the hospital-erp source; entity and service excerpts are quoted directly.