Design a Spring Batch job that validates a file, then branches: process normally, skip to a notify step when there is no data, run compensation and fail on validation error, and pause for manual review on an ambiguous result. Walk through the flow.
answer
- decider emits VALID/NO_DATA/INVALID/AMBIGUOUS
- NO_DATA->notify->end (success)
- INVALID->compensate->fail (restartable)
- AMBIGUOUS->stopAndRestart(review) (pause)
- fallback + most-specific-wins; end/fail set BatchStatus
basics
~10 sHave the validate step emit custom exit codes (VALID/NO_DATA/INVALID/AMBIGUOUS) via a StepExecutionListener or decider. Then branch: on("NO_DATA").to(notify).end(), on("INVALID").to(compensate) then compensate.on("*").fail(), on("AMBIGUOUS").stopAndRestart(review), on("*").to(process).end().
solid answer
~40 sFirst make the validate step express business outcomes as exit codes. Cleanest is a `JobExecutionDecider` (or a `StepExecutionListener.afterStep`) returning "VALID", "NO_DATA", "INVALID", or "AMBIGUOUS". Then compose the flow: from the decider, `on("NO_DATA").to(notifyEmpty)` and finish that branch with `end()` (successful, nothing to do); `on("INVALID").to(compensate)` and from compensate `on("*").fail()` so the job records FAILED and is restartable; `on("AMBIGUOUS").stopAndRestart(review)` to pause as STOPPED and resume at the review step; `on("*").to(process)` then `on("*").end()` for the happy path. Key decisions: keep routing in the decider not scattered in steps; always include a `*` fallback so no exit code is unhandled; use fail() vs stop() deliberately based on whether the outcome is an error or an intentional pause; and remember custom exit codes don't change BatchStatus, so terminal transitions are what actually mark the job's fate.
code
java · 15 lines@Bean
Job ingestJob(JobRepository repo, Step validate, JobExecutionDecider decider,
Step process, Step notifyEmpty, Step compensate, Step review) {
return new JobBuilder("ingestJob", repo)
.start(validate)
.next(decider)
.on("NO_DATA").to(notifyEmpty)
.from(decider).on("INVALID").to(compensate)
.from(decider).on("AMBIGUOUS").stopAndRestart(review)
.from(decider).on("*").to(process)
.from(notifyEmpty).on("*").end() // COMPLETED
.from(compensate).on("*").fail() // FAILED, restartable
.from(process).on("*").end() // COMPLETED
.build();
}go deeper
Not expected to design a multi-branch graph.
Could wire a two-way branch but likely misses decider vs listener and restart nuances.
Should produce a correct four-branch flow with proper terminal choices and fallback.
Expected to justify decider placement, fail-vs-stop semantics, idempotent compensation, restart resume points, and factoring into reusable Flow beans.
## Goal A branching pipeline with four outcomes off one validation gate. This exercises deciders, custom exit codes, non-terminal (`to`) and all three terminal transitions (`end`/`fail`/`stop`), wildcards, and the specificity rule. ## Step 1 — produce a routing key Don't overload one step's ExitStatus for complex logic; use a `JobExecutionDecider` node that returns a `FlowExecutionStatus`: ```java public class ValidationDecider implements JobExecutionDecider { @Override public FlowExecutionStatus decide(JobExecution job, StepExecution step) { long read = step != null ? step.getReadCount() : 0; if (read == 0) return new FlowExecutionStatus("NO_DATA"); if (hasSchemaErrors(step)) return new FlowExecutionStatus("INVALID"); if (isAmbiguous(step)) return new FlowExecutionStatus("AMBIGUOUS"); return new FlowExecutionStatus("VALID"); } } ``` A decider is preferable to a listener here because the routing key is derived from aggregate/business state and belongs in a dedicated flow node, keeping steps single-responsibility. ## Step 2 — compose the flow ```java @Bean Job ingestJob(JobRepository repo, Step validate, JobExecutionDecider decider, Step process, Step notifyEmpty, Step compensate, Step review) { return new JobBuilder("ingestJob", repo) .start(validate) .next(decider) .on("NO_DATA").to(notifyEmpty) // branch: empty input .from(decider).on("INVALID").to(compensate) .from(decider).on("AMBIGUOUS").stopAndRestart(review) .from(decider).on("*").to(process) // VALID + anything else -> happy path // terminate each branch .from(notifyEmpty).on("*").end() // COMPLETED: nothing to do, success .from(compensate).on("*").fail() // FAILED: compensated, mark job failed/restartable .from(process).on("*").end() // COMPLETED: happy path .build(); } ``` ### Branch-by-branch - **NO_DATA -> notifyEmpty -> end()**: an empty file is a valid, expected outcome; we send a notification and finish COMPLETED. Using `end()` (not `fail()`) means ops isn't paged. - **INVALID -> compensate -> fail()**: run cleanup/compensation (delete partial rows, roll back external side effects), then `fail()` so BatchStatus is FAILED. The job is restartable; a fixed file can be reprocessed as a new instance (new identifying params) or the same instance re-run depending on parameters. - **AMBIGUOUS -> stopAndRestart(review)**: `stop()` semantics — the job halts as STOPPED (not an error). `stopAndRestart(review)` records that a restart resumes at `review`, where a human-triggered or reconciled decision continues the flow. - **VALID / `*` -> process -> end()**: normal processing, then COMPLETED. ## Why the `*` fallback on the decider The decider's `on("*")` catches "VALID" and any unforeseen code, guaranteeing no exit code is left unhandled (an unhandled code aborts the flow with an error). Thanks to the **most-specific-wins** rule, the literal `"NO_DATA"`/`"INVALID"`/`"AMBIGUOUS"` patterns take precedence over `"*"` regardless of builder order. ## Cross-cutting design decisions 1. **Routing key location**: decider (aggregate/business routing) vs StepExecutionListener afterStep (step-local outcome). Prefer the decider for clarity here. 2. **fail() vs stop()**: fail = genuine error, pages ops, restartable; stop = intentional pause for human/external input, restartable with a designated resume step. Choosing wrong causes false alarms or silent stalls. 3. **Exit code vs BatchStatus**: custom codes only route; the terminal transition (end/fail) is what sets the final BatchStatus. If you forgot the terminal and just `.to()`'d a dead-end, the job outcome would be whatever the last step produced. 4. **Coverage**: every node that can branch has a `*` fallback. 5. **Restart correctness**: side effects in compensate must be idempotent because a restart of a FAILED job may re-run incomplete steps. 6. **Observability**: fail() with no exception yields a FAILED job without a stack trace — log the reason in the compensate step so operators understand it. ## Alternative: split flows / nested flows For very large graphs, factor branches into reusable `Flow` beans (`new FlowBuilder<Flow>("name")...`) and compose with `.start(flow)`, improving testability. Each sub-flow can be unit-tested with `FlowBuilderException`-safe assertions and `JobLauncherTestUtils`.
- Why put the routing logic in a JobExecutionDecider instead of overloading the validate step's ExitStatus?Keeps the validate step single-responsibility (validate, don't route), makes the routing key an explicit, unit-testable flow node, and lets the decision draw on job/aggregate state via JobExecution/StepExecution rather than being wedged into afterStep. It reads better as the graph grows.
- The compensate branch ends with fail(). What must be true about the compensate step for a safe restart?Its side effects must be idempotent. A FAILED job is restartable and Spring Batch may re-run steps that didn't complete; compensation that isn't idempotent (double-deletes, duplicate external calls) would corrupt state on the second run.
- How would you resume the AMBIGUOUS branch after a human review?The stopAndRestart(review) recorded review as the restart entry point. Re-launch the same job instance (via JobOperator.restart or re-running with the same identifying parameters); Spring Batch resumes at the review step, from which the flow continues based on the reviewer's outcome.
saying these in an interview costs you the question
- Ending the INVALID branch with end() (marks the job COMPLETED, hiding the failure)
- Omitting the `*` fallback on the decider, leaving VALID unhandled and aborting the flow
- Assuming custom exit codes set BatchStatus, so no terminal transition is needed
- Non-idempotent compensation despite fail() making the job restartable