Skip to main content

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:

PackageFilesWhat lives there
controller/86One @RestController per resource; thin — parse, delegate, return
service/81Service interfaces — the contract the controller calls
service/impl/97The implementations, plus pure-math helpers like DailyPayCalculator
repository/89Spring Data JPA interfaces
repository/specification/43Specification builders for dynamic filters (lesson 08)
model/112JPA entities (ninety-two @Entity classes today), enums, one converter
dto/319The API's wire shapes — requests and responses, never entities
mapper/22MapStruct interfaces, one per module prefix (lesson 09)
config/17@Configuration classes, seeders, schedulers
security/20JWT, ALTCHA, rate limiting, login — the authn stack (lessons 10–11)
tenancy/6The tenant context (lesson 12)
mail/6The MailService port, SMTP impl, {{token}} template renderer
metrics/4Micrometer counters behind the Grafana dashboards
exception/11GlobalControllerAdvice 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 @Column names its column explicitly, in snake_case. Java says basicSalary, the database says basic_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 the Position loads only when someone asks for it.
  • extends TenantOwned — the mapped superclass that stamps and filters tenant_id on every tenant-owned entity; lesson 12 owns that story.
  • Comments live in the source. The restDayOfWeek comment 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​

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, LAZY associations, and extends TenantOwned for anything a tenant owns.
  • There is no JPA auditing. created_at is a database DEFAULT; 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: validate turns entity/schema drift into a boot failure — the loud, early kind.

Next: 05 — One error shape, and the 500 that is supposed to happen.