Skip to main content

Exception Handling Framework Design

Comprehensive low-level design for a robust exception handling framework with centralized error management, custom exceptions, and structured logging capabilities.

A well-designed exception handling framework is crucial for building resilient backend systems that can gracefully manage errors, provide meaningful feedback, and maintain system stability across distributed services.

1. Problem Statement & Requirements

Clarifying questions

  1. What types of exceptions are we handling? Are we designing a general-purpose exception handling framework or one focused on specific types like HTTP errors, database failures, or external service timeouts?
  2. What is the expected scale of the system? Will this framework be used in a single service or across multiple microservices with varying error handling needs?
  3. What are the consistency and reliability requirements? Should all errors be logged, retried, or should some be allowed to fail silently?
  4. What is the expected read/write ratio and latency sensitivity? For example, if this is a high-throughput financial transaction system, errors must be handled with minimal latency and maximum reliability.
  5. What authentication and authorization model is in place? Will the error handling framework need to consider user context or roles when determining how to handle errors?
  6. What external integrations are expected? Will the framework need to handle timeouts, circuit breaker errors, or retryable failures from external APIs?
  7. What is the desired level of observability? Should errors be automatically reported to monitoring systems, or should they only be logged?

Functional requirements

  • Unified error classification: The framework must categorize errors into distinct types (e.g., validation, business logic, infrastructure, external service).
  • Consistent error response format: All API responses must return errors in a standardized JSON format with consistent fields (error code, message, details).
  • Error propagation and logging: Errors must be logged with appropriate context (trace ID, user ID, request parameters) and propagated up the call stack.
  • Retry and fallback logic: The framework must support configurable retry mechanisms for transient errors (e.g., network timeouts) and fallback behaviors for external dependencies.
  • Idempotency support: The framework must handle idempotent operations and prevent duplicate error handling for retries.
  • Audit trail integration: Errors must be logged with user, timestamp, and operation context for compliance and debugging.

Non-functional requirements

  • Latency targets:
    • p50: <5ms for error handling overhead
    • p95: <10ms for error handling overhead
    • p99: <20ms for error handling overhead
  • Throughput: The framework must support 10,000+ concurrent error handling operations per second without degradation.
  • Availability: 99.9% uptime for error handling infrastructure, with graceful degradation in case of system failure.
  • Scalability: The framework must scale horizontally across microservices and support distributed tracing.

Constraints and assumptions

  • Assumption: The system uses a microservices architecture with multiple services exposing REST APIs.
  • Constraint: All error responses must be in JSON format to ensure interoperability with frontend and other services.
  • Assumption: The framework will be used in a cloud-native environment with structured logging and tracing.
  • Constraint: Error handling logic must not introduce performance bottlenecks or increase latency significantly.
  • Assumption: The system will use OpenTelemetry for distributed tracing and structured logging.

Out of scope

  • External service integration: The framework will not implement specific logic for handling external service errors (e.g., HTTP 429, 503) — that will be handled by the calling service or middleware.
  • UI/UX error presentation: The framework does not handle how errors are displayed to end users or translated into localized messages.
  • Database transaction rollback: The framework does not manage database transaction rollbacks or savepoint handling — this is handled by the persistence layer.
  • Security-specific error handling: The framework does not include logic for masking sensitive data in error responses or handling authentication/authorization errors.
  • Error recovery orchestration: The framework does not implement complex recovery workflows or error remediation logic — that is handled by external systems or manual intervention.

2. API Design

API Style Selection

For an exception handling framework, I'll select REST as the primary API style because:

  • It provides clear, well-understood semantics for error responses
  • REST APIs are widely supported across client platforms and tools
  • It allows for proper HTTP status code usage to indicate error types
  • It enables easy integration with existing monitoring and alerting systems

Endpoint Definitions

MethodPathDescriptionAuth
GET/v1/exceptionsList all exceptions with filtering and paginationJWT
POST/v1/exceptionsCreate a new exception recordJWT
GET/v1/exceptions/{id}Retrieve specific exception by IDJWT
PUT/v1/exceptions/{id}Update an existing exception recordJWT
DELETE/v1/exceptions/{id}Soft delete an exception recordJWT
GET/v1/exceptions/{id}/stacktraceGet detailed stack trace for exceptionJWT
POST/v1/exceptions/{id}/acknowledgeAcknowledge an exception as handledJWT
GET/v1/exceptions/statsGet aggregate statistics about exceptionsJWT

Request and Response Schemas

Create Exception Request:

{
  "exceptionType": "com.example.BusinessLogicException",
  "message": "Business logic validation failed",
  "stackTrace": "com.example.BusinessLogicException: Validation failed\n\tat com.example.Service.validate(...)",
  "userId": "user-123",
  "context": {
    "requestId": "req-456",
    "endpoint": "/api/v1/users",
    "httpMethod": "POST"
  },
  "severity": "ERROR",
  "tags": ["validation", "user-service"]
}

Exception Response:

{
  "id": "exception-789",
  "exceptionType": "com.example.BusinessLogicException",
  "message": "Business logic validation failed",
  "stackTrace": "com.example.BusinessLogicException: Validation failed\n\tat com.example.Service.validate(...)",
  "userId": "user-123",
  "context": {
    "requestId": "req-456",
    "endpoint": "/api/v1/users",
    "httpMethod": "POST"
  },
  "severity": "ERROR",
  "tags": ["validation", "user-service"],
  "createdAt": "2023-10-15T14:30:00Z",
  "updatedAt": "2023-10-15T14:30:00Z",
  "acknowledged": false,
  "acknowledgedAt": null
}

List Exceptions Response (with pagination):

{
  "data": [
    {
      "id": "exception-789",
      "exceptionType": "com.example.BusinessLogicException",
      "message": "Business logic validation failed",
      "userId": "user-123",
      "severity": "ERROR",
      "createdAt": "2023-10-15T14:30:00Z",
      "acknowledged": false
    }
  ],
  "pagination": {
    "page": 1,
    "size": 20,
    "total": 150,
    "hasNext": true
  }
}

HTTP Status Codes and Standardized Error Response Format

Status CodeError TypeDescription
200OKSuccessful GET, PUT, DELETE
201CreatedSuccessful POST
400BadRequestInvalid request body, missing required fields
401UnauthorizedAuthentication failed or missing
403ForbiddenInsufficient permissions
404NotFoundResource not found
422UnprocessableEntityValidation errors
429TooManyRequestsRate limit exceeded
500InternalServerErrorServer error

Standardized Error Response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input provided",
    "details": [
      {
        "field": "userId",
        "message": "User ID is required"
      }
    ],
    "timestamp": "2023-10-15T14:30:00Z",
    "requestId": "req-456"
  }
}

API Versioning Strategy

Version will be handled via path prefix: /v1/exceptions. This approach:

  • Is explicit and easy to understand
  • Allows for clean migration between versions
  • Supports multiple concurrent versions
  • Follows REST best practices

Pagination Design

Using cursor-based pagination because:

  • It's more stable and consistent across data changes
  • Provides better performance for large datasets
  • Avoids issues with skipped items due to concurrent modifications
  • Allows for consistent ordering and filtering

Authentication and Authorization

All endpoints require JWT authentication with the following scopes:

  • exceptions:read - for GET operations
  • exceptions:write - for POST, PUT, DELETE operations

Rate Limiting

Rate limiting per endpoint:

  • GET /v1/exceptions - 100 requests/minute
  • POST /v1/exceptions - 50 requests/minute
  • PUT/DELETE /v1/exceptions/{id} - 30 requests/minute

Rate limiting will be enforced using Redis with sliding window algorithm to prevent bursty traffic.

Idempotency Design

For mutation endpoints (POST, PUT, DELETE), idempotency keys will be supported:

  • Clients can include X-Idempotency-Key header
  • Server will store the operation result for the key
  • Subsequent requests with same key return cached result
  • Idempotency keys expire after 1 hour
// Example of idempotency key handling in controller
@PostMapping("/v1/exceptions")
public ResponseEntity<ExceptionResponse> createException(
    @RequestHeader("X-Idempotency-Key") String idempotencyKey,
    @RequestBody CreateExceptionRequest request) {
    
    if (idempotencyKey != null) {
        return ResponseEntity.ok(
            exceptionService.createExceptionWithIdempotency(request, idempotencyKey));
    }
    
    return ResponseEntity.status(HttpStatus.CREATED)
        .body(exceptionService.createException(request));
}

