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.
| Package | Contents (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.
-
Servlet filter chain — assembled in
SecurityConfiguration:LoginRateLimitFilter→AltchaVerificationFilter→JwtAuthenticationFilter. The first two only act on the login/register endpoints; the JWT filter validates theAuthorization: Beareraccess token and populates theSecurityContextwith the user's permission names as authorities (resolved byUserDetailsServiceImpl, which walks the role hierarchy and unions inherited permissions). -
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_CREATEis the stringhr.employees.create. No permission →AccessDeniedException→ 403 viaGlobalControllerAdvice(§4). -
Validation —
@Validruns thejakarta.validationannotations onEmployeeCreateRequest(@NotBlanknames,@PatternstatusPROBATIONARY|REGULAR|INACTIVE, …). A failure becomesMethodArgumentNotValidException→ 400 with the per-field messages joined into one string. -
Service —
EmployeeServiceImpl.createis 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 viaexistsBy…queries and throwsConflictException(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 newEmployee(ignoringemployeeNumberandposition);employee.setPosition(findPosition(request.getPositionId()))— resolves the FK, throwingResourceNotFoundException(404) if the position doesn't exist;employeeRepository.save(employee)— theINSERT;employeeMapper.toDto(findEmployeeWithPosition(saved.getEmployeeNumber()))— re-reads with the position+department fetch join and maps toEmployeeDtofor the response.
-
After the response —
AuditInterceptorlogsPOST /api/employeesto theuser_logtable (§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:
- Guards — the run must exist (404), must be in
Draftstatus (400 otherwise), and must not already have payslips (generation is once-only; delete-and-recreate is the correction path — see payroll-regeneration.md). - Employee selection — all employees via
EmployeeSpecifications.notDeleted()+.fetchPositionAndDepartment(), thenINACTIVEones are filtered out. No active employees → fail. - Context load — the singleton
PayrollSettingsrow (or its coded defaults), aWithholdingTaxCalculatorbound 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. 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 oneIllegalStateExceptionnaming every affected employee, instead of aborting mid-run on whichever employee happens to iterate first.- Per-employee generation —
createPayslipForEmployee(...)computes the full payslip (attendance-derived pay viaDailyPayCalculator, premiums, statutory deductions, withholding tax, year-end true-up — all the domain rules with real numbers are in ../business-rules.md), thenapplyCustomDeductionItems(...)snapshots each applicable custom deduction intopayslip_deduction_itemsand auto-deactivates one-time deductions so they never reapply. - History — every generated payslip gets a
payslip_historyrow (status: null → Generated, with gross/net in the details) viaPayslipHistoryService. - Status transition — the run flips
Draft → Pending(awaiting approval),payroll_changesrecords the transition, and the updatedPayrollDtois 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).
| HTTP | Exception(s) | Typical trigger |
|---|---|---|
| 400 | MethodArgumentNotValidException | @Valid DTO failure (message = joined field errors) |
| 400 | IllegalArgumentException | Domain rule broken (e.g. generating payslips on a non-Draft run) |
| 400 | HttpMessageNotReadableException | Missing/malformed JSON body |
| 400 | MethodArgumentTypeMismatchException | e.g. abc where an id was expected |
| 400 | PropertyReferenceException | Unknown sort/filter property in a grid query |
| 401 | BadCredentialsException | Bad login, invalid/reused refresh token |
| 403 | AccessDeniedException | Authenticated but lacking the @PreAuthorize permission |
| 403 | DisabledException | Login to an Inactive account |
| 403 | UnauthorizedTimesheetActionException | Acting on someone else's timesheet |
| 404 | ResourceNotFoundException | Any findX miss |
| 409 | ConflictException | Duplicate government ID, and similar uniqueness conflicts |
| 422 | InvalidTimesheetStateException | Timesheet state machine violation |
| 423 | LockedException | Account temporarily locked after repeated failed logins |
| 500 | Exception (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: AuditInterceptor → user_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.