Skip to main content

01 — Fifteen lines of boot, and the pom that explains itself

Read this first: open backend/src/main/java/ expecting a wall of bootstrap code and you find a main class of fifteen lines that does almost nothing — on purpose. The real map of this backend is backend/pom.xml, and it turns out to be the rare build file that annotates its own decisions, in comments long enough to be primary sources. This lesson reads the class whole, then walks the pom's commented blocks until nothing in it is mysterious.

Time: about 20 minutes. This is the first lesson — it assumes you know basic Java and Spring and nothing about this repo.

The whole boot class​

package com.motorph.payroll;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class PayrollBackendApplication {

public static void main(String[] args) {
SpringApplication.run(PayrollBackendApplication.class, args);
}

}

That is the entire file. @SpringBootApplication is the standard three-in-one — @Configuration, @EnableAutoConfiguration, @ComponentScan from this package down — and @EnableScheduling is the single addition, because this backend does timed work with no request in flight: holiday generation, trial expiry, the schedulers in config/ that lesson 14 reads. Everything else that happens at startup is autoconfiguration reacting to the classpath, shaped by the classes in config/ — lesson 03 tours them in execution order. If any annotation is new, keep the annotations glossary open beside this.

The consequence is worth internalizing: in a Boot application, the dependency list is the architecture. What this app is — web server, JPA, security, websockets, mail, metrics — is decided in pom.xml, not in Java. So that is where the rest of this lesson goes.

A pom that keeps a decision log​

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.7</version>
</parent>

Spring Boot 4.0.7 on Java 21. The parent buys two things: managed versions — every dependency below that has no <version> tag gets one from Boot's curated list — and plugin defaults. Read the <properties> block with that in mind and it stops being noise: it is exactly the list of dependencies Boot does not manage (jwt.version, mapstruct.version, pdfbox.version, …). A version property on a dependency is the pom telling you Boot has no opinion about it.

One motif to watch for: Boot 4 split its autoconfiguration into per-technology modules, and this pom collides with that split three times — tracing, Flyway, and the test slices. Two of the three collisions earned comments. They are the documentation.

The starter, not the bridge​

<!-- Distributed tracing. Spring's Observations become OpenTelemetry spans,
exported to the OTel Collector (infra/monitoring/otel/config.yml),
which forwards them to Tempo.

The STARTER, not micrometer-tracing-bridge-otel on its own: Boot 4 has
split autoconfiguration into per-technology modules, so the bridge and
the OTLP exporter alone put the libraries on the classpath and
configure precisely nothing — the app starts clean, exports no spans,
and gives no hint why. The starter pulls in
spring-boot-micrometer-tracing-opentelemetry and
spring-boot-opentelemetry, which are the parts that actually wire it up.

Inert unless management.tracing.enabled=true, which is off by default
in application.yml: a fresh clone, CI and `mvn test` boot with nothing
listening on 4318 and must not spend startup retrying against a
collector that isn't there. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
<!-- … one exclusion, next section … -->
</dependency>

In Boot 3 you added the Micrometer bridge and an exporter, and autoconfiguration found them. In Boot 4 the wiring lives in separate spring-boot-* modules, so libraries alone produce the worst kind of failure:

the app starts clean, exports no spans, and gives no hint why

Nothing is broken enough to log. The starter exists to drag in the two modules that actually wire the pipeline, and the comment names them so nobody rediscovers which ones. Its last paragraph is a doctrine you meet again in lesson 02: heavy subsystems ship in the build but boot off by default, because a fresh clone and mvn test must start on a machine where no collector is listening on 4318.

Excluded, not disabled​

<exclusions>
<!-- The starter also brings an OTLP *metrics* registry, which
autoconfigures itself on and pushes to localhost:4318 every
minute. Metrics here are PULLED from /actuator/prometheus, so
that push has nowhere to go and logs a stack trace on every
publish. Excluded rather than disabled by property: a
dependency that exists only to be switched off is a trap for
whoever reads this next. -->
<exclusion>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-otlp</artifactId>
</exclusion>
</exclusions>