3. Data Model Design

Entity-Relationship diagram (Mermaid)

erDiagram
    EXCEPTION_LOG {
        string id PK
        string user_id
        string error_code
        string message
        string stack_trace
        string request_context
        string service_name
        string version
        timestamp created_at
        timestamp updated_at
        string status
    }

    ERROR_CATEGORY {
        string id PK
        string name
        string description
        string severity
        timestamp created_at
        timestamp updated_at
    }

    EXCEPTION_LOG ||--o{ ERROR_CATEGORY : "categorized_by"

    EXCEPTION_LOG ||--o{ USER : "logged_by"

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

exception_logs table

FieldTypeConstraintsDescription
idUUIDPRIMARY KEYUnique identifier for exception log
user_idUUIDFOREIGN KEY (users.id)User who triggered the exception
error_codeVARCHAR(50)NOT NULLStandardized error code
messageTEXTNOT NULLHuman-readable error message
stack_traceTEXTFull stack trace for debugging
request_contextJSONBRequest metadata (headers, params)
service_nameVARCHAR(100)NOT NULLService name where error occurred
versionVARCHAR(20)Service version
created_atTIMESTAMPNOT NULL, DEFAULT NOW()When log was created
updated_atTIMESTAMPNOT NULL, DEFAULT NOW()When log was last updated
statusVARCHAR(20)NOT NULL, DEFAULT 'ACTIVE'Log status (ACTIVE, ARCHIVED, RESOLVED)

error_categories table

FieldTypeConstraintsDescription
idUUIDPRIMARY KEYUnique identifier for error category
nameVARCHAR(100)NOT NULL, UNIQUECategory name (e.g., "VALIDATION", "DATABASE")
descriptionTEXTDetailed description of category
severityVARCHAR(20)NOT NULL, DEFAULT 'ERROR'Severity level (INFO, WARN, ERROR, FATAL)
created_atTIMESTAMPNOT NULL, DEFAULT NOW()When category was created
updated_atTIMESTAMPNOT NULL, DEFAULT NOW()When category was last updated

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

UUID Strategy

Using UUIDs as primary keys for both tables provides the following advantages:

  1. Global Uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination
  2. No Hotspotting: Unlike auto-increment IDs, UUIDs don't create write hotspots in distributed systems
  3. No Dependency on Sequence Generation: UUIDs can be generated client-side without database round-trips
  4. Scalability: Supports horizontal sharding and microservices architecture without ID conflicts

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

exception_logs table indexes

Index NameFieldsTypePurpose
idx_exception_logs_user_iduser_idStandardFast lookup by user
idx_exception_logs_error_codeerror_codeStandardEfficient filtering by error code
idx_exception_logs_created_atcreated_atStandardTime-based queries for analytics
idx_exception_logs_service_nameservice_nameStandardService-level error aggregation
idx_exception_logs_status_created_atstatus, created_atCompositeEfficient status-based filtering
idx_exception_logs_user_erroruser_id, error_codeCompositeFast user-specific error analysis
idx_exception_logs_service_createdservice_name, created_atCompositeService-wise recent error tracking
idx_exception_logs_status_severitystatus, severityPartialFilter by status and severity levels

error_categories table indexes

Index NameFieldsTypePurpose
idx_error_categories_namenameUniqueFast lookup by category name
idx_error_categories_severityseverityStandardEfficient severity-based filtering

Data partitioning / sharding strategy

Time-based partitioning with monthly granularity

Partitioning by created_at field using monthly ranges:

-- Example partitioning strategy for exception_logs table
CREATE TABLE exception_logs_2023_01 (
    CHECK (created_at >= '2023-01-01' AND created_at < '2023-02-01')
) INHERITS (exception_logs);

CREATE TABLE exception_logs_2023_02 (
    CHECK (created_at >= '2023-02-01' AND created_at < '2023-03-01')
) INHERITS (exception_logs);

Justification:

  1. Query Performance: Time-based queries are common for analytics and monitoring
  2. Data Lifecycle Management: Easy to archive or drop old partitions
  3. Scalability: Reduces I/O on large tables by scanning only relevant partitions

Soft delete vs. hard delete decision with justification

Soft Delete Strategy

Using a status field instead of physical deletion:

  • Status Enum: ACTIVE, ARCHIVED, RESOLVED
  • Reasoning:
    • Audit Compliance: Historical error logs required for compliance and forensic analysis
    • Analytics: Need to maintain historical trends for error rate analysis
    • Recovery: Ability to restore logs if accidentally deleted
    • Referential Integrity: Other systems may reference these logs

Audit trail design (who, what, when)

Audit Fields in exception_logs table:

  1. created_at - When the log was created (timestamp)
  2. updated_at - When the log was last modified (timestamp)
  3. user_id - Who triggered the exception (UUID reference)
  4. service_name - What service generated the error (string)

Data archival strategy and retention policy

Archival Strategy

  1. Daily Archiving:

    • Move logs older than 30 days to archive storage (e.g., S3)
    • Keep active logs in primary database for fast querying
  2. Retention Policy:

    • Active Logs: 30 days (for troubleshooting)
    • Archived Logs: 1 year (for compliance and analytics)
    • Historical Logs: 5 years (for long-term trend analysis)
  3. Archive Format:

    • Compressed JSON files
    • Partitioned by date (monthly)
    • Metadata stored in separate index table for searchability
// Example retention policy configuration
public class ArchivePolicy {
    private static final int ACTIVE_RETENTION_DAYS = 30;
    private static final int ARCHIVE_RETENTION_DAYS = 365;
    private static final int HISTORICAL_RETENTION_YEARS = 5;
    
    public boolean shouldArchive(LocalDate logDate) {
        return !logDate.isAfter(LocalDate.now().minusDays(ACTIVE_RETENTION_DAYS));
    }
}

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class ExceptionHandler {
        +handleException(Exception, HttpServletRequest, HttpServletResponse)
        +buildErrorResponse(Exception, String, int)
    }
    
    class GlobalExceptionHandler {
        +handleException(Exception, HttpServletRequest, HttpServletResponse)
        +logError(Exception, String)
    }
    
    class ErrorInfo {
        -errorCode: String
        -message: String
        -timestamp: Instant
        -traceId: String
        +getErrorCode(): String
        +getMessage(): String
        +getTimestamp(): Instant
        +getTraceId(): String
    }
    
    class ExceptionMapping {
        -exceptionClass: Class~?~
        -errorCode: String
        -httpStatus: HttpStatus
        -messageTemplate: String
        +getExceptionClass(): Class~?~
        +getErrorCode(): String
        +getHttpStatus(): HttpStatus
        +getMessageTemplate(): String
    }
    
    class ExceptionMapper {
        -mappings: List~ExceptionMapping~
        +mapException(Exception): ErrorInfo
        +registerMapping(ExceptionMapping)
    }
    
    class ValidationException {
        -field: String
        -reason: String
        +getField(): String
        +getReason(): String
    }
    
    class BusinessException {
        -errorCode: String
        -message: String
        +getErrorCode(): String
        +getMessage(): String
    }
    
    ExceptionHandler <|-- GlobalExceptionHandler
    ExceptionHandler <|-- ExceptionMapper
    ExceptionMapper --> ErrorInfo
    ExceptionMapper --> ExceptionMapping
    
    BusinessException <|-- ValidationException

State Machine Diagram

stateDiagram-v2
    [*] --> Processing
    Processing --> ErrorHandling : Exception occurs
    ErrorHandling --> ErrorLogged : Log error
    ErrorLogged --> ResponseBuilt : Build response
    ResponseBuilt --> Completed : Send response

Service Class Structure and Responsibilities

The exception handling framework consists of several core components:

  1. GlobalExceptionHandler: Main entry point for handling uncaught exceptions in the application
  2. ExceptionMapper: Maps specific exception types to standardized error responses
  3. ErrorInfo: Data structure representing the standardized error response format
  4. ExceptionMapping: Configuration mapping between exception classes and error codes

Interface / Abstract Class Design

public interface ExceptionHandler {
    void handleException(Exception ex, HttpServletRequest request, HttpServletResponse response);
}

public abstract class BaseExceptionHandler implements ExceptionHandler {
    protected abstract ErrorInfo mapExceptionToError(Exception ex);
    protected abstract HttpStatus determineHttpStatus(Exception ex);
    
    @Override
    public void handleException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
        ErrorInfo errorInfo = mapExceptionToError(ex);
        HttpStatus status = determineHttpStatus(ex);
        
        response.setStatus(status.value());
        response.setContentType("application/json");
        
        try {
            String jsonResponse = buildErrorResponse(errorInfo, request);
            response.getWriter().write(jsonResponse);
        } catch (IOException e) {
            // Log and handle write failure
            throw new RuntimeException("Failed to write error response", e);
        }
    }
    
    protected String buildErrorResponse(ErrorInfo errorInfo, HttpServletRequest request) {
        // Implementation in concrete class
        return "";
    }
}

Business Logic Encapsulation Patterns

The framework uses the Strategy Pattern for mapping exceptions to error responses:

public class ExceptionMappingStrategy {
    private final List<ExceptionMapping> mappings = new ArrayList<>();
    
    public void registerMapping(ExceptionMapping mapping) {
        this.mappings.add(mapping);
    }
    
    public ErrorInfo mapException(Exception ex) {
        ExceptionMapping mapping = findMatchingMapping(ex);
        if (mapping == null) {
            return buildDefaultError(ex);
        }
        
        return new ErrorInfo(
            mapping.getErrorCode(),
            formatMessage(mapping.getMessageTemplate(), ex),
            Instant.now(),
            getCurrentTraceId()
        );
    }
    
    private ExceptionMapping findMatchingMapping(Exception ex) {
        return mappings.stream()
            .filter(m -> m.getExceptionClass().isInstance(ex))
            .findFirst()
            .orElse(null);
    }
    
    private String formatMessage(String template, Exception ex) {
        // Simple placeholder replacement or use a proper templating engine
        return template.replace("{message}", ex.getMessage());
    }
}

Critical-Path Method Implementation

public class GlobalExceptionHandler extends BaseExceptionHandler {
    private final ExceptionMappingStrategy exceptionMapper;
    private final ObjectMapper objectMapper;
    
    public GlobalExceptionHandler(ExceptionMappingStrategy exceptionMapper, ObjectMapper objectMapper) {
        this.exceptionMapper = exceptionMapper;
        this.objectMapper = objectMapper;
    }
    
    @Override
    public void handleException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
        // Transaction boundary - ensure no partial writes in error handling
        try {
            ErrorInfo errorInfo = exceptionMapper.mapException(ex);
            String jsonResponse = buildErrorResponse(errorInfo, request);
            
            // Log the error before sending response
            logError(ex, errorInfo.getTraceId());
            
            // Set proper headers and send response
            response.setStatus(errorInfo.getHttpStatus().value());
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            
            // Write the error response
            try (PrintWriter writer = response.getWriter()) {
                writer.write(jsonResponse);
                writer.flush();
            }
        } catch (Exception loggingError) {
            // Fallback if error response building fails
            try {
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
                response.setContentType("application/json");
                response.getWriter().write("{\"error\":\"Failed to handle exception\"}");
            } catch (IOException ioException) {
                // Last resort - no logging or response
                logger.error("Failed to send error response", ioException);
            }
        }
    }
    
    @Override
    protected ErrorInfo mapExceptionToError(Exception ex) {
        return exceptionMapper.mapException(ex);
    }
    
    @Override
    protected HttpStatus determineHttpStatus(Exception ex) {
        // Default to internal server error, but can be overridden
        return HttpStatus.INTERNAL_SERVER_ERROR;
    }
    
    protected String buildErrorResponse(ErrorInfo errorInfo, HttpServletRequest request) throws IOException {
        Map<String, Object> response = new HashMap<>();
        response.put("timestamp", errorInfo.getTimestamp());
        response.put("error", errorInfo.getErrorCode());
        response.put("message", errorInfo.getMessage());
        response.put("path", request.getRequestURI());
        
        return objectMapper.writeValueAsString(response);
    }
    
    protected void logError(Exception ex, String traceId) {
        // Log error with structured logging
        logger.error("Exception occurred in request", 
            "traceId", traceId,
            "exceptionType", ex.getClass().getSimpleName(),
            "message", ex.getMessage());
    }
}

