RU | EN | DE

Spring Core

Inversion of Control

Inversion of Control — this is a principle where the creation of objects and passing dependencies to them is delegated to a special container (e.g., Spring), so that the classes themselves do not manage this process and do not know where their dependencies come from.

Types of Dependency Injection

  • Field injection using annotation — Autowired
@Autowired
private UserService userService;
  • Constructor injection (the most popular option)
@Service
public class TestService {        
 
	private final ProcessService processService;    
 
	public TestService(ProcessService processService) {        
		this.processService = processService;    
	}
}
  • Setter injection
@Service
public class TestService {    
	private ProcessService processService;    
	
	@Autowired    
	public void setProcessService(ProcessService processService) {        
		this.processService = processService;    
	}
}

Differences between @Component, @Service, @Repository, @Controller

  • @Component — base annotation; marks any class as a Spring bean.
  • @Service — same as @Component, but semantically for business logic; helps readability and architectural structure.
  • @Repository — @Component for the DAO layer; additionally intercepts database exceptions and converts them to Spring DataAccessException.
  • @Controller — used for the web layer in MVC applications; returns HTML/templates by default, not JSON.
  • @RestController — this is @Controller + @ResponseBody, and returns JSON by default.

@ComponentScan

ComponentScan — this is an annotation that tells Spring where to look for classes with @Component, @Service, @Repository, @Controller, @RestController and @Configuration annotations, to automatically create beans — including those defined through @Bean methods inside these configuration classes, and pass them under the control of the container.

Bean Lifecycle

  1. Creation — the container creates the bean object.
  2. Dependency injection — all dependencies are injected (DI).
  3. Initialization — initialization methods are called:
    • annotation @PostConstruct
    • if the bean is via @Bean, you can specify initMethod.
  4. Destruction — destruction methods are called:
    • annotation @PreDestroy
    • or destroyMethod at @Bean.

Bean Scopes

  • Singleton (default) — one instance for the entire container.
  • Prototype — a new instance on each bean request.
  • Request / Session / Application — for web applications, created per HTTP request, session, or application.

Pitfall: if a Prototype is injected into a Singleton, Spring will create only one instance when creating the Singleton, not a new one each time.

BeanFactoryPostProcessor and BeanPostProcessor

  • BeanFactoryPostProcessor — allows modifying bean metadata before they are created by the container. Example: PropertySourcesPlaceholderConfigurer.
  • BeanPostProcessor — intercepts the already created bean before use. Proxying, AOP, and @Transactional are based on this. Methods are called in order: postProcessBeforeInitialization → initialization → postProcessAfterInitialization.

Spring AOP

Spring AOP — this is a mechanism of aspect-oriented programming that allows extracting repetitive functionality (logging, transactions, security) into separate aspects, without mixing it with business logic.

Aspect — this is a module containing advice (code that executes before, after, or around a method) and pointcut (rules for where this code should be applied).

Limitations of Spring AOP:

  • Calls within the same class — methods call each other directly, proxy is not triggered.
  • Final classes and methods — CGLIB proxies cannot override final, JDK proxies don’t work with classes.
  • Private methods — proxy only works with public/protected/package-private methods, private cannot be AOP-proxied.

CGLIB — this is a library that Spring uses to create proxy classes through inheritance. It allows wrapping a bean if it does not implement an interface (unlike JDK Dynamic Proxy, which only works with interfaces).

That is, AOP works through proxies, and these limitations are direct consequences of this mechanism.

With the AOP limitation, interview problems are often given, example:

@Service
public class OrderService {    
	
	@PostConstruct    
	public void init() {        
		// Call of a transactional method inside @PostConstruct        
		processPayment(); // @Transactional won't work here    
	}    
	
	@Transactional    
	public void processPayment() {        
		System.out.println("Payment processed");    
	}
}

Why it doesn’t work:

  1. Proxy mechanism: Spring creates a proxy around the bean to handle @Transactional
  2. Internal call: When you call processPayment() from init() of the same class, you bypass the proxy and call the method directly
  3. AOP not applied: The transaction interceptor doesn’t trigger, since the call doesn’t go through the proxy

How to work around it:

From the Spring docs:

BeanPostProcessors are applied before any initialization methods (such as @PostConstruct)

The Spring Framework Reference explicitly states that BeanPostProcessors (which create AOP proxies) are applied before calling @PostConstruct.

@Component
public class OrderService {        
	
	@Autowired    
	private ApplicationContext context;        
	
	@PostConstruct    
	public void init() {        
		context.getBean(MyService.class).processPayment();    
	}    
	
	@Transactional    
	public void processPayment() {        
		System.out.println("Payment processed");    
	}
}

This solution with ApplicationContext should work. But you immediately want to say, say that you need to extract the method to a separate bean in that case, so if they say you can’t and we really want it in this one, then you can mention ApplicationContext.

@SpringBootApplication

