Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring & Spring Boot Interview Questions

The Spring questions asked at every level answered with mechanisms: how auto-configuration decides, why self-invocation breaks @Transactional, proxy modes, bean scopes and testing slices.

Intermediate7 min readUpdated
On this page

Spring questions separate people who use the framework from people who understand it. The difference shows in one place: whether you can explain the mechanism rather than the annotation.

Key Takeaways

  • Spring AOP works by proxying. Every surprising behaviour in @Transactional, @Async, @Cacheable and @PreAuthorize follows from that.
  • Self-invocation bypasses the proxy — the single most-asked Spring gotcha.
  • Auto-configuration is conditional configuration classes discovered from the classpath.
  • @Transactional rolls back on unchecked exceptions only, unless you say otherwise.
  • Injecting a prototype into a singleton gives you one instance forever.

Inversion of control, briefly

The container owns object creation and wiring; your code declares what it needs. The practical payoff is testability — a class taking its dependencies through a constructor can be instantiated in a unit test with fakes, and needs no framework at all.

constructor injection, and why
@Service
public class OrderService {
    private final OrderRepository repository;      // final: cannot be reassigned
    private final PaymentClient payments;
 
    // No @Autowired needed on a single constructor since Spring 4.3.
    public OrderService(OrderRepository repository, PaymentClient payments) {
        this.repository = repository;
        this.payments = payments;
    }
}

Three reasons constructor injection beats field injection, and they are all worth saying: the fields can be final so the object is immutable and thread-safe; the class is usable without Spring, so unit tests need no context; and a constructor with eight parameters is visibly a design problem, whereas eight @Autowired fields hide it.

Bean lifecycle

the order
1.  Instantiate (constructor)
2.  Populate properties / inject dependencies
3.  *Aware callbacks: BeanNameAware, ApplicationContextAware
4.  BeanPostProcessor.postProcessBeforeInitialization
5.  @PostConstruct
6.  InitializingBean.afterPropertiesSet
7.  Custom init-method
8.  BeanPostProcessor.postProcessAfterInitialization   <- proxies are created HERE
9.  ... bean is in use ...
10. @PreDestroy
11. DisposableBean.destroy
12. Custom destroy-method

Step 8 is the one that matters. AOP proxies — transactions, caching, security, async — are created by a BeanPostProcessor after initialisation. That is why a @Transactional method called from inside @PostConstruct is not transactional: the proxy does not exist yet.

The proxy, and self-invocation

Spring injects the proxy, not the bean. A call through `this` never reaches the proxy, so the annotation does nothing.
the bug
@Service
public class OrderService {
 
    public void processAll(List<String> ids) {
        for (String id : ids) {
            processOne(id);          // `this.processOne` — the proxy is bypassed.
        }                            // NO transaction. NO retry. NO caching.
    }
 
    @Transactional
    public void processOne(String id) { ... }
}

Three fixes, in order of preference:

1 — move it to another bean (best)
@Service
public class OrderService {
    private final OrderProcessor processor;      // a different bean = a real proxy call
    public void processAll(List<String> ids) { ids.forEach(processor::processOne); }
}
2 — inject a self-reference
@Service
public class OrderService {
    @Lazy private final OrderService self;       // @Lazy breaks the circular dependency
    public void processAll(List<String> ids) { ids.forEach(self::processOne); }
}
3 — AspectJ load-time weaving
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
// Weaves the advice into the bytecode, so self-invocation works — at the cost
// of a weaving agent and a more complex build.

The same rule explains why @Transactional on a private, final or static method does nothing: the proxy cannot override it.

@Transactional in depth

the settings that get asked about
@Transactional(
    propagation = Propagation.REQUIRED,      // default: join, or start one
    isolation   = Isolation.READ_COMMITTED,
    timeout     = 10,
    readOnly    = false,
    rollbackFor = Exception.class            // by default, CHECKED exceptions do NOT roll back
)

Rollback rules. By default Spring rolls back on RuntimeException and Error, and commits on a checked exception. That surprises almost everyone the first time. If you throw a checked InsufficientFundsException, the transaction commits unless you add rollbackFor.

Propagation. REQUIRED joins an existing transaction or starts one. REQUIRES_NEW suspends the current one and starts an independent one — useful for audit records that must survive a rollback, and a common source of connection-pool pressure, because it holds two connections at once. NESTED uses a savepoint. MANDATORY throws if there is no transaction; NEVER throws if there is.

readOnly. Sets the JDBC connection read-only, which lets Hibernate skip dirty checking and lets a routing datasource send the query to a replica. Worth setting on every read path.

Auto-configuration

