- 1. Module Map
- 2. spring-boot-rest-sample
- 3. spring-boot-config-sample
- 4. spring-boot-admin-server
- 5. spring-boot-config-server
- 6. spring-boot-data-jpa-flyway
- 7. spring-boot-openapi-root
- 8. spring-boot-thymeleaf
- 9. spring-boot-security-root
- 10. spring-boot-ai-samples
- 11. spring-boot-batch-parent
- 11.1. spring-boot-batch-restart — what "continue" actually means
- 11.2. spring-boot-batch-flow — routing on ExitStatus
- 11.3. spring-boot-batch-tasklet — the two step shapes
- 11.4. spring-boot-batch-partition — stride, not contiguous
- 11.5. spring-boot-batch-jobstep — a whole Job inside a step
- 11.6. spring-boot-batch-split — parallel flows, and a Flow worth extracting
- 11.7. spring-boot-batch-promotion — the value that did not arrive
This project is a multi-module Spring Boot playground showcasing REST APIs, configuration, security, OpenAPI, JPA + Flyway, Thymeleaf MVC, Admin monitoring, and AI integrations.
📖 Documentation: https://www.alexmond.org/spring-boot-playground/current/
-
spring-boot-rest-sample — REST API basics
-
spring-boot-config-sample — Externalized configuration patterns
-
spring-boot-admin-server — Admin UI for monitoring apps
-
spring-boot-config-server — Centralized configuration service
-
spring-boot-data-jpa-flyway — Persistence + migrations
-
spring-boot-openapi-root — Parent for OpenAPI samples
-
spring-boot-thymeleaf — Server-side MVC with Thymeleaf
-
spring-boot-security-root — Parent for security samples
-
spring-boot-security-basic — Basic auth demo
-
spring-boot-security-jwt — JWT-based security demo
-
spring-boot-ai-samples — Spring AI integrations
-
spring-boot-batch-parent — Parent for Spring Batch samples
-
spring-boot-batch-restart — JobInstance identity, restart vs a new run
-
spring-boot-batch-flow — Conditional flow, ExitStatus routing, deciders
-
spring-boot-batch-tasklet — Tasklet vs chunk, CONTINUABLE paging
-
spring-boot-batch-partition — Partitioned steps, stride vs contiguous
-
spring-boot-batch-jobstep — A Job nested in a step, and the extractor that decides its identity
-
spring-boot-batch-split — Parallel flows, and a reusable Flow shared by two jobs
-
spring-boot-batch-promotion — Passing a value between steps, and the three ways it does not arrive
-
openapi-sample (under spring-boot-openapi-root) — Concrete OpenAPI demo
A reference REST service demonstrating: - CRUD endpoints with JSON payloads - Bean validation and error handling - Controller advice for exceptions - Meaningful HTTP status codes
How to run: - mvn spring-boot:run - Explore endpoints under /api
Learning goals: - Build clean REST controllers - Structure DTOs/validation - Centralize error responses
Externalized configuration patterns: - application.yaml + profiles - @ConfigurationProperties with validation - Metadata via configuration processor
How to run: - Use -Dspring.profiles.active=dev|prod to switch environments
Learning goals: - Safe, typed configuration binding - Profile-specific settings - Documenting configuration
Admin console to monitor Spring Boot apps: - Health, metrics, environment, logs - JVM info and endpoints overview
How to run: - Start this server, then register client apps with admin client starter
Learning goals: - Observability with minimal setup - Operational visibility for teams
Centralized configuration with Spring Cloud Config: - Git-backed properties - Environment-specific files - Encryption/decryption support
How to run: - Point spring.cloud.config.server.git.uri to a repo - Clients fetch configuration at bootstrap
Learning goals: - Central config management - Safe secret handling and refresh
Persistence + migrations: - Spring Data JPA repositories - Transactions and entity mapping - Flyway versioned migrations
How to run: - mvn spring-boot:run (Flyway runs at startup)
Learning goals: - Reliable schema evolution - Clean repository design - Testing data layers
Parent for OpenAPI samples providing: - Dependency and plugin alignment - Shared conventions for documentation
Learning goals: - Organizing API documentation across modules
Server-side MVC with Thymeleaf: - Templates, fragments, and layouts - Form binding and validation - I18n support
How to run: - mvn spring-boot:run - Visit web UI routes
Learning goals: - Productive SSR development - Clean template composition
Parent for security samples: - Baseline dependencies - Shared configuration practices
Learning goals: - Consistent security setup across modules
Basic authentication: - In-memory users/roles - Form login and protected routes
How to run: - mvn spring-boot:run - Access a protected endpoint to see login flow
Learning goals: - Security filter chain basics - Method and URL-based authorization
AI integrations with Spring AI: - Chat/completions flows - Prompt engineering patterns - Retry and observability
How to run: - Configure AI provider credentials via env/properties - mvn spring-boot:run
Learning goals: - Safely integrate AI services - Reusable abstractions for AI features
Seven Spring Batch samples, one mechanism each. They assert behaviour rather than describe it: every claim below is a passing test, and several of them replaced an assumption that turned out to be wrong when measured.
A job continues only when an interrupted execution is relaunched with the same
identifying parameters. New content is a new JobInstance, not a re-run.
-
identical parameters after completion are refused with
JobInstanceAlreadyCompleteException -
different parameters make a new instance and everything runs again
-
an interrupted instance relaunched resumes — the already-completed step’s body does not execute a second time
-
a random per-launch token orphans the failed instance and silently redoes the work
The finding nobody asks for: Boot 4 ships no JDBC job-repository
auto-configuration. Add spring-boot-starter-batch and a DataSource and you
get ResourcelessJobRepository. The job runs, every step executes, it reports
COMPLETED — and nothing is written down. Every execution is id 1, no BATCH_*
table is created, and restart therefore does not exist. Not refused: absent.
Nothing warns you, so ResourcelessDefaultTest pins it.
BatchStatus is the recorded outcome; ExitStatus is a string and the only thing
on(…) matches. A step that succeeded can still route away from the happy path
by returning a custom ExitStatus from a StepExecutionListener.
A JobExecutionDecider answers a question about the state of the world without
doing work — and leaves no StepExecution behind, so "the gate was shut" stays
distinguishable from "the gate was open and there was nothing to do".
A chunk step commits every N items with the framework owning the loop; a tasklet is
one method you own. Returning RepeatStatus.CONTINUABLE re-invokes it in a new
transaction each time, which is how a long sweep commits as it goes and survives
being killed halfway.
Two assumptions here were wrong before measurement: Batch 6 does not add a
final empty commit (7 items at chunk 3 is 3 commits, not 4), and the current
transaction’s name cannot distinguish two transactions — it is derived from the
step and is identical every time. Counting afterCommit callbacks can.
Worker k taking [k*size, (k+1)*size) is correct and usually the wrong choice: ids
follow insertion order, which follows walk order, so one contiguous block lands on
the expensive material and the step takes as long as its unluckiest worker.
Measured at 60 items over 4 workers with cost clustered at one end:
| scheme | slowest worker | vs a perfect split |
|---|---|---|
stride |
18,895 |
1.08x |
contiguous |
40,840 |
2.33x |
Both cover the same population; only the balance differs. The slowest worker is the wall clock, which is the number the test asserts on.
A JobStep runs jobOperator.start(childJob, parameters). The child gets its own
JobExecution and its own JobInstance; the parent sees one step and an exit
status, not the child’s steps. A nested failure surfaces as
UnexpectedJobExecutionException — the parent is told that the child failed,
never why.
Nothing else crosses the boundary: a value the child promotes into the child’s job execution context is invisible to the parent, whose job execution context is a different object that nothing copies into. One ExitStatus string per nesting level is the whole channel.
The child’s restart identity is whatever the JobParametersExtractor hands it, and
that one object decides everything:
-
no extractor — the default copies every parent parameter, identifying flags intact, so the child instance tracks the parent’s. Relaunching a failed parent resumes the child: the completed parent step and the completed child step both stay done. Restart nests, and you get it by writing nothing.
-
a fixed extractor — "the child always imports the catalogue" is the obvious thing to write and it makes the parent succeed exactly once. The second parent run, a legitimately new instance, dies on
JobInstanceAlreadyCompleteExceptionthrown from inside the JobStep.
The finding that changes how you read the API: JobStep caches the extracted
parameters in its own step execution context on first use and reads them back on
restart. An extractor that stamps each launch with a fresh value is called once, not
twice — and that cache is not an optimisation, it is the only reason a nested
restart can land on the same child instance at all.
split(TaskExecutor) runs flows concurrently and joins. Two things decide how to
read any split:
-
the flow passed to
start(…)is itself a branch —start(a).split(e).add(b)runsaandbat the same time. The branch count is one more than the number ofadd(…)arguments. Pinned by a rendezvous latch: neither branch can leave until the other has arrived, which sequential execution cannot satisfy. -
a failing branch does not cancel its siblings — every branch runs to completion, then the worst status wins. A split costs its slowest branch even when another failed in the first second.
A Flow bean built with FlowBuilder is shared by two jobs here, and each job
execution gets its own step executions. A Flow is structure, not an instance:
naming the same flow twice inside one job adds no second traversal.
The one that cost the most to find: a branch that completes and reports a custom
ExitStatus — the ordinary routing signal the flow sample is built on — ends the
job FAILED, with no failure exception and an empty exit description. Every
step in it completed. Running that branch alone fails identically, so it is not a
property of split at all: a flow whose step ends on a status no transition matches
ends FAILED. A split only makes it harder to see, because the job status is then the
only evidence and it does not say which branch produced it.
A step’s ExecutionContext is invisible downstream; the job’s is visible to every
later step. ExecutionContextPromotionListener is the bridge, and it is opt-in per
key, per exit status, and silent:
-
a key you did not name reads back
nullin the later step — not an error, not a warning, and indistinguishable from a value that was never computed. It is still there, in the writing step’s own context, where nothing downstream will look. -
promotion defaults to
statuses = {"COMPLETED"}. A step that completes and reports a custom exit status promotes nothing, including the key you did name — so the step that succeeded and routed, the one whose numbers you most want, is the case the default excludes.
The surprise: strict = true does not make it loud. The listener does raise
IllegalArgumentException, and AbstractStep catches every exception a listener
throws from afterStep and logs it. The step completes, the job completes, nothing
is recorded against the step execution, the next step runs with nothing to read, and
the only trace is one ERROR line. A misspelled promotion key is not detectable from
a job’s outcome under either setting — if it matters, the reading step has to assert
on what it read.