Skip to main content

State Machine Design Patterns and Implementation

Comprehensive low-level design guide for implementing robust state machines with event-driven transitions and persistence capabilities.

State machines are fundamental constructs for modeling system behavior through well-defined states and transitions. This document presents a production-ready low-level design approach for implementing flexible, scalable state machines with proper error handling and persistence support.

1. Problem Statement & Requirements

Clarifying questions

  1. What specific system or domain does this state machine apply to? Is it for order lifecycle, ticket booking, user account status, or another domain?
  2. What is the expected scale of state transitions? Will we be handling millions of concurrent state changes or is this a smaller-scale system?
  3. What consistency requirements are there? Should state transitions be strongly consistent, eventually consistent, or can eventual consistency work for some operations?
  4. What is the read/write ratio for state data? Is this primarily a write-heavy system or does it require frequent reads of current state?
  5. What are the latency sensitivity requirements? Are there strict SLAs for state transitions (e.g., <100ms for order status change)?
  6. What authentication and authorization model is in place? Will state transitions be restricted to specific roles or users?
  7. Are there external systems that need to be notified of state changes? Do we need to integrate with third-party services or external APIs?

Functional requirements

  • Support state transitions for a defined set of entities (e.g., orders, tickets, user accounts)
  • Maintain audit trail of all state transitions with timestamps and actors
  • Allow querying current state of entities
  • Support conditional state transitions (e.g., only allow transition from "pending" to "confirmed" if payment is received)
  • Enforce state transition rules (e.g., cannot go from "completed" to "pending")
  • Support rollback or revert capability for specific state transitions (if needed)
  • Handle concurrent state changes without data corruption
  • Support bulk state update operations where appropriate

Non-functional requirements

  • Latency targets: p50: 20ms, p95: 50ms, p99: 100ms for state transition operations
  • Throughput: 50,000 state transitions per second across all entities
  • Availability: 99.9% uptime with graceful degradation capability
  • Scalability: Support horizontal scaling to handle peak loads (e.g., 1M concurrent state transitions)
  • Durability: All state transitions must be persisted to disk and not lost during system failures
  • Consistency: Strong consistency for state reads within 100ms of write

Constraints and assumptions

  • State machine will be implemented in a distributed system with eventual consistency for cross-service operations
  • All state transitions must be idempotent to handle retries and network issues
  • State data will be stored in a relational database with ACID guarantees for each transition operation
  • System assumes that state transitions are infrequent compared to reads (read-heavy workload)
  • No external dependencies for core state machine functionality
  • System will not support real-time state change notifications (e.g., WebSocket push) in this design

Out of scope

  • Real-time state change notifications or push-based updates to clients
  • Complex business logic validation beyond simple state transition rules (e.g., payment validation, inventory checks)
  • Multi-step workflows or complex conditional logic beyond simple state transitions
  • Integration with external systems for state change notifications (e.g., sending emails or calling webhooks)
  • Support for state machine versioning or backward compatibility of state definitions
  • UI or dashboard components for managing state machines
  • Support for state machine configuration via external APIs (e.g., runtime modification of state rules)
  • Integration with message queues or event streaming platforms for async state change processing

2. API Design

API Style Selection

For this state machine system, I'll select gRPC as the primary API style due to:

  1. Strong typing: gRPC's Protocol Buffers provide compile-time type safety, reducing runtime errors
  2. Efficient serialization: Binary protocol is more efficient than JSON for high-throughput systems
  3. Built-in streaming support: Enables real-time state change notifications and event streams
  4. Service contracts: Clear, versioned interfaces that support evolution over time
  5. Cross-language support: Enables multi-language microservices ecosystem

Endpoint Definitions

MethodPathDescriptionAuthRate Limit
GetState/v1/state/{id}Retrieve current state of a state machine instanceJWT100 RPS
Transition/v1/state/{id}/transitionTrigger a state transitionJWT50 RPS
ListStates/v1/statesList all state machine instancesJWT20 RPS
GetHistory/v1/state/{id}/historyRetrieve state transition historyJWT50 RPS
ResetState/v1/state/{id}/resetReset state machine to initial stateJWT10 RPS

Request and Response Schemas

// StateMachine.proto
syntax = "proto3";

package state_machine;

message StateRequest {
  string id = 1;
  string state_type = 2;
}

message StateResponse {
  string id = 1;
  string current_state = 2;
  string state_type = 3;
  int64 created_at = 4;
  int64 updated_at = 5;
  repeated StateTransition history = 6;
}

message StateTransition {
  string from_state = 1;
  string to_state = 2;
  string event_type = 3;
  int64 timestamp = 4;
  string actor_id = 5;
  optional string reason = 6;
}

message TransitionRequest {
  string id = 1;
  string event_type = 2;
  optional string actor_id = 3;
  optional string reason = 4;
  optional string metadata = 5;
}

message TransitionResponse {
  string id = 1;
  string current_state = 2;
  string previous_state = 3;
  repeated StateTransition history = 4;
  bool success = 5;
}

message ListStatesRequest {
  string state_type = 1;
  int32 page_size = 2;
  string page_token = 3;
}

message ListStatesResponse {
  repeated StateResponse states = 1;
  string next_page_token = 2;
}

message ResetRequest {
  string id = 1;
  optional string actor_id = 2;
  optional string reason = 3;
}

message ResetResponse {
  string id = 1;
  string new_state = 2;
  bool success = 3;
}

HTTP Status Codes and Error Response Format

Standard HTTP status codes:

  • 200 OK - Successful GET, PUT, POST
  • 204 No Content - Successful DELETE or state reset
  • 400 Bad Request - Invalid request parameters
  • 401 Unauthorized - Missing or invalid authentication
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - State machine instance not found
  • 429 Too Many Requests - Rate limit exceeded
  • 500 Internal Server Error - Unexpected server error

Standardized error response format:

{
  "error": {
    "code": "STATE_TRANSITION_FAILED",
    "message": "Cannot transition from 'ACTIVE' to 'CANCELLED'",
    "details": {
      "state_id": "sm-123",
      "from_state": "ACTIVE",
      "to_state": "CANCELLED"
    },
    "timestamp": "2023-10-15T14:30:00Z"
  }
}

API Versioning Strategy

Versioned using path prefix: /v1/ with future versions as /v2/, etc.

Pagination Design

Cursor-based pagination using page_token field in request/response. This approach:

  • Provides consistent results even when data changes
  • Avoids issues with offset-based pagination when items are inserted/deleted
  • Is more efficient for large datasets

Authentication and Authorization

All endpoints require JWT authentication with scopes:

  • state.read - Read access to state machines
  • state.write - Write access to state machines
  • state.reset - Reset capability for state machines

Rate Limiting Strategy

Rate limiting implemented per endpoint with:

  • Token bucket algorithm for burst handling
  • Per-user rate limits (100 RPS GET, 50 RPS POST)
  • IP-based fallback for anonymous requests
  • Redis-based rate limiting with sliding window

Idempotency Design

