skip to content

Explain the difference between BatchStatus and ExitStatus, and how you customize ExitStatus to drive a conditional transition.

level: middleimportance: must knowfreq 50%

answer

  1. BatchStatus = enum, restart/success
  2. ExitStatus = string code, flow matches this
  3. customize in afterStep or JobExecutionDecider
  4. custom code does NOT change BatchStatus
  5. COMPLETED WITH SKIPS bites on(COMPLETED)

basics

~20 s

BatchStatus is the framework's enum (COMPLETED/FAILED/STOPPED) deciding success and restart. ExitStatus is a customizable string that flow patterns (on(...)) match on. Override ExitStatus in a StepExecutionListener's afterStep to return custom codes like "NO_DATA" and branch on them.

solid answer

~40 s

`BatchStatus` is an enum on the execution (`COMPLETED`, `FAILED`, `STOPPED`, `ABANDONED`, `STARTING`, ...) that the framework uses to determine overall job outcome and restartability. `ExitStatus` is a richer value object wrapping a **string exit code** plus a description — and it is the exit code that `on(pattern)` transitions match against. By default they align: a successful step yields `ExitStatus.COMPLETED` (code "COMPLETED"). To branch on business conditions you customize the ExitStatus, typically in a `StepExecutionListener.afterStep(...)` returning `new ExitStatus("NO_DATA")`, or from a decider. Then `on("NO_DATA").to(skipStep)` routes accordingly. Custom codes let a technically-successful step steer the flow. Note that returning a custom ExitStatus does not change the BatchStatus, so the step still counts as COMPLETED for restart purposes.

code

java · 14 lines
java
public class DataPresenceListener implements StepExecutionListener {
    @Override public ExitStatus afterStep(StepExecution se) {
        return se.getReadCount() == 0 ? new ExitStatus("NO_DATA") : se.getExitStatus();
    }
}

@Bean Job loadJob(JobRepository repo, Step load, Step process, Step notifyEmpty) {
    return new JobBuilder("loadJob", repo)
        .start(load)
        .on("NO_DATA").to(notifyEmpty)
        .from(load).on("*").to(process)
        .end()
        .build();
}

go deeper

for a junior

May only know default COMPLETED/FAILED codes; edge is fine.

for a middle

Expected to cleanly separate the two statuses and show afterStep customization.

for a senior

Should mention decider alternative and the COMPLETED-WITH-SKIPS trap.

for a principal

Should reason about restart implications of decoupling exit code from BatchStatus and choose decider vs listener deliberately.

## Two status fields, two jobs Every `StepExecution` (and `JobExecution`) carries two distinct status concepts: ### BatchStatus (enum) `org.springframework.batch.core.BatchStatus` is an **enum**: `COMPLETED`, `STARTING`, `STARTED`, `STOPPING`, `STOPPED`, `FAILED`, `ABANDONED`, `UNKNOWN`. The framework uses it to: - decide whether the job as a whole succeeded, - decide restartability (a `FAILED` or `STOPPED` job can be restarted; `COMPLETED` normally cannot re-run with the same identifying job parameters), - persist a canonical machine outcome in the `BATCH_STEP_EXECUTION` / `BATCH_JOB_EXECUTION` tables. ### ExitStatus (value object) `org.springframework.batch.core.ExitStatus` wraps **two strings**: an **exit code** (e.g. "COMPLETED", "FAILED", or anything custom) and a human-readable **exit description** (often a stack trace on failure). Crucially, **the exit code is what conditional flow transitions match against** in `on(...)`. ## Default mapping Out of the box the exit code mirrors the BatchStatus name: a completed step has exit code "COMPLETED", a failed one "FAILED". That is why `on("COMPLETED")` and `on("FAILED")` work with zero customization. ## Customizing ExitStatus to branch The whole point of decoupling the two is that a step can complete *successfully* (BatchStatus COMPLETED) yet report a *business* outcome that changes the route. You set a custom exit code in a listener: ```java public class DataPresenceListener implements StepExecutionListener { @Override public ExitStatus afterStep(StepExecution se) { if (se.getReadCount() == 0) { return new ExitStatus("NO_DATA"); // custom exit code } return se.getExitStatus(); // leave default (COMPLETED) } } ``` Returning `null` from `afterStep` keeps the existing ExitStatus. Then: ```java job.start(loadStep) .on("NO_DATA").to(notifyEmptyStep) .from(loadStep).on("*").to(processStep) .end(); ``` ## Important: custom exit code does NOT change BatchStatus Returning `new ExitStatus("NO_DATA")` leaves BatchStatus at COMPLETED. So the step is still considered successfully completed for restart accounting. If you actually want to *fail* or *stop* the job, use terminal transitions (`fail()`, `stop()`) or throw — do not rely on the exit code alone. ## JobExecutionDecider — the cleaner alternative When branching logic is complex or not tied to a single step's execution, implement `JobExecutionDecider`: ```java public class MyDecider implements JobExecutionDecider { public FlowExecutionStatus decide(JobExecution jobEx, StepExecution stepEx) { return new FlowExecutionStatus(readCount > threshold ? "LARGE" : "SMALL"); } } // job.start(a).next(decider).on("LARGE").to(bigStep).from(decider).on("SMALL").to(smallStep).end(); ``` A `FlowExecutionStatus` string is matched by `on(...)` exactly like an ExitStatus code. The decider is preferred when the routing key is not naturally a step's own exit status. ## Gotchas - Exit codes are compared with `*`/`?` glob patterns, not equals — `on("COMPLETED WITH SKIPS")` won't match if you registered `on("COMPLETED")` (a completed-with-skips code is a *different* string). Add explicit patterns or a `*` fallback. - Skip/retry configurations can produce the built-in `"COMPLETED WITH SKIPS"` exit code, which will fall through `on("COMPLETED")` — a classic bug. Use `on("COMPLETED*")` or a `*` catch-all.

  • A step configured with skip limits sometimes routes to the wrong branch on `on("COMPLETED")`. Why?
    When skips occur the framework sets the exit code to "COMPLETED WITH SKIPS", a different string that the glob `"COMPLETED"` does not match. Use `on("COMPLETED*")` or a `*` catch-all to cover it.
  • When would you use a JobExecutionDecider instead of a StepExecutionListener to set the routing key?
    When the routing decision is not a property of one step's execution — e.g. it depends on external state, job parameters, or aggregate results — a decider gives a dedicated flow node returning a FlowExecutionStatus, keeping steps free of routing concerns.

saying these in an interview costs you the question

  • Claiming returning a custom ExitStatus also flips the BatchStatus to failed
  • Assuming `on("COMPLETED")` catches "COMPLETED WITH SKIPS"
  • Confusing ExitStatus (string, matched by flow) with BatchStatus (enum, restart logic)

context