Transaction Boundary Design

The transaction boundary for error handling is defined as:

  1. Exception occurs in business logic or API layer
  2. GlobalExceptionHandler receives the exception
  3. ErrorInfo is built and logged (within same transaction context)
  4. Response is written to client (commit of error handling operation)

Service Interface Contracts

public interface ExceptionHandlingService {
    void registerExceptionMapping(ExceptionMapping mapping);
    ErrorInfo handleException(Exception ex, HttpServletRequest request);
    String buildErrorResponse(ErrorInfo errorInfo, HttpServletRequest request);
}

Saga / Orchestration Pattern

For distributed systems, the exception handling framework supports:

  1. Event-driven error propagation through message queues
  2. Retry mechanisms for transient errors
  3. Dead letter queue handling for persistent failures

Domain Object / Value Object Design

public class ErrorInfo {
    private final String errorCode;
    private final String message;
    private final Instant timestamp;
    private final String traceId;
    private final HttpStatus httpStatus;
    
    public ErrorInfo(String errorCode, String message, Instant timestamp, String traceId) {
        this(errorCode, message, timestamp, traceId, HttpStatus.INTERNAL_SERVER_ERROR);
    }
    
    public ErrorInfo(String errorCode, String message, Instant timestamp, String traceId, HttpStatus httpStatus) {
        this.errorCode = errorCode;
        this.message = message;
        this.timestamp = timestamp;
        this.traceId = traceId;
        this.httpStatus = httpStatus;
    }
    
    // Getters and equals/hashCode methods
}

Exception Mapping Configuration

public class ExceptionMapping {
    private final Class<? extends Exception> exceptionClass;
    private final String errorCode;
    private final HttpStatus httpStatus;
    private final String messageTemplate;
    
    public ExceptionMapping(Class<? extends Exception> exceptionClass, 
                           String errorCode, 
                           HttpStatus httpStatus,
                           String messageTemplate) {
        this.exceptionClass = exceptionClass;
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.messageTemplate = messageTemplate;
    }
    
    // Getters
}

5. Concurrency & Thread Safety

Concurrency Model

The exception handling framework operates in a multi-threaded environment where multiple concurrent requests may be processed simultaneously. The system uses a thread-per-request model with a thread pool for handling incoming requests, typical of modern web frameworks like Spring Boot or Vert.x.

Each request is handled by a dedicated thread from the application's thread pool, ensuring that concurrent processing does not interfere with each other's execution context. The framework leverages Java's ThreadLocal for storing request-scoped exception context information, ensuring thread safety without requiring explicit synchronization.

Race Condition Prevention Strategy

Race conditions in the exception handling framework are mitigated through:

  1. Thread-local storage: All exception context information (request ID, user ID, timestamp) is stored in ThreadLocal variables to ensure each thread has its own copy and no cross-contamination occurs.

  2. Immutable error objects: Exception metadata objects are designed to be immutable once created, preventing any modification during concurrent processing.

  3. Synchronized logging: When writing to centralized logs or error tracking systems, the framework uses synchronized blocks or lock-free concurrent data structures like ConcurrentHashMap to prevent race conditions in shared state.

Optimistic vs. Pessimistic Locking Selection

The exception handling framework does not require explicit locking mechanisms since:

  • Exception context is request-scoped and stored in ThreadLocal
  • Error reporting and logging operations are stateless
  • No shared mutable state is accessed across threads during exception handling

Therefore, optimistic concurrency control is preferred over pessimistic locking, as it avoids blocking and improves throughput while maintaining correctness through immutability and proper request isolation.

Deadlock Prevention Strategy

Deadlock prevention in the exception handling framework is achieved through:

  1. Consistent lock ordering: When any locking occurs (e.g., for centralized error reporting), all threads acquire locks in a consistent order to prevent circular wait conditions.

  2. Timeout-based acquisition: Any internal locking uses timeout mechanisms to ensure no thread waits indefinitely for a lock.

  3. Minimal scope of locks: Locks are held for the shortest possible duration to reduce contention.

Idempotency Implementation for Concurrent Duplicate Requests