For mutation endpoints, idempotency key strategy:

  1. Clients provide X-Idempotency-Key header
  2. Server stores operation result by key
  3. If same key is used, return cached result
  4. Key format: state-transition-{state_id}-{event_type}-{timestamp}
// IdempotencyKeyGenerator.java
public class IdempotencyKeyGenerator {
    public static String generateKey(String stateId, String eventType, String actorId) {
        return String.format("state-transition-%s-%s-%s", 
                           stateId, eventType, actorId);
    }
}

3. Data Model Design

Entity-Relationship diagram (Mermaid)

erDiagram
    STATE_MACHINE {
        string id PK
        string name
        string initial_state
        string current_state
        jsonb states
        jsonb transitions
        timestamp created_at
        timestamp updated_at
        string created_by
        string updated_by
    }

    STATE_MACHINE_EVENT {
        string id PK
        string state_machine_id FK
        string event_name
        string source_state
        string target_state
        timestamp occurred_at
        jsonb metadata
    }

    STATE_MACHINE_STATE {
        string id PK
        string state_machine_id FK
        string state_name
        boolean is_final
        jsonb properties
        timestamp created_at
    }

    STATE_MACHINE_TRANSITION {
        string id PK
        string state_machine_id FK
        string from_state
        string to_state
        string event_name
        jsonb conditions
        jsonb actions
        timestamp created_at
    }

    STATE_MACHINE {
        string id PK
    }

    STATE_MACHINE_EVENT {
        string state_machine_id FK
    }

    STATE_MACHINE_STATE {
        string state_machine_id FK
    }

    STATE_MACHINE_TRANSITION {
        string state_machine_id FK
    }

Table / Collection schema definitions with field types, constraints, indices

state_machines

CREATE TABLE state_machines (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    initial_state VARCHAR(100) NOT NULL,
    current_state VARCHAR(100),
    states JSONB NOT NULL,
    transitions JSONB NOT NULL,
    created_by VARCHAR(255) NOT NULL,
    updated_by VARCHAR(255),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP WITH TIME ZONE
);

CREATE INDEX idx_state_machines_name ON state_machines(name);
CREATE INDEX idx_state_machines_created_at ON state_machines(created_at);
CREATE INDEX idx_state_machines_current_state ON state_machines(current_state);
CREATE INDEX idx_state_machines_deleted_at ON state_machines(deleted_at) WHERE deleted_at IS NULL;

state_machine_events

CREATE TABLE state_machine_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    state_machine_id UUID NOT NULL REFERENCES state_machines(id) ON DELETE CASCADE,
    event_name VARCHAR(255) NOT NULL,
    source_state VARCHAR(100) NOT NULL,
    target_state VARCHAR(100),
    occurred_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    metadata JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_state_machine_events_state_machine_id ON state_machine_events(state_machine_id);
CREATE INDEX idx_state_machine_events_event_name ON state_machine_events(event_name);
CREATE INDEX idx_state_machine_events_occurred_at ON state_machine_events(occurred_at);

state_machine_states

