Spring is the most popular application framework in the Java ecosystem, providing IoC container, AOP, MVC, transaction management, and more — greatly simplifying enterprise application development.
Spring Core Modules
The Spring Framework is composed of multiple modules that can be included as needed:
| Module | Description |
|---|---|
| spring-core | Core foundation of the IoC container |
| spring-beans | BeanFactory — responsible for Bean creation and management |
| spring-context | ApplicationContext — extends beans with richer features |
| spring-aop | Aspect-Oriented Programming support |
| spring-webmvc | Spring MVC framework |
| spring-tx | Transaction management |
| spring-data-jpa | JPA integration |
| spring-boot | Convention over configuration, auto-configuration |
IoC (Inversion of Control) & DI (Dependency Injection)
What is IoC
In the traditional model, objects are responsible for creating their own dependencies:
public class UserService {
// Instantiated directly — high coupling
private UserRepository repo = new UserRepositoryImpl();
}
IoC (Inversion of Control) hands over the creation of objects and the management of dependencies to the Spring container. Objects only declare what they need, and the container injects it:
@Service
public class UserService {
private final UserRepository repo;
// Spring injects automatically
public UserService(UserRepository repo) {
this.repo = repo;
}
}
Control is "inverted" from the object itself to the container — that's the core idea of IoC.
Three Ways to Inject Dependencies
1. Constructor Injection (Recommended)
@Service
public class OrderService {
private final UserRepository userRepo;
private final ProductRepository productRepo;
// @Autowired can be omitted when there is only one constructor
public OrderService(UserRepository userRepo, ProductRepository productRepo) {
this.userRepo = userRepo;
this.productRepo = productRepo;
}
}
Advantages: dependencies can be declared final, making them immutable, easy to test, and ready as soon as the object is constructed.
2. Setter Injection
@Service
public class NotificationService {
private EmailSender emailSender;
@Autowired
public void setEmailSender(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
Suitable for optional dependencies or scenarios where the implementation needs to be swapped at runtime.
3. Field Injection (Not Recommended)
@Service
public class UserService {
@Autowired
private UserRepository userRepo; // cannot be final; hard to unit-test
}
Concise, but cannot use final, and the dependency is null when the object is instantiated outside the container. Not recommended in production code.
Bean Management
Defining Beans
Annotation-based (most common)
@Component // General component
@Service // Business layer
@Repository // Data access layer (also enables JPA exception translation)
@Controller // MVC controller
@RestController // = @Controller + @ResponseBody
Java Config
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public EmailSender emailSender() {
return new SmtpEmailSender();
}
}
XML (still used in legacy projects)
<bean id="userService" class="com.example.UserService">
<constructor-arg ref="userRepository"/>
</bean>
Bean Scopes
@Component
@Scope("singleton") // Default — only one instance in the container
public class AppConfig { }
@Component
@Scope("prototype") // A new instance is created on every getBean()
public class RequestProcessor { }
// Web-environment specific
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
public class RequestContext { }
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
public class UserSession { }
| Scope | Description |
|---|---|
| singleton | Default — one unique instance per container |
| prototype | New instance created for every injection / getBean() |
| request | One instance per HTTP request (web environment) |
| session | One instance per HTTP Session (web environment) |
Bean Lifecycle
Instantiation
→ Property injection
→ BeanNameAware / BeanFactoryAware / ApplicationContextAware
→ BeanPostProcessor#postProcessBeforeInitialization
→ @PostConstruct
→ InitializingBean#afterPropertiesSet
→ init-method
→ BeanPostProcessor#postProcessAfterInitialization
→ 【Bean ready】
→ @PreDestroy
→ DisposableBean#destroy
→ destroy-method
→ Destroyed
Common lifecycle hooks:
@Component
public class DataSourcePool {
@PostConstruct
public void init() {
// Runs after the Bean is fully initialized — good for connection pool warm-up
System.out.println("Connection pool initialized");
}
@PreDestroy
public void destroy() {
// Runs before the container shuts down — good for releasing resources
System.out.println("Releasing connection pool resources");
}
}
Common Annotations
Injection & Configuration
// Inject by type
@Autowired
private UserRepository userRepo;
// When multiple beans of the same type exist, pick by name
@Autowired
@Qualifier("mysqlUserRepo")
private UserRepository userRepo;
// Equivalent to @Autowired + @Qualifier, more concise (JSR-250)
@Resource(name = "mysqlUserRepo")
private UserRepository userRepo;
// Inject a value from the configuration file
@Value("${app.name}")
private String appName;
// Inject with a default value
@Value("${app.timeout:30}")
private int timeout;
// Inject a SpEL expression
@Value("#{systemProperties['user.home']}")
private String userHome;
Configuration Class Annotations
@Configuration // Marks a configuration class
@ComponentScan("com.example") // Specifies the base package to scan
@PropertySource("classpath:app.properties") // Loads an extra properties file
@Import(SecurityConfig.class) // Imports another configuration class
public class AppConfig { }
Conditional Assembly (Common in Spring Boot)
// Assemble only when the property exists and has the specified value
@ConditionalOnProperty(name = "feature.cache.enabled", havingValue = "true")
@Bean
public CacheManager cacheManager() {
return new RedisCacheManager(...);
}
// Assemble only when no bean of the specified type exists (provide a default implementation)
@ConditionalOnMissingBean(DataSource.class)
@Bean
public DataSource embeddedDataSource() {
return new EmbeddedDatabaseBuilder().build();
}
// Assemble only when the specified class is on the classpath
@ConditionalOnClass(RedisTemplate.class)
@Configuration
public class RedisAutoConfig { }
AOP (Aspect-Oriented Programming)
AOP allows you to handle cross-cutting concerns — such as logging, authorization, performance monitoring, and transactions — without modifying business code.
Core Concepts
| Concept | Description |
|---|---|
| Aspect | A class that encapsulates cross-cutting logic, annotated with @Aspect |
| Pointcut | Defines which methods to intercept, described by an expression |
| Advice | The code to execute when a pointcut is triggered |
| JoinPoint | A point in execution that can be intercepted (method call, exception, …) |
| Weaving | The process of applying aspects to target objects |
Advice Types
@Aspect
@Component
public class LoggingAspect {
// Pointcut expression — matches all methods in the service package
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceMethods() {}
// Before method execution
@Before("serviceMethods()")
public void logBefore(JoinPoint joinPoint) {
System.out.println("Calling: " + joinPoint.getSignature().getName());
}
// After method execution (regardless of outcome)
@After("serviceMethods()")
public void logAfter(JoinPoint joinPoint) {
System.out.println("Method finished");
}
// After normal return
@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void logAfterReturning(Object result) {
System.out.println("Return value: " + result);
}
// After an exception is thrown
@AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
public void logAfterThrowing(Exception ex) {
System.out.println("Exception: " + ex.getMessage());
}
// Around advice (most powerful — can control whether the method runs)
@Around("serviceMethods()")
public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // Execute the target method
long elapsed = System.currentTimeMillis() - start;
System.out.println(pjp.getSignature() + " took: " + elapsed + "ms");
return result;
} catch (Exception e) {
System.out.println("Exception: " + e.getMessage());
throw e;
}
}
}
Pointcut Expressions
// Match a specific method exactly
execution(public String com.example.service.UserService.findById(Long))
// Match all methods in a class
execution(* com.example.service.UserService.*(..))
// Match all methods in a package and its sub-packages (.. means any sub-package)
execution(* com.example.service..*.*(..))
// Match methods annotated with a specific annotation
@annotation(com.example.annotation.Log)
// Match all types within a package
within(com.example.service.*)
// Combination (exclude methods starting with "get")
@Pointcut("execution(* com.example.service.*.*(..)) && !execution(* com.example.service.*.get*(..))")
AOP in Practice: Centralized Operation Logging
// Custom annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String value() default "";
}
// Aspect implementation
@Aspect
@Component
public class OperationLogAspect {
@Autowired
private LogService logService;
@Around("@annotation(operationLog)")
public Object record(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
String operation = operationLog.value();
try {
Object result = pjp.proceed();
logService.save(username, operation, "SUCCESS");
return result;
} catch (Exception e) {
logService.save(username, operation, "FAILED: " + e.getMessage());
throw e;
}
}
}
// Usage
@Service
public class UserService {
@OperationLog("Delete user")
public void deleteUser(Long id) {
// Business code doesn't need to care about logging
}
}
Spring MVC
Request Processing Flow
Client request
→ DispatcherServlet (front controller)
→ HandlerMapping (find the matching Controller method)
→ HandlerAdapter (execute the Controller method)
→ Return ModelAndView or @ResponseBody data
→ ViewResolver (if it's a view name, find the corresponding view)
→ Render → Respond to client
Common Controller Annotations
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
// GET /api/users
@GetMapping
public List<User> list() {
return userService.findAll();
}
// GET /api/users/1
@GetMapping("/{id}")
public ResponseEntity<User> getById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/users
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User create(@RequestBody @Valid CreateUserRequest req) {
return userService.create(req);
}
// PUT /api/users/1
@PutMapping("/{id}")
public User update(@PathVariable Long id, @RequestBody @Valid UpdateUserRequest req) {
return userService.update(id, req);
}
// DELETE /api/users/1
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
// GET /api/users?page=0&size=10&keyword=Alice
@GetMapping("/search")
public Page<User> search(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword
) {
return userService.search(keyword, PageRequest.of(page, size));
}
}
Parameter Binding
// Path variable
@GetMapping("/{id}")
public User get(@PathVariable Long id) { ... }
// Query parameter (with default value)
@GetMapping
public List<User> list(
@RequestParam String name,
@RequestParam(defaultValue = "0") int page
) { ... }
// Request body (JSON → object)
@PostMapping
public User create(@RequestBody CreateUserRequest req) { ... }
// Request header
@GetMapping
public String auth(@RequestHeader("Authorization") String token) { ... }
// Cookie
@GetMapping
public String session(@CookieValue("sessionId") String sid) { ... }
// Query parameters auto-bound to an object (?name=Alice&age=25 → UserSearchRequest)
@GetMapping
public Page<User> search(UserSearchRequest req) { ... }
Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(EntityNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors()
.stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.toList());
return new ErrorResponse("VALIDATION_FAILED", errors.toString());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex) {
return new ErrorResponse("INTERNAL_ERROR", "Internal server error");
}
}
Spring Boot
Auto-Configuration Internals
Spring Boot enables auto-configuration via @SpringBootApplication, which is a composite of three annotations:
@SpringBootConfiguration // Equivalent to @Configuration
@EnableAutoConfiguration // Core: enables auto-configuration
@ComponentScan // Scans the current package and sub-packages
public @interface SpringBootApplication { }
@EnableAutoConfiguration uses AutoConfigurationImportSelector to read all auto-configuration classes listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, then evaluates each @ConditionalOnXxx annotation to decide whether to register the Bean.
The overall flow can be simplified as:
Startup
→ Scan AutoConfiguration.imports
→ Check each @ConditionalOnXxx condition
→ Condition met → register Bean
→ Condition not met → skip
Configuration Files
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: secret
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
server:
port: 8080
servlet:
context-path: /api
app:
jwt:
secret: my-secret-key
expiration: 86400
Multi-environment configuration
# application.yml
spring:
profiles:
active: dev # Activate dev environment by default
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:mysql://prod-server:3306/mydb
Binding configuration to a class
@ConfigurationProperties(prefix = "app.jwt")
@Component
public class JwtProperties {
private String secret;
private long expiration;
// getters & setters
}
Transaction Management
Basic Usage
@Service
public class TransferService {
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId).orElseThrow();
Account to = accountRepo.findById(toId).orElseThrow();
from.deduct(amount);
to.add(amount);
accountRepo.save(from);
accountRepo.save(to);
// Method returns normally → auto-commit
// RuntimeException thrown → auto-rollback
}
}
Transaction Propagation
// REQUIRED (default): join existing transaction or create a new one
@Transactional(propagation = Propagation.REQUIRED)
// REQUIRES_NEW: always create a new transaction, suspend the current one
@Transactional(propagation = Propagation.REQUIRES_NEW)
// SUPPORTS: join existing transaction or run non-transactionally
@Transactional(propagation = Propagation.SUPPORTS)
// NOT_SUPPORTED: run non-transactionally, suspend current transaction if one exists
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// NEVER: must not run inside a transaction; throws exception if one exists
@Transactional(propagation = Propagation.NEVER)
// MANDATORY: must run inside a transaction; throws exception if none exists
@Transactional(propagation = Propagation.MANDATORY)
// NESTED: nested transaction (uses savepoints); outer rollback rolls back inner, inner rollback doesn't affect outer
@Transactional(propagation = Propagation.NESTED)
Isolation Levels
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ_UNCOMMITTED | ✅ Possible | ✅ Possible | ✅ Possible |
| READ_COMMITTED | ❌ No | ✅ Possible | ✅ Possible |
| REPEATABLE_READ | ❌ No | ❌ No | ✅ Possible |
| SERIALIZABLE | ❌ No | ❌ No | ❌ No |
@Transactional(isolation = Isolation.READ_COMMITTED) // Oracle default
@Transactional(isolation = Isolation.REPEATABLE_READ) // MySQL InnoDB default
@Transactional(isolation = Isolation.SERIALIZABLE) // Strictest, lowest performance
Caveats
@Service
public class UserService {
// ❌ Self-invocation does not trigger the transaction (this is not the proxy — AOP is bypassed)
public void outer() {
this.inner();
}
@Transactional
public void inner() { ... }
// ✅ Obtain the proxy from the container and call through it
@Autowired
private ApplicationContext context;
public void outer2() {
context.getBean(UserService.class).inner();
}
// ❌ By default, only RuntimeException triggers rollback; checked exceptions do not
@Transactional
public void riskyOp() throws IOException {
// IOException does not roll back by default
}
// ✅ Explicitly roll back on all exceptions
@Transactional(rollbackFor = Exception.class)
public void safeOp() throws IOException { ... }
// ❌ @Transactional on a private method has no effect (AOP cannot proxy private methods)
@Transactional
private void internalUpdate() { ... }
}
Spring Data JPA
Repository Layer
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Method-naming convention — Spring generates the implementation automatically
List<User> findByEmail(String email);
List<User> findByAgeGreaterThan(int age);
Optional<User> findByEmailAndStatus(String email, UserStatus status);
boolean existsByEmail(String email);
long countByStatus(UserStatus status);
// Pagination and sorting
Page<User> findByStatus(UserStatus status, Pageable pageable);
List<User> findAll(Sort sort);
// Custom JPQL
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword% OR u.email LIKE %:keyword%")
List<User> search(@Param("keyword") String keyword);
// Native SQL
@Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
List<User> findCreatedAfter(@Param("date") LocalDate date);
// Update / delete operations require @Modifying
@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
}
Entity Definition
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING)
private UserStatus status;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// Lazy loading to avoid the N+1 problem
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Order> orders;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
}
Common Pitfalls & Best Practices
Circular Dependencies
Spring Boot 2.6+ prohibits circular dependencies by default and throws BeanCurrentlyInCreationException when one is detected.
// ❌ Circular dependency (A depends on B, B depends on A)
@Service class A { @Autowired B b; }
@Service class B { @Autowired A a; }
// ✅ Solution 1: Redesign — extract shared logic into a new class C (recommended)
@Service class C { /* shared logic */ }
@Service class A { @Autowired C c; }
@Service class B { @Autowired C c; }
// ✅ Solution 2: @Lazy deferred injection — breaks the initialization cycle
@Service class A {
@Autowired @Lazy B b;
}
// ✅ Solution 3: Setter injection (not recommended — avoids rather than solves)
@Service class A {
private B b;
@Autowired
public void setB(B b) { this.b = b; }
}
Selecting Among Multiple Beans
// Two UserRepository implementations
@Component("mysqlUserRepo")
public class MysqlUserRepository implements UserRepository { ... }
@Component("redisUserRepo")
public class RedisUserRepository implements UserRepository { ... }
// Option 1: @Qualifier — specify by name
@Autowired
@Qualifier("mysqlUserRepo")
private UserRepository userRepo;
// Option 2: @Primary — mark the default implementation
@Primary
@Component
public class MysqlUserRepository implements UserRepository { ... }
// Option 3: inject all implementations and select manually
@Autowired
private List<UserRepository> allRepos;
@Autowired
private Map<String, UserRepository> repoMap; // key is the bean name
Common Gotchas
// 1. @Transactional on a private method has no effect
@Transactional // ❌ no effect — AOP cannot proxy private methods
private void internalUpdate() { ... }
// 2. Annotations on static methods have no effect (AOP relies on instance proxies)
@Cacheable("users") // ❌ no effect
public static User staticGet(Long id) { ... }
// 3. Injecting a prototype Bean into a singleton — you always get the same instance
// ✅ Use @Lookup so Spring creates a new prototype on every call
@Component
public class SingletonBean {
@Lookup
public PrototypeBean getPrototype() {
return null; // Spring overrides this method and returns a new instance each time
}
}
// 4. Accessing a lazily-loaded JPA association after the Session is closed throws LazyInitializationException
// ✅ Solution: use @Transactional in the Service layer to keep the Session open, or use JOIN FETCH
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
Fonnpo