03 — Startup is a sequence: four runners, in order
Read this first: Spring logs Started PayrollBackendApplication and then, before any request
that matters, runs four more classes in a fixed order. This chapter tours the config/ package,
then decodes those four ApplicationRunners — what each one checks or seeds, and why the order
cannot change. Every quote below is copied from the source files.
Time: about 20 minutes. Assumes lesson 02.
The config/ package in one table
backend/src/main/java/com/motorph/payroll/config/ holds 17 classes. Grouped by role:
| Role | Class | One line |
|---|---|---|
| Security | SecurityConfiguration | The filter chain and the list of public paths. |
| Security | PasswordEncoderConfiguration | Argon2id for new hashes; bcrypt stays registered so old ones keep matching. |
| WebSocket | WebSocketConfig | STOMP endpoint at /ws, simple broker on /topic and /queue. |
| WebSocket | WebSocketAuthChannelInterceptor | Authenticates STOMP frames and guards per-tenant topics. |
| OpenAPI | OpenApiConfiguration | Document metadata plus the URL-prefix groups that split ~86 controllers into readable sections. |
| Tenancy plumbing | TenantDataSourceConfiguration | Wraps the connection pool so every connection announces its tenant to Postgres. |
| Runners and schedulers | RowLevelSecurityCheck | This chapter. |
| Runners and schedulers | PlatformAdminSeeder | This chapter. |
| Runners and schedulers | DefaultHolidaySeeder | This chapter. |
| Runners and schedulers | DemoDataSeeder | This chapter. |
| Runners and schedulers | HolidayGenerationScheduler | Every December 1, seeds the next year's holidays for servers that cross a year boundary without a restart. |
| Runners and schedulers | TrialExpiryScheduler | Nightly sweep that closes lapsed trials, so the operator's registry agrees with what Tenant.isActive() already enforces per request. |
| Properties and small beans | ClockConfig | One Clock bean pinned to Asia/Manila; anything that needs "now" injects it. |
| Properties and small beans | WebMvcConfig | Registers the audit interceptor on /api/**. |
| Properties and small beans | BillingConfiguration | The first outbound HTTP client in the codebase, with explicit timeouts. |
| Properties and small beans | BillingProperties | billing.* settings: provider choice, checkout links. |
| Properties and small beans | ChatwootProperties | Support-widget settings; its origin is mirrored in the frontend CSP. |
The big ones get whole lessons — lesson 10 for the security chain,
lesson 13 for the tenancy plumbing,
lesson 14 for the websocket and scheduler machinery. Today is only the four
classes that implement ApplicationRunner.
Four runners, four numbers
After the context is built and the Started line is logged, Boot calls every ApplicationRunner
bean, lowest @Order first:
@Order(10) RowLevelSecurityCheck is tenant isolation actually in force?
@Order(80) PlatformAdminSeeder first platform account, only if none exists
@Order(90) DefaultHolidaySeeder PH holiday calendar, every tenant, idempotent
@Order(100) DemoDataSeeder demo records, default tenant, first boot only
One thing has already happened before any of them: the schema. DemoDataSeeder's javadoc pins the
moment —
/**
* Auto-seeds demo attendance, leave, overtime, bonus, and payroll data on first startup
* (e.g. {@code docker compose up --build} against a fresh database) so the application is
* ready for a client demo without any manual setup. Runs after Flyway migrations, since those
* execute during the {@code DataSource} bean initialization which this runner depends on.
*/
Flyway runs while the DataSource bean is being created, which is long before any runner. So every
runner sees a fully migrated schema, always. No runner checks whether a table exists yet.
@Order(10): the canary that asks Postgres a question
RowLevelSecurityCheck writes nothing. It asks the database one question and logs the answer. Its
javadoc is the argument for its existence:
/**
* Says, at every startup, whether row-level security is actually in force.
*
* <p>The analytics and reporting screens are built on SQL views read through raw JDBC, which
* Hibernate's tenant filter never sees. Row-level security is what scopes them, and it is enforced
* by the database only for a role that does not bypass it — connect as the owner and every policy
* silently becomes a no-op while the application carries on looking healthy. That is precisely the
* failure this exists to make audible: a dashboard showing another company's headcount looks like
* data, not like an error.
*
* <p>Cheap enough to run unconditionally, and worth it: the difference between enforced and
* bypassed is one environment variable.
*/
The question itself is one row from Postgres's own catalog:
Boolean bypasses = jdbcTemplate.queryForObject(
"SELECT rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user",
Boolean.class);
If the connected role is a superuser or carries BYPASSRLS, every policy is a no-op and the check
logs a loud WARN that names the fix — set APP_DB_USER and APP_DB_PASSWORD so the application
connects as the non-bypassing runtime role. Otherwise it logs one calm INFO line. Either way the
verdict sits at the top of every startup log.
And if the query itself fails, the catch block explains its own restraint:
} catch (Exception ex) {
// Never worth failing startup over a diagnostic.
log.warn("Could not determine whether row-level security is in force: {}", ex.getMessage());
}
What RLS actually enforces, and why raw-JDBC views need it, is lesson 13. Here it is only a canary — and its position matters, which the Predict below is about.
@Order(80): the first account has to come from somewhere
PlatformAdminSeeder solves a bootstrap problem, and says so:
/**
* Creates the first platform account, so that a fresh installation has someone who can create
* tenants.
*
* <p>Every other account in the system is created by somebody already inside it. The platform
* operator has no such somebody -- and an invite has to be sent by someone -- so the first one has
* to come from configuration. It is created only when no platform account exists at all: this
* never overwrites, promotes or resets an existing one.
*/
The password property defaults to blank, and the field's javadoc explains why that is the safe default:
/**
* Blank in every environment that has not set one. A blank password produces an account that
* cannot be logged into until an operator supplies one, which is a better default than a
* well-known password on a reachable host.
*/
@Value("${motorph.platform.admin.password:}")
private String password;
So a fresh install with nothing configured gets no platform account at all — just a WARN telling
the operator to set PLATFORM_ADMIN_PASSWORD and restart. A well-known default credential on a
reachable host is the alternative, and this class refuses it.
The "never overwrites" clause has a failure mode of its own, and the code narrates it at the exact branch where it happens:
if (userRepository.existsByTenantIdIsNull()) {
// Said out loud, because the alternative is a silent no-op that looks exactly
// like a wrong password: an operator who changes PLATFORM_ADMIN_PASSWORD and
// restarts, expecting it to take effect, gets no clue that it did not.
log.info("A platform administrator already exists; PLATFORM_ADMIN_PASSWORD is "
+ "not applied to it. Changing that account's password is done through "
+ "the application, not through configuration.");
return;
}
Notice the existence check: existsByTenantIdIsNull(). A platform account is defined by having no
tenant — the whole class runs inside TenantContext.runAsGlobal(...), which is
lesson 12 territory. And when the account is created, one last comment
closes the loop on the configured secret:
// The password arrived through configuration, so it is as widely known as the
// config is. First login changes it.
admin.setMustChangePassword(true);
@Order(90): the calendar before the payroll
DefaultHolidaySeeder's javadoc contains, in one sentence, both its job and its position:
/**
* Ensures the deterministic default Philippine holidays exist for the current and
* upcoming year on every startup, so payroll never computes a statutory holiday as an
* ordinary day just because the calendar was left unseeded. Idempotent: existing
* (date, name) rows — including admin edits — are never touched. Runs before
* {@link DemoDataSeeder} so demo payroll runs compute premiums against a populated
* calendar.
*/
That last sentence is why 90 comes before 100. The demo seeder generates payroll runs, and holiday premiums are computed against whatever calendar exists at that moment. Swap the numbers and a fresh install computes its demo payslips against an empty calendar — every statutory holiday priced as an ordinary day, with no error anywhere.
It also seeds wide, not once, and the comment explains the shape:
// Holidays are per tenant: the defaults are national, but each company edits them and adds
// its own, so this seeds every tenant's calendar rather than one shared one.
tenantScopedExecutor.forEachActiveTenant("default holiday seeding", tenant -> {
Idempotent, every startup, current year and next: a calendar row you edited by hand survives,
because only missing (date, name) rows are inserted.
@Order(100): demo data, once, for the default tenant only
The last runner fills a fresh database with something to look at — attendance, leave, overtime,
bonus, and payroll records. Its run method's javadoc draws the boundary:
/**
* Seeds the default tenant only. Demo data exists so a fresh install has something to look at,
* and the fresh install is the default tenant; a real tenant created through the portal gets a
* clean workspace, not fabricated payslips.
*/
It runs inside TenantContext.runAs(Tenant.DEFAULT_TENANT_ID, ...), checks
demoDataService.getStatus().isSeeded() and returns if the data is already there — so it fires
once, on first boot against an empty database, and never again. Like the others, its catch block
logs a warning and lets startup continue: a failed demo seed is a shrug, not an outage. And note
what the class does not contain — not one credential. It fabricates records for accounts that
already exist; it never mints a login.
Predict: move the canary to run last
Suppose a refactor changes RowLevelSecurityCheck to @Order(110), after all three seeders.
Nothing throws — it is only a log line. Predict: what class of bug can now ship silently?
The bug it exists to catch: an application connected as a role that bypasses RLS, where every
reporting view quietly returns every tenant's rows. That failure produces no exception, no 500,
no red anywhere — "a dashboard showing another company's headcount looks like data, not like an
error." The check's entire value is that the verdict lands before anything acts on the
environment it judged. At @Order(10) the first thing a startup log says is whether isolation is
in force, and the seeders — one of which iterates every active tenant — do their data work under a
verdict already on record. At @Order(110) the same warning prints after pages of seeder output,
below the point where a person tailing the log has stopped reading. The environment did not change;
the chance of a human noticing did. Canary first, data work second. What the policies actually do
is lesson 13.
The gaps are on purpose
Read the numbers again: 10, 80, 90, 100. Not 1, 2, 3, 4. The spacing is the old BASIC line-number
trick — when a fifth runner needs to exist between the platform account and the holidays, it takes
@Order(85) and nothing else changes. The sequence also reads as a dependency ladder: diagnostics
before identity, identity before reference data, reference data before demo data that consumes it.
Startup here is not "whatever Spring finds, in whatever order" — it is a deliberate sequence,
written where the compiler can see it instead of in a README that would drift.
Where this shows up in MotorPH
- RowLevelSecurityCheck.java — the canary; try its
pg_rolesquery yourself. - PlatformAdminSeeder.java — the bootstrap account and the said-out-loud no-op.
- DefaultHolidaySeeder.java — per-tenant, idempotent, deliberately before demo data.
- DemoDataSeeder.java — first boot, default tenant only.
- pgAdmin guide — a place to run the canary's catalog query by hand.
- Entities and migrations — the Flyway side of "the schema exists before any runner".
- Scheduling and websockets — the two
@Scheduledcousins from the table.
Recap
- Runners fire after the
Startedline, lowest@Orderfirst — and Flyway has already run duringDataSourceinitialization, so every runner sees a migrated schema. - The canary precedes the data work. A bypassed-RLS environment looks healthy; the check's worth is putting the verdict at the top of the log, before anything acts on that environment.
- Seeders create; they never overwrite. Each guards on existence — a platform account, a
(date, name)holiday row, a seeded flag — so operator changes survive every restart. - Silent no-ops are said out loud. The skip branches log why nothing happened, because a quiet skip is indistinguishable from a bug to the operator staring at it.
- The gaps in 10-80-90-100 are room to insert, and none of these classes will fail startup over seed data — they warn and let the application come up.
Next: 04 — The package map, the entities, and the migrations.