Skip to main content

Backend: Package Map and Request Walkthrough

How the Spring Boot backend is laid out and what actually happens to a request, traced against the real code. The system-level picture (containers, auth boundaries, deployment shapes) is in ../architecture.md — this doc starts where that one stops: inside backend/src/main/java/com/motorph/payroll/.

Related deep dives: entities-and-migrations.md (data layer), annotations.md (what each Spring annotation does, for newcomers), scheduling-and-websockets.md, ../erd.md, ../business-rules.md (the payroll math), and ../security/request-pipeline.md.

1. Package map

Classic layered architecture. A request flows controller → service (interface) → service.impl → repository → model, with DTOs at the controller boundary — entities never leave the service layer.

PackageContents (file counts as of this writing)
controller/78 classes: 77 @RestControllers (one per resource, e.g. EmployeeController) plus TypingController, a STOMP @Controller for chat typing events.
service/76 service interfaces. service/impl/ holds 84 implementations (plus pure-math helpers like DailyPayCalculator); service/billing/ is the payment-provider port (billing.md).
repository/82 Spring Data JPA interfaces. repository/specification/ has 39 Specification builders that translate AG-Grid-style filter/sort params into criteria queries.
model/100 classes: 85 @Entitys plus enums (PayFrequency, TimesheetStatus, …) and JPA AttributeConverters.
dto/287 request/response records and classes — the API's wire shapes, with jakarta.validation annotations on request DTOs.
mapper/Exactly two MapStruct mappers — see the note below.
security/18 classes in jwt/, altcha/, ratelimit/, service/ (incl. RefreshTokenService), dto/, utils/ — the whole authn stack (../security/authentication.md).
config/11 @Configuration/startup classes: SecurityConfiguration, WebSocketConfig, WebMvcConfig, seeders and the holiday scheduler (scheduling-and-websockets.md).
constants/PermissionConstants — every permission string used in @PreAuthorize, in one place.
interceptor/AuditInterceptor — writes the audit trail (§5).
mail/The MailService port, its SMTP implementation and the {{token}} template renderer; templates live in resources/mail/ (email.md).
metrics/AuthMetrics — Micrometer counters behind the Grafana auth-security dashboard — and MailMetrics, the only record that an email was attempted.
ats/Resume parsing + candidate match scoring for recruitment (5 classes).
exception/7 classes: GlobalControllerAdvice, the ApiExceptionResponse body, and the domain exceptions mapped in §4.
util/PhHolidayDefaults and RequestUtils (client-IP resolution).

The MapStruct note (read before writing a mapper)

MapStruct is on the classpath, but only two mappers use it: EmployeeMapper and TimesheetMapper. Everywhere else, DTO ↔ entity mapping is hand-written inside the service impl (private toDto(...) / applyRequest(...) methods — see PayrollServiceImpl.toDto for the typical shape). Follow the convention of the module you're touching; don't introduce a third style.

2. Annotated trace: POST /api/employees (create an employee)

Every hop below is a real line of code you can click into.

  1. Servlet filter chain — assembled in SecurityConfiguration: LoginRateLimitFilterAltchaVerificationFilterJwtAuthenticationFilter. The first two only act on the login/register endpoints; the JWT filter validates the Authorization: Bearer access token and populates the SecurityContext with the user's permission names as authorities (resolved by UserDetailsServiceImpl, which walks the role hierarchy and unions inherited permissions).

  2. Authorization — on EmployeeController.create:

    @PostMapping
    @PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_CREATE + "')")
    public ResponseEntity<EmployeeDto> create(@Valid @RequestBody EmployeeCreateRequest request) {
    return ResponseEntity.ok(employeeService.create(request));
    }

    HR_EMPLOYEES_CREATE is the string hr.employees.create. No permission → AccessDeniedException403 via GlobalControllerAdvice (§4).

  3. Validation@Valid runs the jakarta.validation annotations on EmployeeCreateRequest (@NotBlank names, @Pattern status PROBATIONARY|REGULAR|INACTIVE, …). A failure becomes MethodArgumentNotValidException400 with the per-field messages joined into one string.

  4. ServiceEmployeeServiceImpl.create is annotated @Transactional (overriding the class-level @Transactional(readOnly = true) — §6) and does, in order:

    • assertUniqueGovernmentIds(request, null) — checks SSS, PhilHealth, TIN and Pag-IBIG numbers via existsBy… queries and throws ConflictException (409) with a human message, instead of letting the DB unique constraint surface as an opaque 500;
    • employeeMapper.toEntity(request) — MapStruct maps the DTO onto a new Employee (ignoring employeeNumber and position);
    • employee.setPosition(findPosition(request.getPositionId())) — resolves the FK, throwing ResourceNotFoundException (404) if the position doesn't exist;
    • employeeRepository.save(employee) — the INSERT;
    • employeeMapper.toDto(findEmployeeWithPosition(saved.getEmployeeNumber())) — re-reads with the position+department fetch join and maps to EmployeeDto for the response.
  5. After the responseAuditInterceptor logs POST /api/employees to the user_log table (§5).