how a starter decides
@AutoConfiguration
@ConditionalOnClass(DataSource.class)                    // is the class on the classpath?
@ConditionalOnMissingBean(DataSource.class)              // did the user define their own?
@ConditionalOnProperty(prefix = "spring.datasource", name = "url")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
    @Bean
    DataSource dataSource(DataSourceProperties props) { ... }
}

@SpringBootApplication implies @EnableAutoConfiguration, which loads candidate class names from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports in every jar on the classpath. Each candidate's conditions are evaluated at startup, and it applies only if they all pass.

That single mechanism explains the two behaviours people find magical: adding a dependency configures things (a new class on the classpath satisfies @ConditionalOnClass), and defining your own bean silently disables the default (@ConditionalOnMissingBean now fails).

see what it decided, and why not
java -jar app.jar --debug
# Prints the CONDITIONS EVALUATION REPORT: positive matches, negative
# matches with the exact condition that failed, and exclusions.

Knowing that flag is a strong practical signal — it is how you debug "why is my bean not being created?" in thirty seconds instead of an hour.

Scopes

ScopeOne instance per
singleton (default)Container
prototypeInjection or getBean call
requestHTTP request
sessionHTTP session
applicationServletContext
prototype in a singleton — the trap
@Service
public class ReportService {
    // Injected ONCE when ReportService is created. The same instance forever.
    private final ReportBuilder builder;         // wrong for a prototype
}
 
@Service
public class ReportService {
    private final ObjectProvider<ReportBuilder> builders;   // a factory
 
    public Report build() {
        return builders.getObject().build();     // a new instance per call
    }
}

The alternative is a scoped proxy: @Scope(value = "prototype", proxyMode = TARGET_CLASS), which injects a proxy that resolves a fresh instance on each method call.

Singleton beans must be stateless, because one instance serves every concurrent request. A mutable field on a @Service is a data race — and, if it holds request data, a security bug.

Testing slices

pick the smallest context that proves the thing
@ExtendWith(MockitoExtension.class)   // no Spring at all — milliseconds
class OrderServiceTest { }
 
@DataJpaTest                          // JPA, an in-memory or Testcontainers database, nothing else
class OrderRepositoryTest { }
 
@WebMvcTest(OrderController.class)    // the web layer only; services are @MockBean
class OrderControllerTest { }
 
@SpringBootTest(webEnvironment = RANDOM_PORT)   // the whole application — slow, use sparingly
class OrderIntegrationTest { }

Two things worth saying about this. Slices exist because a full @SpringBootTest per test class means a multi-second context start each time, and a suite of two hundred of them is unusable. And Spring caches contexts by their configuration, so every distinct combination of @MockBeans or properties creates another context — which is why a suite gets mysteriously slower as it grows.

The questions by level

Junior. IoC and dependency injection. Constructor versus field injection. What @SpringBootApplication does. @Component versus @Service versus @Repository. Bean scopes.

Mid. The bean lifecycle. How auto-configuration works. @Transactional propagation and rollback rules. Testing slices. Configuration properties and profiles.

Senior. The self-invocation problem and its fixes. Proxy modes (JDK dynamic versus CGLIB, and that Spring Boot defaults to CGLIB). Why a network call inside a transaction is dangerous. Context caching in tests. How you would write a starter.

The single highest-value thing to prepare is the proxy mechanism, because it is the honest answer to at least four separate questions.

Frequently Asked Questions

Why does calling a @Transactional method from another method in the same class do nothing?
Because the transaction is applied by a proxy that wraps the bean. External callers get the proxy and the interceptor runs; a call from inside the same instance uses the plain this reference and bypasses the proxy entirely, so no transaction starts. The same applies to @Async, @Cacheable and @PreAuthorize. Fixes are to move the method to another bean, inject a self-reference, or use AspectJ weaving.
How does Spring Boot auto-configuration actually work?
EnableAutoConfiguration triggers a loader that reads candidate configuration class names from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports in every jar on the classpath. Each candidate is guarded by conditional annotations — ConditionalOnClass, ConditionalOnMissingBean, ConditionalOnProperty — that are evaluated at startup. A configuration applies only if its conditions pass, which is why adding a dependency configures things and defining your own bean silently disables the default.
What happens if you inject a prototype bean into a singleton?
The prototype is instantiated once, at the point the singleton is created, and the same instance is reused forever — which defeats the scope entirely. To get a new instance per call you need a lookup: an ObjectProvider or ObjectFactory injected instead of the bean, a scoped proxy with proxyMode TARGET_CLASS, or the older @Lookup method injection.

Related tutorials