16 — The run lifecycle: gates, snapshots, and a clock that keeps counting
Read this first: this lesson decodes PayrollServiceImpl — at about 1,160 lines, the densest
file in the backend — by its comment trail only. You will read javadoc, inline comments, method
signatures, and one exception message. Never a method body. That is deliberate: the bodies hold the
arithmetic that belongs to the business-rules reference, while the comments
hold the design decisions, and the comments alone are enough to answer every "why" this lesson
poses. When you eventually do read the bodies, section 3 of
the backend README is the hop-by-hop trace to read them with.
Time: about 30 minutes. Assumes lesson 15.
What a run is, in one sentence
A payroll run is one Payroll row: a pay period (start and end date), a run date, a status
string, and — since one particular migration — a snapshot of the pay frequency it was created
under. Payslips hang off it, one per employee, created by a generation pass you will meet properly
in lesson 17. This lesson is about everything around that pass: what may
be created, what may change state, and what gets measured.
A run remembers its own schedule
The most consequential field on the entity is also the most heavily commented one. From
Payroll.java:
// Pay frequency in force when this run was created. Payslip generation uses this
// rather than the live settings singleton, so switching frequency mid-year doesn't
// reinterpret an older run. NULL on runs created before the column existed; the
// engine falls back to the current settings for those.
@Column(name = "pay_frequency", length = 20)
@Enumerated(EnumType.STRING)
private PayFrequency payFrequency;
The settings singleton from lesson 15 says what the schedule is now. This column says what it was when the run was born — and generation reads the column, not the singleton. The comment at the create site says the same thing from the other side:
// Snapshot the schedule this run was created under, so a later settings change
// can't reinterpret it on regeneration or in year-end reporting.
Predict: a company runs semi-monthly payroll from January to May, then switches the settings to weekly in June. In July, someone regenerates one of January's runs, and year-end reporting later sweeps the whole year. Which frequency do January's runs use?
Their own. Each January run snapshotted SEMI_MONTHLY at creation, so regeneration and reporting
both see the schedule those periods were actually cut under — the June settings change cannot reach
back and reinterpret them. The one exception is honest about history: runs created before the
column existed carry NULL, and for those the engine falls back to current settings, because no
snapshot was ever taken to prefer.
The gate at the door
Creation rejects twice before a row exists. First, in plain prose: a new period may not overlap any
existing run's period — unless that run is Cancelled or Rejected, which no longer count as
occupying their dates. Second, the schedule gate, whose javadoc is the best paragraph in the file:
/**
* Rejects a period that departs from the configured pay schedule, unless the caller
* confirms it. Only the anchored frequency is checked: weekly payroll decides
* which cutoff closes the month by walking the anchor grid, so an
* off-grid or wrong-length period can leave a month with no final cutoff and
* under-deduct that month's SSS/PhilHealth/Pag-IBIG. Semi-monthly and monthly runs
* stay free-form — off-cycle and correction runs are established practice there.
*/
private void validateAgainstSchedule(PayrollCreateRequest request) {
Unpack the middle sentence, because the whole gate exists for it. Under weekly pay, "which cutoff is the month's last one" is not a property of any single period — it is derived by walking the configured anchor grid and asking whether the next scheduled period ends in a different month. Statutory contributions to SSS, PhilHealth, and Pag-IBIG are monthly obligations that reconcile on that final cutoff (the actual amounts live in the business-rules reference, not here). Create one off-grid or wrong-length weekly period and the walk can skip a month's final cutoff entirely — a month that never reconciles, and quietly under-deducts. Semi-monthly and monthly runs are calendar-aligned, so finality is never ambiguous and they stay free-form.
When the gate fires, it does not just say no. The ScheduleMismatchException message teaches the
operator the rule and offers the way through:
This period doesn't match the configured pay schedule: <the specific problems>.
Statutory contributions reconcile on the month's final cutoff, which is derived
from the schedule — confirm to create it anyway as an off-cycle run.
You met this exception in lesson 05 as one of the nine domain exceptions — a 422, not a 400 — and its class javadoc explains exactly why it earns its own type:
/**
* A requested pay period doesn't match the configured pay schedule. Distinct from a
* plain validation error because the caller can knowingly proceed: the create request
* accepts a confirmation flag that skips this check for genuine off-cycle runs.
*/
public class ScheduleMismatchException extends RuntimeException {
A validation error means "fix your input". This means "you are departing from the schedule — if that is what you mean, say so": the create request carries a confirmation flag, and a confirmed request walks straight past the gate as a deliberate off-cycle run.
Status is a small state machine
Five status strings, and every legal move between them:
create ──► Draft ──generate──► Pending ──decide──► Approved ──► Processed
│
└───────decide──► Rejected
Cancelled : reachable from any state EXCEPT Processed (and itself);
a reason is mandatory, and the cancellation is audit-logged.
delete : refused once Processed, or once any payslip exists —
"cancel it instead".
The rules, in words. A run is born Draft. Generating payslips is legal only in Draft and only
once — a second attempt is refused because the payslips already exist. Successful generation moves
the run to Pending, and only a Pending run can be decided into Approved or Rejected.
Processed — money has moved — is reachable only from Approved.
Cancelled is the escape valve, with two locks on it: you cannot cancel what is already
Processed or already Cancelled, and you cannot cancel without a reason. The reason is not
decoration — it lands in the audit log alongside the run's period and prior status, so "why was run
#41 cancelled?" has a permanent answer. Deletion is stricter still: a Processed run can never be
deleted, and neither can any run that has generated payslips — the error message itself redirects
you to cancellation, which preserves history instead of erasing it. Every transition, whatever the
path, is also written to the payroll change log with its old and new status.
The clock that keeps counting
Generation is the expensive step, so it is timed. But look at how it is timed — the public method exists only to hold a stopwatch around a private one, and its javadoc says why:
/**
* Times the generation run and records its outcome, then delegates. Split from
* {@link #doGeneratePayslips} so that a run which throws is still timed: the
* PayrollGenerationFailed alert reads the failure-tagged timer, and a failure that
* never stopped the clock would be invisible to it.
*/
public PayrollDto generatePayslips(Integer id, String username) {
Predict: generation throws halfway through — say, an employee's salary falls outside every seeded contribution bracket. Does the timer record a sample?
Yes — that is the entire point of the split. The wrapper starts the sample, delegates, and stops
the clock on both exits: tagged success on return, tagged failure on a rethrown exception.
This is lesson 14's doctrine cashing out: an alert can only read what a
metric records, and PayrollGenerationFailed alerts on the failure-tagged timer. Time only the
happy path and every failed run is a sample that never arrived — the alert watches a series that
stays flat precisely when things go wrong, which is the most expensive kind of dashboard lie.
A deliberately odd three lines
One inline comment in the decide path reads like a bug apology, and is actually a specimen of two conventions meeting:
// Approved / Rejected, both pinned by @Pattern on the request DTO -- the tag can
// only ever take those two values. Lower-cased so it matches the casing of the
// other lifecycle actions on the same counter.
Why is a status string being lower-cased before it becomes a metric tag? Because two naming worlds
collide here. Status strings are capitalized (Approved, Rejected) to match what the entity
stores, and the @Pattern-on-String house style from lesson 05 pins
the request to exactly those two values — so the tag cannot explode into unbounded cardinality.
But the same counter already carries tags like created and payslips_generated, which are
lower-case. One counter, one casing, so the value bends at the boundary — and the comment exists so
the next reader doesn't "fix" it into a mismatched tag set. The status-update path does the same
for Processed / Cancelled, with the same one-line justification.
Where this shows up in MotorPH
- PayrollServiceImpl.java — the file this lesson reads by its comments: the snapshot echo, the schedule gate, the transition checks, the timer wrapper, the tag-casing comment.
- Payroll.java — the entity,
including the
payFrequencysnapshot comment quoted above. - ScheduleMismatchException.java — the exception whose javadoc defines the confirm-to-proceed contract.
- ../backend/README.md — section 3 traces
POST /api/payroll/{id}/generate-payslipshop by hop; read it when you want the bodies. - ../api/payroll.md — the endpoints this lifecycle sits behind.
- ../business-rules.md — the statutory rates and amounts this lesson deliberately never states.
Recap
- A run snapshots its pay frequency at creation. Generation and reporting read the run's own
column, not the live settings singleton, so a mid-year schedule change never reinterprets an
older run; only pre-column
NULLruns fall back to current settings. - The schedule gate protects the month's final cutoff. Only the anchored (weekly) frequency is checked, because an off-grid period can leave a month that never reconciles its statutory contributions — and the exception message offers a confirmed off-cycle escape hatch.
- Status moves through a small one-way machine. Draft → Pending → Approved/Rejected; Processed only from Approved; Cancelled needs a reason and is audit-logged; delete is refused once a run is Processed or has payslips — cancel preserves history, delete would erase it.
- The generation clock stops on both exits. A throw still records a failure-tagged sample,
because the
PayrollGenerationFailedalert reads that tag and an untimed failure would be invisible to it. - Metric tags bend to the counter's casing.
@Patternpins the values; lower-casing keeps one counter's tag set consistent — the comment exists so nobody "fixes" it.
Next: 17 — The pure core: a calculator and a planner with no dependencies.