Introduction

When building modern web applications with Spring Boot, one of the most crucial architectural decisions you'll make is whether to design your application as stateful or stateless. This choice significantly impacts scalability, performance, security, and maintainability of your application.

In this comprehensive guide, we'll explore both approaches, their trade-offs, and provide practical examples to help you make informed decisions for your Spring Boot applications.

What is State in Web Applications?

Before diving into stateful vs stateless architectures, let's understand what "state" means in the context of web applications.

State refers to any information that an application needs to remember between different requests from the same client. This can include:

  • User authentication status
  • Shopping cart contents
  • User preferences
  • Session data
  • Temporary form data
  • Application workflow progress

Stateful Applications

Definition

A stateful application maintains client state on the server side. The server remembers previous interactions and uses this information to process subsequent requests. Each client session has associated data stored on the server.

How Stateful Works in Spring Boot

In Spring Boot, stateful behavior is typically implemented using:

  1. HTTP Sessions — Server-side session storage
  2. Session Beans — Spring beans with session scope
  3. In-memory data structures — Storing user data in server memory
  4. Database sessions — Persisting session data to database

Example: Stateful Shopping Cart

@RestController
@RequestMapping("/api/cart")
public class StatefulCartController {
    
    @PostMapping("/add")
    public ResponseEntity<String> addToCart(
            @RequestBody CartItem item, 
            HttpServletRequest request) {
        
        HttpSession session = request.getSession(true);
        List<CartItem> cart = (List<CartItem>) session.getAttribute("cart");
        
        if (cart == null) {
            cart = new ArrayList<>();
        }
        
        cart.add(item);
        session.setAttribute("cart", cart);
        
        return ResponseEntity.ok("Item added to cart");
    }
    
    @GetMapping("/items")
    public ResponseEntity<List<CartItem>> getCartItems(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        
        if (session == null) {
            return ResponseEntity.ok(new ArrayList<>());
        }
        
        List<CartItem> cart = (List<CartItem>) session.getAttribute("cart");
        return ResponseEntity.ok(cart != null ? cart : new ArrayList<>());
    }
}

Session-Scoped Bean Example

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, 
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSession {
    
    private String userId;
    private String username;
    private List<String> permissions;
    private LocalDateTime loginTime;
    
    // Getters and setters
    public void initializeSession(String userId, String username) {
        this.userId = userId;
        this.username = username;
        this.loginTime = LocalDateTime.now();
        this.permissions = loadUserPermissions(userId);
    }
    
    public boolean hasPermission(String permission) {
        return permissions != null && permissions.contains(permission);
    }
    
    private List<String> loadUserPermissions(String userId) {
        // Load permissions from database
        return Arrays.asList("READ", "WRITE");
    }
}
@Service
public class UserService {
    
    @Autowired
    private UserSession userSession;
    
    public boolean canAccessResource(String resource) {
        return userSession.hasPermission("READ");
    }
}

Advantages of Stateful Applications

  1. Simplified Development: Easier to implement complex user workflows
  2. Better User Experience: Can maintain context across requests
  3. Reduced Database Calls: Frequently accessed data cached in session
  4. Complex State Management: Natural handling of multi-step processes

Disadvantages of Stateful Applications

  1. Poor Scalability: Difficult to scale horizontally
  2. Memory Consumption: Server memory usage grows with active sessions
  3. Single Point of Failure: Session data loss on server restart
  4. Load Balancer Complexity: Requires sticky sessions
  5. Resource Cleanup: Need to manage session lifecycle

Stateless Applications

Definition

A stateless application doesn't store any client-specific data on the server between requests. Each request contains all the information needed to process it completely. The server treats each request independently.

How Stateless Works in Spring Boot

Stateless applications in Spring Boot typically use:

  1. JWT Tokens — Self-contained authentication tokens
  2. Database lookups — Fetch required data for each request
  3. External state stores — Redis, external databases
  4. Request-contained data — All necessary data in request payload

Example: Stateless Authentication with JWT

@RestController
@RequestMapping("/api/auth")
public class StatelessAuthController {
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private UserService userService;
    
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
        // Validate credentials
        User user = userService.validateCredentials(request.getUsername(), request.getPassword());
        
        if (user != null) {
            // Generate JWT token with user information
            String token = jwtTokenUtil.generateToken(user);
            return ResponseEntity.ok(new AuthResponse(token, user.getUsername()));
        }
        
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}
@Component
public class JwtTokenUtil {
    
    private String secret = "mySecretKey";
    private int jwtExpiration = 86400; // 24 hours
    
    public String generateToken(User user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("username", user.getUsername());
        claims.put("roles", user.getRoles());
        
        return createToken(claims, user.getUsername());
    }
    
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration * 1000))
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }
    
    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }
    
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }
}

JWT Authentication Filter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private UserService userService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain chain) throws ServletException, IOException {
        
        final String requestTokenHeader = request.getHeader("Authorization");
        
        String username = null;
        String jwtToken = null;
        
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (IllegalArgumentException e) {
                logger.error("Unable to get JWT Token");
            } catch (ExpiredJwtException e) {
                logger.error("JWT Token has expired");
            }
        }
        
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.loadUserByUsername(username);
            
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        
        chain.doFilter(request, response);
    }
}

Stateless API Example

@RestController
@RequestMapping("/api/orders")
public class StatelessOrderController {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @PostMapping
    public ResponseEntity<Order> createOrder(
            @RequestBody CreateOrderRequest request,
            @RequestHeader("Authorization") String authHeader) {
        
        // Extract user information from JWT token
        String token = authHeader.substring(7);
        String userId = jwtTokenUtil.getClaimFromToken(token, claims -> 
            claims.get("userId", String.class));
        
        Order order = orderService.createOrder(userId, request);
        return ResponseEntity.ok(order);
    }
    