To understand what @SpringBootApplication calls and how it works, it’s enough to look at the docs or go directly into the annotation from IDEA:

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),	
	@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
 
public @interface SpringBootApplication

How Spring Boot raises the context:

  1. Environment — collects properties and profiles.
  2. ApplicationContext — creates and configures the bean container.
  3. BeanFactory — registers all beans and dependencies.
  4. Refresh — bean initialization and lifecycle method calls.
  5. Listeners — application events are triggered (ApplicationReadyEvent, etc.).
  6. Embedded server — if it’s a web application, an embedded server is started (Tomcat/Jetty).

That is, Boot goes through the chain: configuration → bean creation → initialization → events → web server start.

@Primary

  • Marks a bean as primary if there are multiple candidates of the same type.
  • Spring will automatically select it during injection, if no @Qualifier is specified.
@Primary
@Component
public class PaypalPaymentService implements PaymentService { }
 
@Component
public class StripePaymentService implements PaymentService { }
 
@Service
public class OrderService {    
	@Autowired    
	private PaymentService paymentService; // PaypalPaymentService will be selected
}

@Qualifier

Allows explicitly specifying which bean to use, even if there are multiple candidates.

@Service
public class OrderService {    
	@Autowired    
	@Qualifier("stripePaymentService")    
	private PaymentService paymentService; // StripePaymentService will be selected
}

@Transactional

I know there are already a million articles on the topic of @Transactional, how it works under the hood, what issues there are, etc. I’ll explain it very briefly:

  • Proxy around the method — Spring creates a proxy (JDK or CGLIB) that intercepts the method call and manages the transaction.
  • TransactionInterceptor — a component that wraps the method, opens a transaction before execution, and commits/rolls back after.
  • Propagation — rules for how a method participates in an existing transaction:
    • REQUIRED — use existing or create a new transaction (Default).
    • REQUIRES_NEW — always create a new one, suspending the current one.
    • NESTED — nested transaction, can be partially rolled back.
    • SUPPORTS — use a transaction if there is one, otherwise work without one.
    • NOT_SUPPORTED — work outside a transaction, suspending the existing one.
    • NEVER — throw an error if a transaction is already present.
    • MANDATORY — must use an existing transaction, otherwise exception.
  • Isolation levels — isolation level: READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE and READ_UNCOMMITTED.
  • Why a transaction may not work:
    • private method (proxy doesn’t see the call)
    • internal call of a method of the same class (this.method())
    • final class or final method (CGLIB can’t create a proxy)
    • calling setRollbackOnly() without proper rollback

@Profile

@Profile - annotation for conditional inclusion of a bean or configuration depending on the active application profile:

@Configuration
@Profile("dev")
public class DevConfig {    
	@Bean    
	public DataSource dataSource() {        
		return new H2DataSource();    
	}
}

Important: if a bean is marked with @Profile, and you try to inject it in code with an inactive profile, Spring will not find this bean, and the application will crash with a NoSuchBeanDefinitionException error.

@ConditionalOnProperty

@ConditionalOnProperty — a Spring Boot annotation that allows creating a bean only if a certain property is set in application.properties or application.yml.

@Service
@ConditionalOnProperty(name = "app.payment.enabled", havingValue = "true")
public class PaymentService {    
	// bean will only be created if feature.payment.enabled=true
}

How to Inject Multiple Beans Implementing the Same Interface

A question I’ve been asked more than once)
Let’s imagine we have a PaymentService interface with 2 implementations, and we want to run both payment methods in a service:

public interface PaymentService {    
	void pay();
}
 
@Service("creditCardPayment")
public class CreditCardPaymentService implements PaymentService {    
	@Override    
	public void pay() {        
		System.out.println("Credit card payment");
	}
}
 
@Service("paypalPayment")
public class PaypalPaymentService implements PaymentService {    
	@Override    
	public void pay() {        
		System.out.println("PayPal payment");    
	}
}

Injection via List<PaymentService>

Often injection via List\<PaymentService\> is used to get all implementation services:

@Service
public class OrderService {    
	private final List<PaymentService> paymentServices;    
	
	@Autowired    
	public OrderService(List<PaymentService> paymentServices) {
		this.paymentServices = paymentServices;    
	}    
	
	public void processAllPayments() {
		paymentServices.forEach(PaymentService::pay);    
	}
}

Injection via Map<String, PaymentService>

We can inject such beans via Map<String, PaymentService>. In this case, the keys will be bean names ("creditCardPayment" and "paypalPayment"), and the values will be the corresponding implementations.

@Service
public class OrderService {    
	private final Map<String, PaymentService> paymentServices;    
	
	@Autowired    
	public OrderService(Map<String, PaymentService> paymentServices) {
		this.paymentServices = paymentServices;    
	} 
	
	public void processSpecificPayment(String type) {
		paymentServices.get(type).pay();
	}
}

Self Injection

Self injection - this is when a class injects itself via the Spring container, usually through a proxy.

