logo Fonnpo

Contact Me
May 30, 2026 3 min read Fonnpo

The Complete Spring Framework Guide

Spring is the most popular application framework in the Java ecosystem. This guide covers IoC and DI, Bean lifecycle and scopes, AOP, Spring MVC request handling, Spring Boot auto-configuration, transaction propagation and isolation, Spring Data JPA, and common pitfalls like circular dependencies.

#Java#Spring#Backend

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:

ModuleDescription
spring-coreCore foundation of the IoC container
spring-beansBeanFactory — responsible for Bean creation and management
spring-contextApplicationContext — extends beans with richer features
spring-aopAspect-Oriented Programming support
spring-webmvcSpring MVC framework
spring-txTransaction management
spring-data-jpaJPA integration
spring-bootConvention 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:

Java
        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:

Java
        @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)

Java
        @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

Java
        @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)

Java
        @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)

Java
        @Component        // General component
@Service          // Business layer
@Repository       // Data access layer (also enables JPA exception translation)
@Controller       // MVC controller
@RestController   // = @Controller + @ResponseBody

    

Java Config

Java
        @Configuration
public class AppConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public EmailSender emailSender() {
        return new SmtpEmailSender();
    }
}

    

XML (still used in legacy projects)

Xml
        <bean id="userService" class="com.example.UserService">
    <constructor-arg ref="userRepository"/>
</bean>

    

Bean Scopes

Java
        @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 { }

    
ScopeDescription
singletonDefault — one unique instance per container
prototypeNew instance created for every injection / getBean()
requestOne instance per HTTP request (web environment)
sessionOne instance per HTTP Session (web environment)

Bean Lifecycle

Properties
        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:

Java
        @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

Java
        // 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

Java
        @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)

Java
        // 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

ConceptDescription
AspectA class that encapsulates cross-cutting logic, annotated with @Aspect
PointcutDefines which methods to intercept, described by an expression
AdviceThe code to execute when a pointcut is triggered
JoinPointA point in execution that can be intercepted (method call, exception, …)
WeavingThe process of applying aspects to target objects

Advice Types

Java
        @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;
        }
    }
}

    
Collapse

Pointcut Expressions

Java
        // 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

Java
        // 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
    }
}

    
Collapse

Spring MVC

Request Processing Flow

Properties
        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

Java
        @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));
    }
}

    
Collapse

Parameter Binding

Java
        // 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

Java
        @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:

Java
        @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:

Properties
        Startup
  → Scan AutoConfiguration.imports
  → Check each @ConditionalOnXxx condition
  → Condition met → register Bean
  → Condition not met → skip

    

Configuration Files

Yaml
        # 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

Yaml
        # 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

Java
        @ConfigurationProperties(prefix = "app.jwt")
@Component
public class JwtProperties {
    private String secret;
    private long expiration;
    // getters & setters
}

    

Transaction Management

Basic Usage

Java
        @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

Java
        // 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 LevelDirty ReadNon-Repeatable ReadPhantom Read
READ_UNCOMMITTED✅ Possible✅ Possible✅ Possible
READ_COMMITTED❌ No✅ Possible✅ Possible
REPEATABLE_READ❌ No❌ No✅ Possible
SERIALIZABLE❌ No❌ No❌ No
Java
        @Transactional(isolation = Isolation.READ_COMMITTED)   // Oracle default
@Transactional(isolation = Isolation.REPEATABLE_READ)  // MySQL InnoDB default
@Transactional(isolation = Isolation.SERIALIZABLE)     // Strictest, lowest performance

    

Caveats

Java
        @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() { ... }
}

    
Collapse

Spring Data JPA

Repository Layer

Java
        @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

Java
        @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;
}

    
Collapse

Common Pitfalls & Best Practices

Circular Dependencies

Spring Boot 2.6+ prohibits circular dependencies by default and throws BeanCurrentlyInCreationException when one is detected.

Java
        // ❌ 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

Java
        // 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

Java
        // 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