To handle concurrent duplicate requests that might trigger the same exception scenario:

  1. Request ID tracking: Each incoming request is assigned a unique identifier that is propagated through the exception handling chain.

  2. Idempotency key pattern: For retry scenarios, the framework supports idempotency keys in error responses to prevent duplicate error reporting.

  3. De-duplication logic: When logging errors or sending notifications, the framework checks for duplicate request IDs to prevent duplicate error entries in monitoring systems.

public class ExceptionHandlingFramework {
    private static final ThreadLocal<String> currentRequestId = new ThreadLocal<>();
    
    public void handleException(Exception ex, String userId) {
        String requestId = generateRequestId();
        currentRequestId.set(requestId);
        
        // Thread-safe error processing
        ErrorContext context = new ErrorContext(
            requestId, 
            userId, 
            System.currentTimeMillis(),
            ex
        );
        
        // Idempotency check for duplicate errors
        if (!isDuplicateError(requestId, ex)) {
            logError(context);
            sendNotification(context);
        }
    }
    
    private boolean isDuplicateError(String requestId, Exception ex) {
        // Implementation to check if this error has already been processed
        return false; // Simplified for example
    }
}

Thread Safety in Error Reporting Components

All components involved in error reporting are designed to be thread-safe:

  1. Error logging service: Uses java.util.concurrent.ConcurrentHashMap for storing error metadata and java.util.logging.Logger with thread-safe appenders.

  2. Notification service: Implements a thread-safe queue for asynchronous error notifications using BlockingQueue.

  3. Metrics collection: Uses atomic counters and thread-safe collections for tracking error rates and types.

Synchronization Primitives Used

The framework uses minimal synchronization primitives:

  • ThreadLocal for request-scoped state
  • java.util.concurrent.ConcurrentHashMap for shared error metadata storage
  • java.util.concurrent.atomic.AtomicLong for generating unique error IDs
  • ReentrantLock only in specific scenarios where shared state modification is needed

Performance Considerations

The framework prioritizes performance by:

  1. Avoiding unnecessary synchronization: Most operations are thread-local and thus inherently safe
  2. Using lock-free data structures: Where shared state is required, concurrent collections are preferred
  3. Asynchronous error processing: Error reporting and notification are handled asynchronously to avoid blocking the main request thread

Testing Strategy for Concurrency

Concurrency testing includes:

  1. Stress testing: Simulating multiple concurrent requests to ensure no race conditions
  2. Thread dump analysis: Verifying that no deadlocks occur under high load
  3. JVM thread monitoring: Using tools like JConsole or VisualVM to observe thread behavior
  4. Unit tests with multiple threads: Using ExecutorService to simulate concurrent exception handling
@Test
public void testConcurrentExceptionHandling() throws InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(100);
    
    for (int i = 0; i < 100; i++) {
        final int threadId = i;
        executor.submit(() -> {
            try {
                // Simulate concurrent exception handling
                handleException(new RuntimeException("Test error " + threadId));
            } finally {
                latch.countDown();
            }
        });
    }
    
    latch.await(5, TimeUnit.SECONDS);
    assertEquals(0, latch.getCount());
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant Client
    participant Gateway
    participant Controller
    participant Service
    participant Repository
    participant DB

    Client->>Gateway: HTTP Request
    Gateway->>Controller: Route to Controller
    Controller->>Service: Invoke Business Logic
    Service->>Repository: Query Data
    Repository->>DB: Execute Query
    DB-->>Repository: Result Set
    Repository-->>Service: Return Data
    Service-->>Controller: Business Response
    Controller-->>Gateway: HTTP Response
    Gateway-->>Client: Final Response
sequenceDiagram
    participant Client
    participant Gateway
    participant Controller
    participant Service
    participant Repository
    participant DB

    Client->>Gateway: HTTP Request
    Gateway->>Controller: Route to Controller
    Controller->>Service: Invoke Business Logic
    Service->>Repository: Query Data
    Repository->>DB: Execute Query
    DB-->>Repository: Database Exception
    Repository-->>Service: Propagate Exception
    Service-->>Controller: Handle Exception
    Controller-->>Gateway: Return Error Response
    Gateway-->>Client: HTTP 500 with Error Details
sequenceDiagram
    participant Client
    participant Gateway
    participant Controller
    participant Service
    participant EventPublisher
    participant MessageBroker

    Client->>Gateway: HTTP Request
    Gateway->>Controller: Route to Controller
    Controller->>Service: Invoke Business Logic
    Service->>Service: Perform Operation
    Service->>EventPublisher: Publish Event
    EventPublisher->>MessageBroker: Send to Queue
    MessageBroker-->>EventPublisher: Acknowledge
    EventPublisher-->>Service: Success
    Service-->>Controller: Business Response
    Controller-->>Gateway: HTTP Response
    Gateway-->>Client: Final Response
sequenceDiagram
    participant Client
    participant Gateway
    participant Controller
    participant Service
    participant EventPublisher
    participant MessageBroker

    Client->>Gateway: HTTP Request
    Gateway->>Controller: Route to Controller
    Controller->>Service: Invoke Business Logic
    Service->>Service: Perform Operation
    Service->>EventPublisher: Publish Event
    EventPublisher->>MessageBroker: Send to Queue
    MessageBroker-->>EventPublisher: Delivery Failure
    EventPublisher->>Service: Retry Logic
    Service-->>Controller: Handle Exception
    Controller-->>Gateway: Return Error Response
    Gateway-->>Client: HTTP 500 with Error Details

7. Caching Design

Cache Strategy Selection

For this exception handling framework, we'll use a cache-aside pattern with write-through behavior for critical error metadata. This approach provides the best balance between performance and consistency for error tracking and reporting use cases.

Cache Key Design

Cache keys will follow a structured namespace pattern:

exception:{errorType}:{errorCode}:{userId}:{timestamp}
exception:stats:{errorType}:{timeWindow}
exception:config:{configKey}

TTL Configuration

Data TypeTTLReason
Error instances24 hoursRetain recent errors for debugging
Error statistics7 daysMaintain trend analysis
Configuration1 hourAllow quick refresh of error handling rules
User-specific errors1 hourRecent user errors for context

Cache Eviction Policy

We'll implement a hybrid LRU + TTL eviction strategy:

  • Primary: LRU-based eviction for memory pressure control
  • Secondary: TTL-based cleanup for time-bound data

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic early expiry: Set TTL to 80% of configured value for cache entries
  2. Distributed locks: Use Redis distributed locks for expensive cache population operations
  3. Cache warming: Pre-populate frequently accessed error configurations

Distributed Cache Architecture

Using Redis Cluster with:

  • 3 master nodes for high availability
  • 3 replica nodes per master for read scaling
  • Redis Sentinel for automatic failover
  • Memory policy: allkeys-lru with maxmemory set to 2GB per node

Cache Invalidation Strategy

We'll implement event-driven invalidation:

  1. Write-through: Cache is updated when error configuration changes
  2. Explicit purge: Cache entries invalidated on configuration updates
  3. TTL-based: Automatic cleanup based on time-to-live values

Cache Implementation Example (Java)

@Component
public class ExceptionCacheManager {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;
    
    public void cacheError(ExceptionInfo errorInfo) {
        String key = buildCacheKey(errorInfo);
        String statsKey = buildStatsKey(errorInfo);
        
        // Cache the error instance
        redisTemplate.opsForValue().set(
            key, 
            objectMapper.writeValueAsString(errorInfo),
            Duration.ofHours(24)
        );
        
        // Update statistics
        redisTemplate.opsForZSet().add(
            statsKey,
            errorInfo.getErrorType() + ":" + errorInfo.getErrorCode(),
            System.currentTimeMillis()
        );
        
        // Update hourly stats
        String hourlyKey = buildHourlyStatsKey(errorInfo);
        redisTemplate.opsForHash().increment(
            hourlyKey,
            errorInfo.getErrorType() + ":" + errorInfo.getErrorCode(),
            1
        );
    }
    
    public ExceptionInfo getCachedError(String errorType, String errorCode, String userId) {
        String key = buildCacheKey(errorType, errorCode, userId);
        String cachedValue = redisTemplate.opsForValue().get(key);
        
        if (cachedValue != null) {
            try {
                return objectMapper.readValue(cachedValue, ExceptionInfo.class);
            } catch (Exception e) {
                // Log and continue with fresh lookup
                log.warn("Failed to deserialize cached error", e);
            }
        }
        
        return null;
    }
    
