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
- 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?
- 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?
- What are the consistency and reliability requirements? Should all errors be logged, retried, or should some be allowed to fail silently?
- 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.
- 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?
- What external integrations are expected? Will the framework need to handle timeouts, circuit breaker errors, or retryable failures from external APIs?
- 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
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /v1/exceptions | List all exceptions with filtering and pagination | JWT |
| POST | /v1/exceptions | Create a new exception record | JWT |
| GET | /v1/exceptions/{id} | Retrieve specific exception by ID | JWT |
| PUT | /v1/exceptions/{id} | Update an existing exception record | JWT |
| DELETE | /v1/exceptions/{id} | Soft delete an exception record | JWT |
| GET | /v1/exceptions/{id}/stacktrace | Get detailed stack trace for exception | JWT |
| POST | /v1/exceptions/{id}/acknowledge | Acknowledge an exception as handled | JWT |
| GET | /v1/exceptions/stats | Get aggregate statistics about exceptions | JWT |
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 Code | Error Type | Description |
|---|---|---|
| 200 | OK | Successful GET, PUT, DELETE |
| 201 | Created | Successful POST |
| 400 | BadRequest | Invalid request body, missing required fields |
| 401 | Unauthorized | Authentication failed or missing |
| 403 | Forbidden | Insufficient permissions |
| 404 | NotFound | Resource not found |
| 422 | UnprocessableEntity | Validation errors |
| 429 | TooManyRequests | Rate limit exceeded |
| 500 | InternalServerError | Server 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 operationsexceptions: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-Keyheader - 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
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PRIMARY KEY | Unique identifier for exception log |
| user_id | UUID | FOREIGN KEY (users.id) | User who triggered the exception |
| error_code | VARCHAR(50) | NOT NULL | Standardized error code |
| message | TEXT | NOT NULL | Human-readable error message |
| stack_trace | TEXT | Full stack trace for debugging | |
| request_context | JSONB | Request metadata (headers, params) | |
| service_name | VARCHAR(100) | NOT NULL | Service name where error occurred |
| version | VARCHAR(20) | Service version | |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | When log was created |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | When log was last updated |
| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | Log status (ACTIVE, ARCHIVED, RESOLVED) |
error_categories table
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PRIMARY KEY | Unique identifier for error category |
| name | VARCHAR(100) | NOT NULL, UNIQUE | Category name (e.g., "VALIDATION", "DATABASE") |
| description | TEXT | Detailed description of category | |
| severity | VARCHAR(20) | NOT NULL, DEFAULT 'ERROR' | Severity level (INFO, WARN, ERROR, FATAL) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | When category was created |
| updated_at | TIMESTAMP | NOT 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:
- Global Uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination
- No Hotspotting: Unlike auto-increment IDs, UUIDs don't create write hotspots in distributed systems
- No Dependency on Sequence Generation: UUIDs can be generated client-side without database round-trips
- 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 Name | Fields | Type | Purpose |
|---|---|---|---|
| idx_exception_logs_user_id | user_id | Standard | Fast lookup by user |
| idx_exception_logs_error_code | error_code | Standard | Efficient filtering by error code |
| idx_exception_logs_created_at | created_at | Standard | Time-based queries for analytics |
| idx_exception_logs_service_name | service_name | Standard | Service-level error aggregation |
| idx_exception_logs_status_created_at | status, created_at | Composite | Efficient status-based filtering |
| idx_exception_logs_user_error | user_id, error_code | Composite | Fast user-specific error analysis |
| idx_exception_logs_service_created | service_name, created_at | Composite | Service-wise recent error tracking |
| idx_exception_logs_status_severity | status, severity | Partial | Filter by status and severity levels |
error_categories table indexes
| Index Name | Fields | Type | Purpose |
|---|---|---|---|
| idx_error_categories_name | name | Unique | Fast lookup by category name |
| idx_error_categories_severity | severity | Standard | Efficient 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:
- Query Performance: Time-based queries are common for analytics and monitoring
- Data Lifecycle Management: Easy to archive or drop old partitions
- 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:
created_at- When the log was created (timestamp)updated_at- When the log was last modified (timestamp)user_id- Who triggered the exception (UUID reference)service_name- What service generated the error (string)
Data archival strategy and retention policy
Archival Strategy
-
Daily Archiving:
- Move logs older than 30 days to archive storage (e.g., S3)
- Keep active logs in primary database for fast querying
-
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)
-
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:
- GlobalExceptionHandler: Main entry point for handling uncaught exceptions in the application
- ExceptionMapper: Maps specific exception types to standardized error responses
- ErrorInfo: Data structure representing the standardized error response format
- 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:
- Exception occurs in business logic or API layer
- GlobalExceptionHandler receives the exception
- ErrorInfo is built and logged (within same transaction context)
- 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:
- Event-driven error propagation through message queues
- Retry mechanisms for transient errors
- 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:
-
Thread-local storage: All exception context information (request ID, user ID, timestamp) is stored in
ThreadLocalvariables to ensure each thread has its own copy and no cross-contamination occurs. -
Immutable error objects: Exception metadata objects are designed to be immutable once created, preventing any modification during concurrent processing.
-
Synchronized logging: When writing to centralized logs or error tracking systems, the framework uses synchronized blocks or lock-free concurrent data structures like
ConcurrentHashMapto 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:
-
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.
-
Timeout-based acquisition: Any internal locking uses timeout mechanisms to ensure no thread waits indefinitely for a lock.
-
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:
-
Request ID tracking: Each incoming request is assigned a unique identifier that is propagated through the exception handling chain.
-
Idempotency key pattern: For retry scenarios, the framework supports idempotency keys in error responses to prevent duplicate error reporting.
-
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:
-
Error logging service: Uses
java.util.concurrent.ConcurrentHashMapfor storing error metadata andjava.util.logging.Loggerwith thread-safe appenders. -
Notification service: Implements a thread-safe queue for asynchronous error notifications using
BlockingQueue. -
Metrics collection: Uses atomic counters and thread-safe collections for tracking error rates and types.
Synchronization Primitives Used
The framework uses minimal synchronization primitives:
ThreadLocalfor request-scoped statejava.util.concurrent.ConcurrentHashMapfor shared error metadata storagejava.util.concurrent.atomic.AtomicLongfor generating unique error IDsReentrantLockonly in specific scenarios where shared state modification is needed
Performance Considerations
The framework prioritizes performance by:
- Avoiding unnecessary synchronization: Most operations are thread-local and thus inherently safe
- Using lock-free data structures: Where shared state is required, concurrent collections are preferred
- Asynchronous error processing: Error reporting and notification are handled asynchronously to avoid blocking the main request thread
Testing Strategy for Concurrency
Concurrency testing includes:
- Stress testing: Simulating multiple concurrent requests to ensure no race conditions
- Thread dump analysis: Verifying that no deadlocks occur under high load
- JVM thread monitoring: Using tools like JConsole or VisualVM to observe thread behavior
- Unit tests with multiple threads: Using
ExecutorServiceto 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 Type | TTL | Reason |
|---|---|---|
| Error instances | 24 hours | Retain recent errors for debugging |
| Error statistics | 7 days | Maintain trend analysis |
| Configuration | 1 hour | Allow quick refresh of error handling rules |
| User-specific errors | 1 hour | Recent 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:
- Probabilistic early expiry: Set TTL to 80% of configured value for cache entries
- Distributed locks: Use Redis distributed locks for expensive cache population operations
- 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-lruwith maxmemory set to 2GB per node
Cache Invalidation Strategy
We'll implement event-driven invalidation:
- Write-through: Cache is updated when error configuration changes
- Explicit purge: Cache entries invalidated on configuration updates
- 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:
- 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.
- Alerting and notifications: When critical exceptions occur, system alerts should be sent to monitoring tools or teams without blocking the user-facing request.
- 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:
- Retry limit: Messages are retried up to 3 times with exponential backoff (1s, 4s, 9s)
- DLQ routing: After max retries, message is sent to a dedicated DLQ topic with a retention policy of 7 days
- 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:
- Exception events are idempotent in nature - multiple deliveries of the same event don't cause harm
- The system prioritizes ensuring all exceptions are captured over preventing duplicates
- 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:
- The exception handling framework is designed to be lightweight and focused on core error processing
- For most use cases, the async publishing of exception events is acceptable with at-least-once delivery guarantee
- The framework assumes that the underlying messaging system (e.g., Kafka) provides durability and reliability guarantees
- 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
| Metric | Warning Level | Critical Level | Alerting Service |
|---|---|---|---|
| Error Rate (5m) | > 1% | > 5% | PagerDuty |
| Response Time (p99) | > 500ms | > 2s | Grafana |
| Circuit Breaker Open | 1+ open circuits | 5+ open circuits | Slack |
| Retry Failures | > 100 in 1h | > 500 in 1h | Email 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
| Parameter | Value | Description |
|---|---|---|
| Max Retries | 3 | Maximum retry attempts before failure |
| Base Delay | 1000ms | Initial delay between retries |
| Backoff Multiplier | 2.0 | Exponential backoff factor |
| Jitter Factor | ±20% | Random variation to prevent thundering herd |
| Retry Timeout | 30s | Maximum time spent retrying before giving up |
Circuit Breaker Configuration
| Parameter | Value | Description |
|---|---|---|
| Failure Threshold | 5 | Number of failures before opening circuit |
| Timeout | 30s | Time to wait before attempting half-open state |
| Success Threshold | 3 | Successful calls needed to close circuit |
| Half-Open Retry Limit | 1 | Number of retries in half-open state |
Fallback Configuration
| Operation | Fallback Strategy | When Used |
|---|---|---|
| External API Call | Cache read | When external service unavailable |
| Database Query | Default Value | When DB is down |
| Payment Processing | Manual Review | When 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 timeexternal.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 privacyrequest.id- unique request identifier for correlationerror.type- exception class name for categorizationretry.count- retry attempts for idempotent operations
Metrics Design
RED Method Metrics (Rate, Errors, Duration)
| Metric Name | Type | Labels | Description |
|---|---|---|---|
http_requests_total | Counter | method, path, status_code | Request count by endpoint |
http_request_duration_seconds | Histogram | method, path, status_code | Response time distribution |
database_queries_total | Counter | operation, db.name | Query execution count |
database_query_duration_seconds | Histogram | operation, db.name | Query execution time |
external_api_calls_total | Counter | endpoint, status_code | External service call count |
external_api_duration_seconds | Histogram | endpoint | External API response time |
business_operation_errors_total | Counter | operation, error_code | Business logic error count |
business_operation_duration_seconds | Histogram | operation | Business operation execution time |
Health Check Endpoint Design
Endpoint Definitions:
| Path | Purpose | Checks Performed |
|---|---|---|
/health | Overall service health | All dependencies, DB connectivity |
/ready | Readiness for traffic | DB, cache, external services |
/live | Liveness check | Service process status |
/startup | Startup validation | Configuration, 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):
| Metric | Threshold | Alert Level |
|---|---|---|
http_request_duration_seconds (p99) | > 500ms | Critical |
business_operation_duration_seconds (p99) | > 1000ms | Critical |
http_requests_total (rate) | > 1000/sec | Critical |
database_query_duration_seconds (p99) | > 200ms | Critical |
external_api_duration_seconds (p99) | > 500ms | Critical |
business_operation_errors_total (rate) | > 10/sec | Critical |
Warning Alerts:
| Metric | Threshold | Alert Level |
|---|---|---|
http_request_duration_seconds (p99) | > 200ms | Warning |
business_operation_duration_seconds (p99) | > 500ms | Warning |
database_query_duration_seconds (p99) | > 100ms | Warning |
external_api_duration_seconds (p99) | > 200ms | Warning |
business_operation_errors_total (rate) | > 5/sec | Warning |
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.