CREATE TABLE state_machine_states (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    state_machine_id UUID NOT NULL REFERENCES state_machines(id) ON DELETE CASCADE,
    state_name VARCHAR(100) NOT NULL,
    is_final BOOLEAN DEFAULT FALSE,
    properties JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_state_machine_states_state_machine_id ON state_machine_states(state_machine_id);
CREATE INDEX idx_state_machine_states_state_name ON state_machine_states(state_name);

state_machine_transitions

CREATE TABLE state_machine_transitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    state_machine_id UUID NOT NULL REFERENCES state_machines(id) ON DELETE CASCADE,
    from_state VARCHAR(100) NOT NULL,
    to_state VARCHAR(100) NOT NULL,
    event_name VARCHAR(255) NOT NULL,
    conditions JSONB,
    actions JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_state_machine_transitions_state_machine_id ON state_machine_transitions(state_machine_id);
CREATE INDEX idx_state_machine_transitions_from_to_states ON state_machine_transitions(from_state, to_state);
CREATE INDEX idx_state_machine_transitions_event_name ON state_machine_transitions(event_name);

Primary key strategy (UUID vs. auto-increment vs. ULID — with justification)

Using UUID as primary key for all tables to support:

  1. Distributed system compatibility (no coordination needed for ID generation)
  2. Multi-region deployment capability
  3. Audit trail integrity (can be joined across systems)
  4. No hotspots in database sharding
  5. Better security (no sequential predictability)

Index design (composite, partial, covering indices — with justification per index)

  1. idx_state_machines_name: For efficient querying by state machine name
  2. idx_state_machines_created_at: For time-based queries and audit trails
  3. idx_state_machines_current_state: For filtering state machines by current state (common query pattern)
  4. idx_state_machines_deleted_at: Partial index to efficiently filter active records only
  5. idx_state_machine_events_state_machine_id: For efficient event lookups by state machine
  6. idx_state_machine_events_event_name: For filtering events by event type
  7. idx_state_machine_events_occurred_at: For time-series analysis and chronological queries
  8. idx_state_machine_states_state_machine_id: For efficient state lookups by state machine
  9. idx_state_machine_states_state_name: For direct state name lookups
  10. idx_state_machine_transitions_state_machine_id: For efficient transition lookups by state machine
  11. idx_state_machine_transitions_from_to_states: Composite index for fast transition lookups by state pair
  12. idx_state_machine_transitions_event_name: For event-driven transition filtering

Data partitioning / sharding strategy

Partition by created_at timestamp monthly to support:

  1. Time-based data lifecycle management
  2. Improved query performance for recent state machine operations
  3. Easier archival of older data
  4. Better distribution of read/write loads across time periods

Soft delete vs. hard delete decision with justification

Soft delete using deleted_at timestamp field because:

  1. Audit trail preservation for compliance and debugging
  2. Ability to restore accidentally deleted state machines
  3. Historical analysis of system evolution over time
  4. Data integrity for dependent event tracking and reporting

Audit trail design (who, what, when)

All state machine operations capture:

  • created_by and updated_by fields for user tracking
  • created_at and updated_at timestamps for temporal tracking
  • state_machine_events table captures all state transitions with metadata
  • Full audit trail maintained in state_machine_events for compliance and debugging

Data archival strategy and retention policy

  1. Daily Archival: State machine events older than 30 days are moved to archive storage (S3 or similar)
  2. Monthly Archival: State machine definitions older than 1 year are archived
  3. Quarterly Cleanup: Completely remove archived state machines after 3 years of inactivity
  4. Retention Policy:
    • Active state machines: Keep forever (with soft delete)
    • Event history: Keep 30 days for operational analysis
    • Historical state machine definitions: Keep 1 year for compliance
  5. Archival Format: JSON format with metadata preserved for full audit capability

4. Class Design & Service Layer

classDiagram
    class StateMachine {
        -currentState: State
        -stateTransitions: Map~State, Set~State~~
        +handleEvent(event: Event): Result
        +getCurrentState(): State
    }
    
    class State {
        -name: String
        -actions: List~Action~
        +execute(context: Context): Result
    }
    
    class Event {
        -name: String
        -payload: Map~String, Object~
    }
    
    class Context {
        -data: Map~String, Object~
        -userId: String
        -entityId: String
    }
    
    class Action {
        -name: String
        -handler: EventHandler
        +execute(context: Context): Result
    }
    
    class EventHandler {
        +handle(context: Context): Result
    }
    
    StateMachine --> State : manages
    State --> Action : contains
    StateMachine --> Event : processes
    Context --> State : provides context for
    Action --> EventHandler : delegates to
stateDiagram-v2
    [*] --> Created
    Created --> Pending : create()
    Pending --> Active : approve()
    Active --> Completed : complete()
    Active --> Cancelled : cancel()
    Pending --> Cancelled : reject()
    Cancelled --> [*]
    Completed --> [*]

Service Class Structure and Responsibilities

The state machine service layer will include:

  1. StateMachineService - Main orchestrator for state transitions
  2. StateTransitionValidator - Validates state transitions before execution
  3. EventPublisher - Publishes state change events to message queue
  4. StateRepository - Persists current state and audit logs

Interface / Abstract Class Design

public interface StateMachine {
    Result handleEvent(Event event);
    State getCurrentState();
}

public interface EventHandler {
    Result handle(Context context);
}

public abstract class AbstractStateMachine implements StateMachine {
    protected State currentState;
    protected Map<State, Set<State>> validTransitions;
    
    @Override
    public Result handleEvent(Event event) {
        if (!isValidTransition(event)) {
            throw new InvalidStateException("Invalid state transition");
        }
        
        return executeTransition(event);
    }
    
    protected abstract boolean isValidTransition(Event event);
    protected abstract Result executeTransition(Event event);
}

Business Logic Encapsulation Patterns

We'll use the State Pattern to encapsulate state-specific behavior:

public class OrderStateMachine extends AbstractStateMachine {
    private final EventPublisher eventPublisher;
    private final StateRepository stateRepository;
    
    public OrderStateMachine(EventPublisher eventPublisher, 
                           StateRepository stateRepository) {
        this.eventPublisher = eventPublisher;
        this.stateRepository = stateRepository;
        initializeTransitions();
    }
    
    @Override
    protected boolean isValidTransition(Event event) {
        return validTransitions.getOrDefault(currentState, Collections.emptySet())
                              .contains(event.getTargetState());
    }
    
    @Override
    protected Result executeTransition(Event event) {
        State previousState = currentState;
        currentState = event.getTargetState();
        
        // Update database
        stateRepository.updateState(event.getEntityId(), currentState, previousState);
        
        // Publish event
        eventPublisher.publish(new StateChangeEvent(
            event.getEntityId(), 
            previousState, 
            currentState,
            event.getMetadata()
        ));
        
        return new Result("SUCCESS", "State transition completed");
    }
    
    private void initializeTransitions() {
        validTransitions = new HashMap<>();
        validTransitions.put(OrderState.CREATED, Set.of(OrderState.PENDING));
        validTransitions.put(OrderState.PENDING, Set.of(OrderState.ACTIVE, OrderState.CANCELLED));
        validTransitions.put(OrderState.ACTIVE, Set.of(OrderState.COMPLETED, OrderState.CANCELLED));
    }
}

Critical-Path Method Implementation

public class OrderService {
    private final OrderRepository orderRepository;
    private final StateMachineService stateMachineService;
    private final EventPublisher eventPublisher;
    private final TransactionManager transactionManager;
    
    public Result processOrderEvent(String orderId, OrderEvent event) {
        return transactionManager.executeInTransaction(() -> {
            // Load order with pessimistic locking
            Order order = orderRepository.findByIdWithLock(orderId);
            
            // Validate order state and event
            if (!stateMachineService.canHandleEvent(order.getState(), event)) {
                throw new InvalidOrderStateException(
                    "Cannot process event " + event.getType() + 
                    " in current state " + order.getState()
                );
            }
            
            // Execute state transition
            StateChangeResult stateChange = stateMachineService.handleEvent(
                new EventBuilder()
                    .withEntityId(orderId)
                    .withEventType(event.getType())
                    .withMetadata(event.getMetadata())
                    .build()
            );
            
            // Update order with new state
            order.setState(stateChange.getNewState());
            order.setLastUpdated(Instant.now());
            
            // Save updated order
            orderRepository.save(order);
            
            // Publish domain events
            eventPublisher.publish(
                new OrderStateChangedEvent(
                    order.getId(),
                    order.getState(),
                    stateChange.getPreviousState(),
                    event.getMetadata()
                )
            );
            
            return new Result("SUCCESS", "Order processed successfully");
        });
    }
}

Transaction Boundary Design

A single transaction includes:

  1. Loading the entity with pessimistic locking
  2. Validating state transition
  3. Updating the entity state in database
  4. Publishing domain events (in same transaction for reliability)

Service Interface Contracts

public interface OrderService {
    Result processOrderEvent(String orderId, OrderEvent event);
    OrderState getCurrentState(String orderId);
    List<OrderState> getValidTransitions(String orderId);
}

Saga / Orchestration Pattern

For distributed transactions involving external systems:

public class OrderSaga {
    private final SagaManager sagaManager;
    
    public void handleOrderCreation(Order order) {
        sagaManager.startSaga(
            new OrderCreationSaga(order)
                .withCompensationHandler(this::handleOrderCancellation)
        );
    }
}

Domain Object / Value Object Design

public class Order {
    private String id;
    private OrderState state;
    private Map<String, Object> metadata;
    private Instant createdAt;
    private Instant lastUpdated;
    
    // Getters and setters
}

public enum OrderState {
    CREATED, PENDING, ACTIVE, COMPLETED, CANCELLED
}

public class OrderEvent {
    private String orderId;
    private String eventType;
    private Map<String, Object> metadata;
}

5. Concurrency & Thread Safety

Concurrency Model

The system uses a thread-per-request model with a fixed-size thread pool for handling concurrent operations. For state machine transitions, we employ a combination of:

  • Java's java.util.concurrent for thread-safe operations
  • ReentrantLock for fine-grained locking where needed
  • AtomicReference and AtomicBoolean for lock-free state updates
  • ThreadLocal for per-request context (e.g., trace IDs, user contexts)

Race Condition Prevention Strategy

Race conditions in state machine transitions are mitigated through:

  1. Optimistic locking using version numbers on state entities
  2. Atomic operations for state value updates
  3. Synchronized blocks for critical sections where necessary
  4. Immutable state objects to prevent concurrent modification

Optimistic vs. Pessimistic Locking Selection

We choose optimistic locking for state machine transitions because:

  • Low contention: State transitions are infrequent and typically involve single entities
  • Better throughput: Avoids blocking threads during lock acquisition
  • Scalability: Reduces lock contention in high-concurrency scenarios
  • Simpler recovery: Failed updates can be retried with minimal overhead

Implementation uses a version field in the state entity:

public class StateMachineEntity {
    private Long version;
    private String currentState;
    
    // Getters and setters
}

Deadlock Prevention Strategy

Deadlock prevention is implemented through:

  1. Lock ordering: All state transitions acquire locks in a consistent order (e.g., by entity ID)
  2. Timeout-based acquisition: Use tryLock() with timeout to prevent indefinite blocking
  3. Hierarchical lock management: Lock entities in a consistent hierarchy (e.g., by domain, then by ID)
  4. Deadlock detection: Periodic monitoring of lock wait chains

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is handled through:

  1. Idempotency keys in request headers (e.g., X-Idempotency-Key)
  2. Request deduplication cache using Redis or in-memory store with TTL
  3. State transition validation: Check if the requested state change is already applied
  4. Version-based idempotency: If version matches, no-op the operation
public class IdempotencyService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public boolean isIdempotent(String idempotencyKey) {
        String key = "idempotency:" + idempotencyKey;
        return redisTemplate.hasKey(key);
    }
    
    public void markIdempotent(String idempotencyKey) {
        String key = "idempotency:" + idempotencyKey;
        redisTemplate.opsForValue().set(key, "processed", 30, TimeUnit.MINUTES);
    }
}