Metrics in this stack are pull-based — Prometheus scrapes /actuator/prometheus, which is what micrometer-registry-prometheus further up the pom is for. The tracing starter also ships a push metrics registry that wakes up configured-on and stack-traces every minute at a collector that is not accepting metrics. Two fixes exist: turn it off with a property, or remove the jar. The comment picks the second and says why:

a dependency that exists only to be switched off is a trap for whoever reads this next

The pom states classpath truth. If a jar's only job is to be off, the honest build has no jar.

The rest of the list, in one pass​

The remaining commented blocks each answer a "why is this here" in one breath:

  • logstash-logback-encoder — "used only by the json logback profile"; Promtail parses those lines and "lifts trace_id into Loki structured metadata, which is what links a log line to its trace." The tracing starter is the span half; this is the log half of the same system.
  • bcprov-jdk18on — "Required by Spring Security's Argon2PasswordEncoder (Argon2BytesGenerator)." Spring Security declares the Argon2 encoder but does not ship the math; BouncyCastle is the math. Lesson 11 meets it, along with the uncommented java-jwt and bucket4j_jdk17-core — the token and the rate limiter.
  • altcha plus org.json — the self-hosted proof-of-work CAPTCHA and its runtime dependency, "declared provided upstream": altcha needs org.json at runtime but does not bring it.
  • The Flyway trio — flyway-core, flyway-database-postgresql, spring-boot-flyway. The module split, second appearance: the engine, its Postgres plugin, and the Boot 4 module that actually runs migrations at startup. Drop the third and everything compiles and nothing migrates.
  • springdoc-openapi-starter-webmvc-ui excludes its transitive commons-lang3 because the pom already declares its own at ${commons-lang3.version} — one copy, one deliberate version.
  • zxing and pdfbox carry no comments because they need none: a payroll system prints and stamps things, server-side.

The test half — and the split, third time​

<!--
Tenant isolation cannot be proven against mocked repositories: the thing under test is
the SQL Hibernate emits. These give the tenancy tests a real Postgres with the real
Flyway migrations applied. See TenantTestSupport.
-->

That comment sits on spring-boot-testcontainers and the Testcontainers Postgres artifacts, and it is a thesis statement for two later lessons: the isolation it refuses to fake is lesson 13, and the harness it names is lesson 21. When the thing under test is the SQL itself, a mock proves nothing — so the build carries a real containerized Postgres.

<!-- Boot 4 ships each test slice in its own module: @DataJpaTest and
@AutoConfigureTestDatabase are no longer on spring-boot-starter-test's classpath. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>

The module split's third appearance. In a Boot 3 project, @DataJpaTest rode along with spring-boot-starter-test; here that import fails to resolve unless the slice's own artifact is declared (spring-boot-data-jpa-test, and its sibling spring-boot-jdbc-test right below it). The comment exists so the next person greps the pom instead of their Maven cache.

Predict: declare one block, lose Lombok​

Back in the dependencies section, one comment points downward:

<!-- mapstruct-processor and lombok-mapstruct-binding are annotation processors,
not runtime dependencies; they are declared only in the compiler plugin's
annotationProcessorPaths below so they stay out of the fat jar. -->

Annotation processors run inside javac; nothing in the shipped jar ever calls them, so they do not belong in <dependencies>. They are declared in the compiler plugin instead.

Predict: suppose the plugin listed only what that comment mentions — the MapStruct processor and its Lombok binding — and left Lombok where it already sits, on the compile classpath with provided scope. Lombok's jar carries its own processor, and javac normally discovers processors from the classpath. Does Lombok keep working? Write your answer down before reading on.

<!-- Version and <parameters>true</parameters> are inherited from
spring-boot-starter-parent. Declaring annotationProcessorPaths turns OFF
classpath ServiceLoader discovery of processors, so EVERY processor this
build needs must be listed here - including Lombok. Order: lombok first,
then the binding that lets MapStruct see Lombok-generated accessors,
then the MapStruct processor itself. -->
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<!-- … compilerArgs, next section … -->
</configuration>