    @GetMapping("/{orderId}")
    public ResponseEntity<Order> getOrder(
            @PathVariable String orderId,
            @RequestHeader("Authorization") String authHeader) {
        
        String token = authHeader.substring(7);
        String userId = jwtTokenUtil.getClaimFromToken(token, claims -> 
            claims.get("userId", String.class));
        
        Order order = orderService.getOrderByIdAndUserId(orderId, userId);
        
        if (order != null) {
            return ResponseEntity.ok(order);
        }
        
        return ResponseEntity.notFound().build();
    }
}

Advantages of Stateless Applications

  1. Excellent Scalability: Easy to scale horizontally
  2. High Availability: No single point of failure
  3. Load Balancer Friendly: Any server can handle any request
  4. Fault Tolerance: Server failures don't lose user state
  5. Simpler Deployment: No session synchronization needed
  6. Microservices Ready: Perfect for distributed architectures

Disadvantages of Stateless Applications

  1. Increased Network Traffic: More data sent with each request
  2. Database Load: More frequent database queries
  3. Complex State Management: Difficult to maintain complex workflows
  4. Security Considerations: Token management and validation overhead
  5. Development Complexity: Need to design self-contained requests

Configuration Examples

Stateful Configuration

@Configuration
@EnableWebSecurity
public class StatefulSecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false)
                .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/login", "/register").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .and()
            .logout()
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true);
        
        return http.build();
    }
}
# application.yml for stateful
server:
  servlet:
    session:
      timeout: 30m
      cookie:
        http-only: true
        secure: true
        same-site: strict

Stateless Configuration

@Configuration
@EnableWebSecurity
public class StatelessSecurityConfig {
    
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint);
        
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}
# application.yml for stateless
jwt:
  secret: myJWTSecretKey
  expiration: 86400
  
spring:
  session:
    store-type: none

Hybrid Approaches

Using Redis for Distributed Sessions

@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
    
    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379));
    }
}

Caching with Stateless Design

@Service
@CacheConfig(cacheNames = "users")
public class UserService {
    
    @Cacheable(key = "#userId")
    public User getUserById(String userId) {
        // Database call - cached result
        return userRepository.findById(userId);
    }
    
    @CacheEvict(key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }
}

When to Choose Stateful vs Stateless

Choose Stateful When:

  • Building traditional web applications with server-side rendering
  • Complex user workflows with multiple steps
  • Limited number of concurrent users
  • Need for real-time features with server-pushed updates
  • Legacy system integration requirements

Choose Stateless When:

  • Building REST APIs or microservices
  • Need high scalability and availability
  • Planning for cloud deployment
  • Multiple client types (web, mobile, IoT)
  • Distributed system architecture

Best Practices

For Stateful Applications:

  1. Session Management: Use appropriate session timeout values
  2. Memory Management: Monitor and limit session data size
  3. Clustering: Implement session replication for high availability
  4. Security: Secure session cookies and implement CSRF protection
  5. Cleanup: Implement proper session invalidation

For Stateless Applications:

  1. Token Security: Use strong signing algorithms for JWTs
  2. Token Expiration: Implement appropriate token lifecycle management
  3. Validation: Always validate tokens on each request
  4. Error Handling: Graceful handling of expired or invalid tokens
  5. Performance: Cache frequently accessed data

Performance Considerations

Stateful Performance Tips:

// Optimize session attribute access
@Service
public class OptimizedStatefulService {
    
    public void processRequest(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        
        // Cache frequently accessed session data
        UserContext userContext = (UserContext) session.getAttribute("userContext");
        if (userContext == null) {
            userContext = loadUserContext();
            session.setAttribute("userContext", userContext);
        }
        
        // Use cached data
        processWithContext(userContext);
    }
}

Stateless Performance Tips:

// Optimize JWT parsing
@Component
public class OptimizedJwtUtil {
    
    private final Cache<String, Claims> tokenCache = 
        Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    
    public Claims getClaimsFromToken(String token) {
        return tokenCache.get(token, this::parseToken);
    }
    
    private Claims parseToken(String token) {
        return Jwts.parser()
            .setSigningKey(secret)
            .parseClaimsJws(token)
            .getBody();
    }
}

Testing Approaches

Testing Stateful Applications:

@SpringBootTest
@AutoConfigureMockMvc
class StatefulControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void testStatefulWorkflow() throws Exception {
        // First request - establish session
        MvcResult result = mockMvc.perform(post("/api/login")
                .content("{\"username\":\"user\",\"password\":\"pass\"}")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andReturn();
        
        // Extract session
        MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
        
        // Subsequent request using same session
        mockMvc.perform(get("/api/profile")
                .session(session))
                .andExpect(status().isOk());
    }
}

Testing Stateless Applications:

@SpringBootTest
@AutoConfigureMockMvc
class StatelessControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Test
    void testStatelessAPI() throws Exception {
        // Generate test token
        String token = jwtTokenUtil.generateToken(createTestUser());
        
        // Test API with token
        mockMvc.perform(get("/api/orders")
                .header("Authorization", "Bearer " + token))
                .andExpect(status().isOk());
    }
}

Conclusion

The choice between stateful and stateless architecture in Spring Boot depends on your specific requirements, scalability needs, and system architecture. Stateful applications offer simplicity and rich user experiences but sacrifice scalability. Stateless applications provide excellent scalability and fault tolerance but require more careful design.

Modern applications often benefit from a hybrid approach, using stateless design for APIs while leveraging distributed caching and external state stores when needed. Understanding both patterns allows you to make informed architectural decisions that align with your application's goals and constraints.

Remember that architectural patterns are tools in your toolkit. The best choice depends on your specific context, team expertise, performance requirements, and long-term maintenance considerations.