Thread Safety in State Machine Operations

State machine operations are made thread-safe through:

  1. Immutable state objects - once created, state values don't change
  2. Thread-local state tracking for operation context
  3. Synchronized access to shared mutable state
  4. Atomic reference updates for state transitions
public class ThreadSafeStateMachine {
    private final AtomicReference<State> currentState = new AtomicReference<>();
    
    public boolean transitionTo(State newState) {
        return currentState.compareAndSet(currentState.get(), newState);
    }
}

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant U as User
    participant API as API Gateway
    participant Service as StateMachineService
    participant Repo as StateMachineRepository
    participant DB as Database

    U->>API: POST /state-machines/{id}/transition
    API->>Service: validateRequest()
    Service->>Repo: findById(id)
    Repo->>DB: SELECT * FROM state_machines WHERE id = ?
    DB-->>Repo: return StateMachineEntity
    Repo-->>Service: return StateMachine
    Service->>Service: validateTransition(fromState, toState)
    Service->>Repo: updateState(id, newState)
    Repo->>DB: UPDATE state_machines SET current_state = ? WHERE id = ?
    DB-->>Repo: return updated StateMachine
    Repo-->>Service: return updated StateMachine
    Service->>Service: publishStateChangeEvent()
    Service-->>API: return success response
    API-->>U: 200 OK

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant U as User
    participant API as API Gateway
    participant Service as StateMachineService
    participant Repo as StateMachineRepository

    U->>API: POST /state-machines/{id}/transition
    API->>Service: validateRequest()
    Service->>Repo: findById(id)
    Repo-->>Service: return null (not found)
    Service->>Service: throw StateMachineNotFoundException
    Service-->>API: return 404 Not Found
    API-->>U: 404 Not Found

Async Flow Sequence Diagram

sequenceDiagram
    participant U as User
    participant API as API Gateway
    participant Service as StateMachineService
    participant EventPublisher as EventPublisher
    participant EventBus as MessageBus

    U->>API: POST /state-machines/{id}/transition
    API->>Service: validateRequest()
    Service->>Service: validateTransition(fromState, toState)
    Service->>EventPublisher: publishStateChangeEvent()
    EventPublisher->>EventBus: send event to queue
    EventBus-->>EventPublisher: event acknowledged
    EventPublisher-->>Service: return success
    Service-->>API: return 200 OK
    Note over EventBus,EventPublisher: Async processing of state change events

Error Handling Flow

sequenceDiagram
    participant U as User
    participant API as API Gateway
    participant Service as StateMachineService
    participant Repo as StateMachineRepository
    participant DB as Database

    U->>API: POST /state-machines/{id}/transition
    API->>Service: validateRequest()
    Service->>Repo: findById(id)
    Repo->>DB: SELECT * FROM state_machines WHERE id = ?
    DB-->>Repo: return StateMachineEntity
    Repo-->>Service: return StateMachine
    Service->>Service: validateTransition(fromState, toState)
    Service->>Service: throw InvalidTransitionException
    Service-->>API: return 400 Bad Request
    API-->>U: 400 Bad Request with error details

7. Caching Design

Cache Strategy Selection

For this system, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between simplicity, performance, and control over cache behavior. Cache-aside allows us to explicitly manage when data is loaded into cache, enabling better control over cache warming and invalidation strategies.

Cache Key Design

Cache keys will follow a structured namespace pattern:

{namespace}:{entityType}:{entityId}:{version}

Example key structure for user data:

user:profile:12345:v1

TTL Configuration

Data TypeTTLReason
User Profile5 minutesFrequent updates, short-lived
User Preferences1 hourLess frequent changes
Product Catalog24 hoursStable data, expensive to rebuild
Session Tokens30 minutesSecurity-sensitive, short-lived
Order Status15 minutesCritical for UI responsiveness

Cache Eviction Policy

We'll implement LRU (Least Recently Used) eviction policy with a secondary TTL-based cleanup. This ensures that:

  1. Frequently accessed items remain in cache
  2. Memory pressure is managed through TTL expiration
  3. Cache warming can be controlled via explicit key refresh

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic early expiry: 20% of TTL is used for early refresh
  2. Distributed locks: For cache population operations using Redis distributed locks
  3. Cache warming: Pre-warm hot keys during low-traffic periods

Distributed Cache Architecture

We'll use Redis Cluster with:

  • 3 master nodes for data distribution
  • 3 replica nodes per shard for high availability
  • Redis Sentinel for automatic failover
  • Memory eviction policy set to allkeys-lru

Cache Invalidation Strategy

We'll implement event-driven invalidation:

  1. Write-through pattern: Cache is updated on write operations
  2. Event-driven invalidation: Cache entries invalidated via publish-subscribe when data changes
  3. Explicit purge: Manual cache clearing for administrative operations

Cache Implementation Example