Why it’s needed:

  • To call your own method annotated with @Transactional or @Async, so the Spring proxy correctly handles the aspect.
  • A direct method call via this does not go through the proxy, so annotations won’t work.
@Service
public class OrderService {    
	@Autowired    
	@Lazy    
	private OrderService self;    
	
	@Transactional    
	public void processOrder() {        
		// code    
	}    
	
	public void startProcess() {        
		self.processOrder(); // proxy will be activated    
	}
}

@Cacheable

There is such a wonderful annotation as @Cacheable, which enables caching on a method, so that when calling the method with the same parameters, we don’t execute it again, but get the value from cache:

@Cacheable(cacheNames = "test", key = "#test")  
public String test(int test) {      
	return LocalDateTime.now().toString();  
}

Sometimes a similar question to transactions is asked, but with cache. Let’s take as an example:

@Service
@EnableCaching
public class CacheClass {        
	
	@SneakyThrows    
	@PostConstruct    
	public void init() {        
		System.out.println(test(1));        
		Thread.sleep(1000);        
		System.out.println(test(2));        
		Thread.sleep(1000);        
		System.out.println(test(1));    
	}    
 
	@Cacheable(cacheNames = "test", key = "#integer")    
	public String test(int integer) {        
		return LocalDateTime.now().toString();    
	}
}

Here we see that there is neither self injection nor ApplicationContext, which means this won’t work. With self injection or ApplicationContext everything would work, but for @Cacheable the situation is even worse: the cache aspect is initialized later, so calling @Cacheable from @PostConstruct usually doesn’t workthere is an explicit issue for this in Spring.

Here you can use ApplicationRunner or CommandLineRunner, which can help, simply by implementing it:

@Service
@EnableCaching
public class CacheClass implements ApplicationRunner {    
	@Autowired    
	@Lazy    
	private CacheClass self;        
 
	@Override    
	public void run(ApplicationArguments args) throws Exception {       
		System.out.println(self.test(1));        
		Thread.sleep(1000);        
		System.out.println(self.test(2));        
		Thread.sleep(1000);        
		System.out.println(self.test(1));    
	}    
	
	@Cacheable(cacheNames = "test", key = "#integer")    
	public String test(int integer) {        
		return LocalDateTime.now().toString();    
	}
}

Code to Experiment with @Transactional

To check different ways of working with @Transactional, I used this code:

@Service
public class TestClass {    
	@Autowired    
	@Lazy    
	private TestClass self;    
 
	@Autowired    
	private ApplicationContext applicationContext;    
 
	@PostConstruct    
	public void init() {        
		System.out.println("Testing @Transactional in @PostConstruct");        
 
		// Test 1: Direct call (should not work)        
		System.out.println("1. Direct call:");        
 
		try {            
			directTransactionalMethod();        
		} catch (Exception e) {            
			System.out.println("Error: " + e.getMessage());        
		}        
 
		// Test 2: Via self + @Lazy        
		System.out.println("2. self + @Lazy:");        
		self.selfTransactionalMethod();        
		
		// Test 3: Via ApplicationContext        
		System.out.println("3. ApplicationContext:");        
		TestClass proxy = applicationContext.getBean(TestClass.class);        
		proxy.contextTransactionalMethod();        
		System.out.println("@PostConstruct executed");    
	}    
 
	@Transactional(propagation = Propagation.REQUIRES_NEW)    
	public void directTransactionalMethod() {
		checkTransaction("directTransactionalMethod");    
	}    
 
	@Transactional(propagation = Propagation.REQUIRES_NEW)    
	public void selfTransactionalMethod() {
		checkTransaction("selfTransactionalMethod");    
	}    
 
	@Transactional(propagation = Propagation.REQUIRES_NEW)    
	public void contextTransactionalMethod() {
		checkTransaction("contextTransactionalMethod");    
	}    
 
	private void checkTransaction(String methodName) {        
		boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();   
		String transactionName = TransactionSynchronizationManager.getCurrentTransactionName();        
		System.out.println("   " + methodName + ":");        
		System.out.println("   - Transaction active: " + isActive);        
		System.out.println("   - Transaction name: " + transactionName);    
	}
}

Result:

Testing @Transactional in @PostConstruct
1. Direct call:
   directTransactionalMethod:
   - Transaction active: false
   - Transaction name: null
2. self + @Lazy:
   selfTransactionalMethod:
   - Transaction active: true
   - Transaction name: TestClass.selfTransactionalMethod
3. ApplicationContext:
   contextTransactionalMethod:
   - Transaction active: true
   - Transaction name: TestClass.contextTransactionalMethod

It’s quite simple; with its help you can try different methods or test your own)

Summary

Today we reviewed the key aspects of Spring: working with beans, the main annotations and pitfalls that often come up at Java/Kotlin interviews. The list of topics was compiled based on my experience and the experience of colleagues who went through interviews for positions from Junior to Senior.