A full-stack version of this trace (including the React side) lives in ../learn/employees-walkthrough.md.

3. Annotated trace: POST /api/payroll/{id}/generate-payslips

The heart of the system. Endpoint: PayrollController.generatePayslips (@PreAuthorize on payroll.manage), delegating to PayrollServiceImpl.generatePayslips — one @Transactional method, so a failure anywhere rolls back the whole run:

  1. Guards — the run must exist (404), must be in Draft status (400 otherwise), and must not already have payslips (generation is once-only; delete-and-recreate is the correction path — see payroll-regeneration.md).
  2. Employee selection — all employees via EmployeeSpecifications .notDeleted() + .fetchPositionAndDepartment(), then INACTIVE ones are filtered out. No active employees → fail.
  3. Context load — the singleton PayrollSettings row (or its coded defaults), a WithholdingTaxCalculator bound to the configured pay frequency, the PH-holiday calendar and work-suspension dates for the period (fetched with a 7-day lookback so previous-working-day holiday-eligibility checks can see just before the period; when two calendar entries share a date, the regular holiday wins), de-minimis benefit ceilings, and the company's BMBE tax-exemption flag.
  4. validateContributionBracketCoverage — fail loudly, before any writes. For every employee it checks that an SSS, PhilHealth and Pag-IBIG bracket covers their monthly salary; if any lookup would fail it throws one IllegalStateException naming every affected employee, instead of aborting mid-run on whichever employee happens to iterate first.
  5. Per-employee generationcreatePayslipForEmployee(...) computes the full payslip (attendance-derived pay via DailyPayCalculator, premiums, statutory deductions, withholding tax, year-end true-up — all the domain rules with real numbers are in ../business-rules.md), then applyCustomDeductionItems(...) snapshots each applicable custom deduction into payslip_deduction_items and auto-deactivates one-time deductions so they never reapply.
  6. History — every generated payslip gets a payslip_history row (status: null → Generated, with gross/net in the details) via PayslipHistoryService.
  7. Status transition — the run flips Draft → Pending (awaiting approval), payroll_changes records the transition, and the updated PayrollDto is returned. The rest of the lifecycle (decide, updateStatus) is sequenced in the diagram at the end of ../business-rules.md.

4. Error handling: GlobalControllerAdvice

All controllers share one @RestControllerAdvice (GlobalControllerAdvice). Every handler returns the same ApiExceptionResponse body — { message, status, timestamp } (timestamp in Asia/Manila).

HTTPException(s)Typical trigger
400MethodArgumentNotValidException@Valid DTO failure (message = joined field errors)
400IllegalArgumentExceptionDomain rule broken (e.g. generating payslips on a non-Draft run)
400HttpMessageNotReadableExceptionMissing/malformed JSON body
400MethodArgumentTypeMismatchExceptione.g. abc where an id was expected
400PropertyReferenceExceptionUnknown sort/filter property in a grid query
401BadCredentialsExceptionBad login, invalid/reused refresh token
403AccessDeniedExceptionAuthenticated but lacking the @PreAuthorize permission
403DisabledExceptionLogin to an Inactive account
403UnauthorizedTimesheetActionExceptionActing on someone else's timesheet
404ResourceNotFoundExceptionAny findX miss
409ConflictExceptionDuplicate government ID, and similar uniqueness conflicts
422InvalidTimesheetStateExceptionTimesheet state machine violation
423LockedExceptionAccount temporarily locked after repeated failed logins
500Exception (fallback)Anything unhandled

Note that IllegalStateException is not specially mapped — deliberate fail-loud checks like bracket-coverage validation surface as 500s, which is the correct severity for "the reference data is broken".

5. Audit trail: AuditInterceptoruser_log

AuditInterceptor is a HandlerInterceptor registered in WebMvcConfig for /api/** (excluding /api/auth/login, which AuthController audits manually). In afterCompletion it records to the user_log table:

  • every successful (2xx) mutation (POST/PUT/PATCH/DELETE) as "<METHOD> <URI>", attributed to the authenticated user + client IP;
  • every 403 as "DENIED <METHOD> <URI>" — an authenticated caller being refused is exactly the signal an audit trail should capture. 401s are skipped (no principal to attribute).

Logging failures are swallowed with a warning — auditing must never break the request itself.

6. Transaction convention

Service impls declare @Transactional(readOnly = true) at class level and override per write method with plain @Transactional — see EmployeeServiceImpl or PayrollServiceImpl. Reads get the cheaper read-only path by default, and a write method missing its annotation fails fast instead of silently writing outside a transaction.

The deliberate exception: RefreshTokenService has no method-level transactions, and the comment in the file explains why: each save commits via Spring Data's own per-call transactionality, and the reuse-detection in rotate() revokes the whole token family and then throws. Wrapped in one outer transaction, the exception would roll the revocation back — precisely the write that must survive a suspected token theft. Don't "fix" this by adding @Transactional.