@Component
public class CacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;
    
    public <T> T getFromCache(String key, Class<T> type) {
        String cachedValue = redisTemplate.opsForValue().get(key);
        if (cachedValue != null) {
            try {
                return objectMapper.readValue(cachedValue, type);
            } catch (IOException e) {
                log.error("Failed to deserialize cached value for key: {}", key, e);
                return null;
            }
        }
        return null;
    }
    
    public <T> void putInCache(String key, T value, Duration ttl) {
        try {
            String jsonValue = objectMapper.writeValueAsString(value);
            redisTemplate.opsForValue().set(key, jsonValue, ttl);
        } catch (IOException e) {
            log.error("Failed to serialize object for caching: {}", value, e);
        }
    }
    
    public void invalidateCache(String key) {
        redisTemplate.delete(key);
    }
    
    public boolean isCacheAvailable() {
        try {
            return redisTemplate.getConnectionFactory().getConnection().ping().equals("PONG");
        } catch (Exception e) {
            return false;
        }
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and persistence concerns. Each entity will have its own repository interface and concrete implementation.

public interface OrderRepository {
    Optional<Order> findById(UUID orderId);
    List<Order> findByUserId(UUID userId);
    Order save(Order order);
    void delete(UUID orderId);
    Page<Order> findAll(Pageable pageable);
}

@Repository
public class OrderRepositoryImpl implements OrderRepository {
    private final JdbcTemplate jdbcTemplate;
    private final OrderRowMapper rowMapper;
    
    @Override
    public Optional<Order> findById(UUID orderId) {
        String sql = "SELECT * FROM orders WHERE id = ?";
        try {
            return Optional.of(jdbcTemplate.queryForObject(sql, rowMapper, orderId));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
    
    @Override
    public Order save(Order order) {
        if (order.getId() == null) {
            return createOrder(order);
        } else {
            return updateOrder(order);
        }
    }
    
    private Order createOrder(Order order) {
        String sql = """
            INSERT INTO orders (id, user_id, status, created_at, updated_at) 
            VALUES (?, ?, ?, ?, ?)
            """;
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            ps.setObject(1, order.getId());
            ps.setObject(2, order.getUserId());
            ps.setString(3, order.getStatus().name());
            ps.setTimestamp(4, Timestamp.valueOf(order.getCreatedAt()));
            ps.setTimestamp(5, Timestamp.valueOf(order.getUpdatedAt()));
            return ps;
        }, keyHolder);
        
        order.setId((UUID) keyHolder.getKey());
        return order;
    }
    
    private Order updateOrder(Order order) {
        String sql = """
            UPDATE orders 
            SET status = ?, updated_at = ? 
            WHERE id = ?
            """;
        jdbcTemplate.update(sql, order.getStatus().name(), 
                           Timestamp.valueOf(order.getUpdatedAt()), order.getId());
        return order;
    }
}

ORM vs. Raw Query Decisions

For this system, we use a hybrid approach:

  • ORM for complex domain objects: Hibernate is used for entities with complex relationships and business logic
  • Raw queries for performance-critical operations: Direct JDBC is used for read-heavy operations like analytics or batch processing
  • JPA for standard CRUD: Standard JPA repositories are used for common operations

Read vs. Write Separation

The CQRS (Command Query Responsibility Segregation) pattern is applied to separate read and write operations:

// Write model - used for mutations
@Repository
public class OrderWriteRepository {
    // Implementation for write operations
}

// Read model - optimized for queries
@Repository
public class OrderReadRepository {
    // Implementation optimized for read operations with denormalized data
    
    public List<OrderSummary> findRecentOrdersByUserId(UUID userId) {
        String sql = """
            SELECT o.id, o.status, o.created_at, o.total_amount
            FROM orders o 
            WHERE o.user_id = ? 
            ORDER BY o.created_at DESC
            LIMIT 50
            """;
        return jdbcTemplate.query(sql, new OrderSummaryRowMapper(), userId);
    }
}

Connection Pool Configuration

Connection pool is configured with:

  • Pool size: 20-50 connections based on concurrent load
  • Timeout: 30 seconds for connection acquisition
  • Validation query: SELECT 1 to verify connection health
@Configuration
public class DatabaseConfig {
    @Bean
    public HikariDataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/myapp");
        config.setUsername("user");
        config.setPassword("password");
        config.setMaximumPoolSize(30);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        config.setValidationTimeout(5000);
        config.setLeakDetectionThreshold(60000);
        return new HikariDataSource(config);
    }
}

N+1 Query Prevention Strategy

To prevent N+1 query problems:

  • Eager loading: Use JOINs in queries to fetch related data
  • Batch loading: Implement batch operations for collections
// Instead of N+1 queries, use batch loading
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.userId = :userId")
List<Order> findOrdersWithItemsByUserId(@Param("userId") UUID userId);

// Batch operation for order items
@Modifying
@Query("UPDATE OrderItem oi SET oi.status = :status WHERE oi.orderId IN :orderIds")
void updateOrderItemsStatus(@Param("orderIds") List<UUID> orderIds, 
                           @Param("status") OrderItemStatus status);

Batch Operation Design

Batch operations are implemented for high-throughput scenarios:

public class OrderBatchRepository {
    
    public void batchCreateOrders(List<Order> orders) {
        String sql = """
            INSERT INTO orders (id, user_id, status, created_at, updated_at) 
            VALUES (?, ?, ?, ?, ?)
            """;
            
        jdbcTemplate.batchUpdate(orders, (ps, order) -> {
            ps.setObject(1, order.getId());
            ps.setObject(2, order.getUserId());
            ps.setString(3, order.getStatus().name());
            ps.setTimestamp(4, Timestamp.valueOf(order.getCreatedAt()));
            ps.setTimestamp(5, Timestamp.valueOf(order.getUpdatedAt()));
        });
    }
    
    public void batchUpdateOrders(List<Order> orders) {
        String sql = """
            UPDATE orders 
            SET status = ?, updated_at = ? 
            WHERE id = ?
            """;
            
        jdbcTemplate.batchUpdate(orders, (ps, order) -> {
            ps.setString(1, order.getStatus().name());
            ps.setTimestamp(2, Timestamp.valueOf(order.getUpdatedAt()));
            ps.setObject(3, order.getId());
        });
    }
}

Transaction Management

Transaction boundaries are carefully defined to ensure data consistency:

@Service
@Transactional
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private OrderEventPublisher eventPublisher;
    
    public Order createOrder(Order order) {
        // Transaction boundary starts here
        Order saved = orderRepository.save(order);
        
        // Publish event within same transaction
        eventPublisher.publishOrderCreated(saved);
        
        return saved;
        // Transaction commits here
    }
}

Data Access Layer Testing

Unit tests for repository layer use embedded database:

@ExtendWith(SpringExtension.class)
@SpringBootTest
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1",
    "spring.jpa.hibernate.ddl-auto=create-drop"
})
class OrderRepositoryTest {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void shouldSaveAndRetrieveOrder() {
        // Given
        Order order = new Order(UUID.randomUUID(), 
                               UUID.randomUUID(), 
                               OrderStatus.PENDING);
        
        // When
        Order saved = orderRepository.save(order);
        Optional<Order> found = orderRepository.findById(saved.getId());
        
        // Then
        assertThat(found).isPresent();
        assertThat(found.get().getId()).isEqualTo(saved.getId());
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The state machine implementation will use a hybrid synchronous/asynchronous approach. Critical state transitions that must be immediately consistent (e.g., order placement, payment confirmation) will be handled synchronously to ensure immediate consistency and error handling. Non-critical transitions that can tolerate eventual consistency (e.g., sending confirmation emails, updating analytics) will be handled asynchronously through message queues.

The primary state transitions that trigger async processing:

  • ORDER_CREATED → PAYMENT_CONFIRMED (send email notification)
  • PAYMENT_CONFIRMED → ORDER_SHIPPED (update inventory, send shipping notification)
  • ORDER_SHIPPED → ORDER_DELIVERED (send delivery confirmation, update analytics)
  • ORDER_CANCELLED → REFUND_INITIATED (process refund, send cancellation email)

Message schema design with sample payload

{
  "version": "1.0",
  "event_type": "order.shipped",
  "timestamp": "2023-10-15T14:30:00Z",
  "correlation_id": "order-uuid-12345",
  "payload": {
    "order_id": "uuid-12345",
    "status": "SHIPPED",
    "shipment_tracking_number": "TRK123456789",
    "shipped_at": "2023-10-15T14:30:00Z"
  }
}

Dead letter queue strategy and poison message handling

Poison messages will be routed to a dead-letter queue (DLQ) after 3 failed delivery attempts. Messages in the DLQ will be automatically moved to a manual review queue with a 24-hour TTL before permanent deletion. The DLQ will be monitored by an alerting system that triggers on message count thresholds exceeding 100 messages in a 5-minute window.

At-least-once vs. exactly-once delivery

At-least-once delivery is selected for this feature because:

  1. State transitions are idempotent and can safely be processed multiple times
  2. The system uses a state machine with explicit state transitions that prevent duplicate processing
  3. The cost of implementing exactly-once delivery (distributed transactions, idempotency keys) outweighs the benefits for this use case

Retry policy for consumers

Retry policy:

  • Max attempts: 3
  • Backoff formula: Exponential with jitter (1s, 2s, 4s ± 1s)
  • Retryable errors: Network timeouts, service unavailable (503), rate limiting (429)
  • Non-retryable errors: Invalid payload (400), authentication failed (401)

Outbox pattern for reliable event publishing

The outbox pattern is implemented to ensure eventual consistency between state changes and message publishing:

@Entity
@Table(name = "order_events")
public class OrderEvent {
    @Id
    private UUID id;
    
    private UUID orderId;
    private String eventType;
    private String payload;
    private EventStatus status;
    private LocalDateTime createdAt;
    private LocalDateTime processedAt;
    
    public enum EventStatus {
        PENDING, PROCESSING, PROCESSED, FAILED
    }
}

Sequence diagram for async flows

sequenceDiagram
    participant OrderService
    participant EventPublisher
    participant MessageQueue
    participant EmailConsumer
    participant InventoryConsumer
    
    OrderService->>EventPublisher: Publish ORDER_SHIPPED event
    EventPublisher->>MessageQueue: Queue event message
    MessageQueue->>EmailConsumer: Deliver email notification
    MessageQueue->>InventoryConsumer: Deliver inventory update
    
    EmailConsumer-->>EventPublisher: Acknowledge receipt
    InventoryConsumer-->>EventPublisher: Acknowledge receipt
    
    EventPublisher->>OrderService: Update event status to PROCESSED

10. Error Handling Design

Error Classification Hierarchy

The error handling strategy follows a hierarchical classification system to enable proper routing and response generation:

  1. Domain Errors - Business logic violations (e.g., insufficient funds, invalid state transitions)
  2. Validation Errors - Input data format or constraints violations (e.g., missing required fields, invalid email)
  3. Infrastructure Errors - System-level issues (e.g., database connection failure, timeout)
  4. Authorization Errors - Access control violations (e.g., insufficient permissions)
  5. Internal System Errors - Unexpected system failures (e.g., unhandled exceptions, missing dependencies)

Error Propagation Strategy

Errors are propagated up the call stack with appropriate wrapping to preserve context:

// Example error propagation in service layer
public class OrderService {
    public void processOrder(OrderRequest request) throws OrderProcessingException {
        try {
            validateOrder(request);
            // ... business logic
            saveOrder(order);
        } catch (ValidationException e) {
            throw new OrderProcessingException("Failed to process order due to validation error", e);
        } catch (DatabaseException e) {
            throw new OrderProcessingException("Failed to save order due to database error", e);
        }
    }
}

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, a retry mechanism is implemented using exponential backoff with jitter:

public class RetryableOperation {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    private static final double JITTER_FACTOR = 0.2;

    public <T> T executeWithRetry(Supplier<T> operation) throws Exception {
        int attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (TransientException e) {
                if (attempts >= MAX_RETRIES - 1) throw e;
                
                long delay = calculateBackoffDelay(attempts);
                Thread.sleep(delay);
                attempts++;
            }
        }
        throw new RuntimeException("Max retries exceeded");
    }

    private long calculateBackoffDelay(int attempt) {
        long baseDelay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
        long jitter = (long) (baseDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
        return baseDelay + jitter;
    }
}

Circuit Breaker Design

For external dependencies, a circuit breaker pattern is implemented to prevent cascading failures:

public class CircuitBreaker {
    private final int failureThreshold;
    private final long timeout;
    private final int successThreshold;
    
    private volatile CircuitState state = CircuitState.CLOSED;
    private volatile int failureCount = 0;
    private volatile long lastFailureTime = 0;

    public <T> T execute(Supplier<T> operation) throws Exception {
        switch (state) {
            case CLOSED:
                return executeClosed(operation);
            case OPEN:
                return executeOpen(operation);
            case HALF_OPEN:
                return executeHalfOpen(operation);
        }
        throw new IllegalStateException("Invalid circuit state");
    }

    private <T> T executeClosed(Supplier<T> operation) throws Exception {
        try {
            T result = operation.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure(e);
            throw e;
        }
    }

    private <T> T executeOpen(Supplier<T> operation) throws Exception {
        if (System.currentTimeMillis() - lastFailureTime > timeout) {
            state = CircuitState.HALF_OPEN;
            throw new CircuitBreakerOpenException("Circuit is half-open");
        }
        throw new CircuitBreakerOpenException("Circuit is open");
    }

    private <T> T executeHalfOpen(Supplier<T> operation) throws Exception {
        try {
            T result = operation.get();
            state = CircuitState.CLOSED;
            failureCount = 0;
            return result;
        } catch (Exception e) {
            state = CircuitState.OPEN;
            throw e;
        }
    }

    private void onSuccess() {
        if (state == CircuitState.HALF_OPEN) {
            failureCount = 0;
        }
    }

    private void onFailure(Exception e) {
        if (state == CircuitState.CLOSED) {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();
            if (failureCount >= failureThreshold) {
                state = CircuitState.OPEN;
            }
        }
    }

    enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }
}

Fallback Behavior Design

Each external dependency has a fallback implementation to ensure graceful degradation:

public class OrderService {
    private final PaymentGateway paymentGateway;
    private final InventoryService inventoryService;

    public OrderResult processOrder(OrderRequest request) {
        try {
            return paymentGateway.processPayment(request.getPaymentDetails());
        } catch (PaymentException e) {
            // Fallback to manual processing or logging
            logger.warn("Payment gateway failed, falling back to manual processing", e);
            return new OrderResult(OrderStatus.MANUAL_REVIEW, "Payment failed, requires manual review");
        }
    }

    public InventoryCheckResult checkInventory(List<OrderItem> items) {
        try {
            return inventoryService.checkAvailability(items);
        } catch (InventoryException e) {
            // Fallback to default inventory check or return error
            logger.warn("Inventory service unavailable, using fallback logic", e);
            return new InventoryCheckResult(true, "Fallback: Assuming inventory available");
        }
    }
}

Error Logging Format and Alerting Thresholds

Structured logging is implemented with consistent error fields:

public class ErrorLogger {
    private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);

