04 — The package map, the entities, and the migrations
Read this first: this lesson gives you the two maps you navigate by for the rest of the course.
The first is the package tree under com.motorph.payroll — where a new endpoint's files go, and the
five files it always needs. The second is the migration directory — 107 SQL files that are the
schema's entire history, in order. By the end you can place any file in the first map and any
V<N>__ file in the second.
Time: about 30 minutes. Assumes lesson 03.
The map
Everything lives under one root package. The layout is horizontal layers, not feature modules — a
"module" like payroll or recruitment is a name prefix (PayrollController,
RecruitmentController) that appears in every layer, never a package of its own. Counts below are
from the tree as of this writing — nine hundred forty-two Java files in total:
| Package | Files | What lives there |
|---|---|---|
controller/ | 86 | One @RestController per resource; thin — parse, delegate, return |
service/ | 81 | Service interfaces — the contract the controller calls |
service/impl/ | 97 | The implementations, plus pure-math helpers like DailyPayCalculator |
repository/ | 89 | Spring Data JPA interfaces |
repository/specification/ | 43 | Specification builders for dynamic filters (lesson 08) |
model/ | 112 | JPA entities (ninety-two @Entity classes today), enums, one converter |
dto/ | 319 | The API's wire shapes — requests and responses, never entities |
mapper/ | 22 | MapStruct interfaces, one per module prefix (lesson 09) |
config/ | 17 | @Configuration classes, seeders, schedulers |
security/ | 20 | JWT, ALTCHA, rate limiting, login — the authn stack (lessons 10–11) |
tenancy/ | 6 | The tenant context (lesson 12) |
mail/ | 6 | The MailService port, SMTP impl, {{token}} template renderer |
metrics/ | 4 | Micrometer counters behind the Grafana dashboards |
exception/ | 11 | GlobalControllerAdvice and the domain exceptions (lesson 05) |
The remainder is small: ats/ (resume parsing), constants/ (PermissionConstants — every
@PreAuthorize string in one place), interceptor/ (the audit trail), util/, and the lone class
at the root, PayrollBackendApplication.
The five files
Every endpoint you will ever add to this codebase is the same five files, one per layer:
controller/FooController.java the HTTP surface
service/FooService.java the interface
service/impl/FooServiceImpl.java the implementation
dto/FooDto.java (+ FooCreateRequest, ...) the wire shapes
repository/FooRepository.java usually — reuse it if the entity already has one
Plus a method on the module's MapStruct mapper to cross the DTO/entity boundary — that is
lesson 09's whole subject. The naming is not a suggestion:
../coding-standards.md has the exact table (FooController,
FooService/FooServiceImpl, FooRepository, FooSpecifications), and the fastest way to build
any of the five is to copy the nearest existing example.
One thing this course deliberately does not do: ../backend/README.md
already walks POST /api/employees hop by hop, from controller annotation to audit log. Read it —
then note that its file counts lag the tree (it says seventy-eight controllers; the tree has
eighty-six today). When a doc and the source disagree, source wins. Lessons
06 through 08 take the
read path instead — GET /api/employees — so between the two documents you have both directions.
One entity, read closely
model/Employee.java is the representative citizen of model/. Trimmed to its shape:
@Entity
@Table(name = "employee")
@Getter
@Setter
@NoArgsConstructor
public class Employee extends TenantOwned {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_number")
private Integer employeeNumber;
@Column(name = "last_name", nullable = false)
private String lastName;
// ... sss_number, philhealth_number, tin_number, pagibig_number: all unique ...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "position_id", nullable = false)
private Position position;
@Column(name = "basic_salary", nullable = false)
private BigDecimal basicSalary;
// ISO day of week (1 = Monday .. 7 = Sunday); NULL uses payroll_settings.default_rest_day.
@Column(name = "rest_day_of_week")
private Integer restDayOfWeek;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
}
Four habits to copy:
- Every
@Columnnames its column explicitly, in snake_case. Java saysbasicSalary, the database saysbasic_salary, and nothing is left to a naming strategy's imagination. Put the entity next to its migration and they read as mirror images:
CREATE TABLE employee (
employee_number INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_name VARCHAR(100) NOT NULL,
first_name VARCHAR(100) NOT NULL,
...
- Associations are
@ManyToOne(fetch = FetchType.LAZY). Eager fetching is how one query becomes forty; here thePositionloads only when someone asks for it. extends TenantOwned— the mapped superclass that stamps and filterstenant_idon every tenant-owned entity; lesson 12 owns that story.- Comments live in the source. The
restDayOfWeekcomment above is the actual documentation for that field's NULL semantics — not a wiki page.
Timestamps: the database's job, not an auditing framework's
Search the backend for @EnableJpaAuditing, @CreatedDate, or @EntityListeners and you get zero
hits. This codebase does not use JPA auditing at all. created_at columns get their value from a
database DEFAULT in the migration — here is V108's, verbatim:
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
A handful of entities — eight today, Notification among them — need the value in the Java
object before the insert returns, so they set it themselves with a hand-written callback:
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}
That is the whole auditing machinery: a column default, and an explicit @PrePersist where one is
genuinely needed. When you add an entity, do not reach for an auditing annotation that nothing in
this codebase enables.
The migrations: 107 files, numbered to 108
The schema's history is backend/src/main/resources/db/migration/ — one hundred seven files,
named V<N>__snake_case_description.sql, from V1__core_rbac.sql to
V108__recognition_awards.sql. Count them against the version numbers and the arithmetic is off by
one: V26 does not exist. The sequence jumps V25__message_cursor_index.sql →
V27__item_pricing.sql. That migration was written on a branch that never shipped, and its number
is retired with it — do not "fill the gap." Deployed databases record which versions have run;
version numbers are history, not sequence.
Two Flyway settings make that history workable with parallel branches. From application.yml:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
out-of-order: true
out-of-order: true exists because branches merge out of order: a branch that claimed V96 early
can land after the branch that claimed V97, and Flyway must be willing to run the lower number
late instead of refusing. baseline-on-migrate lets Flyway adopt a database that already has a
schema. The rule for you is simple — take the next unused number at merge time, and never renumber
a migration that has run anywhere.
Hibernate validates; Flyway owns
Right above the Flyway block sits the other half of the contract:
jpa:
hibernate:
ddl-auto: validate
Hibernate never creates or alters tables here. At startup it compares every entity against the real schema and refuses to boot on any mismatch. ../coding-standards.md states the ownership in one line: "Schema changes are Flyway migrations only."
Predict: you add a middle_initial field to Employee, annotate it @Column, and forget to
write a migration. When and how do you find out?
At the very next boot — before the app serves a single request. Lesson 03's startup sequence runs
Flyway first (nothing to do), then Hibernate's schema validation, which fails fast with a
SchemaManagementException naming the missing column, and the application never starts. Drift
between the map (entities) and the territory (schema) is a loud, early failure, not a runtime
surprise three weeks later.
Migrations explain themselves
The SQL files carry their own reasoning, and reading the comments is how you learn the schema's decisions. The very first file opens by explaining a deliberate ordering trick:
-- Core lookup and RBAC tables: department, position, role, permission, role_permission, users, user_role
-- Note: the FK from users.employee_id -> employee.employee_number is added in V2 (after the
-- employee table exists), to keep this file focused on RBAC/lookup tables.
And the newest file explains a product decision — why a brand-new feature ships dark:
-- Master switch, default OFF: brand-new feature with email side effects and a new
-- employee-facing surface; tenants opt in from Payroll Settings.
ALTER TABLE payroll_settings
ADD COLUMN recognition_awards_enabled BOOLEAN NOT NULL DEFAULT FALSE;
When you write V109, write it like these: say what it creates, and say why anything non-obvious is the way it is. The migration is the only place that explanation can never go stale relative to the schema, because it is the schema.
Where this shows up in MotorPH
- ../backend/README.md — the full package map with per-class links, and the
annotated
POST /api/employeestrace this course intentionally does not repeat. - ../backend/entities-and-migrations.md — the data layer's deep dive; the coding standards point here for every schema change.
- ../coding-standards.md — the
Foo*naming table behind the five-file rule. - ../adr/0001-postgresql-flyway.md — the decision record for PostgreSQL + Flyway itself.
- ../architecture.md — the system-level picture one floor above this lesson.
- Source: Employee.java, Notification.java, V1__core_rbac.sql, V108__recognition_awards.sql, and application.yml.
Recap
- Layers are packages; modules are name prefixes. A new endpoint is five files — controller, service interface, impl, DTO(s), and (usually) a repository — plus a mapper method.
- Entities name their columns. Explicit snake_case
@Column,LAZYassociations, andextends TenantOwnedfor anything a tenant owns. - There is no JPA auditing.
created_atis a databaseDEFAULT; the eight entities that need the value in Java write their own@PrePersist. - Version numbers are history, not sequence. One hundred seven files reach V108 because V26 never shipped; take the next unused number and never renumber or reuse.
- Flyway owns the schema; Hibernate only checks.
ddl-auto: validateturns entity/schema drift into a boot failure — the loud, early kind.
Next: 05 — One error shape, and the 500 that is supposed to happen.