    private String buildCacheKey(ExceptionInfo errorInfo) {
        return "exception:" + errorInfo.getErrorType() + ":" + 
               errorInfo.getErrorCode() + ":" + errorInfo.getUserId() + ":" + 
               System.currentTimeMillis();
    }
    
    private String buildStatsKey(ExceptionInfo errorInfo) {
        return "exception:stats:" + errorInfo.getErrorType() + ":" + 
               getHourlyWindow();
    }
    
    private String buildHourlyStatsKey(ExceptionInfo errorInfo) {
        return "exception:stats:hourly:" + getHourlyWindow();
    }
    
    private String getHourlyWindow() {
        return LocalDateTime.now().truncatedTo(ChronoUnit.HOURS).toString();
    }
}

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 repository encapsulates CRUD operations for specific entities while maintaining consistency with the overall data model design.

public interface UserRepository extends Repository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByStatus(UserStatus status);
    Page<User> findByCriteria(UserSearchCriteria criteria, Pageable pageable);
}

public interface OrderRepository extends Repository<Order, String> {
    Optional<Order> findByUserIdAndOrderId(String userId, String orderId);
    List<Order> findByUserId(String userId);
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}

ORM vs. Raw Query Decisions

For this framework, we use a hybrid approach:

  • ORM for simple CRUD operations: Hibernate/JPA for standard entity persistence
  • Raw SQL for complex queries: Direct JDBC for performance-critical analytics and reporting queries

This approach balances developer productivity with query optimization flexibility.

@Repository
public class UserRepositoryImpl implements UserRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public Optional<User> findByEmail(String email) {
        try {
            return entityManager.createQuery(
                "SELECT u FROM User u WHERE u.email = :email", User.class)
                .setParameter("email", email)
                .getResultStream()
                .findFirst();
        } catch (Exception e) {
            throw new DataAccessException("Failed to find user by email", e);
        }
    }
    
    // Raw SQL for complex analytics
    public List<UserActivitySummary> getUserActivitySummary(String userId) {
        String sql = """
            SELECT u.id, u.name, COUNT(o.id) as order_count, 
                   SUM(o.total_amount) as total_spent
            FROM users u
            LEFT JOIN orders o ON u.id = o.user_id
            WHERE u.id = :userId
            GROUP BY u.id, u.name
        """;
        
        return entityManager.createNativeQuery(sql, "UserActivitySummary")
            .setParameter("userId", userId)
            .getResultList();
    }
}

Read vs. Write Separation

We implement CQRS (Command Query Responsibility Segregation) for read-heavy workloads:

// Write side - primary database with ACID guarantees
@Repository
public class OrderWriteRepository {
    @PersistenceContext
    private EntityManager entityManager;
    
    public void save(Order order) {
        entityManager.persist(order);
    }
}

// Read side - denormalized read model
@Repository
public class OrderReadRepository {
    private final JdbcTemplate jdbcTemplate;
    
    public List<OrderReadModel> findByUserId(String userId) {
        return jdbcTemplate.query(
            "SELECT * FROM order_read_model WHERE user_id = ?",
            new Object[]{userId},
            new OrderReadModelRowMapper()
        );
    }
}

Connection Pool Configuration

Connection pool is configured with optimal settings for high-throughput applications:

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      validation-timeout: 5000
      leak-detection-threshold: 60000

N+1 Query Prevention Strategy

We implement eager loading and batch fetching to prevent N+1 queries:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 20)
    private List<OrderItem> items;
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "user_id")
    private User user;
}

// In repository query with JOIN FETCH
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.items " +
       "JOIN FETCH o.user " +
       "WHERE o.id = :orderId")
Optional<Order> findByIdWithDetails(@Param("orderId") String orderId);

Batch Operation Design

Batch operations are implemented with bulk inserts and updates:

@Repository
public class BatchUserRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public void batchInsertUsers(List<User> users) {
        String sql = "INSERT INTO users (id, email, name) VALUES (?, ?, ?)";
        
        try (Connection conn = entityManager.unwrap(Session.class).connection()) {
            PreparedStatement stmt = conn.prepareStatement(sql);
            
            for (User user : users) {
                stmt.setLong(1, user.getId());
                stmt.setString(2, user.getEmail());
                stmt.setString(3, user.getName());
                stmt.addBatch();
            }
            
            stmt.executeBatch();
        } catch (SQLException e) {
            throw new DataAccessException("Failed to batch insert users", e);
        }
    }
    
    public void batchUpdateUserStatus(List<Long> userIds, UserStatus newStatus) {
        String sql = "UPDATE users SET status = ? WHERE id IN (";
        
        // Build placeholders dynamically
        String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
        sql += placeholders + ")";
        
        try (Connection conn = entityManager.unwrap(Session.class).connection()) {
            PreparedStatement stmt = conn.prepareStatement(sql);
            stmt.setString(1, newStatus.name());
            
            for (int i = 0; i < userIds.size(); i++) {
                stmt.setLong(i + 2, userIds.get(i));
            }
            
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new DataAccessException("Failed to batch update user status", e);
        }
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why

The exception handling framework will primarily operate synchronously within the request lifecycle to ensure immediate feedback to clients. However, asynchronous processing is used for:

  1. Audit logging and monitoring: Exception details need to be persisted for compliance and debugging, but can be processed asynchronously to avoid blocking the main request flow.
  2. Alerting and notifications: When critical exceptions occur, system alerts should be sent to monitoring tools or teams without blocking the user-facing request.
  3. Error analytics and aggregation: Aggregating error patterns for dashboards and ML-driven anomaly detection should happen asynchronously to avoid performance impact.

Synchronous operations remain for:

  • Immediate client response generation
  • Exception classification and routing within the framework
  • Request context propagation

Message schema design with a sample payload (Avro / Protobuf / JSON Schema — with versioning strategy)

{
  "type": "object",
  "title": "ExceptionEvent",
  "version": "1.0.0",
  "properties": {
    "eventId": {
      "type": "string",
      "description": "Unique identifier for the exception event"
    },
    "timestamp": {
      "type": "string",
      "format": "date-time",
      "description": "ISO 8601 timestamp of when the exception occurred"
    },
    "exceptionType": {
      "type": "string",
      "description": "Class name or type of the exception"
    },
    "message": {
      "type": "string",
      "description": "Exception message"
    },
    "stackTrace": {
      "type": "string",
      "description": "Full stack trace"
    },
    "requestContext": {
      "type": "object",
      "properties": {
        "traceId": {"type": "string"},
        "spanId": {"type": "string"},
        "userId": {"type": "string"},
        "endpoint": {"type": "string"}
      }
    },
    "severity": {
      "type": "string",
      "enum": ["INFO", "WARN", "ERROR", "CRITICAL"]
    },
    "sourceService": {
      "type": "string",
      "description": "Name of the service that generated this event"
    }
  },
  "required": ["eventId", "timestamp", "exceptionType", "message"]
}

Versioning strategy uses semantic versioning with major version changes requiring backward compatibility guarantees. Minor versions allow new optional fields, while patch versions only fix bugs in existing fields.

Dead letter queue strategy and poison message handling

Poison messages (messages that repeatedly fail processing) are handled through a dead letter queue (DLQ) system:

  1. Retry limit: Messages are retried up to 3 times with exponential backoff (1s, 4s, 9s)
  2. DLQ routing: After max retries, message is sent to a dedicated DLQ topic with a retention policy of 7 days
  3. Manual reprocessing: DLQ messages are flagged with metadata indicating they're in a failed state and can be manually reprocessed by operators

At-least-once vs. exactly-once delivery — decision with justification for this feature

At-least-once delivery is chosen because:

  1. Exception events are idempotent in nature - multiple deliveries of the same event don't cause harm
  2. The system prioritizes ensuring all exceptions are captured over preventing duplicates
  3. Simpler implementation and less risk of data loss in case of system failures

Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)

Retry policy configuration:

  • Max attempts: 3 retries
  • Backoff formula: Exponential with jitter (base delay: 1s, max delay: 30s)
  • Jitter: ±25% randomization to prevent thundering herd
  • Retryable errors: Network timeouts, service unavailability, transient database issues
  • Non-retryable errors: Invalid payload format, authentication failures, business logic violations
