Loading content...
One of the most consequential decisions in software design is choosing between stateful and stateless architectures. This choice ripples through every layer of your system—affecting scalability, reliability, testability, and complexity.
In stateful design, objects remember. They accumulate history, track context, and behave differently based on what happened before. In stateless design, objects forget. Each operation is independent, self-contained, and identical regardless of history.
Neither approach is universally better. The art is knowing when each is appropriate.
By the end of this page, you will understand the precise distinction between stateful and stateless design, the trade-offs of each approach, when to choose one over the other, common patterns for managing state, and how to design hybrid systems that combine both approaches effectively.
Stateful objects maintain internal state that influences their behavior. The same method call can produce different results depending on the object's current state. The object "remembers" past interactions.
Stateless objects have no such memory. Given the same inputs, they always produce the same outputs. All information needed to process a request must be provided with that request.
This distinction isn't binary—it's a spectrum. Most real systems combine both approaches at different layers.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ========== STATEFUL: Counter ==========// Behavior depends on internal state public class Counter { private int count = 0; // Internal state public int increment() { return ++count; // Result depends on previous state } public int getCount() { return count; // Exposes current state }} // Each call to increment() returns a DIFFERENT resultCounter c = new Counter();c.increment(); // Returns 1c.increment(); // Returns 2 (different result, same method!)c.increment(); // Returns 3 // ========== STATELESS: Calculator ==========// Behavior depends only on inputs public class Calculator { // No instance fields (no state) public int add(int a, int b) { return a + b; // Result depends ONLY on inputs } public double sqrt(double x) { return Math.sqrt(x); // Pure function of input }} // Same inputs ALWAYS produce same outputsCalculator calc = new Calculator();calc.add(2, 3); // Returns 5calc.add(2, 3); // Returns 5 (identical call, identical result)calc.add(2, 3); // Returns 5 (always!) // Different Calculator instances are interchangeableCalculator calc2 = new Calculator();calc2.add(2, 3); // Also returns 5 (instance doesn't matter)Ask: "Can I create a new instance of this class and get identical behavior?" For stateless objects, yes—instances are interchangeable. For stateful objects, no—each instance has its own identity and history. This test reveals whether state is truly absent or just hidden.
The choice between stateful and stateless design involves fundamental trade-offs. Understanding these deeply helps you make informed architectural decisions.
| Aspect | Stateful | Stateless |
|---|---|---|
| Scalability | Harder: must route to specific instance or replicate state | Easier: any instance can handle any request |
| Reliability | Complex: instance failure loses state | Simple: failed requests retry anywhere |
| Testability | Requires setup and teardown of state | Pure input/output testing |
| Complexity | Higher: state management logic | Lower: each call independent |
| Performance | Often faster: cached/precomputed state | May repeat work each call |
| Memory | Higher: state must be stored | Lower: only request data in memory |
| Concurrency | Requires synchronization | Naturally thread-safe |
| Debugging | Must understand state history | Each call isolated |
Scalability Deep Dive:
Stateless services scale horizontally with ease. You can add servers, and a load balancer distributes requests arbitrarily. Any instance handles any request identically.
Stateful services face the sticky session problem. If a user's shopping cart is in server A's memory, all their requests must go to server A. This limits load balancing, complicates failover, and creates hotspots.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ========== STATELESS: Scales horizontally ========== @RestControllerpublic class PricingController { // No instance state - any instance handles any request @Autowired private PricingService pricingService; // Also stateless @GetMapping("/price") public BigDecimal calculatePrice( @RequestParam String productId, @RequestParam int quantity, @RequestParam String couponCode // All info in request ) { // Pure computation from inputs return pricingService.calculate(productId, quantity, couponCode); } // With 10 instances behind a load balancer: // - Request 1 → Instance 3 → correct result // - Request 2 → Instance 7 → correct result (same inputs = same output) // - Instance 5 crashes → no data lost, other instances handle traffic} // ========== STATEFUL: Scaling challenges ========== @RestController @SessionScope // Creates instance per sessionpublic class ShoppingCartController { // Instance holds user-specific state private List<CartItem> items = new ArrayList<>(); private BigDecimal total = BigDecimal.ZERO; @PostMapping("/cart/add") public void addItem(@RequestBody CartItem item) { items.add(item); total = total.add(item.getPrice()); } @GetMapping("/cart") public CartResponse getCart() { return new CartResponse(items, total); } // With 10 instances: // - Request 1 (add item) → Instance 3 → items=[item1] // - Request 2 (get cart) → Instance 7 → items=[] WRONG! (state in Instance 3) // - Instance 3 crashes → cart data LOST // Solution: sticky sessions (limits scaling) or external state store}Watch for hidden state: caches, connection pools, thread-local variables, memoization, and singleton configurations all introduce statefulness. A 'stateless' service using an in-memory cache is actually stateful—cache differences between instances cause inconsistent behavior.
Several patterns enable stateless design or externalize state to achieve stateless characteristics:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// ========== PATTERN 1: PASSING STATE AS PARAMETERS ==========// Push state to client, receive it back with each request public class OrderProcessor { // No instance state public OrderResult process(Order order, ProcessingContext context) { // All information needed is in parameters // context contains: user info, preferences, permissions, etc. BigDecimal total = calculateTotal(order, context.getTaxRate()); boolean canShip = checkInventory(order, context.getWarehouse()); return new OrderResult(total, canShip); } // Pure function: same order + context always yields same result} // ========== PATTERN 2: EXTERNALIZED STATE (DATABASE/CACHE) ==========// State lives outside the service, fetched on demand @Servicepublic class ShoppingCartService { @Autowired private CartRepository cartRepository; // External state store public Cart getCart(String userId) { return cartRepository.findByUserId(userId); } public Cart addItem(String userId, CartItem item) { Cart cart = cartRepository.findByUserId(userId); cart.addItem(item); return cartRepository.save(cart); // Persisted externally } // Service is stateless: any instance can handle any user // State is in the database, not in service instances} // ========== PATTERN 3: TOKEN-BASED IDENTITY ==========// Client carries identity proof, server verifies without state @RestControllerpublic class ApiController { @Autowired private JwtValidator jwtValidator; // Stateless validation @GetMapping("/protected") public Response protectedEndpoint(@RequestHeader("Authorization") String token) { // Validate JWT without storing sessions Claims claims = jwtValidator.validate(token); // Pure operation String userId = claims.getSubject(); List<String> roles = claims.get("roles", List.class); // All user context comes from the token // No server-side session storage needed return buildResponse(userId, roles); } // Server keeps no per-user state // Scales infinitely: any server can validate any token} // ========== PATTERN 4: FUNCTIONAL STATELESS OBJECTS ==========// Objects with no fields, only pure methods public final class EmailValidator { // No fields at all public boolean isValid(String email) { return email != null && email.contains("@") && email.length() <= 254 && EMAIL_PATTERN.matcher(email).matches(); } // Can be singleton: no reason to have multiple instances public static final EmailValidator INSTANCE = new EmailValidator();} // ========== PATTERN 5: IMMUTABLE REQUEST/RESPONSE ==========// All state travels with the message public record PriceRequest( String productId, int quantity, String currency, String customerTier, List<String> appliedCoupons, Instant requestTime) { // Everything needed to compute price is in this record // No hidden context; completely self-describing} public record PriceResponse( BigDecimal basePrice, BigDecimal discount, BigDecimal tax, BigDecimal finalPrice, List<String> appliedCoupons) { // Complete response; no need to query service for more info}The key to stateless design: everything needed to process a request travels WITH the request. User identity, permissions, preferences, transaction context—all embedded in request headers, tokens, or parameters. The server becomes a pure function from request to response.
When state is genuinely needed, these patterns help manage it effectively:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// ========== PATTERN 1: STATE OBJECT ==========// Encapsulate related state in a dedicated class public class ConversationState { private final String conversationId; private final String userId; private List<Message> messages; private ConversationStatus status; private Instant startedAt; private Instant lastActiveAt; public void addMessage(Message message) { messages.add(message); lastActiveAt = Instant.now(); } public void close() { this.status = ConversationStatus.CLOSED; } // Clear lifecycle, well-defined transitions} // ========== PATTERN 2: STATE PROVIDER/MANAGER ==========// Centralized state management with clear access patterns @Servicepublic class SessionManager { private final Map<String, UserSession> sessions = new ConcurrentHashMap<>(); private final ScheduledExecutorService cleaner = Executors.newScheduledThreadPool(1); public SessionManager() { // Automatic cleanup of expired sessions cleaner.scheduleAtFixedRate(this::cleanExpiredSessions, 1, 1, TimeUnit.MINUTES); } public UserSession createSession(User user) { UserSession session = new UserSession(user, Duration.ofHours(24)); sessions.put(session.getId(), session); return session; } public Optional<UserSession> getSession(String sessionId) { UserSession session = sessions.get(sessionId); if (session != null && !session.isExpired()) { session.touch(); // Update last access time return Optional.of(session); } return Optional.empty(); } public void invalidateSession(String sessionId) { sessions.remove(sessionId); } private void cleanExpiredSessions() { sessions.entrySet().removeIf(e -> e.getValue().isExpired()); }} // ========== PATTERN 3: BUILDER WITH ACCUMULATED STATE ==========// State accumulates during construction, then frozen public class QueryBuilder { private String table; private List<String> columns = new ArrayList<>(); private List<String> conditions = new ArrayList<>(); private List<String> orderBy = new ArrayList<>(); private Integer limit; public QueryBuilder from(String table) { this.table = table; return this; } public QueryBuilder select(String... cols) { columns.addAll(Arrays.asList(cols)); return this; } public QueryBuilder where(String condition) { conditions.add(condition); return this; } public QueryBuilder orderBy(String column) { orderBy.add(column); return this; } public QueryBuilder limit(int limit) { this.limit = limit; return this; } public String build() { // Accumulated state used to construct final result StringBuilder sql = new StringBuilder("SELECT "); sql.append(columns.isEmpty() ? "*" : String.join(", ", columns)); sql.append(" FROM ").append(table); if (!conditions.isEmpty()) { sql.append(" WHERE ").append(String.join(" AND ", conditions)); } if (!orderBy.isEmpty()) { sql.append(" ORDER BY ").append(String.join(", ", orderBy)); } if (limit != null) { sql.append(" LIMIT ").append(limit); } return sql.toString(); }} // ========== PATTERN 4: OBSERVABLE STATE ==========// State changes notify observers public class ObservableInventory { private final Map<String, Integer> stock = new ConcurrentHashMap<>(); private final List<InventoryObserver> observers = new CopyOnWriteArrayList<>(); public void addObserver(InventoryObserver observer) { observers.add(observer); } public void updateStock(String productId, int newQuantity) { int oldQuantity = stock.getOrDefault(productId, 0); stock.put(productId, newQuantity); // Notify observers of state change StockUpdate update = new StockUpdate(productId, oldQuantity, newQuantity); observers.forEach(o -> o.onStockUpdate(update)); if (newQuantity == 0 && oldQuantity > 0) { observers.forEach(o -> o.onOutOfStock(productId)); } } public int getStock(String productId) { return stock.getOrDefault(productId, 0); }}When using stateful objects, define clear boundaries: who owns the state, who can modify it, how long it lives, and what happens on failure. Undefined boundaries lead to state corruption, race conditions, and debugging nightmares.
The choice isn't always obvious. Here's a framework for deciding:
| Use Case | Recommended Approach | Rationale |
|---|---|---|
| REST API endpoints | Stateless | Each request self-contained; easy scaling |
| Shopping cart | Stateless service + External state | Cart state in database; service instances interchangeable |
| WebSocket connections | Stateful | Connection is inherently stateful |
| Authentication | Stateless (JWT) | Token carries identity; no server sessions |
| Multiplayer game | Stateful | Game world must be consistent across actions |
| Data validation | Stateless | Pure transformation; no side effects |
| Workflow engine | Stateful | Steps depend on previous steps |
| Cache | Stateful | Storing computed results is the point |
| Load balancer | Stateless | Any backend can handle any request |
When uncertain, start stateless. It's easier to add state later than to remove it. You can always externalize state to a database or cache while keeping services stateless. Going the other way—removing embedded state—requires significant refactoring.
Most real systems combine stateful and stateless components. The key is placing state at the right layer—typically, stateless processing with state externalized to dedicated stores.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
/** * Hybrid Architecture Example: E-Commerce System * * Layer 1 (Stateless): API Gateway / Controllers * - Handles HTTP requests * - Validates tokens * - Routes to services * - Any instance handles any request * * Layer 2 (Stateless): Business Logic Services * - Pure domain logic * - No instance state * - All state passed as parameters or fetched from stores * * Layer 3 (Stateful): State Stores * - Redis for sessions/carts * - PostgreSQL for persistent data * - Kafka for event streams */ // API Layer: Completely stateless@RestControllerpublic class OrderController { @Autowired private OrderService orderService; // Stateless @Autowired private SessionStore sessionStore; // External state store @PostMapping("/orders") public Order createOrder(@RequestBody CreateOrderRequest request, @RequestHeader("Authorization") String token) { // Step 1: Stateless validation String userId = jwtValidator.extractUserId(token); // Step 2: Fetch state from external store Cart cart = sessionStore.getCart(userId); UserProfile user = userRepository.findById(userId); // Step 3: Stateless business logic Order order = orderService.createOrder(cart, user, request); // Step 4: Persist state to external store orderRepository.save(order); sessionStore.clearCart(userId); return order; }} // Business Logic: Stateless services@Servicepublic class OrderService { // No instance fields @Autowired private InventoryChecker inventoryChecker; // Stateless @Autowired private PricingEngine pricingEngine; // Stateless @Autowired private TaxCalculator taxCalculator; // Stateless public Order createOrder(Cart cart, UserProfile user, CreateOrderRequest request) { // Pure business logic: all inputs provided inventoryChecker.validateAvailability(cart); BigDecimal subtotal = pricingEngine.calculate(cart, user.getTier()); BigDecimal tax = taxCalculator.calculate(subtotal, user.getAddress()); BigDecimal total = subtotal.add(tax); return Order.builder() .userId(user.getId()) .items(cart.getItems()) .subtotal(subtotal) .tax(tax) .total(total) .shippingAddress(request.getShippingAddress()) .build(); } // Testable in isolation: provide inputs, verify output} // State Stores: Externalized stateful components@Componentpublic class SessionStore { @Autowired private RedisTemplate<String, Object> redis; public Cart getCart(String userId) { return (Cart) redis.opsForValue().get("cart:" + userId); } public void saveCart(String userId, Cart cart) { redis.opsForValue().set("cart:" + userId, cart, Duration.ofHours(24)); } public void clearCart(String userId) { redis.delete("cart:" + userId); }}Even 'stateless' architectures have state somewhere—usually a database. The question isn't whether state exists but where it lives. Externalizing state to purpose-built stores (databases, caches, message queues) rather than embedding it in application instances is the essence of stateless service design.
The stateful/stateless distinction dramatically affects testing strategies:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
// ========== TESTING STATELESS COMPONENTS ==========// Pure input/output: straightforward class PricingEngineTest { private final PricingEngine engine = new PricingEngine(); @Test void shouldCalculateBasicPrice() { // Given: inputs var cart = Cart.of( new CartItem("PROD-1", 2, new BigDecimal("10.00")), new CartItem("PROD-2", 1, new BigDecimal("25.00")) ); // When: pure computation BigDecimal result = engine.calculate(cart, CustomerTier.STANDARD); // Then: verify output assertEquals(new BigDecimal("45.00"), result); } @ParameterizedTest @CsvSource({ "STANDARD, 45.00", "SILVER, 42.75", // 5% discount "GOLD, 40.50", // 10% discount "PLATINUM, 36.00" // 20% discount }) void shouldApplyTierDiscounts(CustomerTier tier, String expectedPrice) { var cart = Cart.of(new CartItem("PROD-1", 1, new BigDecimal("45.00"))); BigDecimal result = engine.calculate(cart, tier); assertEquals(new BigDecimal(expectedPrice), result); } // No setup, no teardown, no mocks, no state to manage // Tests are independent and can run in any order} // ========== TESTING STATEFUL COMPONENTS ==========// Requires careful lifecycle management class ShoppingCartTest { private ShoppingCart cart; @BeforeEach void setUp() { // Must establish initial state cart = new ShoppingCart(); } @Test void shouldTrackItemsAcrossMultipleAdds() { // Stateful: each add affects the next cart.addItem(new Product("A", new BigDecimal("10")), 1); cart.addItem(new Product("B", new BigDecimal("20")), 2); assertEquals(3, cart.getItemCount()); assertEquals(new BigDecimal("50"), cart.getTotal()); } @Test void shouldRemoveItemsCorrectly() { // Must set up state first cart.addItem(new Product("A", new BigDecimal("10")), 2); cart.addItem(new Product("B", new BigDecimal("20")), 1); // Then test state transition cart.removeItem("A"); assertEquals(1, cart.getItemCount()); assertEquals(new BigDecimal("20"), cart.getTotal()); } @Test void shouldHandleStateThroughCompleteLifecycle() { // Integration test: state through multiple phases assertTrue(cart.isEmpty()); cart.addItem(new Product("A", new BigDecimal("10")), 1); assertFalse(cart.isEmpty()); cart.clear(); assertTrue(cart.isEmpty()); } // Test order may matter if tests share state (they shouldn't!) // Must reset state between tests} // ========== TESTING HYBRID SYSTEMS ==========// Mock external state stores; test business logic in isolation class OrderServiceTest { @Mock private SessionStore sessionStore; @Mock private OrderRepository orderRepository; @Mock private InventoryChecker inventoryChecker; @InjectMocks private OrderService orderService; @Test void shouldCreateOrderFromCart() { // Arrange: mock stateful dependencies Cart cart = Cart.of(new CartItem("PROD-1", 2, new BigDecimal("50.00"))); UserProfile user = new UserProfile("USER-1", CustomerTier.GOLD, address); when(inventoryChecker.isAvailable(any())).thenReturn(true); // Act: test stateless business logic Order order = orderService.createOrder(cart, user, request); // Assert: verify computation assertEquals(new BigDecimal("90.00"), order.getTotal()); // 10% GOLD discount + tax assertEquals("USER-1", order.getUserId()); }}Stateless functions are ideal for property-based testing. You can verify invariants hold across thousands of randomly-generated inputs: 'For all carts and tiers, price is always non-negative.' Stateful components need scenario-based tests that trace through specific state sequences.
The choice between stateful and stateless design is fundamental. Let's consolidate the key insights:
Module Complete:
Congratulations! You have completed Module 5: State and Behavior Modeling. You now understand object state deeply—what it is, how it transitions, how behavior manipulates it, and the critical choice between stateful and stateless design. These concepts are foundational to all object-oriented design and will inform every system you build.
You have mastered state and behavior modeling. You understand object state, state transitions and lifecycles, behavior as state manipulation, and the stateful vs stateless design trade-offs. You're now equipped to design objects that properly encapsulate, manage, and transform state—the core of object-oriented programming.