    public void logError(String operation, Exception error, Map<String, Object> context) {
        logger.error(
            "Operation failed: {} | Error code: {} | User ID: {} | Entity ID: {} | Stack trace: {}",
            operation,
            getErrorCode(error),
            context.get("userId"),
            context.get("entityId"),
            error.getMessage(),
            error
        );
    }

    private String getErrorCode(Exception error) {
        if (error instanceof ValidationException) return "VALIDATION_ERROR";
        if (error instanceof AuthorizationException) return "AUTHORIZATION_ERROR";
        if (error instanceof DatabaseException) return "DATABASE_ERROR";
        return "INTERNAL_ERROR";
    }
}

Alerting thresholds are set for critical errors:

Error TypeAlert ThresholdSeverity
Database connection failures>5 errors in 5 minutesCritical
Payment gateway timeouts>10 errors in 10 minutesWarning
Authorization failures>50 errors in 1 hourCritical
Validation errors>100 errors in 1 hourWarning

Error Response Format

Standardized JSON error responses are returned to clients:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid email format provided",
    "details": {
      "field": "email",
      "value": "invalid-email"
    },
    "timestamp": "2023-10-15T14:30:00Z",
    "requestId": "req-12345"
  }
}

Error Handling in Async Flows

For asynchronous operations, error handling includes dead-letter queue processing:

public class AsyncErrorHandler {
    private final DeadLetterQueue deadLetterQueue;
    
    public void handleAsyncError(String messageId, Exception error) {
        // Log the error
        logger.error("Async operation failed: {}", messageId, error);
        
        // Move to dead letter queue for manual inspection
        deadLetterQueue.enqueue(messageId, error);
        
        // Send alert to monitoring system
        alertService.sendAlert("Async operation failed", error);
    }
}

11. Security Design

Input Validation and Sanitization Strategy

All API inputs are validated using a combination of:

  • Schema validation with JSON Schema for structured data (e.g., request bodies, query parameters)
  • Custom validation rules using Apache BVal or Bean Validation annotations in Java
  • Sanitization with OWASP Java HTML Sanitizer for untrusted text inputs to prevent XSS
  • Length and format constraints (e.g., email regex, UUID format) to prevent malformed data

SQL Injection Prevention

  • Prepared statements are used exclusively for all database queries in Java using JDBC or JPA
  • Parameterized queries are enforced via ORM (Hibernate) with strict type mapping
  • No dynamic SQL string concatenation - all queries are built using parameterized approaches
  • Input escaping for any non-parameterized fallback paths (e.g., raw SQL in exception cases)

Authentication Middleware Design

  • JWT-based authentication for API access with 2-hour expiration and refresh token support
  • OAuth2 integration via Okta or Auth0 for third-party identity providers (if applicable)
  • Authentication middleware intercepts all requests and validates JWT tokens using HS256 signing
  • Token revocation via Redis-based blacklisting for session management (refresh tokens can be invalidated)

Authorization Enforcement

  • Role-Based Access Control (RBAC) with hierarchical permissions:
    • ADMIN → full access to all resources
    • USER → read/write access to own resources only
    • MANAGER → read access to all, write access to team resources
  • Authorization checks performed at service layer before any domain operation:
    if (!hasPermission(user, resource, action)) {
        throw new AccessDeniedException("Insufficient permissions");
    }
  • Resource ownership verification using database joins or precomputed user-resource mappings

Sensitive Data Encryption

  • At-rest encryption using AES-256 with keys managed by AWS KMS or HashiCorp Vault
  • In-transit encryption enforced via TLS 1.3 for all API endpoints (HTTPS only)
  • PII encryption using field-level encryption (FLE) for sensitive fields like email, phone, SSN
  • Key rotation policy every 90 days with automatic backup and failover

PII Handling and Masking Strategy

  • PII fields (email, phone, SSN) are encrypted at rest and masked in logs
  • Log masking using regex patterns to redact PII from structured logs:
    String masked = logMessage.replaceAll("\\b\\d{3}-?\\d{2}-?\\d{4}\\b", "XXX-XX-XXXX");
  • PII in URLs and query parameters are stripped or encoded before logging
  • Audit trail logs only non-sensitive metadata (user ID, timestamp, action type)

Secrets Management

  • Secrets stored in AWS Secrets Manager or HashiCorp Vault with automatic rotation
  • Environment-specific secrets (DB credentials, API keys) loaded via secure config providers
  • No hardcoded secrets in source code or configuration files
  • Secrets rotation policy every 90 days with automated failover in case of key rotation

Audit Logging Design

  • Audit events captured for all sensitive operations:
    • User login/logout
    • Resource creation/modification/deletion
    • Permission changes
    • Admin actions (e.g., user ban, role change)
  • Event format includes:
    {
      "timestamp": "2023-10-15T14:30:00Z",
      "userId": "uuid-123",
      "action": "USER_LOGIN",
      "resourceType": "USER",
      "resourceId": "uuid-456",
      "ipAddress": "192.168.1.100",
      "userAgent": "Mozilla/5.0...",
      "details": {}
    }
  • Retention policy logs retained for 90 days with archiving to S3 for compliance
  • Compliance monitoring alerts triggered on unauthorized access patterns or admin abuse

12. Testing Strategy

Unit Testing Scope and Coverage Targets

Unit testing will target 85% code coverage for business logic, with a minimum of 90% coverage for critical paths and error handling branches. Each service class will have dedicated unit tests covering:

  • Happy path scenarios with valid inputs
  • All error conditions and boundary cases
  • State transition validation
  • Concurrent access handling
  • Integration with external dependencies (mocked)

Key areas to test include:

  • State validation logic for all valid state transitions
  • Business rule enforcement during state changes
  • Idempotency handling in concurrent scenarios
  • Event publishing and error rollback behavior

Integration Testing Strategy

Integration testing will use testcontainers for database and external service simulation, focusing on:

  • Database persistence correctness across state transitions
  • Event publishing to message brokers (e.g., Kafka/Redis)
  • External dependency interactions (payment gateway, notification service)
  • Transaction boundary validation

Test data will be managed using factory patterns and fixtures to ensure consistent test environments. Embedded databases (H2 for in-memory testing) will be used for faster unit tests, while real database integration tests will run against containerized instances.

Contract Testing

Contract testing will be implemented using Pact for API contracts between services. This ensures that:

  • External service consumers can validate their expectations
  • Changes to state machine behavior don't break client integrations
  • Event schemas and payloads maintain backward compatibility

Performance Testing Tool

Performance testing will be conducted using k6 with the following key scenarios:

  • Concurrent state transition requests (100-1000 concurrent users)
  • High-volume state change operations (10k+ requests per minute)
  • Latency targets: p50 < 20ms, p95 < 100ms, p99 < 500ms
  • Throughput targets: 1000+ state transitions/sec under load

Test Data Management Strategy

Test data will be managed using:

  • Factory patterns for generating valid test entities
  • Fixture files for consistent baseline data
  • Database seeding scripts for integration test setup
  • Data cleanup procedures to ensure test isolation

Test data will include various state combinations, edge cases, and concurrent access scenarios. Each test case will have a clean database state to prevent cross-contamination between tests.

