Spring Framework
Spring Boot hides most of Spring’s complexity behind auto-configuration. But when something goes wrong — a bean doesn’t wire, a transaction doesn’t commit, AOP advice doesn’t fire — you need to understand what Spring is actually doing. That means understanding the IoC container, how beans are created and managed, and how the ApplicationContext works.
Everything Spring Boot does is built on Spring Framework. Understanding the core makes Boot’s magic legible.
🟢 Junior
The IoC container
Inversion of Control means Spring creates your objects instead of you creating them. You describe what you need; Spring wires it together.
The ApplicationContext is the container. It reads your configuration (annotations, Java config, or XML), instantiates all the beans, resolves their dependencies, and makes them available for injection.
// Java-based configuration
@Configuration
public class AppConfig {
@Bean
public UserRepository userRepository(DataSource dataSource) {
return new JdbcUserRepository(dataSource);
}
@Bean
public UserService userService(UserRepository userRepository) {
return new UserServiceImpl(userRepository);
}
}
// Bootstrap manually (Spring Boot does this automatically)
ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);
UserService service = ctx.getBean(UserService.class);
You rarely write this boilerplate when using Spring Boot — @SpringBootApplication handles it. But this is what’s happening underneath.
Dependency injection styles
Spring supports three injection styles. Constructor injection is the right choice almost always.
// Constructor injection — preferred
@Service
public class OrderService {
private final UserRepository userRepo;
private final PaymentGateway payment;
public OrderService(UserRepository userRepo, PaymentGateway payment) {
this.userRepo = userRepo;
this.payment = payment;
}
}
// Setter injection — useful for optional dependencies or circular references
@Service
public class ReportService {
private EmailSender emailSender;
@Autowired
public void setEmailSender(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
// Field injection — avoid this
@Service
public class BadService {
@Autowired
private UserRepository userRepo; // can't set in tests without reflection
}
Constructor injection makes dependencies explicit, allows fields to be final, and works in tests without Spring or reflection.
Component scanning
@Component, @Service, @Repository, and @Controller are all stereotypes — they register a class as a Spring bean. They’re interchangeable except for:
@Repositoryenables translation of persistence exceptions to Spring’sDataAccessException@Controllerenables Spring MVC request mapping@Serviceis semantic — no extra behavior, but signals business logic
@Component // generic
@Service // business logic
@Repository // data access layer
@Controller // Spring MVC
@RestController // @Controller + @ResponseBody
@ComponentScan on your @Configuration class tells Spring which packages to scan. @SpringBootApplication includes a component scan of the package it’s in and all subpackages.
Bean scopes
Every bean has a scope that controls how many instances Spring creates:
@Component
@Scope("singleton") // default — one instance per ApplicationContext
public class SharedCache { }
@Component
@Scope("prototype") // new instance every injection/getBean call
public class RequestProcessor { }
@Component
@Scope("request") // one per HTTP request (web apps only)
public class RequestContext { }
@Component
@Scope("session") // one per HTTP session (web apps only)
public class UserSession { }
Singletons are the default and the right choice for stateless services. Prototype scope is useful for stateful objects that shouldn’t be shared — but Spring manages creation, not destruction. Call applicationContext.close() or destroy beans manually if they hold resources.
🟡 Medior
Bean lifecycle
Spring beans go through a defined lifecycle from creation to destruction:
Instantiate → Populate properties (inject dependencies) → BeanNameAware → BeanFactoryAware
→ ApplicationContextAware → BeanPostProcessor (before) → @PostConstruct / InitializingBean
→ Custom init-method → BeanPostProcessor (after) → Bean is ready
→ ... use the bean ...
→ @PreDestroy / DisposableBean → Custom destroy-method
@Component
public class DatabaseConnectionPool {
private ConnectionPool pool;
@PostConstruct
public void init() {
pool = ConnectionPool.create(maxConnections);
pool.warmUp();
}
@PreDestroy
public void cleanup() {
pool.drainAndClose();
}
}
@PostConstruct runs after dependency injection completes — the right place to initialize state that depends on injected dependencies. @PreDestroy runs before the context closes — use it to release resources.
ApplicationContext events
Beans can listen for lifecycle events, both Spring’s built-in events and custom application events:
@Component
public class StartupListener implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// Runs every time context is refreshed (once on startup normally)
System.out.println("Application context refreshed");
}
}
// Modern annotation style
@Component
public class EventListeners {
@EventListener
public void onStartup(ContextStartedEvent event) {
System.out.println("Context started");
}
@EventListener(condition = "#event.user.admin")
public void onAdminLogin(UserLoginEvent event) {
auditService.logAdminAccess(event.getUser());
}
@EventListener
@Async
public void onOrderCreated(OrderCreatedEvent event) {
emailService.sendConfirmation(event.getOrder());
}
}
// Publishing custom events
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public void createOrder(CreateOrderRequest req) {
Order order = orderRepo.save(new Order(req));
publisher.publishEvent(new OrderCreatedEvent(order));
}
}
Aspect-Oriented Programming
AOP adds behavior to existing code without modifying it. Spring uses proxy-based AOP — it wraps beans in dynamic proxies that intercept method calls.
@Aspect
@Component
public class LoggingAspect {
@Around("@annotation(Logged)")
public Object logExecution(ProceedingJoinPoint jp) throws Throwable {
String method = jp.getSignature().toShortString();
long start = System.currentTimeMillis();
try {
Object result = jp.proceed();
log.info("{} completed in {}ms", method, System.currentTimeMillis() - start);
return result;
} catch (Throwable ex) {
log.error("{} failed after {}ms: {}", method, System.currentTimeMillis() - start, ex.getMessage());
throw ex;
}
}
@Before("execution(* com.example.service.*Service.*(..)) && @annotation(RequiresAdmin)")
public void checkAdminRole(JoinPoint jp) {
if (!securityContext.currentUser().isAdmin()) {
throw new AccessDeniedException("Admin required");
}
}
@AfterReturning(
pointcut = "execution(* com.example.repository.UserRepository.save(..))",
returning = "result"
)
public void afterUserSaved(JoinPoint jp, Object result) {
cacheEvict("users");
}
}
Pointcut expressions target which methods the advice applies to:
execution(* com.example.service.*.*(..))— any method on any class in the service package@annotation(Logged)— any method annotated with@Loggedwithin(com.example.repository.*)— any join point within the repository packageargs(Long, ..)— methods whose first argument is a Long
Property sources and profiles
Spring’s Environment abstraction unifies system properties, environment variables, and application properties:
@Configuration
@PropertySource("classpath:app.properties")
public class AppConfig {
@Value("${app.timeout:30}") // with default
private int timeout;
@Autowired
private Environment env;
@Bean
@Profile("production") // only created when 'production' profile is active
public CacheManager redisCacheManager() { ... }
@Bean
@Profile("!production") // created in any non-production profile
public CacheManager inMemoryCacheManager() { ... }
}
Activate profiles via spring.profiles.active in properties, SPRING_PROFILES_ACTIVE environment variable, or SpringApplication.setAdditionalProfiles() in code.
🔴 Senior
Spring MVC request processing
Understanding how a request flows through Spring MVC explains behavior that otherwise seems magical:
HTTP Request
→ DispatcherServlet
→ HandlerMapping (find which controller method handles this URL)
→ HandlerAdapter (adapt the handler — MVC controller, functional endpoint, etc.)
→ HandlerInterceptor.preHandle (before controller method)
→ MessageConverter reads request body (JSON → Java object)
→ Controller method executes
→ HandlerInterceptor.postHandle (after controller, before view resolution)
→ ExceptionResolver (if an exception was thrown)
→ MessageConverter writes response body (Java object → JSON)
→ HandlerInterceptor.afterCompletion (always, even on exception)
→ HTTP Response
Writing a HandlerInterceptor:
@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
req.setAttribute("startTime", System.currentTimeMillis());
MDC.put("requestId", UUID.randomUUID().toString());
return true; // false = abort processing, return false and send your own response
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
long duration = System.currentTimeMillis() - (long) req.getAttribute("startTime");
log.info("{} {} {} {}ms", req.getMethod(), req.getRequestURI(), res.getStatus(), duration);
MDC.clear();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestLoggingInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health");
}
}
BeanPostProcessor
BeanPostProcessor lets you modify beans after Spring creates them but before they’re injected into other beans. This is how annotations like @Autowired, @Async, and @Transactional are implemented — as BeanPostProcessors that wrap beans with proxies.
@Component
public class AuditableBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean.getClass().isAnnotationPresent(Auditable.class)) {
// Return a proxy that adds audit logging around all methods
return Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, method, args) -> {
Object result = method.invoke(bean, args);
auditLog.record(beanName, method.getName(), args);
return result;
}
);
}
return bean;
}
}
Senior Gotchas
AOP only works on Spring-managed beans. If you call new MyService() yourself, the proxy is never created, and no AOP advice fires. Spring must create the instance.
Self-invocation bypasses the proxy. When a method inside a bean calls another method in the same bean, it calls this — not the proxy. @Transactional and @Async on those inner methods are silently ignored. Extract to a separate bean or inject self.
@Async requires @EnableAsync on a configuration class. Without it, async methods run synchronously with no error. The annotation is silently ignored.
Circular dependencies between constructor-injected singletons cause a BeanCurrentlyInCreationException. Spring can’t resolve A needs B, B needs A during construction. Solutions: refactor to remove the cycle, use setter injection on one side, or use @Lazy on one injection point. Circular dependencies are almost always a design problem worth fixing.
Prototype beans injected into singletons are only created once. The singleton is created once, and at that time it gets one prototype instance. If you need a new prototype each time, inject the ApplicationContext and call getBean(), or use ObjectFactory<PrototypeBean> or Provider<PrototypeBean>.
@Order on beans doesn’t affect @Autowired injection order. It controls ordering in List<T> injections and some other scenarios but has no effect on which bean is autowired when there are multiple candidates. Use @Primary or @Qualifier for that.