public class RetryPolicy {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    private static final long MAX_DELAY_MS = 30000;
    private static final double JITTER_FACTOR = 0.25;

    public boolean shouldRetry(Exception ex) {
        return ex instanceof NetworkException || 
               ex instanceof DatabaseException ||
               ex instanceof ServiceUnavailableException;
    }

    public long getDelay(int attempt) {
        long delay = Math.min(BASE_DELAY_MS * (1L << attempt), MAX_DELAY_MS);
        long jitter = (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
        return delay + jitter;
    }
}

Outbox pattern for reliable event publishing — include or explicitly justify omission

Outbox pattern is omitted in this design because:

  1. The exception handling framework is designed to be lightweight and focused on core error processing
  2. For most use cases, the async publishing of exception events is acceptable with at-least-once delivery guarantee
  3. The framework assumes that the underlying messaging system (e.g., Kafka) provides durability and reliability guarantees
  4. Adding outbox pattern would significantly increase complexity without proportional benefit for this specific use case

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant Client
    participant Service
    participant ExceptionHandler
    participant MessageBroker
    participant DLQ

    Client->>Service: Request with exception
    Service->>ExceptionHandler: Handle exception
    ExceptionHandler->>MessageBroker: Publish exception event (async)
    MessageBroker-->>ExceptionHandler: Acknowledge
    ExceptionHandler->>Client: Return error response
    
    Note over ExceptionHandler,MessageBroker: Async processing continues here
    
    loop Retry attempts (max 3)
        MessageBroker->>ExceptionHandler: Delivery failure
        ExceptionHandler->>MessageBroker: Retry with exponential backoff
    end
    
    alt After max retries
        ExceptionHandler->>DLQ: Move to DLQ
        DLQ-->>ExceptionHandler: DLQ acknowledged
    end

10. Error Handling Design

Error Classification Hierarchy

The error handling framework implements a hierarchical error classification system to enable proper error routing and response generation:

public abstract class BaseException extends RuntimeException {
    private final ErrorCode errorCode;
    private final ErrorCategory category;
    
    public BaseException(ErrorCode errorCode, ErrorCategory category, String message) {
        super(message);
        this.errorCode = errorCode;
        this.category = category;
    }
    
    public ErrorCode getErrorCode() { return errorCode; }
    public ErrorCategory getCategory() { return category; }
}

public enum ErrorCategory {
    VALIDATION, BUSINESS_LOGIC, INFRASTRUCTURE, SYSTEM_ERROR
}

public enum ErrorCode {
    // Validation errors
    INVALID_INPUT(1001, "Invalid input provided"),
    MISSING_REQUIRED_FIELD(1002, "Required field is missing"),
    
    // Business logic errors
    RESOURCE_NOT_FOUND(2001, "Requested resource not found"),
    INSUFFICIENT_PERMISSIONS(2002, "Insufficient permissions to perform operation"),
    
    // Infrastructure errors
    DATABASE_ERROR(3001, "Database operation failed"),
    EXTERNAL_SERVICE_TIMEOUT(3002, "External service timeout"),
    
    // System errors
    INTERNAL_SERVER_ERROR(5001, "Internal server error"),
    UNHANDLED_EXCEPTION(5002, "Unhandled exception occurred");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
}

Error Propagation Strategy

The framework follows a layered propagation strategy:

  • Validation errors: Caught and transformed at service boundary
  • Business logic errors: Bubble up to controller layer for proper HTTP status code mapping
  • Infrastructure errors: Transformed into appropriate domain exceptions or retried with circuit breaker
  • System errors: Logged at ERROR level and returned as 500 to client

Retry Strategy with Exponential Backoff and 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) {
        int attempts = 0;
        Exception lastException = null;

        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                attempts++;
                
                if (attempts >= MAX_RETRIES) {
                    throw new RetryFailedException("Operation failed after " + MAX_RETRIES + " attempts", e);
                }

                long delay = calculateBackoffDelay(attempts);
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RetryFailedException("Retry interrupted", ie);
                }
            }
        }

        throw new RetryFailedException("Operation failed after " + MAX_RETRIES + " attempts", lastException);
    }

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

Circuit Breaker Design

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

    public enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }

    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);
            default:
                throw new IllegalStateException("Unknown 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 > timeoutMs) {
            state = CircuitState.HALF_OPEN;
            return executeHalfOpen(operation);
        }
        throw new CircuitBreakerOpenException("Circuit is currently 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;
            lastFailureTime = System.currentTimeMillis();
            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;
            }
        }
    }
}

Fallback Behavior Design

public class FallbackHandler {
    private final Map<String, Supplier<Object>> fallbacks = new ConcurrentHashMap<>();

    public void registerFallback(String operationKey, Supplier<Object> fallback) {
        fallbacks.put(operationKey, fallback);
    }

    public Object executeWithFallback(String operationKey, Supplier<Object> operation) {
        try {
            return operation.get();
        } catch (Exception e) {
            Supplier<Object> fallback = fallbacks.get(operationKey);
            if (fallback != null) {
                return fallback.get();
            }
            throw new FallbackNotAvailableException("No fallback available for operation: " + operationKey, e);
        }
    }
}

Error Logging Format and Alerting Thresholds

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

    public void logError(String operation, Exception exception, Map<String, Object> context) {
        Map<String, Object> logFields = new HashMap<>();
        logFields.put("timestamp", Instant.now().toString());
        logFields.put("operation", operation);
        logFields.put("error_type", exception.getClass().getSimpleName());
        logFields.put("error_message", exception.getMessage());
        logFields.put("stack_trace", getStackTrace(exception));
        logFields.putAll(context);

        // Log as structured JSON
        logger.error("{}", JsonUtil.toJson(logFields));
        
        // Alerting logic for critical errors
        if (isAlertableError(exception)) {
            alertOnCriticalError(operation, exception);
        }
    }

    private boolean isAlertableError(Exception ex) {
        return ex instanceof InfrastructureException || 
               ex instanceof SystemException ||
               (ex instanceof BaseException && 
                ((BaseException) ex).getErrorCode().getCode() >= 5000);
    }

    private void alertOnCriticalError(String operation, Exception exception) {
        // Integration with monitoring system (e.g., PagerDuty, Slack)
        // This would typically send to alerting service with severity levels
    }
}

Alerting Thresholds

MetricWarning LevelCritical LevelAlerting Service
Error Rate (5m)> 1%> 5%PagerDuty
Response Time (p99)> 500ms> 2sGrafana
Circuit Breaker Open1+ open circuits5+ open circuitsSlack
Retry Failures> 100 in 1h> 500 in 1hEmail Alert

Error Handling Middleware

@RestControllerAdvice
public class GlobalExceptionHandler {
    private final StructuredErrorLogger errorLogger;

    public GlobalExceptionHandler(StructuredErrorLogger errorLogger) {
        this.errorLogger = errorLogger;
    }

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponse> handleBaseException(BaseException ex) {
        ErrorResponse errorResponse = new ErrorResponse(
            ex.getErrorCode().getCode(),
            ex.getErrorCode().getMessage(),
            ex.getMessage()
        );

        // Log structured error
        errorLogger.logError("base_exception", ex, Map.of(
            "error_code", ex.getErrorCode().getCode(),
            "category", ex.getCategory()
        ));

        return ResponseEntity.status(getHttpStatus(ex)).body(errorResponse);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        // Log as system error
        errorLogger.logError("generic_exception", ex, Map.of(
            "error_type", ex.getClass().getSimpleName()
        ));

        ErrorResponse errorResponse = new ErrorResponse(
            ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
            ErrorCode.INTERNAL_SERVER_ERROR.getMessage(),
            "Internal server error occurred"
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }

    private HttpStatus getHttpStatus(BaseException ex) {
        switch (ex.getCategory()) {
            case VALIDATION:
                return HttpStatus.BAD_REQUEST;
            case BUSINESS_LOGIC:
                return HttpStatus.FORBIDDEN; // or appropriate code
            case INFRASTRUCTURE:
                return HttpStatus.SERVICE_UNAVAILABLE;
            default:
                return HttpStatus.INTERNAL_SERVER_ERROR;
        }
    }
}

Error Response Format

public class ErrorResponse {
    private final int code;
    private final String message;
    private final String details;
    private final long timestamp;