// Example unit test for state transition validation
@Test
public void testValidStateTransition() {
    // Given: Valid initial state and transition request
    StateMachine stateMachine = new StateMachine();
    stateMachine.setCurrentState(OrderState.CREATED);
    
    // When: Valid transition is requested
    OrderState newState = stateMachine.transition(OrderState.CONFIRMED);
    
    // Then: State should be updated correctly
    assertEquals(OrderState.CONFIRMED, newState);
    verify(eventPublisher).publish(any(StateChangeEvent.class));
}

13. Observability Design

Structured Logging Schema

All logs must follow a consistent schema to enable effective search, filtering, and correlation across services. The logging structure includes:

{
  "timestamp": "2023-10-27T14:30:00.000Z",
  "level": "INFO|WARN|ERROR",
  "service": "booking-service",
  "trace_id": "1234567890abcdef",
  "span_id": "abcdef1234567890",
  "user_id": "user-123",
  "entity_id": "booking-456",
  "operation": "createBooking",
  "message": "Booking created successfully",
  "error_code": "BOOKING_CREATION_FAILED",
  "error_details": {
    "stack_trace": "...",
    "cause": "Invalid seat selection"
  },
  "duration_ms": 125,
  "request_id": "req-789"
}

PII masking rules:

  • User IDs and email addresses are masked in logs using hash-based obfuscation
  • Credit card numbers and other sensitive fields are completely redacted

Distributed Tracing Instrumentation

Using OpenTelemetry, we'll instrument key operations with custom spans:

Span types and attributes:

  • booking.create - Attributes: booking_id, user_id, seat_id
  • payment.process - Attributes: payment_method, amount, currency
  • inventory.check - Attributes: seat_id, show_time, venue_id

Custom span example in Java:

Span span = tracer.spanBuilder("booking.create")
    .setAttribute("booking_id", booking.getId())
    .setAttribute("user_id", userId)
    .startSpan();

try (Scope scope = span.makeCurrent()) {
    // Business logic
} finally {
    span.end();
}

Metrics Design

RED Method metrics per service endpoint:

Metric NameTypeLabelsDescription
booking_requests_totalCounteroperation, status, error_codeTotal booking operations
booking_processing_duration_secondsHistogramoperationRequest processing time
booking_errors_totalCounteroperation, error_codeFailed booking operations
active_bookings_countGaugestatusCurrent active bookings

Example histogram bucket configuration:

Histogram.Timer timer = Histogram.startTimer();
// ... operation logic ...
timer.observeDuration();

Health Check Endpoint Design

Endpoints and their checks:

GET /health
GET /ready
GET /live
GET /startup

Response format:

{
  "status": "healthy|unhealthy",
  "checks": {
    "database": {
      "status": "healthy",
      "details": "Connected to primary DB"
    },
    "cache": {
      "status": "healthy",
      "details": "Redis connected"
    },
    "dependencies": {
      "status": "healthy",
      "details": "All external services reachable"
    }
  },
  "timestamp": "2023-10-27T14:30:00.000Z"
}

Alerting Thresholds

Critical alerts (escalation to on-call):

  • booking_errors_total > 50 errors/minute for 5 minutes → Critical
  • booking_processing_duration_seconds p99 > 500ms for 10 minutes → Warning
  • database_connection_errors_total > 10 errors/minute for 2 minutes → Critical

Warning alerts (monitoring team):

  • booking_requests_total drops below 50% of baseline for 15 minutes → Warning
  • active_bookings_count > 1M active bookings → Warning

On-call escalation path:

  1. First level: Slack channel with alert details
  2. Second level: SMS to primary on-call engineer
  3. Third level: Escalation to engineering manager if no response in 15 minutes

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • State machine scope definition: Should the state machine be generic enough to support multiple domain entities (orders, tickets, bookings) or specific to one entity type? This decision impacts code reuse vs. domain-specific optimization.
  • Event-driven vs. synchronous state transitions: How much of the state transition logic should be asynchronous? This affects system complexity and eventual consistency guarantees.
  • State persistence strategy: Should state machine states be stored in the same database as the main entity or in a dedicated state store? This impacts data consistency and query performance.
  • Versioning strategy for state machines: How should we handle state machine evolution over time? Should we support multiple versions or enforce strict backward compatibility?

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
State persistenceStore in main entity tableDedicated state storeOption 1 simplifies queries but increases coupling. Option 2 provides better separation of concerns and scalability but adds complexity with distributed state management
State transition logicIn-process business logicEvent-driven asynchronousOption 1 provides immediate consistency but can block operations. Option 2 enables loose coupling and better scalability but introduces eventual consistency
State machine reusabilityMonolithic, single-use state machinesGeneric, configurable state machinesOption 1 offers better performance and type safety but reduces code reuse. Option 2 increases flexibility but adds complexity in configuration and potential runtime errors
State persistence granularityFull state serializationDelta-based state changesOption 1 ensures complete state recovery but increases storage and network overhead. Option 2 reduces storage costs but requires more complex state reconstruction logic

Risks and mitigation strategies

  • Race conditions in concurrent state transitions: Risk of multiple concurrent requests modifying the same state machine instance simultaneously. Mitigation: Implement optimistic locking with version numbers and retry logic for state transitions.
  • Event loss in asynchronous processing: Risk of losing state transition events when using message queues. Mitigation: Implement dead letter queue handling and event replay capabilities with idempotency keys.
  • State machine evolution complexity: Risk of breaking existing state machines when evolving the state model. Mitigation: Implement versioned state machine definitions with backward compatibility support and comprehensive testing.
  • Performance bottlenecks in state retrieval: Risk of high latency when loading complex state machine instances. Mitigation: Implement caching strategies and lazy loading for non-critical state information.
  • Inconsistent state across distributed systems: Risk of different services having inconsistent views of the same state machine. Mitigation: Use distributed consensus protocols or event sourcing for maintaining consistent state across microservices.

Technical debt acknowledged

  • State machine configuration complexity: The generic state machine design introduces significant configuration overhead that requires extensive documentation and tooling support to maintain.
  • Event-driven complexity: The asynchronous state transition approach introduces additional complexity in debugging and monitoring, particularly around event ordering and delivery guarantees.
  • Version management overhead: Supporting multiple state machine versions requires ongoing maintenance of migration paths and version compatibility matrices.
  • Testing complexity: Comprehensive testing of state machines requires extensive test coverage for all possible state transitions and edge cases, which can become unwieldy as the state machine grows in complexity.
  • Performance monitoring gaps: The distributed nature of state machines makes it harder to trace and monitor performance characteristics across the entire state transition lifecycle.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend HLD

    Workflow Engine Backend Architecture and Design Patterns

    Comprehensive high-level design document for building a scalable, fault-tolerant workflow engine with distributed processing and state management capabilities.

  • Backend LLD

    Observer-Based Event System Design

    Comprehensive low-level design for a scalable observer pattern event system with publish-subscribe capabilities and thread-safe operations.

  • Backend LLD

    Command Pattern Framework Design and Implementation

    Comprehensive low-level design for a flexible command pattern framework with support for undo/redo, logging, and extensible command execution.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.