The Service Layer, Business Logic & Transactions

Where the rules live, how objects are mapped, and how @Transactional makes a group of writes all-or-nothing
Big idea: Controllers handle HTTP; repositories handle rows. Everything in between — validation beyond field checks, orchestration across repositories, money arithmetic, entity↔DTO mapping, deciding what counts as a conflict — is the service layer. A service method annotated @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 BigDecimal and RoundingMode.

Chapter Structure

4.1 Why a service layer4.2 Mapping entity ⇄ DTO4.3 Orchestration4.4 @Transactional4.5 Business rules4.6 Money math4.7 Wrap-up

4.1 Why a Service Layer

Compare the two neighbours of PatientService:

PatientController
Bind @RequestBody, call patientService.createPatient(request), wrap in 201. ~4 lines per method.
PatientService
Build the entity, generate patientCode + UHID from the new id, write the activity log, map to PatientResponse.

Keeping logic in the service buys you:

  • ReuseBillService.createOrRefreshBillForAppointment is called both by BillController and from inside VisitService.
  • One transaction boundary — the natural place to put @Transactional is 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());
}
DirectionHelperNote
Request DTO → entityapplyRequest(entity, dto)Mutates an existing entity, so the same method serves create and update.
Entity → Response DTOtoResponse(entity)Runs while the entity is still managed, so touching lazy fields is safe.
Why explicit mapping is fine: it is boring but unambiguous. A field is only copied if a line copies it — no accidental exposure, no reflection surprises. For a project this size, hand-mapping is a reasonable, common choice.

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:

patient·doctor·appointment·visit·bill·prescription·prescriptionItem·labTest

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:

  1. A transaction opens when the method is entered (if one is not already running).
  2. Every repository write joins that transaction — nothing is visible to other connections yet.
  3. Normal return → commit. Everything lands atomically.
  4. A RuntimeException propagates 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

ThrownDefault behaviour
RuntimeException / Error (incl. ResponseStatusException)Rollback
Checked ExceptionCommit (!) 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 updateVisit called this.createVisit(...) directly, the annotation on createVisit would be ignored — the call never leaves the object, so the proxy never sees it. Call through an injected bean instead.
  • Only public methods 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");
RuleStatus chosenReasoning
Unknown email / wrong password / wrong role401Same message for all three — never reveal which part was wrong.
Correct credentials but disabled account403Identity is known; the action is refused.
Visit already exists for appointment409State conflict, not a malformed request.
Patient id not found404 (read) / 400 (as a sub-resource reference)getPatientById → 404; createVisit treats a bad patientId in the body as 400.
This inline style is fine for a small app. The README suggests a @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.3 in binary floating point. BigDecimal is exact base-10.
  • divide can produce a non-terminating expansion — you must pass a scale and a RoundingMode or it throws ArithmeticException.
  • Build BigDecimal from a String (new BigDecimal("100")), not from a double.
  • ReportsService.percentDelta guards against divide-by-zero by returning null when 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 clauseorElseThrow / if … throw new ResponseStatusException(status, msg)

MoneyBigDecimal + explicit scale + RoundingMode

Common Mistakes

  1. Business logic creeping into the controller or the entity.
  2. Multiple writes across repositories with no @Transactional — a mid-way failure leaves orphan rows.
  3. Expecting a rollback from a checked exception without rollbackFor.
  4. Calling an @Transactional method via this. and wondering why nothing is transactional.
  5. @Transactional on a private / package-private method (silently ignored by the default proxy).
  6. Using double for money, or new BigDecimal(0.1).
  7. BigDecimal.divide without a rounding mode → ArithmeticException.
  8. Leaking which login field was wrong by using different messages / statuses.

Revision Questions

  1. Give four benefits of a separate service layer.
  2. Why can one applyRequest helper serve both create and update?
  3. Why is entity → DTO mapping done inside the service and not the controller?
  4. List the repositories createVisit touches and say why they need one transaction.
  5. Exactly when does @Transactional commit vs roll back?
  6. Explain the self-invocation limitation.
  7. Why do the three "bad login" cases share one message and status?
  8. Give two reasons currency uses BigDecimal and one rule for constructing it.

Practice

1. Break the transaction. Temporarily make saveLabTest throw for the second lab test; confirm no visit row remains after the failed request.
2. Read-only. Add @Transactional(readOnly = true) to the ReportsService query methods and note what changes in the SQL log.
3. Extract a rule. Move the four 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.

Next chapter: stopping bad input at the door with Bean Validation, and turning failures into clean error responses.

Teaching note: createVisit is shown trimmed; open visit/service/VisitService.java for the full method.