    public ErrorResponse(int code, String message, String details) {
        this.code = code;
        this.message = message;
        this.details = details;
        this.timestamp = System.currentTimeMillis();
    }

    // Getters
}

Retry Configuration Parameters

ParameterValueDescription
Max Retries3Maximum retry attempts before failure
Base Delay1000msInitial delay between retries
Backoff Multiplier2.0Exponential backoff factor
Jitter Factor±20%Random variation to prevent thundering herd
Retry Timeout30sMaximum time spent retrying before giving up

Circuit Breaker Configuration

ParameterValueDescription
Failure Threshold5Number of failures before opening circuit
Timeout30sTime to wait before attempting half-open state
Success Threshold3Successful calls needed to close circuit
Half-Open Retry Limit1Number of retries in half-open state

Fallback Configuration

OperationFallback StrategyWhen Used
External API CallCache readWhen external service unavailable
Database QueryDefault ValueWhen DB is down
Payment ProcessingManual ReviewWhen payment gateway fails

Error Budget Management

The framework supports error budget tracking to prevent over-escalation of error handling:

public class ErrorBudgetManager {
    private final double errorBudgetPercentage = 0.05; // 5% of traffic
    private final AtomicInteger errorCount = new AtomicInteger(0);
    private final AtomicInteger totalRequests = new AtomicInteger(0);

    public boolean shouldAllowError() {
        int total = totalRequests.get();
        int errors = errorCount.get();
        
        if (total == 0) return true;
        
        double errorRate = (double) errors / total;
        return errorRate <= errorBudgetPercentage;
    }

    public void recordError() {
        errorCount.incrementAndGet();
    }
}

11. Security Design

Input Validation and Sanitization Strategy

Input validation follows a defense-in-depth approach using both server-side validation and sanitization:

  • Validation Framework: Use Jakarta Bean Validation (JSR-380) with Hibernate Validator for declarative validation rules
  • Sanitization: Apply OWASP Java HTML Sanitizer for XSS prevention on untrusted text inputs
  • Custom Validation Rules: Implement custom validators for business-specific constraints (e.g., email format, phone number patterns)
public class UserRequest {
    @NotNull(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    @Pattern(regexp = "^[a-zA-Z\\s]+$", message = "Name contains invalid characters")
    private String name;
    
    @Email(message = "Invalid email format")
    @NotBlank(message = "Email is required")
    private String email;
    
    // getters and setters
}

SQL Injection Prevention

  • Prepared Statements: All database queries use parameterized prepared statements exclusively
  • Query Builder: Use JPA Criteria API or MyBatis for query construction to avoid string concatenation
  • Whitelist Approach: Only allow predefined column names and table names in dynamic queries
// Safe query using JPA Criteria API
public List<User> findUsersByName(String name) {
    CriteriaBuilder cb = entityManager.getCriteriaBuilder();
    CriteriaQuery<User> query = cb.createQuery(User.class);
    Root<User> root = query.from(User.class);
    
    // Safe parameter binding
    Predicate predicate = cb.equal(root.get("name"), name);
    query.select(root).where(predicate);
    
    return entityManager.createQuery(query).getResultList();
}

Authentication Middleware Design

