How does @StepScope actually work under the hood — why can a singleton Step inject a step-scoped reader, and what role does the scoped proxy play?
answer
- CGLIB scoped proxy = TARGET_CLASS default
- Proxy injected into singleton Step, delegates per call
- StepSynchronizationManager ThreadLocal binds StepExecution
- Real bean created lazily on first call -> SpEL resolves
- final classes not proxyable; scope inactive outside step
basics
~20 sSpring injects a scoped proxy, not the real bean. The singleton Step holds this proxy; on each method call the proxy looks up the real step-scoped instance bound to the currently running step and delegates to it. That deferral is what makes late binding possible.
solid answer
~50 s@StepScope registers a custom scope named "step" (via @EnableBatchProcessing). When a singleton — like the Step built by StepBuilder — depends on a @StepScope reader, Spring cannot inject a real instance at wiring time because none exists yet and none should be shared across runs. Instead it injects a CGLIB scoped proxy (proxyMode = TARGET_CLASS by default). The proxy is created eagerly and satisfies the singleton's dependency, but every method invocation is delegated to the actual bean resolved from the current step context. That context is tracked by StepSynchronizationManager, which binds the running StepExecution/StepContext to the thread. When the step starts, the proxy triggers lazy creation of the real reader, at which point the SpEL late-binding expressions resolve against the live jobParameters and stepExecutionContext. When the step ends, the scoped instance is discarded. This proxy indirection is precisely why a startup-time singleton can safely reference a per-run bean.
code
java · 25 lines// The Step is a singleton, wired at startup.
@Bean
public Step importStep(JobRepository repo, PlatformTransactionManager tx,
ItemReader<Customer> reader, // <-- actually a scoped PROXY
ItemWriter<Customer> writer) {
return new StepBuilder("importStep", repo)
.<Customer, Customer>chunk(100, tx)
.reader(reader) // proxy stored; real reader resolved per run
.writer(writer)
.build();
}
@Bean
@StepScope // == @Scope(value = "step", proxyMode = TARGET_CLASS)
public FlatFileItemReader<Customer> reader(
@Value("#{jobParameters['inputFile']}") String path) {
// instantiated on first proxy call after the step starts,
// when jobParameters exist -> late binding resolves 'path'
return new FlatFileItemReaderBuilder<Customer>()
.name("customerReader")
.resource(new FileSystemResource(path))
.delimited().names("id", "name")
.targetType(Customer.class)
.build();
}go deeper
Just know a proxy stands in for the real per-step bean.
Explain that the proxy delegates to a lazily created instance so late binding can happen.
Detail CGLIB TARGET_CLASS proxy, StepSynchronizationManager ThreadLocal binding, and the create-on-first-call lifecycle.
Reason about proxyability constraints (final), multi-threaded sharing of the scoped instance, and state-reset semantics per run.
## The core tension A `Step` (and the surrounding `Job`) is a **singleton**, wired once at context startup. A `@StepScope` reader must be created **per step run** and needs runtime data. So at wiring time we have a singleton that depends on a bean that *doesn't exist yet and shouldn't be shared*. How does Spring reconcile this? ## The scoped proxy Spring's answer is a **scoped proxy**. When you write `@StepScope` (which is `@Scope(value="step", proxyMode = TARGET_CLASS)` under the hood), Spring registers a **proxy object** in place of the real bean. This proxy: - is created **eagerly** and is itself effectively a singleton reference, so it satisfies the `Step`'s dependency at startup; - on **every method call**, looks up the *actual* target instance from the `step` scope for the **currently active** step and forwards the call. By default the proxy mode is **`TARGET_CLASS`** → a **CGLIB** subclass proxy, which works even when the bean has no interface (readers/writers often don't). `INTERFACES` (JDK dynamic proxy) is possible but less common in batch. ## Who tracks "the current step"? `StepSynchronizationManager` (and `JobSynchronizationManager` for `@JobScope`) maintain a **`ThreadLocal`-backed** holder of the current `StepContext` / `StepExecution`. Spring Batch's `AbstractStep` registers the execution before the step body runs and unregisters it afterward. When the proxy needs the real bean, the `StepScope` implementation: 1. reads the current `StepContext` from the synchronization manager; 2. looks in that context's attribute map for an existing instance keyed by bean name; 3. if absent, **creates it now** — which is when `@Value("#{jobParameters[...]}")` SpEL is evaluated (late binding) — and stores it; 4. returns it to the proxy to delegate the call. ## Lifecycle - **Startup:** proxy created, injected into singleton `Step`. No real reader yet. - **Step starts:** synchronization manager binds the `StepExecution`. - **First method call through proxy:** real reader instantiated, SpEL resolved, cached in the step context. - **During the step:** all proxy calls hit the same real instance. - **Step ends:** context is cleared, the scoped instance eligible for GC. ## Why this matters (edge cases / gotchas) - **Accessing outside a step:** if code touches the proxy when no `StepContext` is bound (e.g. a `@PostConstruct`, a scheduled task, an integration-test call before launching), the scope is inactive → `IllegalStateException: No context holder available for step scope` / `Scope 'step' is not active for the current thread`. - **Return-type erasure:** builders sometimes need the concrete type. Because the default proxy is CGLIB (`TARGET_CLASS`), returning a concrete class works; returning a very generic type can occasionally hide methods the caller needs — declare the concrete reader type on the `@Bean` method. - **`final` classes/methods:** CGLIB cannot subclass `final` classes or proxy `final` methods — a step-scoped bean must be proxyable. - **State reset:** because a new instance is created per step, per-run state (cursors, counters) is naturally reset — a key reason to prefer `@StepScope` for stateful readers/writers even when you don't strictly need late binding. - **Multi-threaded steps:** the scoped instance is shared across the worker threads of a single `StepExecution`; if it holds mutable state it must be thread-safe (or you should partition instead). ## When to use vs @JobScope Use `@StepScope` for step-local, per-run, or partition-specific beans and anything reading `stepExecutionContext`. Use `@JobScope` (same proxy machinery, `JobSynchronizationManager`) when the instance should live for the whole job.
- What is the default proxyMode for @StepScope and why does it matter?TARGET_CLASS, i.e. a CGLIB subclass proxy. It matters because batch readers/writers usually have no interface, so a JDK dynamic proxy (INTERFACES) couldn't proxy them; CGLIB subclasses the concrete class. The trade-off is that final classes/methods can't be proxied.
- You get 'Scope step is not active for the current thread' — what causes it?Something is dereferencing a step-scoped bean when no StepExecution is bound to the thread — e.g. from @PostConstruct, a scheduler/other thread, or a test that calls the bean before launching a job. The StepSynchronizationManager has no context to resolve the real instance from.
saying these in an interview costs you the question
- Saying the singleton Step holds the real reader instance directly
- Claiming the scoped bean is created at context startup
- Thinking @StepScope uses prototype scope semantics with no proxy
- Believing a final reader class can be step-scoped