The Service Layer, Business Logic & Transactions
@Transactional makes a group of writes all-or-nothing@Transactional runs inside one database transaction: every write commits together or none does.What You Will Learn
- Why a distinct service layer exists (separation of concerns).
- The entity ↔ DTO mapping pattern used throughout the project.
- Orchestrating several repositories in one use case (
VisitService.createVisit). @Transactional: atomicity, rollback rules, proxying, read-only.- Domain rules expressed as guard clauses that throw
ResponseStatusException. - Correct money handling with
BigDecimalandRoundingMode.
Chapter Structure
4.1 Why a Service Layer
Compare the two neighbours of PatientService:
Bind
@RequestBody, call patientService.createPatient(request), wrap in 201. ~4 lines per method.Build the entity, generate
patientCode + UHID from the new id, write the activity log, map to PatientResponse.Keeping logic in the service buys you:
- Reuse —
BillService.createOrRefreshBillForAppointmentis called both byBillControllerand from insideVisitService. - One transaction boundary — the natural place to put
@Transactionalis a service method, one per use case. - Testability — a service is a plain bean with constructor-injected mocks; no HTTP, no servlet container.
- Transport independence — the same rules would back a message listener or a scheduled job.
4.2 Mapping Entity ⇄ DTO
The project maps by hand with small private helpers — no MapStruct, no ModelMapper. PatientService:
private void applyRequest(Patient patient, PatientRequest request) {
patient.setFirstName(request.getFirstName());
patient.setLastName(request.getLastName());
patient.setGender(request.getGender());
patient.setDob(request.getDob());
// ... every client-settable field, explicitly
}
private PatientResponse toResponse(Patient p) {
return new PatientResponse(
p.getId(), p.getPatientCode(), p.getUhid(),
p.getFirstName(), p.getLastName(), p.getGender(), p.getDob(),
/* ... */ p.getCreatedAt(), p.getUpdatedAt());
}| Direction | Helper | Note |
|---|---|---|
| Request DTO → entity | applyRequest(entity, dto) | Mutates an existing entity, so the same method serves create and update. |
| Entity → Response DTO | toResponse(entity) | Runs while the entity is still managed, so touching lazy fields is safe. |
4.3 Orchestration: One Use Case, Many Repositories
VisitService.createVisit is the richest method in the codebase. Trimmed:
@Transactional
public VisitResponse createVisit(VisitRequest request) {
Patient patient = patientRepository.findById(request.getPatientId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Invalid patient"));
Doctor doctor = doctorRepository.findById(request.getDoctorId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Invalid doctor"));
Appointment appointment = null;
if (request.getAppointmentId() != null) {
appointment = appointmentRepository.findById(request.getAppointmentId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Invalid appointment"));
if (visitRepository.existsByAppointmentId(request.getAppointmentId()))
throw new ResponseStatusException(CONFLICT, "A visit already exists for this appointment");
}
Visit visit = new Visit();
// ... copy fields, resolve branch from appointment or current user ...
visitRepository.save(visit);
// link an existing unpaid bill to this visit, if any
if (appointment != null) {
billRepository.findFirstByAppointmentIdOrderByIdDesc(appointment.getId())
.filter(bill -> bill.getVisit() == null)
.ifPresent(bill -> { bill.setVisit(visit); billRepository.save(bill); });
}
Prescription prescription = new Prescription();
prescription.setVisit(visit);
prescription.setDoctor(doctor);
prescriptionRepository.save(prescription);
var itemResponses = request.getItems() ... .map(i -> saveItem(prescription, i)).toList();
var labTestResponses = request.getLabTests()... .map(i -> saveLabTest(visit, i)).toList();
activityLogService.log("completed consultation for", patient.getFirstName() + " " + patient.getLastName());
if (!labTestResponses.isEmpty())
activityLogService.log("recommended lab test for", ...);
return toResponse(visit, prescription.getId(), itemResponses, labTestResponses);
}That single call touches eight repositories:
It also writes several rows: a visit, a prescription, N prescription_items, M lab_tests, an updated bill, and activity log entries. They must succeed together. Hence the annotation.
4.4 @Transactional
import org.springframework.transaction.annotation.Transactional;
@Transactional
public VisitResponse createVisit(VisitRequest request) { ... }What it does:
- A transaction opens when the method is entered (if one is not already running).
- Every repository write joins that transaction — nothing is visible to other connections yet.
- Normal return → commit. Everything lands atomically.
- A
RuntimeExceptionpropagates out → rollback. The half-created visit disappears.
So if saveLabTest fails on the third lab test, the visit, the prescription and the first two items are all undone. The client gets an error and a clean database.
4.4.1 Rollback rules
| Thrown | Default behaviour |
|---|---|
RuntimeException / Error (incl. ResponseStatusException) | Rollback |
Checked Exception | Commit (!) unless you add @Transactional(rollbackFor = Exception.class) |
The project's guard clauses throw ResponseStatusException, which is unchecked — so an invalid patient id both returns a 400 and rolls back any work already done in that method.
4.4.2 It is a proxy — two consequences
Spring implements @Transactional by wrapping the bean in a proxy that opens/commits around the call. Therefore:
- Self-invocation does not work. If
updateVisitcalledthis.createVisit(...)directly, the annotation oncreateVisitwould be ignored — the call never leaves the object, so the proxy never sees it. Call through an injected bean instead. - Only
publicmethods are advised by the default proxy.
4.4.3 Read-only
Query-only service methods (getSummary, getDoctorPerformance in ReportsService) could be marked @Transactional(readOnly = true): it hints the JDBC driver, skips Hibernate dirty-checking/flush, and documents intent. A useful habit even where, as here, the methods are left untransacted.
4.5 Business Rules as Guard Clauses
The project expresses rules as early checks that throw with a specific HTTP status. From AuthService.login:
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new ResponseStatusException(UNAUTHORIZED, "Invalid credentials"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword()))
throw new ResponseStatusException(UNAUTHORIZED, "Invalid credentials");
if (!user.getRole().getId().equals(request.getRoleId()))
throw new ResponseStatusException(UNAUTHORIZED, "Invalid credentials");
if (user.getStatus() == UserStatus.Inactive)
throw new ResponseStatusException(FORBIDDEN, "Account is inactive");| Rule | Status chosen | Reasoning |
|---|---|---|
| Unknown email / wrong password / wrong role | 401 | Same message for all three — never reveal which part was wrong. |
| Correct credentials but disabled account | 403 | Identity is known; the action is refused. |
| Visit already exists for appointment | 409 | State conflict, not a malformed request. |
| Patient id not found | 404 (read) / 400 (as a sub-resource reference) | getPatientById → 404; createVisit treats a bad patientId in the body as 400. |
@ControllerAdvice with typed domain exceptions instead — Chapter 5 covers that trade-off.4.6 Money Math with BigDecimal
BillService never uses double for currency. From createOrRefreshBillForAppointment:
private static final BigDecimal HUNDRED = new BigDecimal("100");
BigDecimal subTotal = resolvedConsultationFee.add(resolvedProcedureFee);
BigDecimal discAmt = subTotal.multiply(resolvedDiscountPct)
.divide(HUNDRED, 2, RoundingMode.HALF_UP);
BigDecimal taxable = subTotal.subtract(discAmt);
BigDecimal gstAmt = taxable.multiply(GST_RATE).setScale(2, RoundingMode.HALF_UP);
BigDecimal total = taxable.add(gstAmt);0.1 + 0.2 != 0.3in binary floating point.BigDecimalis exact base-10.dividecan produce a non-terminating expansion — you must pass a scale and aRoundingModeor it throwsArithmeticException.- Build
BigDecimalfrom aString(new BigDecimal("100")), not from adouble. ReportsService.percentDeltaguards against divide-by-zero by returningnullwhen last month's revenue was zero.
Quick Concept Map
Service layer → rules, orchestration, mapping; one method per use case
applyRequest / toResponse → explicit entity ⇄ DTO mapping
@Transactional → all writes commit together or roll back together
Rollback → on RuntimeException; checked exceptions commit by default
Proxy → no self-invocation, public methods only
Guard clause → orElseThrow / if … throw new ResponseStatusException(status, msg)
Money → BigDecimal + explicit scale + RoundingMode
Common Mistakes
- Business logic creeping into the controller or the entity.
- Multiple writes across repositories with no
@Transactional— a mid-way failure leaves orphan rows. - Expecting a rollback from a checked exception without
rollbackFor. - Calling an
@Transactionalmethod viathis.and wondering why nothing is transactional. @Transactionalon aprivate/ package-private method (silently ignored by the default proxy).- Using
doublefor money, ornew BigDecimal(0.1). BigDecimal.dividewithout a rounding mode →ArithmeticException.- Leaking which login field was wrong by using different messages / statuses.
Revision Questions
- Give four benefits of a separate service layer.
- Why can one
applyRequesthelper serve both create and update? - Why is entity → DTO mapping done inside the service and not the controller?
- List the repositories
createVisittouches and say why they need one transaction. - Exactly when does
@Transactionalcommit vs roll back? - Explain the self-invocation limitation.
- Why do the three "bad login" cases share one message and status?
- Give two reasons currency uses
BigDecimaland one rule for constructing it.
Practice
saveLabTest throw for the second lab test; confirm no visit row remains after the failed request.@Transactional(readOnly = true) to the ReportsService query methods and note what changes in the SQL log.login guard clauses into a private validateLogin(User, LoginRequest) method and keep the statuses identical.Chapter Wrap-Up
The service layer is where a request stops being "HTTP" and becomes "what the hospital actually does". It maps DTOs to entities and back, coordinates multiple repositories,and enforces rules with guard clauses that carry the right HTTP status. @Transactional turns a multi-write use case into an atomic unit — remember it works by proxy, rolls back on unchecked exceptions, and must be entered from outside the bean. Money is BigDecimal, always with an explicit rounding mode.
Teaching note: createVisit is shown trimmed; open visit/service/VisitService.java for the full method.