This document presents a comprehensive low-level design for building a scalable API request validation framework that ensures data integrity, enforces business rules, and provides detailed error responses for API consumers.
1. Problem Statement & Requirements
Clarifying questions
-
What is the expected scale of API requests this validation framework will handle?
- Is it for a small internal service or a high-volume public API with millions of requests per day?
-
What types of validation are required?
- Are we validating only request body content, or also headers, query parameters, and cookies?
-
What is the desired consistency model for validation?
- Should validation be strict (fail fast) or lenient (collect all errors and return them together)?
-
What is the expected read/write ratio for the system?
- Is this primarily a validation service that consumes many request payloads or is it part of a larger system?
-
Are there specific compliance or regulatory requirements (e.g., GDPR, HIPAA) that impact validation logic?
-
What is the expected latency sensitivity for validation operations?
- Should validation be sub-10ms or is it acceptable to take longer for complex validations?
-
What kind of external integrations or dependencies does the validation framework have?
- Is it part of a monolith, microservices architecture, or a distributed system?
Functional requirements
- Input validation — Validate request body, headers, query parameters, and cookies against defined schemas or rules.
- Schema validation — Support for JSON Schema, OpenAPI schema definitions, and custom validation rules.
- Custom validation logic — Allow developers to define custom validator classes or annotations for domain-specific validations.
- Error aggregation and reporting — Collect all validation errors in a single response instead of failing on first error.
- Validation context awareness — Support for conditional validation based on request context (e.g., user role, environment).
- Validation rule inheritance and composition — Allow reuse of validation rules across different endpoints or services.
Non-functional requirements
-
Latency targets
- p50: < 5ms
- p95: < 10ms
- p99: < 25ms
-
Throughput
- Minimum 10,000 requests/sec with 95% of requests processed under 25ms.
-
Availability
- 99.9% uptime with graceful degradation capability for validation errors.
-
Extensibility
- Must support custom validator extensions without modifying core framework code.
Constraints and assumptions
- The validation framework will be used in a Java-based microservices ecosystem.
- Validation logic must not block the main request processing thread.
- All validation errors should be returned in a consistent, structured format.
- The framework will support both synchronous and asynchronous validation flows.
- Validation rules are defined declaratively (e.g., via annotations or config files).
- The system assumes that all input is untrusted and must be validated.
Out of scope
- Authentication and authorization — These are handled by separate middleware or gateways.
- Request rate limiting — This is managed at the API gateway or load balancer level.
- Data sanitization or transformation — The framework only validates, not modifies input data.
- Cross-site scripting (XSS) or injection prevention — These are handled at the application layer or by security proxies.
- Real-time validation rule updates — Rules are expected to be versioned and deployed with the service.
- Validation of response payloads — Only request payload validation is in scope.
2. API Design
API Style Selection
REST is selected for this validation framework due to its widespread adoption, clear semantics, and strong tooling support. REST's resource-based approach aligns well with validation rule definitions and their application to API requests.
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /validation/framework | Retrieve framework configuration and capabilities | JWT |
| POST | /validation/rules | Create a new validation rule | JWT |
| GET | /validation/rules/{id} | Retrieve a specific validation rule | JWT |
| PUT | /validation/rules/{id} | Update an existing validation rule | JWT |
| DELETE | /validation/rules/{id} | Delete a validation rule | JWT |
| POST | /validation/validate | Validate an API request against defined rules | JWT |
| GET | /validation/rules | List all validation rules with filtering and pagination | JWT |
Request and Response Schemas
Create Validation Rule Request
{
"name": "email_format_validator",
"description": "Validates email format using regex",
"type": "regex",
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"target": {
"field": "email",
"location": "body"
},
"severity": "error",
"enabled": true
}
Validate Request
{
"request": {
"method": "POST",
"path": "/users",
"headers": {
"Content-Type": "application/json"
},
"body": {
"email": "invalid-email",
"name": "John Doe"
}
},
"validationRules": ["email_format_validator"]
}
Validation Response
{
"isValid": false,
"errors": [
{
"field": "email",
"message": "Email format is invalid",
"code": "INVALID_EMAIL_FORMAT"
}
],
"warnings": [],
"validatedAt": "2023-10-27T10:30:00Z"
}
HTTP Status Codes and Error Response Format
| Code | Description |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 422 | Unprocessable Entity - Validation errors |
| 500 | Internal Server Error |
Standardized error response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Email format is invalid",
"code": "INVALID_EMAIL_FORMAT"
}
],
"timestamp": "2023-10-27T10:30:00Z"
}
}
API Versioning Strategy
Versioning will be done via header: Accept: application/vnd.api.v1+json. This approach is preferred for its clarity and ease of implementation in middleware.
Pagination Design
Cursor-based pagination will be used with after parameter for better performance and consistency in large datasets. Offset-based pagination is avoided due to its sensitivity to data changes.
Authentication and Authorization
JWT tokens will be used for authentication, with scopes defining access levels:
validation:read- Read access to validation rulesvalidation:write- Write access to validation rules
Rate Limiting
Rate limiting will be implemented at 1000 requests per hour per client, with a sliding window algorithm. Clients will receive X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
Idempotency Design
For mutation endpoints, idempotency keys will be supported via Idempotency-Key header. The framework will store the result of the first request and return it for subsequent identical requests within a 5-minute window.
Validation Rule Schema
{
"name": "string",
"description": "string",
"type": "enum: [regex, min_length, max_length, required, custom]",
"pattern": "string",
"minLength": "integer",
"maxLength": "integer",
"required": "boolean",
"customValidator": "object",
"target": {
"field": "string",
"location": "enum: [body, query, header]"
},
"severity": "enum: [error, warning]",
"enabled": "boolean",
"createdAt": "datetime",
"updatedAt": "datetime"
}
3. Data Model Design
Entity-Relationship diagram (Mermaid)
erDiagram
VALIDATION_RULE {
string id PK
string name
string description
string rule_type
jsonb config
boolean is_active
timestamp created_at
timestamp updated_at
string created_by
string updated_by
}
VALIDATION_RULE_SET {
string id PK
string name
string description
timestamp created_at
timestamp updated_at
string created_by
}
VALIDATION_RULE_SET_RULE {
string rule_set_id PK
string rule_id PK
integer priority
timestamp created_at
}
VALIDATION_CONTEXT {
string id PK
string entity_type
string entity_id
jsonb context_data
timestamp created_at
timestamp updated_at
}
VALIDATION_RESULT {
string id PK
string rule_set_id
string context_id
string validation_status
jsonb details
timestamp created_at
timestamp updated_at
}
VALIDATION_RULE ||--o{ VALIDATION_RULE_SET_RULE : "contains"
VALIDATION_RULE_SET ||--o{ VALIDATION_RULE_SET_RULE : "has"
VALIDATION_CONTEXT ||--o{ VALIDATION_RESULT : "has"
VALIDATION_RULE_SET ||--o{ VALIDATION_RESULT : "applied_to"
Table / Collection schema definitions with field types, constraints, indices
validation_rules
CREATE TABLE validation_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
rule_type VARCHAR(100) NOT NULL, -- e.g., "required", "format", "range"
config JSONB NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255),
updated_by VARCHAR(255),
CONSTRAINT chk_rule_type CHECK (rule_type IN ('required', 'format', 'range', 'custom')),
CONSTRAINT chk_name_not_empty CHECK (LENGTH(TRIM(name)) > 0)
);
CREATE INDEX idx_validation_rules_active ON validation_rules(is_active);
CREATE INDEX idx_validation_rules_type ON validation_rules(rule_type);
validation_rule_sets
CREATE TABLE validation_rule_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255),
updated_by VARCHAR(255),
CONSTRAINT chk_name_not_empty CHECK (LENGTH(TRIM(name)) > 0)
);
validation_rule_set_rules
CREATE TABLE validation_rule_set_rules (
rule_set_id UUID NOT NULL REFERENCES validation_rule_sets(id) ON DELETE CASCADE,
rule_id UUID NOT NULL REFERENCES validation_rules(id) ON DELETE CASCADE,
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (rule_set_id, rule_id),
CONSTRAINT chk_priority_positive CHECK (priority >= 0)
);
CREATE INDEX idx_validation_rule_set_rules_priority ON validation_rule_set_rules(priority);
validation_contexts
CREATE TABLE validation_contexts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
context_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_validation_contexts_entity ON validation_contexts(entity_type, entity_id);
validation_results
CREATE TABLE validation_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_set_id UUID NOT NULL REFERENCES validation_rule_sets(id),
context_id UUID NOT NULL REFERENCES validation_contexts(id) ON DELETE CASCADE,
validation_status VARCHAR(50) NOT NULL, -- "passed", "failed", "skipped"
details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_validation_status CHECK (validation_status IN ('passed', 'failed', 'skipped'))
);
CREATE INDEX idx_validation_results_rule_set ON validation_results(rule_set_id);
CREATE INDEX idx_validation_results_context ON validation_results(context_id);
CREATE INDEX idx_validation_results_status ON validation_results(validation_status);
Primary key strategy (UUID vs. auto-increment vs. ULID — with justification)
UUID Strategy
We use UUIDs as primary keys for all tables in this validation framework. This decision is driven by:
- Global Uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination
- Scalability: No need to coordinate sequence generation across multiple instances or shards
- Decoupling: Applications can generate IDs without database round-trips
- Sharding Friendliness: UUIDs are naturally sharded-friendly with no clustering issues
Index design (composite, partial, covering indices — with justification per index)
-
idx_validation_rules_active:
- Purpose: Fast filtering of active rules for validation runs
- Type: Single column index
-
idx_validation_rules_type:
- Purpose: Efficient rule type filtering for rule categorization
- Type: Single column index
-
idx_validation_rule_set_rules_priority:
- Purpose: Fast rule ordering during validation execution
- Type: Single column index
-
idx_validation_contexts_entity:
- Purpose: Fast lookup by entity type and ID for context-based validation
- Type: Composite index
-
idx_validation_results_rule_set:
- Purpose: Fast retrieval of validation results by rule set for reporting
- Type: Single column index
-
idx_validation_results_context:
- Purpose: Fast lookup of validation results by context for entity-specific reports
- Type: Single column index
-
idx_validation_results_status:
- Purpose: Efficient filtering by validation status for analytics and dashboards
- Type: Single column index
Data partitioning / sharding strategy
For large-scale usage, we implement horizontal sharding by:
- Rule Set ID: Partition validation results by rule set to support rule-specific analytics
- Entity Type: For high-volume entities, we can shard by entity type to reduce contention
- Time-based: For historical data analysis, we can use time-based partitioning on
created_atfields
Soft delete vs. hard delete decision with justification
We use soft deletes for all tables to preserve auditability and allow for data recovery. This is particularly important for:
- Audit Trail: Compliance and forensic analysis require historical data
- Recovery: Accidental deletions can be recovered without complex data restoration
- Analytics: Historical validation patterns and trends are preserved
Audit trail design (who, what, when)
All tables have created_by, updated_by, created_at, and updated_at fields to capture:
- Who: User or system that made the change
- What: The actual data change (captured in the audit fields)
- When: Timestamps for when changes occurred
Data archival strategy and retention policy
- Archival Trigger: Validation results older than 90 days are archived to cold storage
- Retention Policy:
- Active validation rules: 5 years (configurable)
- Validation contexts and results: 30 days (configurable)
- Archive Format: JSON or Parquet format for analytical queries
- Storage: AWS S3 or similar object storage with lifecycle policies
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class ValidationFramework {
+validate(request)
+registerValidator(validator)
+getValidator(name)
}
class Validator {
<<Interface>>
+validate(request)
+getPriority()
+getName()
}
class ValidationContext {
-request: Request
-errors: List~ValidationError~
-validatedFields: Set~String~
+getRequest()
+getErrors()
+addError(error)
+isFieldValidated(field)
+markFieldAsValidated(field)
}
class ValidationError {
-field: String
-message: String
-code: String
+getField()
+getMessage()
+getCode()
}
class Request {
-headers: Map~String, String~
-body: Object
-queryParams: Map~String, String~
+getHeaders()
+getBody()
+getQueryParams()
}
class ValidationRule {
-field: String
-ruleType: String
-params: Map~String, Object~
-message: String
+getField()
+getRuleType()
+getParams()
+getMessage()
}
class ValidationChain {
-validators: List~Validator~
-rules: Map~String, List~ValidationRule~~
+addValidator(validator)
+executeChain(context)
}
class FieldValidator {
-field: String
-rules: List~ValidationRule~
+validate(context)
}
class RuleEngine {
-ruleDefinitions: Map~String, ValidationRule~
+applyRule(rule, context)
}
class ValidationException {
-errors: List~ValidationError~
+getErrors()
}
ValidationFramework --> ValidationChain : orchestrates
ValidationFramework --> Validator : manages
ValidationChain --> Validator : executes
ValidationChain --> ValidationContext : passes data
Validator --> ValidationContext : operates on
FieldValidator --> ValidationRule : uses
RuleEngine --> ValidationRule : executes
ValidationException --> ValidationError : contains
class DefaultValidator {
+validate(context)
+getPriority()
+getName()
}
class FieldLevelValidator {
+validate(context)
+getPriority()
+getName()
}
DefaultValidator ..|> Validator
FieldLevelValidator ..|> Validator
State Machine Diagram
stateDiagram-v2
[*] --> Idle
Idle --> Validating : validate()
Validating --> Validated : success
Validating --> Failed : validation error
Validated --> [*]
Failed --> [*]
Service Class Structure and Responsibilities
The validation framework is designed as a modular, extensible system with clear separation of concerns:
- ValidationFramework: Main orchestrator that manages validator lifecycle and execution flow
- Validator: Interface defining validation contract with priority and name identification
- ValidationContext: Encapsulates request data and validation state tracking
- ValidationError: Represents individual validation failures with structured error information
- Request: Encapsulates incoming request data (headers, body, query parameters)
- ValidationChain: Executes validators in proper order based on priority
- FieldValidator: Handles field-specific validation logic
- RuleEngine: Core engine that applies individual validation rules to fields
Interface / Abstract Class Design
public interface Validator {
ValidationResult validate(ValidationContext context);
int getPriority();
String getName();
}
public abstract class AbstractValidator implements Validator {
private final String name;
private final int priority;
protected AbstractValidator(String name, int priority) {
this.name = name;
this.priority = priority;
}
@Override
public String getName() {
return name;
}
@Override
public int getPriority() {
return priority;
}
}
public class ValidationResult {
private final boolean valid;
private final List<ValidationError> errors;
public ValidationResult(boolean valid, List<ValidationError> errors) {
this.valid = valid;
this.errors = new ArrayList<>(errors);
}
public boolean isValid() {
return valid;
}
public List<ValidationError> getErrors() {
return new ArrayList<>(errors);
}
}
Business Logic Encapsulation Patterns
The framework implements the Strategy pattern for validator registration and execution. Each validator type (field-level, request-level) implements the same interface but provides different validation logic.
Critical-Path Method Implementation
public class ValidationFramework {
private final Map<String, Validator> validators = new ConcurrentHashMap<>();
private final List<Validator> validatorChain = new ArrayList<>();
public ValidationResult validate(Request request) {
ValidationContext context = new ValidationContext(request);
// Execute validators in priority order
for (Validator validator : validatorChain) {
try {
ValidationResult result = validator.validate(context);
if (!result.isValid()) {
return new ValidationResult(false, result.getErrors());
}
} catch (Exception e) {
ValidationError error = new ValidationError(
"framework",
"Validation failed: " + e.getMessage(),
"VALIDATION_ERROR"
);
return new ValidationResult(false, Collections.singletonList(error));
}
}
return new ValidationResult(true, Collections.emptyList());
}
public void registerValidator(Validator validator) {
validators.put(validator.getName(), validator);
rebuildValidatorChain();
}
private void rebuildValidatorChain() {
validatorChain.clear();
validatorChain.addAll(
validators.values().stream()
.sorted(Comparator.comparingInt(Validator::getPriority))
.collect(Collectors.toList())
);
}
}
Transaction Boundary Design
The validation framework operates at the HTTP request boundary. Each incoming request triggers a single validation operation that:
- Processes all validators in sequence
- Collects all errors without early termination
- Returns comprehensive error set for client consumption
Service Interface Contracts
public interface ValidationService {
ValidationResult validate(Request request);
void registerValidator(Validator validator);
List<ValidationError> getErrors();
}
Saga / Orchestration Pattern
For complex validation scenarios involving external systems, the framework supports asynchronous validation through event-driven architecture. However, for simple request validation, synchronous execution is preferred to maintain low latency.
Domain Object / Value Object Design
public class Request {
private final Map<String, String> headers;
private final Object body;
private final Map<String, String> queryParams;
public Request(Map<String, String> headers, Object body, Map<String, String> queryParams) {
this.headers = new HashMap<>(headers);
this.body = body;
this.queryParams = new HashMap<>(queryParams);
}
// Getters with defensive copying
public Map<String, String> getHeaders() {
return new HashMap<>(headers);
}
public Object getBody() {
return body;
}
public Map<String, String> getQueryParams() {
return new HashMap<>(queryParams);
}
}
public class ValidationError {
private final String field;
private final String message;
private final String code;
public ValidationError(String field, String message, String code) {
this.field = field;
this.message = message;
this.code = code;
}
// Getters
public String getField() { return field; }
public String getMessage() { return message; }
public String getCode() { return code; }
}
Key Implementation Details
- Thread Safety: All validator registration and execution is thread-safe using concurrent collections
- Priority Ordering: Validators execute in priority order to ensure proper validation sequence
- Error Aggregation: All validation errors are collected and returned together for comprehensive feedback
- Extensibility: New validator types can be added without modifying core framework logic
- Performance: Minimal memory allocation during validation execution with object reuse where possible
5. Concurrency & Thread Safety
Concurrency Model
The validation framework will operate in a multi-threaded environment where multiple API requests can be processed concurrently. The concurrency model is based on:
- Thread-per-request pattern: Each incoming API request is handled by a dedicated thread from the application's thread pool
- Event loop pattern: For high-concurrency scenarios, the framework supports async processing using Reactor or similar reactive programming models
- Shared mutable state: The validation configuration and rule registry are shared across threads, requiring proper synchronization
Race Condition Prevention Strategy
Race conditions in the validation framework are mitigated through:
- Immutable configuration objects: Validation rules and configurations are built once and never modified after initialization
- Thread-safe collections: Use of concurrent data structures (e.g., ConcurrentHashMap) for storing runtime validation metadata
- Read-locks for configuration access: When validating, read operations on the configuration are protected by read locks to prevent concurrent modifications during validation
- Atomic updates: Configuration changes are made atomically using compare-and-swap operations where necessary
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for the validation framework because:
- Low contention: Validation rules and configurations are typically read more than written, with updates being infrequent
- High throughput: Optimistic locking allows for better performance under concurrent read-heavy workloads
- Simpler implementation: No need to manage lock acquisition and release overhead
- Configuration immutability: Configuration objects are designed to be immutable, making optimistic locking appropriate
Deadlock Prevention Strategy
Deadlock prevention is addressed through:
- Lock ordering: All threads acquire locks in a consistent, predefined order (e.g., by rule name or ID)
- Timeout-based acquisition: Locks are acquired with timeout to prevent indefinite blocking
- Hierarchical lock management: Validation rule locks are managed hierarchically to avoid circular dependencies
- Thread pool size limits: Controlled thread pool sizes prevent excessive resource contention
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented through:
-
Request fingerprinting: Each validation request is identified by a unique fingerprint combining:
- Request path and method
- Query parameters and headers (excluding volatile ones)
- Body content hash
-
In-memory cache with TTL: Duplicate requests within a short time window (e.g., 5 seconds) are cached with a time-to-live to avoid redundant processing
-
Distributed cache for multi-instance deployments: For clustered environments, a distributed cache (e.g., Redis) is used to maintain idempotency across instances
-
Idempotency key header: Clients can provide an
Idempotency-Keyheader that serves as the unique identifier for request deduplication
public class IdempotencyValidator {
private final Map<String, ValidationResult> cache = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public IdempotencyValidator() {
// Clean up expired entries every minute
scheduler.scheduleAtFixedRate(this::cleanupExpired, 60, 60, TimeUnit.SECONDS);
}
public ValidationResult validateWithIdempotency(
String idempotencyKey,
ValidationRequest request) {
// Check if this is a duplicate request
ValidationResult cached = cache.get(idempotencyKey);
if (cached != null && !isExpired(cached)) {
return cached;
}
// Process the validation
ValidationResult result = validate(request);
// Cache the result for future duplicate requests
cache.put(idempotencyKey, result);
return result;
}
private void cleanupExpired() {
long now = System.currentTimeMillis();
cache.entrySet().removeIf(entry -> isExpired(entry.getValue()));
}
private boolean isExpired(ValidationResult result) {
return System.currentTimeMillis() - result.getTimestamp() > 5000; // 5 seconds
}
}
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant Client
participant APIGateway
participant ValidationFramework
participant BusinessService
participant Database
Client->>APIGateway: POST /api/v1/users
APIGateway->>ValidationFramework: Validate request payload
ValidationFramework->>ValidationFramework: Check required fields
ValidationFramework->>ValidationFramework: Validate field formats
ValidationFramework->>ValidationFramework: Apply custom validation rules
ValidationFramework-->>APIGateway: Validated request or error
alt Valid request
APIGateway->>BusinessService: Forward validated request
BusinessService->>Database: Create user record
Database-->>BusinessService: User created
BusinessService-->>APIGateway: Success response
APIGateway-->>Client: 201 Created
else Invalid request
APIGateway-->>Client: 400 Bad Request
end
sequenceDiagram
participant Client
participant APIGateway
participant ValidationFramework
participant BusinessService
participant ExternalService
Client->>APIGateway: PUT /api/v1/orders/123
APIGateway->>ValidationFramework: Validate request payload
ValidationFramework->>ValidationFramework: Check required fields
ValidationFramework->>ValidationFramework: Validate field formats
ValidationFramework->>ValidationFramework: Validate business rules (e.g., order status)
ValidationFramework-->>APIGateway: Validated request or error
alt Valid request
APIGateway->>BusinessService: Forward validated request
BusinessService->>ExternalService: Validate payment details
ExternalService-->>BusinessService: Payment validation result
BusinessService->>Database: Update order record
Database-->>BusinessService: Order updated
BusinessService-->>APIGateway: Success response
APIGateway-->>Client: 200 OK
else Invalid request
APIGateway-->>Client: 400 Bad Request
end
sequenceDiagram
participant Client
participant APIGateway
participant ValidationFramework
participant BusinessService
participant EventPublisher
Client->>APIGateway: POST /api/v1/events
APIGateway->>ValidationFramework: Validate request payload
ValidationFramework->>ValidationFramework: Check required fields
ValidationFramework->>ValidationFramework: Validate field formats
ValidationFramework->>ValidationFramework: Apply custom validation rules
ValidationFramework-->>APIGateway: Validated request or error
alt Valid request
APIGateway->>BusinessService: Forward validated request
BusinessService->>EventPublisher: Publish event to message queue
EventPublisher-->>BusinessService: Event published successfully
BusinessService-->>APIGateway: Success response
APIGateway-->>Client: 202 Accepted
else Invalid request
APIGateway-->>Client: 400 Bad Request
end
7. Caching Design
Cache Strategy Selection
For API request validation framework, we'll use cache-aside pattern with write-through semantics for validation rules and write-behind for cached validation results.
The cache-aside pattern is chosen because:
- It provides explicit control over when cache operations occur
- Allows for proper error handling and fallback logic
- Enables cache warming strategies during system startup
- Supports cache invalidation on rule updates
Write-through for validation rules ensures that all validation configurations are consistently available in cache, while write-behind for results allows for better performance with less immediate consistency requirements.
Cache Key Design
Cache keys will follow a structured namespace pattern:
validation:{ruleId}:{userId}:{requestHash}
validation:rules:{ruleId}
validation:metadata:{requestType}:{version}
The request hash is computed from:
- HTTP method (GET/POST/PUT)
- Request path pattern
- Content-Type header
- Request body content (after normalization)
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Validation Rules | 1 hour | Rules change infrequently, but must be available for immediate use |
| Validation Results | 5 minutes | Recent requests should benefit from caching, but not persist stale validation |
| Metadata | 24 hours | Request metadata patterns don't change frequently |
Cache Eviction Policy
We'll use LRU (Least Recently Used) eviction policy with:
- 10% probabilistic early expiry for hot keys to prevent stampede
- Cache size limit of 1GB per cache node
- Memory pressure monitoring to trigger cleanup
Cache Stampede Prevention
To prevent thundering herd on cache misses:
- Probabilistic Early Expiry: 10% of keys expire 5 minutes early
- Distributed Locking: Use Redis distributed locks for cache population
- Cache Entry Coalescing: Multiple concurrent requests to same key wait on first fetch
Distributed Cache Architecture
We'll implement a Redis Cluster with:
- 3 master nodes for read/write operations
- 3 replica nodes for high availability
- Automatic failover with Redis Sentinel monitoring
- Cross-zone replication for multi-AZ deployment
Cache Invalidation Strategy
Cache invalidation will be event-driven:
- When validation rule is updated or deleted, invalidate all related cached results
- When request metadata changes (new endpoint patterns), invalidate metadata cache
- Use Redis Pub/Sub to broadcast invalidation events across service instances
Implementation Example
@Component
public class ValidationCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
public ValidationCacheService(RedisTemplate<String, Object> redisTemplate,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
public ValidationResponse getValidationResult(String ruleId, String userId,
ValidationRequest request) {
String cacheKey = buildCacheKey(ruleId, userId, request);
// Try to get from cache
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return deserializeValidationResponse(cached);
}
// Cache miss - acquire distributed lock
String lockKey = "lock:" + cacheKey;
if (tryAcquireLock(lockKey, 5000)) {
try {
// Double-check pattern
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return deserializeValidationResponse(cached);
}
// Perform actual validation
ValidationResponse result = performValidation(ruleId, userId, request);
// Cache the result with TTL
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
return result;
} finally {
releaseLock(lockKey);
}
}
// Fallback to direct validation if lock acquisition fails
return performValidation(ruleId, userId, request);
}
private String buildCacheKey(String ruleId, String userId, ValidationRequest request) {
String requestHash = hashRequest(request);
return String.format("validation:%s:%s:%s", ruleId, userId, requestHash);
}
private String hashRequest(ValidationRequest request) {
try {
String json = objectMapper.writeValueAsString(request);
return DigestUtils.md5Hex(json);
} catch (Exception e) {
throw new RuntimeException("Failed to hash request", e);
}
}
private boolean tryAcquireLock(String lockKey, int timeoutMs) {
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, timeoutMs, TimeUnit.MILLISECONDS);
return acquired != null && acquired;
}
private void releaseLock(String lockKey) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
UUID.randomUUID().toString()
);
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern provides a clean abstraction between the domain layer and data access logic, enabling loose coupling and testability. Each repository encapsulates the persistence operations for a specific entity.
public interface UserRepository {
User findById(UUID id);
User findByEmail(String email);
List<User> findAll();
User save(User user);
void delete(UUID id);
boolean existsById(UUID id);
}
public class UserRepositoryImpl implements UserRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public UserRepositoryImpl(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
@Override
public User findById(UUID id) {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id}, this::mapRowToUser);
}
@Override
public User findByEmail(String email) {
String sql = "SELECT * FROM users WHERE email = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{email}, this::mapRowToUser);
}
@Override
public List<User> findAll() {
String sql = "SELECT * FROM users";
return jdbcTemplate.query(sql, this::mapRowToUser);
}
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(UUID.randomUUID());
insertUser(user);
} else {
updateUser(user);
}
return user;
}
private void insertUser(User user) {
String sql = "INSERT INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, user.getId(), user.getEmail(), user.getName(),
user.getCreatedAt(), user.getUpdatedAt());
}
private void updateUser(User user) {
String sql = "UPDATE users SET email = ?, name = ?, updated_at = ? WHERE id = ?";
jdbcTemplate.update(sql, user.getEmail(), user.getName(),
user.getUpdatedAt(), user.getId());
}
@Override
public void delete(UUID id) {
String sql = "DELETE FROM users WHERE id = ?";
jdbcTemplate.update(sql, id);
}
@Override
public boolean existsById(UUID id) {
String sql = "SELECT COUNT(*) FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, Integer.class, id) > 0;
}
private User mapRowToUser(ResultSet rs, int rowNum) throws SQLException {
return User.builder()
.id(rs.getObject("id", UUID.class))
.email(rs.getString("email"))
.name(rs.getString("name"))
.createdAt(rs.getTimestamp("created_at").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())
.updatedAt(rs.getTimestamp("updated_at").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())
.build();
}
}
ORM vs. Raw Query Decisions
For this framework, we use raw JDBC queries with JdbcTemplate for better control over SQL execution and performance tuning. ORM tools like Hibernate introduce overhead through object mapping, lazy loading, and complex query generation that can be unnecessary for a validation framework.
Rationale:
- Direct SQL allows fine-grained control over query execution
- Better performance for simple CRUD operations
- Easier to debug and optimize queries
- Reduced memory overhead from object mapping
Read vs. Write Separation
The framework implements read-write separation using a CQRS pattern where read operations are handled by dedicated readers and write operations by writers.
public interface UserReader {
User findById(UUID id);
User findByEmail(String email);
List<User> findAll();
}
public interface UserWriter {
User save(User user);
void delete(UUID id);
}
public class UserReadRepository implements UserReader {
private final JdbcTemplate jdbcTemplate;
public UserReadRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public User findById(UUID id) {
String sql = "SELECT * FROM users WHERE id = ? AND deleted_at IS NULL";
return jdbcTemplate.queryForObject(sql, new Object[]{id}, this::mapRowToUser);
}
@Override
public User findByEmail(String email) {
String sql = "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL";
return jdbcTemplate.queryForObject(sql, new Object[]{email}, this::mapRowToUser);
}
@Override
public List<User> findAll() {
String sql = "SELECT * FROM users WHERE deleted_at IS NULL";
return jdbcTemplate.query(sql, this::mapRowToUser);
}
private User mapRowToUser(ResultSet rs, int rowNum) throws SQLException {
return User.builder()
.id(rs.getObject("id", UUID.class))
.email(rs.getString("email"))
.name(rs.getString("name"))
.createdAt(rs.getTimestamp("created_at").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())
.updatedAt(rs.getTimestamp("updated_at").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())
.build();
}
}
public class UserWriteRepository implements UserWriter {
private final JdbcTemplate jdbcTemplate;
public UserWriteRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(UUID.randomUUID());
insertUser(user);
} else {
updateUser(user);
}
return user;
}
private void insertUser(User user) {
String sql = "INSERT INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, user.getId(), user.getEmail(), user.getName(),
user.getCreatedAt(), user.getUpdatedAt());
}
private void updateUser(User user) {
String sql = "UPDATE users SET email = ?, name = ?, updated_at = ? WHERE id = ?";
jdbcTemplate.update(sql, user.getEmail(), user.getName(),
user.getUpdatedAt(), user.getId());
}
@Override
public void delete(UUID id) {
String sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?";
jdbcTemplate.update(sql, id);
}
}
Connection Pool Configuration
Connection pooling is configured with optimal settings to handle concurrent database operations efficiently.
@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/validation_framework");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);
return new HikariDataSource(config);
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
N+1 Query Prevention Strategy
To prevent N+1 query problems, we implement eager loading and batch operations:
public class UserBatchRepository {
private final JdbcTemplate jdbcTemplate;
public UserBatchRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<User> findUsersWithRoles(List<UUID> userIds) {
if (userIds.isEmpty()) return Collections.emptyList();
String sql = "SELECT u.id, u.email, u.name, r.role_name FROM users u " +
"LEFT JOIN user_roles ur ON u.id = ur.user_id " +
"LEFT JOIN roles r ON ur.role_id = r.id " +
"WHERE u.id IN (" + String.join(",", Collections.nCopies(userIds.size(), "?")) + ")";
return jdbcTemplate.query(sql, userIds.toArray(), (rs, rowNum) -> {
User user = new User();
user.setId(rs.getObject("id", UUID.class));
user.setEmail(rs.getString("email"));
user.setName(rs.getString("name"));
// Handle role mapping
String roleName = rs.getString("role_name");
if (roleName != null) {
user.getRoles().add(roleName);
}
return user;
});
}
}
Batch Operation Design
Batch operations are implemented to handle bulk data processing efficiently:
public class BatchUserRepository {
private final JdbcTemplate jdbcTemplate;
public BatchUserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void batchInsertUsers(List<User> users) {
String sql = "INSERT INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = users.get(i);
ps.setObject(1, user.getId());
ps.setString(2, user.getEmail());
ps.setString(3, user.getName());
ps.setTimestamp(4, Timestamp.valueOf(user.getCreatedAt()));
ps.setTimestamp(5, Timestamp.valueOf(user.getUpdatedAt()));
}
@Override
public int getBatchSize() {
return users.size();
}
});
}
public void batchUpdateUsers(List<User> users) {
String sql = "UPDATE users SET email = ?, name = ?, updated_at = ? WHERE id = ?";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = users.get(i);
ps.setString(1, user.getEmail());
ps.setString(2, user.getName());
ps.setTimestamp(3, Timestamp.valueOf(user.getUpdatedAt()));
ps.setObject(4, user.getId());
}
@Override
public int getBatchSize() {
return users.size();
}
});
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why
In a request validation framework, most validation operations should be performed synchronously to provide immediate feedback to the client. However, asynchronous processing becomes valuable in these scenarios:
- Validation result logging and analytics: When validation results need to be aggregated for system monitoring or analytics, these operations can be decoupled from the request processing flow to avoid impacting response times.
- Complex validation workflows: Some validations may involve external services or long-running computations that are better suited for asynchronous processing.
- Audit trail generation: Detailed audit logs of validation events can be published asynchronously to maintain system responsiveness.
The framework will remain primarily synchronous but provide hooks for asynchronous processing where appropriate.
Message schema design with a sample payload (Avro / Protobuf / JSON Schema — with versioning strategy)
{
"name": "ValidationEvent",
"type": "record",
"version": "1.0.0",
"fields": [
{
"name": "eventId",
"type": "string",
"doc": "Unique identifier for the validation event"
},
{
"name": "requestId",
"type": "string",
"doc": "Identifier of the original API request"
},
{
"name": "validationType",
"type": "string",
"doc": "Type of validation performed (e.g., 'schema', 'business-rule')"
},
{
"name": "validationResult",
"type": {
"name": "ValidationResult",
"type": "record",
"fields": [
{
"name": "isValid",
"type": "boolean"
},
{
"name": "errors",
"type": {
"type": "array",
"items": {
"name": "ValidationError",
"type": "record",
"fields": [
{
"name": "fieldPath",
"type": "string"
},
{
"name": "errorCode",
"type": "string"
},
{
"name": "message",
"type": "string"
}
]
}
}
}
]
}
},
{
"name": "timestamp",
"type": "long",
"doc": "Unix timestamp in milliseconds"
},
{
"name": "source",
"type": "string",
"doc": "Originating service or component"
}
]
}
Versioning strategy:
- Use semantic versioning (MAJOR.MINOR.PATCH) for message schemas
- Backward compatibility maintained through schema evolution rules (e.g., adding optional fields)
- Schema registry with versioning support to enforce compatibility checks
Dead letter queue strategy and poison message handling
Poison messages will be handled through a dead-letter queue (DLQ) system:
- Retry mechanism: Messages that fail due to transient issues (network timeouts, service unavailability) will be retried with exponential backoff up to 3 attempts.
- DLQ handling: Messages that fail after maximum retries will be moved to a DLQ topic with metadata indicating failure reason and retry count.
- Manual intervention: Messages in DLQ will be flagged for manual inspection and potential reprocessing.
- Alerting: Alerting thresholds set at 10% of total processed messages to notify operations team.
At-least-once vs. exactly-once delivery — decision with justification for this feature
At-least-once delivery is chosen for the validation framework because:
- Idempotency in validation: Validation results are deterministic based on input data, so duplicate processing won't cause harm.
- Simpler implementation: At-least-once is easier to implement and maintain than exactly-once semantics.
- Performance: Avoids the complexity and overhead of maintaining transactional state across systems.
- Monitoring: At-least-once delivery allows for better observability through duplicate detection and alerting.
Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)
Retry policy configuration:
public class ValidationEventRetryPolicy {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_DELAY_MS = 1000;
private static final double BACKOFF_MULTIPLIER = 2.0;
private static final long MAX_DELAY_MS = 30000;
public boolean shouldRetry(Exception exception) {
// Non-retryable errors (e.g., validation errors, client errors)
if (exception instanceof ValidationException ||
exception instanceof IllegalArgumentException) {
return false;
}
// Retryable errors (e.g., network issues, service unavailability)
return exception instanceof IOException ||
exception instanceof ServiceUnavailableException ||
exception instanceof TimeoutException;
}
public long getDelay(int attempt) {
if (attempt <= 0) return 0;
long delay = (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1));
return Math.min(delay, MAX_DELAY_MS);
}
}
Outbox pattern for reliable event publishing — include or explicitly justify omission
Outbox pattern is omitted for this validation framework because:
- Simplicity: The validation framework's primary concern is immediate validation feedback, not persistence guarantees across distributed systems.
- Event-driven scope: The framework is designed to be a lightweight validation layer that doesn't require complex transactional consistency guarantees.
- Performance: Adding outbox pattern would introduce additional latency and complexity without significant benefit for this use case.
- Alternative approach: For systems requiring strong consistency, the validation result can be published to a message broker within the same transaction boundary or use a more robust event store.
Sequence diagram for all async flows (Mermaid)
sequenceDiagram
participant Client
participant ValidationFramework
participant MessageBroker
participant AnalyticsService
Client->>ValidationFramework: Submit API Request
ValidationFramework->>ValidationFramework: Validate Request
ValidationFramework->>MessageBroker: Publish ValidationEvent
MessageBroker->>AnalyticsService: Deliver ValidationEvent
AnalyticsService->>AnalyticsService: Process Validation Event (Async)
ValidationFramework-->>Client: Return Validation Result
10. Error Handling Design
Error Classification Hierarchy
The error handling framework follows a three-tier hierarchy to ensure proper categorization and handling:
- Domain Errors - Business logic violations (e.g., insufficient funds, invalid state transitions)
- Infrastructure Errors - System-level issues (e.g., database connection failures, timeout errors)
- Validation Errors - Input validation failures (e.g., malformed JSON, missing required fields)
Each error type is encapsulated in a dedicated exception class hierarchy that extends from a base ApplicationException.
public abstract class ApplicationException extends RuntimeException {
private final String errorCode;
private final int httpStatus;
protected ApplicationException(String errorCode, int httpStatus, String message) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
// Getters
}
public class ValidationException extends ApplicationException {
public ValidationException(String errorCode, String message) {
super(errorCode, 400, message);
}
}
public class DomainException extends ApplicationException {
public DomainException(String errorCode, String message) {
super(errorCode, 422, message);
}
}
public class InfrastructureException extends ApplicationException {
public InfrastructureException(String errorCode, String message) {
super(errorCode, 500, message);
}
}
Error Propagation Strategy
The framework implements a layered error propagation approach:
- Validation Layer: Errors are immediately thrown and handled at the API boundary
- Service Layer: Business logic errors are wrapped in domain exceptions and re-thrown to maintain stack trace context
- Infrastructure Layer: Database or external service failures are wrapped in infrastructure exceptions and potentially retried
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors (e.g., network timeouts, database connection issues), the framework implements a retry mechanism 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) throws Exception {
Exception lastException = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return operation.get();
} catch (InfrastructureException e) {
lastException = e;
if (attempt == MAX_RETRIES) {
throw e;
}
long delay = calculateBackoffDelay(attempt);
Thread.sleep(delay);
}
}
throw lastException;
}
private long calculateBackoffDelay(int attempt) {
long delay = (long) Math.pow(2, attempt) * BASE_DELAY_MS;
long jitter = (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return delay + jitter;
}
}
Circuit Breaker Design
For external service dependencies, the circuit breaker pattern prevents cascading failures:
public class CircuitBreaker {
private final int failureThreshold;
private final long timeoutMs;
private final int successThreshold;
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private 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);
}
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 breaker is OPEN");
}
private <T> T executeHalfOpen(Supplier<T> operation) throws Exception {
try {
T result = operation.get();
state = CircuitState.CLOSED;
failureCount = 0;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
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
Each external dependency can define fallback behavior for circuit breaker or retry failures:
public interface FallbackHandler<T> {
T handleFallback(Exception cause) throws Exception;
}
public class ExternalServiceClient {
private final CircuitBreaker circuitBreaker = new CircuitBreaker(5, 30000, 3);
public String getExternalData(String id) throws Exception {
return circuitBreaker.execute(() -> {
// External service call
return externalService.getData(id);
});
}
public String getExternalDataWithFallback(String id) throws Exception {
try {
return circuitBreaker.execute(() -> externalService.getData(id));
} catch (Exception e) {
return fallbackHandler.handleFallback(e);
}
}
}
Error Logging Format and Alerting Thresholds
Structured logging is implemented using a standardized format that includes trace context:
public class ErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
public void logError(String traceId, String spanId, String userId,
String errorCode, Exception exception, String context) {
logger.error(
"ERROR|traceId={}|spanId={}|userId={}|errorCode={}|" +
"message={}|context={}|stackTrace={}",
traceId, spanId, userId, errorCode,
exception.getMessage(), context,
Arrays.toString(exception.getStackTrace())
);
}
}
Alerting thresholds are configured per error type:
| Error Type | Alert Threshold | Severity |
|---|---|---|
| Validation Errors | > 100 per hour | Warning |
| Infrastructure Errors | > 50 per hour | Critical |
| Domain Errors | > 1000 per hour | Warning |
Error Response Format
Standardized JSON error responses are returned to clients:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"details": [
{
"field": "email",
"message": "Email is required"
}
],
"timestamp": "2023-10-15T14:30:00Z"
}
}
Error Handling in API Layer
The API layer catches all exceptions and converts them to appropriate HTTP responses:
@RestController
public class ErrorHandlingController {
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
ErrorResponse error = new ErrorResponse(
"VALIDATION_ERROR",
ex.getMessage(),
HttpStatus.BAD_REQUEST.value()
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
ErrorResponse error = new ErrorResponse(
"DOMAIN_ERROR",
ex.getMessage(),
HttpStatus.UNPROCESSABLE_ENTITY.value()
);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
}
@ExceptionHandler(InfrastructureException.class)
public ResponseEntity<ErrorResponse> handleInfrastructure(InfrastructureException ex) {
ErrorResponse error = new ErrorResponse(
"INFRASTRUCTURE_ERROR",
"Service temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE.value()
);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
}
11. Security Design
Input Validation and Sanitization Strategy
Input validation is implemented at multiple layers with a defense-in-depth approach:
-
API Layer Validation:
- Use Jakarta Bean Validation (JSR-380) for structured validation
- Custom constraints for business-specific rules (e.g., valid email format, phone number patterns)
- Validation groups to apply different validation rules based on operation context
-
Sanitization:
- Use OWASP Java HTML Sanitizer for cleaning user-provided HTML content
- Apply regex-based filtering for special characters in text fields
- Implement length limits and character set restrictions
public class UserRequest {
@NotBlank(message = "Name is required")
@Size(max = 100, message = "Name must be less than 100 characters")
private String name;
@Email(message = "Invalid email format")
@NotBlank(message = "Email is required")
private String email;
@ValidPhoneNumber
private String phoneNumber;
}
SQL Injection Prevention
- ORM Usage: All database operations use JPA/Hibernate with parameterized queries
- Dynamic Query Building:
- Use query builder APIs instead of string concatenation
- Validate and sanitize any dynamic column or table names
// Safe query using JPA Criteria API
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("email"), emailParam);
query.select(root).where(predicate);
Authentication Middleware Design
-
JWT-based Authentication:
- JWT tokens issued by Auth0/OAuth2 provider
- Token validation middleware with signature verification
- Token expiration and refresh mechanism
-
Middleware Implementation:
@Component
public class JwtAuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String token = extractToken(httpRequest);
if (token != null && validateToken(token)) {
Authentication auth = createAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
}
Authorization Enforcement
-
Role-Based Access Control (RBAC):
- User roles defined in database (ADMIN, USER, MODERATOR)
- Role hierarchy with inheritance (e.g., MODERATOR inherits USER permissions)
- Permission-based authorization using Spring Security annotations
-
Resource-level Authorization:
@PreAuthorize("@authorizationService.canAccessUser(principal, #userId)")
public User getUser(@PathVariable String userId) {
return userService.findById(userId);
}
Sensitive Data Encryption
-
At Rest:
- AES-256 encryption for sensitive fields (passwords, PII)
- Key rotation every 90 days using AWS KMS or HashiCorp Vault
- Field-level encryption for specific sensitive attributes
-
In Transit:
- TLS 1.3 required for all API endpoints
- HSTS header implementation to enforce HTTPS
- Certificate pinning for mobile clients
PII Handling and Masking Strategy
-
Data Classification:
- Classify fields as PII (SSN, DOB, Address) or non-PII
- Mask sensitive fields in logs and responses
-
Masking Implementation:
public class PiiMasker {
public static String maskSsn(String ssn) {
return ssn.replaceAll("(\\d{3})-?(\\d{2})-?(\\d{4})", "XXX-XX-$3");
}
public static String maskEmail(String email) {
return email.replaceAll("(^\\w{1})\\w+(@.*)", "$1***$2");
}
}
Secrets Management
-
Secrets Storage:
- AWS Secrets Manager for API keys, database credentials
- Environment-specific secret paths with proper IAM roles
- Automatic rotation of secrets every 90 days
-
Secrets Usage:
@Configuration
public class SecretsConfig {
@Value("${aws.secret.name}")
private String secretName;
@Bean
public AWSSecretsManagerClient secretsClient() {
return AWSSecretsManagerClientBuilder.standard()
.withRegion(Regions.US_EAST_1)
.build();
}
}
Audit Logging Design
-
Audit Events:
- Log all authentication attempts (success/failure)
- Track CRUD operations on sensitive resources
- Include user identity, IP address, timestamp, and operation details
-
Log Format:
{
"timestamp": "2023-12-01T10:30:45.123Z",
"userId": "user-123",
"operation": "USER_CREATE",
"ipAddress": "192.168.1.100",
"details": {
"resource": "User",
"action": "CREATE",
"targetId": "user-456"
},
"status": "SUCCESS"
}
- Retention Policy:
- 90-day retention for audit logs
- Archive to S3 Glacier for long-term storage
- Compliance-ready with searchable indexes
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage with 100% branch coverage for business logic paths
- Repository Layer: 90% code coverage, focusing on query execution and error handling
- Validator Components: 100% test coverage with edge case validation for all rules
- Controller Layer: 80% coverage, focusing on request/response transformation and error handling
- Integration Tests: 70% of critical paths, particularly around validation logic and error responses
Integration Testing Strategy
- Test Containers: Use Docker containers for database and external service integration testing (PostgreSQL, Redis)
- Embedded Databases: H2 for unit tests and in-memory databases for fast test execution
- WireMock: For external HTTP service stubbing (e.g., authentication, payment gateways)
- Test Data Management: Factory pattern for generating test entities with realistic data sets
- Database Transaction Isolation: Each integration test runs in a transaction that is rolled back after execution
Contract Testing
- Pact Framework: For consumer-driven contract testing between API gateway and downstream services
- Versioned Contracts: Maintain versioned contract files in Git for each external dependency
- Contract Verification: Run contract verification as part of CI pipeline before deployment to staging
Performance Testing Tool and Key Scenarios
- k6: For load testing with realistic concurrent request patterns (1000 RPS, 5000 concurrent users)
- Key Scenarios:
- Validation of large payloads (5MB JSON documents)
- Concurrent validation under high load (1000 concurrent requests)
- Error response latency under stress conditions
- Memory usage with large validation sets
Test Data Management Strategy
- Fixtures: YAML/JSON files for common test data sets (valid users, valid orders)
- Factories: Java-based factory classes for generating test entities with configurable attributes
- Seeding Strategy: Database seed scripts for initial test data population (e.g., default roles, permissions)
- Test Data Isolation: Each test suite runs with isolated database schema or namespace to prevent cross-contamination
// Example of factory pattern for test data generation
public class TestUserFactory {
public static User createValidUser() {
return User.builder()
.id(UUID.randomUUID())
.email("test@example.com")
.username("testuser")
.password("securePassword123!")
.build();
}
public static User createInvalidUser() {
return User.builder()
.id(UUID.randomUUID())
.email("invalid-email")
.username("")
.password("weak")
.build();
}
}
13. Observability Design
Structured Logging Schema
The logging system follows a structured approach to ensure observability and facilitate log analysis. Each log entry includes the following required fields:
timestamp: ISO 8601 formatted timestamplevel: Log level (INFO, WARN, ERROR)trace_id: Unique identifier for distributed tracingspan_id: Span identifier within the traceservice_name: Service name (e.g., "api-gateway")user_id: Authenticated user identifier (masked for PII)entity_id: Business entity ID (e.g., order_id, user_id)request_id: Unique identifier for the incoming requestmethod: HTTP method (GET, POST, etc.)path: Request pathstatus_code: HTTP status codeduration_ms: Request processing time in millisecondserror_code: Custom error code (if applicable)message: Human-readable log messageerror_stack: Full stack trace (only in ERROR level)correlation_id: Correlation ID for cross-service requests
PII masking strategy:
- User IDs and email addresses are masked using a hash function or tokenization
- Credit card numbers and SSNs are redacted entirely
Distributed Tracing Instrumentation
Using OpenTelemetry for distributed tracing:
Custom Spans to Add:
api.request.processing- Main request processing spanvalidation.middleware- Validation logic executiondatabase.query- Database query execution with query textexternal.service.call- External API calls (e.g., auth, payment)cache.operation- Cache hit/miss operations
Attributes to Capture:
request.method: HTTP method (GET, POST)request.path: Request path patternrequest.params: Query parameters and path variables (redacted for sensitive data)request.body.size: Size of request body in bytesresponse.status.code: HTTP status codedb.statement: SQL query text (with placeholders)db.connection.pool: Connection pool nameexternal.service.name: Service name (e.g., "payment-service")cache.key: Cache key usedcache.hit: Boolean indicating cache hit/miss
Metrics Design at Service Level
RED Method Metrics:
-
Rate (R) - Request count per second
- Counter:
api_requests_total - Labels: method, path, status_code, user_type
- Counter:
-
Errors (E) - Error rate per second
- Counter:
api_errors_total - Labels: method, path, error_code, status_code
- Counter:
-
Duration (D) - Request processing time
- Histogram:
api_request_duration_seconds - Labels: method, path, status_code
- Histogram:
Additional Service Metrics:
validation_rules_applied_total: Count of validation rules applied per requestvalidation_failures_total: Count of validation failures by rule typecache_hits_total,cache_misses_total: Cache performance metricsdatabase_queries_total,database_errors_total: DB operation metrics
Health Check Endpoint Design
Endpoints:
-
/health- Overall service health (liveness probe)- Returns 200 OK if all dependencies are healthy
- Response format:
{ "status": "healthy", "timestamp": "2023-01-01T00:00:00Z", "dependencies": { "database": "healthy", "cache": "healthy", "auth_service": "healthy" } }
-
/ready- Readiness check (readiness probe)- Returns 200 OK if service can accept traffic
- Checks database connectivity, cache availability, and configuration loading
-
/live- Liveness check (liveness probe)- Returns 200 OK if service is running and responsive
- Basic health check without external dependency checks
Alerting Thresholds per Key Metric
Critical Alerts (Escalation to On-call):
api_errors_total- Rate > 50 errors/minute for 5 minutesapi_request_duration_seconds- P99 > 10 seconds for 5 minutesdatabase_errors_total- Rate > 10 errors/minute for 2 minutescache_misses_total- Rate > 1000 misses/minute for 5 minutes
Warning Alerts (Monitoring):
api_errors_total- Rate > 10 errors/minute for 5 minutesapi_request_duration_seconds- P99 > 3 seconds for 5 minutesdatabase_queries_total- Rate > 1000 queries/minute for 5 minutes
Alerting Configuration:
- Alert manager configured with Slack integration
- Critical alerts escalate to on-call engineer within 2 minutes
- Warning alerts trigger email notifications every 15 minutes
- Alert threshold adjustments based on historical data and business hours
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Validation scope granularity: Should validation be applied at the API layer, service layer, or both? A coarse-grained approach (API-only) reduces complexity but may miss edge cases; fine-grained validation (service-level) provides better encapsulation but increases implementation overhead.
- Validation rule inheritance: How should validation rules be inherited across API versions? Should newer versions automatically inherit older rules or allow selective override?
- Error response format consistency: Should all validation errors follow a uniform JSON structure or allow per-endpoint customization for better context?
Known trade-offs and justifications
- Performance vs. flexibility: Using reflection-based validation (e.g., Bean Validation) provides maximum flexibility but introduces runtime overhead. Pre-generated validation code or compile-time annotation processing offers better performance at the cost of reduced flexibility in rule definition.
- Developer productivity vs. security: Leveraging standard validation libraries like Hibernate Validator speeds up development but may introduce security gaps if not properly configured. Custom validation logic provides better control but requires more effort to implement and maintain.
- Single source of truth vs. distributed validation: Centralized validation rules (e.g., in a shared library) ensure consistency but create tight coupling. Distributed validation (per service) allows independence but increases risk of inconsistency.
- Validation complexity vs. error clarity: Complex validation logic with nested conditions provides comprehensive coverage but makes error messages harder to understand for API consumers. Simpler, more granular validation rules with clear error messages improve usability but may miss edge cases.
Risks and mitigation strategies
- Validation bypass risk: If validation is not consistently applied across all entry points, malicious or malformed requests may slip through. Mitigation: Implement validation as a cross-cutting concern with automatic interception at API gateway level.
- Performance degradation: Heavy validation logic can significantly impact response times, especially for high-volume APIs. Mitigation: Implement caching of validation results for frequently accessed endpoints and use async validation where appropriate.
- False negatives in validation: Complex validation rules may have edge cases that aren't covered, leading to false positives or negatives. Mitigation: Comprehensive test coverage with boundary condition testing and regular validation rule audits.
- Version compatibility issues: Changes in validation rules between API versions may break existing clients. Mitigation: Implement strict versioning and backward compatibility checks with clear migration paths.
Technical debt acknowledged
- Rule maintenance overhead: As validation rules evolve, maintaining consistency across multiple services and API versions creates ongoing technical debt. This is mitigated by using shared validation libraries but still requires careful change management.
- Validation rule documentation gap: Generated validation rules lack human-readable documentation, making it harder for developers to understand the business logic behind them. This is addressed by maintaining a separate rule documentation system but creates a synchronization challenge.
- Performance monitoring gap: Without dedicated performance metrics for validation operations, it's difficult to detect when validation logic becomes a bottleneck. This requires proactive monitoring and alerting setup that isn't part of the core framework design.
- Custom rule complexity: Supporting custom validation rules requires extensive extensibility points, which can lead to overly complex architecture if not properly designed. This is mitigated by providing clear guidelines and examples for custom rule implementation but requires ongoing design attention.