  • JWT-based Authentication: Stateless JWT tokens with 15-minute expiration for API access
  • Authentication Filter Chain: Custom Spring Security filter that intercepts all requests and validates JWT tokens
  • Token Refresh Strategy: Support for refresh tokens with 7-day validity period
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws ServletException, IOException {
        
        String token = extractToken(request);
        if (token != null && tokenProvider.validateToken(token)) {
            Authentication auth = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Authorization Enforcement

  • Role-Based Access Control (RBAC):
    • Admin roles for system-level operations
    • User roles for data access permissions
    • Resource-based authorization checks
@PreAuthorize("hasRole('ADMIN') or @userPermissionService.canAccessUser(principal, #userId)")
public User getUser(@PathVariable String userId) {
    return userService.findById(userId);
}

// Permission checker service
@Service
public class UserPermissionService {
    public boolean canAccessUser(Authentication user, String targetUserId) {
        return user.getName().equals(targetUserId) || 
               hasRole(user, "ADMIN");
    }
}

Sensitive Data Encryption

  • At-Rest Encryption: AES-256 encryption using AWS KMS for sensitive fields (passwords, PII)
  • In-Transit Encryption: TLS 1.3 with HSTS header enforcement
  • Key Management: Centralized key management through AWS Secrets Manager
@Service
public class DataEncryptionService {
    @Autowired
    private AwsKmsClient kmsClient;
    
    public String encrypt(String plaintext) {
        EncryptRequest request = EncryptRequest.builder()
            .keyId("arn:aws:kms:us-east-1:123456789012:key/uuid")
            .plaintext(SdkBytes.fromUtf8(plaintext))
            .build();
            
        EncryptResponse response = kmsClient.encrypt(request);
        return response.ciphertextBlob().asUtf8String();
    }
}

PII Handling and Masking Strategy

  • PII Classification:
    • Personal identifiers (SSN, DOB, email)
    • Financial data (account numbers, credit cards)
  • Masking Rules:
    • Email: "john.doe@example.com" → "j***@example.com"
    • Phone: "+1 (555) 123-4567" → "+1 (555) ***-4567"
    • Credit cards: "1234567890123456" → "**** **** **** 3456"
@Component
public class PiiMaskingService {
    public String maskEmail(String email) {
        if (email == null || email.isEmpty()) return email;
        
        String[] parts = email.split("@");
        if (parts.length < 2) return email;
        
        String localPart = parts[0];
        String domain = parts[1];
        
        if (localPart.length() <= 2) {
            return localPart + "@" + domain;
        }
        
        String maskedLocal = localPart.charAt(0) + 
                           "*".repeat(localPart.length() - 2) + 
                           localPart.charAt(localPart.length() - 1);
        
        return maskedLocal + "@" + domain;
    }
}

Secrets Management

  • Centralized Secret Store: AWS Secrets Manager with automatic rotation
  • Secret Rotation: 90-day rotation policy for database credentials and API keys
  • Access Control: IAM roles with least privilege principle for secret access
@Service
public class SecretManagerService {
    @Autowired
    private AWSSecretsManager secretsManager;
    
    public String getDatabasePassword() {
        GetSecretValueRequest request = GetSecretValueRequest.builder()
            .secretId("prod/database/password")
            .build();
            
        GetSecretValueResponse response = secretsManager.getSecretValue(request);
        return response.secretString();
    }
}

Audit Logging Design

  • Audit Events: Capture all security-relevant operations (login, role changes, data access)
  • Event Schema:
    • Timestamp
    • User ID
    • Action type (login, logout, data_access)
    • IP address
    • User agent
    • Session ID
@Component
public class SecurityAuditLogger {
    private static final Logger logger = LoggerFactory.getLogger(SecurityAuditLogger.class);
    
    public void logSecurityEvent(String userId, String action, String ipAddress) {
        Map<String, Object> auditEvent = new HashMap<>();
        auditEvent.put("timestamp", Instant.now());
        auditEvent.put("userId", userId);
        auditEvent.put("action", action);
        auditEvent.put("ipAddress", maskIpAddress(ipAddress));
        auditEvent.put("userAgent", getCurrentUserAgent());
        
        logger.info("SECURITY_AUDIT: {}", auditEvent);
    }
    
    private String maskIpAddress(String ipAddress) {
        if (ipAddress == null || ipAddress.isEmpty()) return "unknown";
        // Simple IPv4 masking
        return ipAddress.replaceAll("\\.\\d+", ".***");
    }
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage with focus on business logic, edge cases, and error paths
  • Repository Layer: 70% coverage with emphasis on query correctness and boundary condition handling
  • Controller Layer: 60% coverage, primarily focusing on request/response validation and happy path scenarios
  • Domain Objects: 90% coverage for value objects, entities, and their validation logic
  • Exception Handling: 100% coverage for all custom exception types and their handling paths
  • Integration Points: 100% coverage for external service calls (mocked) and error scenarios

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL/MySQL for end-to-end DB interaction testing
  • External Service Mocking: WireMock or Mockito for external HTTP service integration testing
  • Event System Testing: Embedded Kafka or RabbitMQ in test containers to validate async flows and message consumption
  • Cross-Service Communication: Use embedded service mocks or in-memory test infrastructure for inter-service testing
  • Transaction Boundary Testing: Validate rollback behavior and consistency across multiple DB operations

Contract Testing

  • Tool: Pact for consumer-driven contract testing between services
  • Scope: All external API contracts, including third-party service integrations (e.g., payment gateways, notification services)
  • Versioning: Contract version alignment with service versions to prevent breaking changes
  • CI/CD Integration: Pact tests run as part of pre-deployment pipeline to validate contract compliance

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based load testing) for scalable performance testing
  • Key Scenarios:
    • Concurrent error response generation under load (1000 concurrent requests)
    • Error propagation through service layers with latency impact analysis
    • High-volume error logging and monitoring under stress conditions
    • Database query performance during error conditions (e.g., failed DB transactions)
  • Metrics Tracked: Response time percentiles, error rates, throughput, memory usage, and CPU utilization

Test Data Management Strategy

  • Fixtures: YAML or JSON-based fixtures for common test data structures (e.g., user profiles, error configurations)
  • Factories: Factory classes for generating test data with realistic variations and edge cases (e.g., invalid input combinations)
  • Seeding: Automated data seeding scripts for test environments to ensure consistent baseline states
  • Data Isolation: Use of database transaction rollback or dedicated test schemas to isolate test data between runs
  • Test Data Generation: Faker libraries for generating realistic but non-sensitive test data (e.g., email addresses, names, error messages)

13. Observability Design

Structured Logging Schema

The logging system follows a structured JSON format to enable efficient parsing and querying. All log entries must include the following required fields:

{
  "timestamp": "2023-10-27T14:30:00.000Z",
  "level": "ERROR",
  "service": "booking-service",
  "trace_id": "1234567890abcdef1234567890abcdef",
  "span_id": "abcdef1234567890abcdef1234567890",
  "user_id": "user-123",
  "entity_id": "booking-456",
  "error_code": "BOOKING_CREATION_FAILED",
  "message": "Failed to create booking for user due to invalid seat selection",
  "error_details": {
    "stack_trace": "...",
    "cause": "InvalidSeatException"
  }
}

PII masking is enforced at the logging layer:

  • User IDs are hashed before logging
  • Email addresses are masked (e.g., j***@**.com)
  • Credit card numbers are fully redacted

Distributed Tracing Instrumentation

Using OpenTelemetry, we instrument key operations with custom spans:

Span Types and Attributes:

  • http.request - captures HTTP request metadata (method, path, status code)
  • db.query - logs database query details and execution time
  • external.api.call - records external service calls (latency, endpoint)
  • business.operation - wraps core business logic with operation name and parameters

Key Attributes Captured:

  • operation.name - e.g., "createBooking", "validateSeat"
  • user.id - hashed for privacy
  • request.id - unique request identifier for correlation
  • error.type - exception class name for categorization
  • retry.count - retry attempts for idempotent operations

Metrics Design

RED Method Metrics (Rate, Errors, Duration)

Metric NameTypeLabelsDescription
http_requests_totalCountermethod, path, status_codeRequest count by endpoint
http_request_duration_secondsHistogrammethod, path, status_codeResponse time distribution
database_queries_totalCounteroperation, db.nameQuery execution count
database_query_duration_secondsHistogramoperation, db.nameQuery execution time
external_api_calls_totalCounterendpoint, status_codeExternal service call count
external_api_duration_secondsHistogramendpointExternal API response time
business_operation_errors_totalCounteroperation, error_codeBusiness logic error count
business_operation_duration_secondsHistogramoperationBusiness operation execution time

Health Check Endpoint Design

Endpoint Definitions:

PathPurposeChecks Performed
/healthOverall service healthAll dependencies, DB connectivity
/readyReadiness for trafficDB, cache, external services
/liveLiveness checkService process status
/startupStartup validationConfiguration, required services

Response Format:

{
  "status": "healthy",
  "timestamp": "2023-10-27T14:30:00.000Z",
  "checks": {
    "database": {
      "status": "healthy",
      "latency_ms": 15
    },
    "cache": {
      "status": "healthy",
      "latency_ms": 5
    },
    "external_api": {
      "status": "healthy",
      "latency_ms": 42
    }
  }
}

Alerting Thresholds

Critical Alerts (Escalation to On-Call):

MetricThresholdAlert Level
http_request_duration_seconds (p99)> 500msCritical
business_operation_duration_seconds (p99)> 1000msCritical
http_requests_total (rate)> 1000/secCritical
database_query_duration_seconds (p99)> 200msCritical
external_api_duration_seconds (p99)> 500msCritical
business_operation_errors_total (rate)> 10/secCritical

Warning Alerts:

MetricThresholdAlert Level
http_request_duration_seconds (p99)> 200msWarning
business_operation_duration_seconds (p99)> 500msWarning
database_query_duration_seconds (p99)> 100msWarning
external_api_duration_seconds (p99)> 200msWarning
business_operation_errors_total (rate)> 5/secWarning

Alerting Logic:

  • Alerts trigger after 3 consecutive failures or sustained over threshold
  • Slack notifications with escalation to on-call engineer within 5 minutes of alert trigger
  • Incident management integration for critical alerts

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Error categorization granularity: Should we implement a 3-tier error hierarchy (domain, infrastructure, system) or a flat classification? The choice affects how teams consume and respond to errors in monitoring and alerting systems.
  • Error response format consistency: Should all services return the same error response structure regardless of the underlying technology stack? This impacts API gateway behavior and client-side error handling.
  • Fallback behavior policy: What constitutes a "fallback" for different types of errors? For example, should all 5xx errors trigger fallbacks or only specific ones like database timeouts?
  • Error logging verbosity: Should we log full stack traces for all errors or only those that require investigation? This affects both performance and debugging capability.

Known trade-offs and justifications

  • Performance vs. observability: Implementing comprehensive error logging and tracing adds overhead to every operation. We've chosen to use structured logging with async write patterns to minimize impact on request latency while maintaining observability.
  • Flexibility vs. consistency: A highly configurable error handling framework offers more customization but increases complexity and risk of inconsistent error handling across services. We've opted for a balanced approach with opinionated defaults and extensible hooks.
  • Error propagation vs. encapsulation: Propagating raw exceptions through layers provides better debugging but exposes internal implementation details. We've chosen to wrap and re-raise exceptions with appropriate context while preserving stack traces for debugging.
  • Centralized vs. distributed error handling: Centralized error handling provides consistency but creates a single point of failure and potential bottlenecks. Distributed approach allows for service-specific handling but requires more coordination.

Risks and mitigation strategies

  • Overhead from async error logging: Async logging can cause memory pressure during high-error rate scenarios. Mitigation includes bounded async queues with overflow handling and periodic monitoring of queue depths.
  • Inconsistent error handling across microservices: Different teams may implement error handling differently, leading to a fragmented monitoring experience. Mitigation includes comprehensive documentation, code review checks, and automated linting for error handling patterns.
  • Error masking: Properly designed error handling can mask root causes, making debugging difficult. Mitigation includes robust logging with full stack traces and structured error context that preserves original exception details.
  • Circuit breaker false positives: Circuit breakers may trip due to transient issues, causing cascading failures. Mitigation includes configurable failure thresholds, circuit breaker health monitoring, and proper error classification to distinguish between transient and permanent failures.

Technical debt acknowledged

  • Framework evolution complexity: As the framework evolves, maintaining backward compatibility while introducing new features creates technical debt. This will require careful versioning and migration strategies.
  • Testing coverage gaps: Comprehensive error handling testing is expensive to maintain. We're accepting some gaps in edge case coverage but have prioritized critical error paths with full test coverage.
  • Dependency on specific logging frameworks: The current implementation assumes certain logging libraries and configurations. Future flexibility would require more abstraction layers, but this adds complexity that's currently not justified.
  • Limited support for external service error propagation: The framework assumes internal service communication. External service errors may require additional handling logic that isn't currently supported in the core framework design.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    CQRS Framework Design and Implementation

    Comprehensive low-level design for a Command Query Responsibility Segregation (CQRS) framework with event sourcing, read/write model separation, and distributed consistency patterns.

  • Backend LLD

    Dynamic Configuration Service Low-Level Design

    Comprehensive LLD for a scalable dynamic configuration service with real-time updates, versioning, and multi-environment support.

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.