No, it does not keep working — the comment is the resolution. annotationProcessorPaths is not additive; the moment the block exists, "classpath ServiceLoader discovery of processors" is off and the block is the complete roster. Leave Lombok out and its processor never runs: every @Getter class in the tree fails compilation with cannot find symbol errors naming your own accessors, and nothing in the output says "Lombok". The order is load-bearing too — Lombok first so the accessors exist, then the binding "that lets MapStruct see Lombok-generated accessors", then MapStruct to read them.

Notice ${lombok.version} — a property this pom never defines. It comes from the Boot parent, which manages Lombok; the properties rule from earlier holds even inside the plugin. And Lombok's exit mirrors its entrance: it is provided in the dependencies and, in the spring-boot-maven-plugin block at the bottom of the build, explicitly excluded from the fat jar. Compile-time tools do not ship; the runtime image carries only what runs.

Three compiler flags, three receipts​

<compilerArgs>
<!-- Byte-stable generated impls: keeps the Docker application layer
reproducible and makes generated-code diffs reviewable. -->
<arg>-Amapstruct.suppressGeneratorTimestamp=true</arg>
<!-- Logs which mapping method MapStruct selects per property, so an
accidental lazy-association auto-map shows up in the build log. -->
<arg>-Amapstruct.verbose=true</arg>
<!-- Belt-and-braces behind MappingDefaults (@MapperConfig): a mapper
that forgets config = MappingDefaults.class still fails the build
on an unmapped target instead of silently warning. -->
<arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
</compilerArgs>

suppressGeneratorTimestamp — by default every generated *MapperImpl carries a generation timestamp, so every build produces different bytes from identical sources, and the Docker application layer can never be reproduced or cache-hit. One flag makes the impls byte-stable, and a mapper change now diffs as a mapping change, not timestamp churn. The layer economics behind this live in Learn DevOps.

verbose — MapStruct's failure mode is silent helpfulness: it auto-maps whatever property names line up, including a lazy JPA association you never meant to touch. This flag logs which mapping method it selects per property, so the selection is auditable in the build log instead of discoverable in production.

unmappedTargetPolicy=ERROR — the house rule, enforced twice on purpose. MappingDefaults (@MapperConfig) sets it per mapper; this flag is the "belt-and-braces" behind it, so a mapper that forgets to opt in still fails the build instead of warning into the void. Why the boundary refuses to guess at all is lesson 09.

The jar becomes an image​

The build does not end at mvn package. backend/Dockerfile is a two-stage build: it resolves dependencies against pom.xml alone (so the expensive layer survives source edits), packages, then runs Boot's jarmode=tools extraction to split the jar into layers — dependencies, loader, snapshot dependencies, application — copied into a JRE-only Alpine image in that stability order, running as a non-root spring user with -XX:MaxRAMPercentage=75 so the heap follows the container's memory limit instead of a hardcoded -Xmx. That application layer is exactly what suppressGeneratorTimestamp keeps reproducible. The full decode of layered images belongs to Learn DevOps; here you only need to know the pom and the Dockerfile are two halves of one build.

Where this shows up in MotorPH​

Recap​

  • The dependency list is the architecture. The boot class is fifteen lines because everything the app is — web, JPA, security, websockets, mail, metrics — is declared in pom.xml and wired by autoconfiguration.
  • A <version> tag marks Boot's silence. The parent manages what it can; the <properties> block is precisely the list of what it cannot.
  • In Boot 4, take the starter — libraries alone wire nothing. Autoconfiguration is split into per-technology modules; the bridge plus exporter "starts clean, exports no spans, and gives no hint why", and the same split explains the Flyway trio and the test-slice artifacts.
  • Exclude the jar; don't configure it off. "A dependency that exists only to be switched off is a trap for whoever reads this next" — the pom states classpath truth.
  • annotationProcessorPaths replaces discovery, so list every processor, in order. lombok → binding → mapstruct; and the three -A flags buy reproducible layers, auditable mappings, and unmapped-target build failures (lesson 09).

Next: 02 — One application.yml, zero profiles.