Persistence with Spring Data JPA

Java objects that map to database rows, and interfaces that become working repositories
Big idea: An entity is a class whose instances correspond to rows in a table. A repository is an interface you declare; Spring Data generates the implementation at startup. You get CRUD for free, derive simple queries from method names, and write JPQL with @Query for anything more involved.

What You Will Learn

  • Mapping annotations: @Entity, @Table, @Id, @GeneratedValue, @Column, @Enumerated.
  • Associations with @ManyToOne and the meaning of FetchType.LAZY vs EAGER.
  • JpaRepository<T, ID> and its built-in methods.
  • Derived query methods from method names.
  • @Query (JPQL), @Param, and Pageable / PageRequest.
  • Interface projections for aggregate reports.
  • The persistence context, dirty checking, and ddl-auto: validate.

Chapter Structure

3.1 Entities3.2 Associations & fetch3.3 Repositories3.4 Derived queries3.5 @Query (JPQL)3.6 Paging3.7 Projections3.8 Persistence context3.9 Wrap-up

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;
}
AnnotationMeaning
@EntityThis class is managed by JPA / Hibernate.
@Table(name = "patient")Maps to that table (otherwise the class name is used).
@IdThe 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 = falseJPA reads this column but never writes it — created_at / updated_at are managed by database defaults / triggers.
Lombok on entities: the project uses @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.LAZYFetchType.EAGER
When the associated row is loadedOn first access of the fieldImmediately, with the owner
Default for @ManyToOneYes (JPA spec)
RiskLazyInitializationException if accessed after the session closesN+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.

The classic trap: returning an entity from a controller and letting Jackson touch a lazy field after the transaction ended → 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)RevokedTokenRepositorySELECT 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.

Guideline: derived methods are great up to about two or three conditions. Past that, the name becomes unreadable — switch to @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 p is the entity, p.firstName is a field — Hibernate translates to the real column.
  • :search is 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 passes null when 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.

Modifying queries: an 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 calls save() — but even without save() 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 code

The 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
ValueBehaviour at startup
noneDo nothing.
validate (this project)Check every entity maps to an existing table/column; fail fast otherwise. Schema is owned by migrations, not Hibernate.
updateAlter the schema to fit the entities. Handy in dev, risky elsewhere.
create / create-dropRecreate the schema (and drop it on shutdown). Tests only.
Why 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

  1. Leaving @ManyToOne on its EAGER default and loading half the graph.
  2. Accessing a lazy association after the transaction closed → LazyInitializationException.
  3. @Enumerated left as ORDINAL; reordering the enum later silently corrupts data.
  4. Putting @Data on an entity (broken equals/hashCode with lazy proxies and null ids).
  5. Writing an ever-growing derived method name instead of a @Query.
  6. Forgetting @Param names line up with :placeholders.
  7. Expecting ddl-auto: validate to create tables — it only checks them.
  8. N+1: iterating a list of entities and touching a lazy field on each; fetch with a join or a projection instead.

Revision Questions

  1. What does each of @Entity, @Id, @GeneratedValue, @Column do?
  2. Why @Enumerated(EnumType.STRING) and not the default?
  3. Explain LAZY vs EAGER and one risk of each.
  4. Which entity keeps an EAGER association in this project, and why is that justified?
  5. List five methods you get from JpaRepository for free.
  6. Translate findFirstByAppointmentIdOrderByIdDesc into words.
  7. What is JPQL written against, and how does :search get its value?
  8. What problem do interface projections solve versus List<Object[]>?
  9. Why does createPatient call save() twice?
  10. What does ddl-auto: validate guarantee, and what does it deliberately not do?

Practice

1. Derive a method. Add countByStatus(String status) to AppointmentRepository and use it somewhere sensible.
2. Projection. Write a @Query + interface projection that returns each doctor's name and their number of completed visits.
3. Paging. Change a "find all" repository call in any list endpoint to accept a 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.

Next chapter: the layer between controller and repository — services, business rules, entity↔DTO mapping and @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.