Skip to main content

API Versioning Strategy Design Patterns and Implementation

Comprehensive low-level design for scalable API versioning strategies including URL path, header, and query parameter approaches with trade-off analysis.

API versioning is a critical design decision that impacts system evolution, backward compatibility, and developer experience. This document explores multiple versioning strategies with implementation considerations and trade-offs for production systems.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the expected scale of API consumers? Are we talking about hundreds, thousands, or tens of thousands of clients using the API?
  2. What is the expected rate of API changes? How often do we expect breaking changes vs. backward-compatible ones?
  3. What is the current API versioning strategy in place? Is there an existing system that we're extending or replacing?
  4. What are the key stakeholders and their tolerance for breaking changes? Will they prefer gradual migration or immediate updates?
  5. Are there any specific compliance or regulatory requirements that impact versioning? For example, GDPR or HIPAA implications for data handling.
  6. What is the expected lifecycle of API versions? Is there a defined time window for supporting older versions?
  7. What is the preferred method of communication to consumers about version changes? Will we use documentation, webhooks, or another mechanism?

Functional requirements

  • Support multiple API versions simultaneously
  • Allow clients to specify the desired version in their requests
  • Enable backward compatibility for existing clients
  • Provide clear migration paths for deprecated features
  • Support version-specific error handling and responses

Non-functional requirements

  • Latency targets: p50 < 50ms, p95 < 100ms, p99 < 200ms for version resolution and routing
  • Throughput: Support at least 10,000 requests/second across all versions
  • Availability: 99.9% uptime for versioned API endpoints
  • Scalability: Support horizontal scaling of versioned endpoints without performance degradation

Constraints and assumptions

  • Assumption: API consumers will migrate to newer versions within a 6-month window after deprecation
  • Constraint: Backward compatibility must be maintained for at least 2 major versions
  • Assumption: All API consumers have the capability to specify version in their requests
  • Constraint: Versioning strategy must not introduce significant latency overhead

Out of scope

  • Authentication and authorization mechanisms (covered in separate sections)
  • Specific implementation details of version resolution logic
  • Migration tooling or scripts for client-side updates
  • Detailed documentation of individual API endpoints (covered in API Design section)
  • Handling of version-specific rate limiting or quotas (covered in separate sections)

2. API Design

API Style Selection

For this system, I'll select REST as the primary API style for the following reasons:

  • Familiarity: REST is widely understood by developers and consumers
  • Statelessness: Aligns well with microservices architecture principles
  • Caching: REST supports HTTP caching mechanisms effectively
  • Tooling: Rich ecosystem of tools for documentation, testing, and monitoring

API Endpoints

MethodPathDescriptionAuth
GET/api/v1/users/{userId}Retrieve user detailsJWT
POST/api/v1/usersCreate new userJWT
PUT/api/v1/users/{userId}Update user detailsJWT
DELETE/api/v1/users/{userId}Delete userJWT
GET/api/v1/users/{userId}/ordersList user ordersJWT
POST/api/v1/users/{userId}/ordersCreate order for userJWT
GET/api/v1/orders/{orderId}Retrieve order detailsJWT
PUT/api/v1/orders/{orderId}Update order statusJWT

Request and Response Schemas

User Creation Request

{
  "name": "John Doe",
  "email": "john.doe@example.com",
  "phone": "+1234567890"
}

User Response

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "name": "John Doe",
  "email": "john.doe@example.com",
  "phone": "+1234567890",
  "createdAt": "2023-01-01T10:00:00Z",
  "updatedAt": "2023-01-01T10:00:00Z"
}

Order Creation Request

{
  "productId": "prod-123",
  "quantity": 2,
  "shippingAddress": {
    "street": "123 Main St",
    "city": "Anytown",
    "zipCode": "12345"
  }
}

Order Response

{
  "id": "456f7890-a12b-34c5-d678-901234567890",
  "userId": "123e4567-e89b-12d3-a456-426614174000",
  "productId": "prod-123",
  "quantity": 2,
  "status": "PENDING",
  "shippingAddress": {
    "street": "123 Main St",
    "city": "Anytown",
    "zipCode": "12345"
  },
  "createdAt": "2023-01-01T10:00:00Z",
  "updatedAt": "2023-01-01T10:00:00Z"
}

HTTP Status Codes and Error Response Format

Standard HTTP status codes will be used:

  • 200 OK - Successful GET, PUT, PATCH
  • 201 Created - Successful POST
  • 204 No Content - Successful DELETE
  • 400 Bad Request - Invalid request data
  • 401 Unauthorized - Missing or invalid auth
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Resource not found
  • 422 Unprocessable Entity - Validation errors
  • 429 Too Many Requests - Rate limiting
  • 500 Internal Server Error - Unexpected server errors

Standardized error response format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input data",
    "details": [
      {
        "field": "email",
        "message": "Email is required"
      }
    ]
  }
}

API Versioning Strategy

We'll implement URL path versioning as our primary strategy:

/api/v1/users
/api/v2/users

This approach provides:

  • Clear version identification in URLs
  • Backward compatibility through multiple versions
  • Easy client migration paths
  • Simple implementation and maintenance

Versioning will be enforced through:

  1. API gateway routing to appropriate version handlers
  2. Version validation in request processing pipeline
  3. Documentation for each version

Pagination Design

We'll use cursor-based pagination with the following design:

public class PaginationRequest {
    private String cursor;
    private Integer limit = 20;
    
    // getters and setters
}
public class PaginatedResponse<T> {
    private List<T> data;
    private String nextCursor;
    private boolean hasNext;
    
    // getters and setters
}

Justification for cursor-based pagination:

  • Handles data changes during pagination gracefully
  • More efficient than offset-based pagination for large datasets
  • Provides consistent results even when data is modified
  • Better performance characteristics at scale

Authentication and Authorization

All endpoints require JWT-based authentication:

  • JWT tokens will be validated using HS256 signing
  • Token expiration set to 24 hours
  • Refresh tokens for long-lived sessions (not part of this design)
  • RBAC-based authorization at service layer

Rate Limiting

Rate limiting will be implemented per endpoint:

  • User endpoints: 1000 requests/minute
  • Order endpoints: 500 requests/minute
  • Public endpoints: 10000 requests/minute

Implementation strategy:

  1. In-memory rate limiter for single instance
  2. Distributed rate limiting using Redis for multi-instance deployments
  3. Token bucket algorithm for smooth rate distribution

Idempotency Design

For mutation endpoints (POST, PUT, DELETE), we implement idempotency key strategy:

public class IdempotencyKey {
    private String key;
    private String operationType;
    private String userId;
    private Instant createdAt;
    
    // getters and setters
}

Implementation:

  1. Clients include X-Idempotency-Key header in requests
  2. Server stores operation result for the key
  3. If same key is used, return stored result instead of reprocessing
  4. Key TTL set to 24 hours

Version Migration Strategy

When introducing new API versions:

  1. Maintain backward compatibility for v1 endpoints
  2. Gradually migrate clients to new version
  3. Provide clear migration guide with breaking changes documentation
  4. Deprecate old versions after 6 months with warning headers
  5. Monitor usage patterns to determine when to remove legacy endpoints

Example Migration Path

// v1 endpoint - backward compatible
@GetMapping("/api/v1/users/{userId}")
public ResponseEntity<User> getUserV1(@PathVariable String userId) {
    return ResponseEntity.ok(userService.findById(userId));
}

// v2 endpoint - enhanced with new fields
@GetMapping("/api/v2/users/{userId}")
public ResponseEntity<UserV2> getUserV2(@PathVariable String userId) {
    return ResponseEntity.ok(userService.findByIdWithEnrichedData(userId));
}

This approach ensures that existing clients continue to work while new features can be introduced in parallel without breaking changes.

3. Data Model Design

Entity-Relationship diagram (Mermaid)

erDiagram
    USER {
        string id PK
        string email
        string name
        timestamp created_at
        timestamp updated_at
    }

    ORDER {
        string id PK
        string user_id FK
        string status
        decimal total_amount
        timestamp created_at
        timestamp updated_at
    }

    ORDER_ITEM {
        string id PK
        string order_id FK
        string product_id
        integer quantity
        decimal price
        timestamp created_at
    }

    PRODUCT {
        string id PK
        string name
        string description
        decimal price
        timestamp created_at
        timestamp updated_at
    }

    USER ||--o{ ORDER : places
    ORDER ||--o{ ORDER_ITEM : contains
    ORDER_ITEM ||--o{ PRODUCT : references

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

Users table (users)

FieldTypeConstraintsDescription
idUUIDPK, NOT NULLUnique identifier
emailVARCHAR(255)UNIQUE, NOT NULLUser's email address
nameVARCHAR(255)NOT NULLUser's full name
created_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMPRecord creation timestamp
updated_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPLast update timestamp

Orders table (orders)

FieldTypeConstraintsDescription
idUUIDPK, NOT NULLUnique identifier
user_idUUIDFK to users.id, NOT NULLForeign key to user who placed order
statusVARCHAR(50)NOT NULL, DEFAULT 'PENDING'Order lifecycle status
total_amountDECIMAL(10,2)NOT NULLTotal order amount
created_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMPRecord creation timestamp
updated_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPLast update timestamp

Order Items table (order_items)

FieldTypeConstraintsDescription
idUUIDPK, NOT NULLUnique identifier
order_idUUIDFK to orders.id, NOT NULLForeign key to order
product_idVARCHAR(100)NOT NULLProduct identifier (external reference)
quantityINTEGERNOT NULL, DEFAULT 1Quantity of product ordered
priceDECIMAL(10,2)NOT NULLPrice per unit at time of order
created_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMPRecord creation timestamp

Products table (products)

FieldTypeConstraintsDescription
idUUIDPK, NOT NULLUnique identifier
nameVARCHAR(255)NOT NULLProduct name
descriptionTEXTProduct description
priceDECIMAL(10,2)NOT NULLCurrent price
created_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMPRecord creation timestamp
updated_atTIMESTAMPNOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPLast update timestamp

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

UUID Strategy

We use UUIDs as primary keys for all tables. This choice provides several advantages:

  1. Global Uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination.
  2. No Centralized ID Generation: Eliminates contention on ID generation in high-concurrency environments.
  3. Decoupling: Allows for easy data sharding and horizontal scaling without ID conflicts.
  4. Security: UUIDs don't reveal information about the creation order or system, providing better security properties.

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

Index NameFieldsTypePurpose
idx_users_emailemailUniqueFast lookup by email
idx_orders_user_id_created_atuser_id, created_atCompositeEfficient query of user's recent orders
idx_orders_status_created_atstatus, created_atPartial (where status in ('PENDING', 'PROCESSING'))Fast filtering of active orders
idx_order_items_order_idorder_idCompositeEfficient retrieval of items by order
idx_products_created_atcreated_atStandardFor time-based queries on products

Data partitioning / sharding strategy

Sharding by User ID (Hash-based)

We shard the orders and order_items tables based on user ID hash to distribute load evenly across database instances:

  • Shard Key: user_id (hashed)
  • Shard Count: 32 shards
  • Partitioning Logic: hash(user_id) % 32
  • Shard Mapping: Maintained in a configuration service or lookup table

This ensures that orders from the same user are stored together, supporting efficient querying and reducing cross-shard joins.

Soft delete vs. hard delete decision with justification

Soft Delete Strategy

We implement soft deletes for all tables using a deleted_at timestamp field:

  • Users: Soft delete by setting deleted_at timestamp
  • Orders: Soft delete by setting deleted_at timestamp
  • Order Items: Soft delete by setting deleted_at timestamp
  • Products: Soft delete by setting deleted_at timestamp

Justification:

  1. Auditability: Allows for historical audit trails and compliance requirements
  2. Recovery: Enables recovery of accidentally deleted data
  3. Data Integrity: Prevents cascading deletes that could break referential integrity
  4. Business Requirements: Some business logic requires historical order data even after deletion

Audit trail design (who, what, when)

We implement audit logging using a separate audit_log table:

CREATE TABLE audit_log (
    id UUID PRIMARY KEY,
    user_id UUID,
    operation VARCHAR(50),
    table_name VARCHAR(100),
    record_id UUID,
    old_values JSONB,
    new_values JSONB,
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

This table captures:

  • Who: User ID (if available)
  • What: Operation type (INSERT, UPDATE, DELETE)
  • When: Timestamp of operation
  • Record Details: Old and new values for updates

Data archival strategy and retention policy

Archival Strategy

  1. Orders Archival: Orders older than 5 years are archived to a separate storage system (e.g., S3)
  2. Order Items Archival: Items associated with archived orders are also moved to archival storage
  3. Audit Logs: Retained for 7 years, then archived to long-term storage

Retention Policy

Data TypeRetention PeriodArchive Location
Orders5 yearsS3 bucket with lifecycle policy
Order Items5 yearsS3 bucket
Audit Logs7 yearsLong-term storage (e.g., Glacier)
UsersIndefinitePrimary database
// Example of archiving orders in Java
public class OrderArchiver {
    private final OrderRepository orderRepository;
    private final S3Client s3Client;

    public void archiveOldOrders() {
        LocalDateTime cutoffDate = LocalDateTime.now().minusYears(5);
        
        List<Order> ordersToArchive = orderRepository.findOrdersCreatedBefore(cutoffDate);
        
        for (Order order : ordersToArchive) {
            // Serialize order to JSON
            String json = objectMapper.writeValueAsString(order);
            
            // Upload to S3
            s3Client.putObject(PutObjectRequest.builder()
                .bucket("orders-archive")
                .key("orders/" + order.getId() + ".json")
                .build(), 
                RequestBody.fromBytes(json.getBytes()));
            
            // Soft delete from primary DB
            order.setDeletedAt(Instant.now());
            orderRepository.save(order);
        }
    }
}

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class ApiVersioningService {
        +versionApi(RequestContext)
        +validateVersionHeader(RequestContext)
        +redirectBasedOnVersion(RequestContext)
    }
    
    class VersionResolver {
        <<interface>>
        +resolveVersion(RequestContext)
    }
    
    class HeaderVersionResolver {
        +resolveVersion(RequestContext)
    }
    
    class PathVersionResolver {
        +resolveVersion(RequestContext)
    }
    
    class QueryParamVersionResolver {
        +resolveVersion(RequestContext)
    }
    
    class VersionStrategy {
        <<abstract>>
        +resolveVersion(RequestContext)
        +validateVersion(String)
        +getSupportedVersions()
    }
    
    class VersionedEndpoint {
        +endpointPath
        +supportedVersions
        +handler
        +version
    }
    
    class RequestContext {
        +headers
        +queryParams
        +path
        +method
    }
    
    class VersionInfo {
        +versionString
        +major
        +minor
        +patch
        +isDeprecated
    }
    
    class VersionComparator {
        +compare(VersionInfo, VersionInfo)
    }
    
    class ApiVersioningConfig {
        +defaultVersion
        +supportedVersions
        +strategy
        +fallbackStrategy
    }
    
    VersionResolver <|-- HeaderVersionResolver
    VersionResolver <|-- PathVersionResolver
    VersionResolver <|-- QueryParamVersionResolver
    
    VersionStrategy <|-- VersionResolver
    
    ApiVersioningService --> VersionResolver : uses
    ApiVersioningService --> VersionInfo : returns
    ApiVersioningService --> VersionComparator : uses
    
    VersionedEndpoint --> VersionInfo : has
    VersionedEndpoint --> VersionStrategy : supports
    
    RequestContext --> VersionInfo : contains resolved version

State Machine Diagram for VersionedEndpoint

stateDiagram-v2
    [*] --> Active
    Active --> Deprecated : version_deprecated
    Active --> Removed : version_removed
    Deprecated --> Removed : version_expired
    Removed --> [*]

Service Class Structure and Responsibilities

The ApiVersioningService is the central component responsible for:

  • Resolving API versions from incoming requests
  • Validating version compatibility
  • Redirecting or routing to appropriate endpoint handlers
  • Managing version lifecycle (active, deprecated, removed)
  • Supporting multiple versioning strategies

Interface / Abstract Class Design

public interface VersionResolver {
    String resolveVersion(RequestContext context);
}

public abstract class VersionStrategy {
    public abstract String resolveVersion(RequestContext context);
    
    public boolean validateVersion(String version) {
        return version != null && version.matches("^\\d+\\.\\d+(\\.\\d+)?$");
    }
    
    public abstract Set<String> getSupportedVersions();
}

Business Logic Encapsulation Patterns

Strategy Pattern Implementation:

public class HeaderVersionResolver implements VersionResolver {
    private static final String VERSION_HEADER = "X-API-Version";
    
    @Override
    public String resolveVersion(RequestContext context) {
        return context.getHeaders().get(VERSION_HEADER);
    }
}

public class PathVersionResolver implements VersionResolver {
    private static final String VERSION_PATH_PREFIX = "/v";
    
    @Override
    public String resolveVersion(RequestContext context) {
        String path = context.getPath();
        int versionStart = path.indexOf(VERSION_PATH_PREFIX);
        if (versionStart == -1) return null;
        
        int versionEnd = path.indexOf("/", versionStart + 2);
        if (versionEnd == -1) versionEnd = path.length();
        
        return path.substring(versionStart + 2, versionEnd);
    }
}

public class QueryParamVersionResolver implements VersionResolver {
    private static final String VERSION_QUERY_PARAM = "version";
    
    @Override
    public String resolveVersion(RequestContext context) {
        return context.getQueryParams().get(VERSION_QUERY_PARAM);
    }
}

Critical-Path Method Implementation

public class ApiVersioningService {
    private final Map<String, VersionResolver> resolvers;
    private final VersionComparator versionComparator;
    private final ApiVersioningConfig config;
    
    public ApiVersioningService(ApiVersioningConfig config) {
        this.config = config;
        this.versionComparator = new VersionComparator();
        this.resolvers = Map.of(
            "header", new HeaderVersionResolver(),
            "path", new PathVersionResolver(),
            "query", new QueryParamVersionResolver()
        );
    }
    
    public VersionInfo versionApi(RequestContext context) {
        String rawVersion = null;
        
        // Strategy selection based on config
        switch (config.getStrategy()) {
            case "header":
                rawVersion = resolvers.get("header").resolveVersion(context);
                break;
            case "path":
                rawVersion = resolvers.get("path").resolveVersion(context);
                break;
            case "query":
                rawVersion = resolvers.get("query").resolveVersion(context);
                break;
            default:
                throw new IllegalArgumentException("Unsupported versioning strategy");
        }
        
        if (rawVersion == null) {
            // Fallback to default version
            rawVersion = config.getDefaultVersion();
        }
        
        VersionInfo resolvedVersion = new VersionInfo(rawVersion);
        
        // Validate version against supported versions
        if (!config.getSupportedVersions().contains(rawVersion)) {
            throw new VersionNotSupportedException(
                "Unsupported version: " + rawVersion);
        }
        
        // Check if version is deprecated or removed
        if (isVersionDeprecated(resolvedVersion)) {
            // Log deprecation event and potentially send warning header
            logDeprecationEvent(context, resolvedVersion);
        }
        
        return resolvedVersion;
    }
    
    private boolean isVersionDeprecated(VersionInfo version) {
        // Implementation would check against config or metadata
        return false; // Simplified for example
    }
    
    private void logDeprecationEvent(RequestContext context, VersionInfo version) {
        // Log to audit trail or monitoring system
        logger.warn("API version {} is deprecated, request from {}", 
                   version.getVersionString(), context.getHeaders().get("User-Agent"));
    }
}

Transaction Boundary Design

For API versioning, the transaction boundary is:

  • Single request processing within a single HTTP request lifecycle
  • Version resolution and validation happens before any business logic execution
  • No database transactions required for versioning logic itself

Service Interface Contracts

public interface ApiVersioningService {
    VersionInfo versionApi(RequestContext context) throws VersionNotSupportedException;
    boolean isVersionSupported(String version);
    String getDefaultVersion();
}

Saga / Orchestration Pattern

For complex versioning scenarios involving multiple services:

public class VersioningSaga {
    public void handleVersionUpgrade(RequestContext context) {
        // Start saga with version upgrade workflow
        // Track state across multiple services if needed
        // Handle compensation for failed steps
    }
}

Domain Object / Value Object Design

public class VersionInfo {
    private final String versionString;
    private final int major;
    private final int minor;
    private final int patch;
    private final boolean isDeprecated;
    
    public VersionInfo(String versionString) {
        this.versionString = versionString;
        String[] parts = versionString.split("\\.");
        this.major = Integer.parseInt(parts[0]);
        this.minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
        this.patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
        this.isDeprecated = false; // Would be determined from config
    }
    
    public String getVersionString() {
        return versionString;
    }
    
    public int getMajor() {
        return major;
    }
    
    // Getters for minor, patch, isDeprecated...
}

Version Comparator Implementation

public class VersionComparator implements Comparator<VersionInfo> {
    @Override
    public int compare(VersionInfo v1, VersionInfo v2) {
        if (v1.getMajor() != v2.getMajor()) {
            return Integer.compare(v1.getMajor(), v2.getMajor());
        }
        if (v1.getMinor() != v2.getMinor()) {
            return Integer.compare(v1.getMinor(), v2.getMinor());
        }
        return Integer.compare(v1.getPatch(), v2.getPatch());
    }
}

5. Concurrency & Thread Safety

Concurrency Model

The backend service will use a thread pool model with Java's ExecutorService and CompletableFuture for handling concurrent requests. Each HTTP request is processed in a separate thread from the thread pool, allowing for efficient handling of multiple concurrent requests without blocking the main I/O thread. For CPU-bound operations, we'll leverage ForkJoinPool for parallel processing where appropriate.

Race Condition Prevention Strategy

Race conditions are mitigated through:

  1. Immutable objects - Domain entities and value objects are designed to be immutable where possible
  2. Synchronized access - Critical sections use synchronized blocks or ReentrantLock for thread-safe operations
  3. Atomic operations - Use of java.util.concurrent.atomic classes for counter and flag updates
  4. Database-level locking - Optimistic locking with version fields in database entities to prevent concurrent modification conflicts

Optimistic vs. Pessimistic Locking Selection

We select optimistic locking for the following reasons:

  • High read-to-write ratio in most business operations
  • Low probability of concurrent updates to the same record
  • Better performance under moderate concurrency
  • Eliminates lock contention and deadlocks

Implementation uses database version fields (@Version in JPA) to detect concurrent modifications and throw OptimisticLockException when conflicts occur.

Deadlock Prevention Strategy

Deadlock prevention includes:

  1. Lock ordering - All operations acquire locks in a consistent global order (e.g., by entity ID)
  2. Timeout configuration - Lock acquisition has a 5-second timeout to prevent indefinite waits
  3. Lock hierarchy - Clear definition of which locks can be acquired together vs. mutually exclusive
  4. Monitoring and alerts - Deadlock detection via database monitoring tools with alerting

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is implemented using:

  1. Idempotency keys - Clients provide a unique idempotency-key header for write operations
  2. Cache-based tracking - Store recent operation IDs in Redis with TTL of 1 hour
  3. Operation result caching - For read operations, cache results with appropriate TTLs to avoid redundant processing
// Example idempotency check in service layer
public Order createOrder(CreateOrderRequest request, String idempotencyKey) {
    if (idempotencyKey != null) {
        String cachedResult = idempotencyCache.get(idempotencyKey);
        if (cachedResult != null) {
            return orderRepository.findById(cachedResult).orElseThrow(() -> new OrderNotFoundException());
        }
    }

    // Proceed with order creation logic
    Order order = orderService.createOrder(request);
    
    if (idempotencyKey != null) {
        idempotencyCache.put(idempotencyKey, order.getId(), Duration.ofHours(1));
    }
    
    return order;
}

Thread Safety in Service Layer

Service methods are designed to be thread-safe by:

  1. Avoiding shared mutable state between concurrent requests
  2. Using thread-local storage for request-scoped data (e.g., user context)
  3. Implementing proper synchronization on shared resources like configuration caches
  4. Using concurrent collections (ConcurrentHashMap, CopyOnWriteArrayList) where appropriate

Transaction Isolation and Concurrency Control

Database transactions use READ_COMMITTED isolation level to prevent dirty reads and non-repeatable reads. For high-concurrency scenarios, we implement:

  1. Optimistic concurrency control with version checks
  2. Batch processing for bulk operations to reduce transaction overhead
  3. Read-replica usage for read-heavy operations to reduce primary DB load

Performance Considerations

  1. Thread pool sizing - Configured based on CPU cores and I/O characteristics (typically 2x CPU cores + I/O bound threads)
  2. Async processing - Use of @Async annotation for non-critical background tasks to free up main threads
  3. Connection pooling - Database connections are managed via HikariCP with proper pool sizing and timeouts

Testing Concurrency Scenarios

Concurrency testing includes:

  1. Stress tests with JMeter or Gatling simulating 1000+ concurrent requests
  2. Race condition tests with synchronized unit tests using CountDownLatch
  3. Deadlock detection through integration testing with various operation sequences
  4. Idempotency validation through repeated requests with same idempotency key

This approach ensures that the system can handle concurrent access efficiently while maintaining data consistency and preventing race conditions.

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant Client
    participant APIGateway
    participant AuthN
    participant Service
    participant DB
    
    Client->>APIGateway: GET /v2/users/123
    APIGateway->>AuthN: Validate JWT token
    AuthN-->>APIGateway: Authenticated
    APIGateway->>Service: Call getUser(id=123)
    Service->>DB: SELECT * FROM users WHERE id = 123
    DB-->>Service: User data
    Service-->>APIGateway: Response with user data
    APIGateway-->>Client: 200 OK with user object

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant Client
    participant APIGateway
    participant AuthN
    participant Service
    participant DB
    
    Client->>APIGateway: GET /v1/users/123
    APIGateway->>AuthN: Validate JWT token
    AuthN-->>APIGateway: Authenticated
    APIGateway->>Service: Call getUser(id=123)
    Service->>DB: SELECT * FROM users WHERE id = 123
    DB-->>Service: User not found
    Service-->>APIGateway: 404 Not Found
    APIGateway-->>Client: 404 Not Found with error message

Async Flow Sequence Diagram

sequenceDiagram
    participant Client
    participant APIGateway
    participant Service
    participant EventPublisher
    participant MessageQueue
    
    Client->>APIGateway: POST /v2/users
    APIGateway->>Service: Create user request
    Service->>DB: INSERT INTO users (name, email)
    DB-->>Service: User created with ID 123
    Service->>EventPublisher: Publish user.created event
    EventPublisher->>MessageQueue: Queue user.created event
    MessageQueue-->>Service: Event acknowledged
    Service-->>APIGateway: 201 Created with user ID
    APIGateway-->>Client: 201 Created with user object

Version Negotiation Flow

sequenceDiagram
    participant Client
    participant APIGateway
    participant Service
    
    Client->>APIGateway: GET /users/123 (no version header)
    APIGateway->>Service: Call getUser(id=123) with default version
    Service-->>APIGateway: Response with user data (v1 format)
    APIGateway-->>Client: 200 OK with v1 user object
    
    Client->>APIGateway: GET /users/123 (Accept: application/vnd.api.v2+json)
    APIGateway->>Service: Call getUser(id=123) with v2 format
    Service-->>APIGateway: Response with user data (v2 format)
    APIGateway-->>Client: 200 OK with v2 user object

7. Caching Design

Cache Strategy Selection

For this system, we will use a cache-aside pattern with write-through behavior for critical data paths. This approach provides the best balance between consistency and performance, allowing us to maintain strong consistency for mission-critical operations while still benefiting from reduced database load.

Cache Key Design

Cache keys will follow a structured namespace pattern to avoid collisions and enable efficient cache management:

public class CacheKeyBuilder {
    private static final String NAMESPACE_SEPARATOR = ":";
    
    public static String buildKey(String namespace, String... identifiers) {
        return Stream.concat(Stream.of(namespace), Arrays.stream(identifiers))
                     .collect(Collectors.joining(NAMESPACE_SEPARATOR));
    }
    
    public static String buildUserKey(String userId) {
        return buildKey("user", userId);
    }
    
    public static String buildProductKey(String productId) {
        return buildKey("product", productId);
    }
    
    public static String buildOrderKey(String orderId) {
        return buildKey("order", orderId);
    }
}

TTL Strategy

Data TypeTTLReason
User Profile5 minutesFrequent updates, low consistency requirements
Product Catalog1 hourStable data, infrequent changes
Order Details30 minutesCritical for order processing, moderate consistency needs
Session Tokens1 hourSecurity-sensitive, short-lived
API Rate Limits1 minuteHigh frequency updates, strict time-based

Cache Eviction Policy

We will implement a Least Recently Used (LRU) eviction policy with TTL-based expiration to handle both memory pressure and time-sensitive data:

public class CacheEvictionPolicy {
    private final int maxEntries;
    private final Map<String, CacheEntry> cacheMap = new LinkedHashMap<String, CacheEntry>() {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
            return size() > maxEntries;
        }
    };
    
    public void put(String key, Object value, long ttlSeconds) {
        cacheMap.put(key, new CacheEntry(value, System.currentTimeMillis() + (ttlSeconds * 1000)));
    }
    
    public Object get(String key) {
        CacheEntry entry = cacheMap.get(key);
        if (entry != null && System.currentTimeMillis() < entry.expiryTime) {
            return entry.value;
        }
        cacheMap.remove(key);
        return null;
    }
    
    private static class CacheEntry {
        final Object value;
        final long expiryTime;
        
        CacheEntry(Object value, long expiryTime) {
            this.value = value;
            this.expiryTime = expiryTime;
        }
    }
}

Cache Stampede Prevention

To prevent cache stampedes, we implement probabilistic early expiry:

public class CacheStampedePrevention {
    private static final double EARLY_EXPIRY_RATIO = 0.2;
    
    public boolean shouldRefreshCache(String key, long ttlSeconds) {
        // Check if cache is stale (early expiration)
        return System.currentTimeMillis() > getCacheExpiryTime(key, ttlSeconds) * (1 - EARLY_EXPIRY_RATIO);
    }
    
    private long getCacheExpiryTime(String key, long ttlSeconds) {
        // Implementation to retrieve cached expiry time
        return System.currentTimeMillis() + (ttlSeconds * 1000);
    }
}

Distributed Cache Architecture

We will use Redis Cluster with the following configuration:

  • 3 master nodes with 3 replicas each (9 total nodes)
  • Automatic failover and sharding
  • Redis Sentinel for monitoring and automatic failover
  • Cross-region replication for disaster recovery

Cache Invalidation Strategy

We will use event-driven invalidation to ensure cache consistency:

@Component
public class CacheInvalidator {
    private final RedisTemplate<String, Object> redisTemplate;
    
    @EventListener
    public void handleEntityUpdated(EntityUpdatedEvent event) {
        String entityType = event.getEntityType();
        String entityId = event.getEntityId();
        
        // Invalidate specific cache entries
        invalidateUserCache(entityId);
        invalidateProductCache(entityId);
        
        // Invalidate related cache entries
        invalidateOrderCache(event.getOrderIds());
    }
    
    private void invalidateUserCache(String userId) {
        redisTemplate.delete(CacheKeyBuilder.buildUserKey(userId));
    }
    
    private void invalidateProductCache(String productId) {
        redisTemplate.delete(CacheKeyBuilder.buildProductKey(productId));
    }
    
    private void invalidateOrderCache(List<String> orderIds) {
        orderIds.forEach(orderId -> 
            redisTemplate.delete(CacheKeyBuilder.buildOrderKey(orderId))
        );
    }
}

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 database operations. Each entity has its own repository interface and implementation.

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);
}

@Repository
public class UserRepositoryImpl implements UserRepository {
    private final JdbcTemplate jdbcTemplate;
    
    @Override
    public User findById(UUID id) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?", 
            new UserRowMapper(), 
            id);
    }
    
    @Override
    public User findByEmail(String email) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE email = ?", 
            new UserRowMapper(), 
            email);
    }
    
    @Override
    public List<User> findAll() {
        return jdbcTemplate.query(
            "SELECT * FROM users", 
            new UserRowMapper());
    }
    
    @Override
    public User save(User user) {
        if (user.getId() == null) {
            user.setId(UUID.randomUUID());
            jdbcTemplate.update(
                "INSERT INTO users (id, email, name, created_at) VALUES (?, ?, ?, ?)",
                user.getId(), user.getEmail(), user.getName(), user.getCreatedAt());
        } else {
            jdbcTemplate.update(
                "UPDATE users SET email = ?, name = ?, updated_at = ? WHERE id = ?",
                user.getEmail(), user.getName(), user.getUpdatedAt(), user.getId());
        }
        return user;
    }
    
    @Override
    public void delete(UUID id) {
        jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
    }
    
    @Override
    public boolean existsById(UUID id) {
        return jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM users WHERE id = ?", 
            Integer.class, 
            id) > 0;
    }
}

ORM vs. Raw Query Decisions

For this system, we use raw JDBC with JdbcTemplate instead of full ORM like Hibernate for the following reasons:

  1. Performance: Raw queries provide better performance with minimal overhead
  2. Control: Fine-grained control over SQL execution and query optimization
  3. Debugging: Easier to debug and profile specific queries
  4. Versioning: Better support for version-specific query changes

Read vs. Write Separation

We implement read-write separation using CQRS pattern where:

  • Read operations (find, query) use a separate read-only database connection
  • Write operations (save, delete) use the primary write database
public class UserRepository {
    private final JdbcTemplate writeJdbcTemplate;
    private final JdbcTemplate readJdbcTemplate;
    
    public User findById(UUID id) {
        return readJdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?", 
            new UserRowMapper(), 
            id);
    }
    
    public User save(User user) {
        if (user.getId() == null) {
            user.setId(UUID.randomUUID());
            writeJdbcTemplate.update(
                "INSERT INTO users (id, email, name, created_at) VALUES (?, ?, ?, ?)",
                user.getId(), user.getEmail(), user.getName(), user.getCreatedAt());
        } else {
            writeJdbcTemplate.update(
                "UPDATE users SET email = ?, name = ?, updated_at = ? WHERE id = ?",
                user.getEmail(), user.getName(), user.getUpdatedAt(), user.getId());
        }
        return user;
    }
}

Connection Pool Configuration

Connection pool is configured with optimal settings for high throughput and low latency:

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

N+1 Query Prevention Strategy

We prevent N+1 queries by implementing eager loading and batch operations:

public class UserService {
    private final UserRepository userRepository;
    private final OrderRepository orderRepository;
    
    public List<UserWithOrders> getUsersWithOrders(List<UUID> userIds) {
        // Single query to fetch all users
        List<User> users = userRepository.findByIds(userIds);
        
        // Batch query to fetch all orders for these users
        List<Order> orders = orderRepository.findByUserIds(userIds);
        
        // Group orders by user ID
        Map<UUID, List<Order>> ordersByUser = orders.stream()
            .collect(Collectors.groupingBy(Order::getUserId));
        
        return users.stream()
            .map(user -> new UserWithOrders(user, ordersByUser.getOrDefault(user.getId(), Collections.emptyList())))
            .collect(Collectors.toList());
    }
}

Batch Operation Design

Batch operations are implemented for high-volume data processing:

public class UserRepository {
    public void batchSave(List<User> users) {
        String sql = "INSERT INTO users (id, email, name, created_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()));
            }
            
            @Override
            public int getBatchSize() {
                return users.size();
            }
        });
    }
}

Transaction Management

Transaction boundaries are carefully defined to ensure data consistency:

@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;
    
    public User createUser(User user) {
        // Transaction boundary starts here
        User savedUser = userRepository.save(user);
        
        // Additional operations within same transaction
        auditService.logUserCreation(savedUser);
        
        return savedUser;
        // Transaction commits here
    }
}

Repository Interface Contracts

Repository interfaces define clear contracts for data access operations:

public interface BaseRepository<T, ID> {
    T findById(ID id);
    List<T> findAll();
    T save(T entity);
    void delete(ID id);
    boolean existsById(ID id);
    
    // Additional common operations
    List<T> findByIds(List<ID> ids);
    long count();
}

Data Access Layer Testing Strategy

@ExtendWith(MockitoExtension.class)
public class UserRepositoryTest {
    @Mock
    private JdbcTemplate jdbcTemplate;
    
    @InjectMocks
    private UserRepositoryImpl userRepository;
    
    @Test
    public void testFindById() {
        // Given
        UUID userId = UUID.randomUUID();
        when(jdbcTemplate.queryForObject(anyString(), any(RowMapper.class), eq(userId)))
            .thenReturn(createMockUser());
        
        // When
        User user = userRepository.findById(userId);
        
        // Then
        assertThat(user).isNotNull();
        verify(jdbcTemplate).queryForObject(anyString(), any(RowMapper.class), eq(userId));
    }
}

The repository layer design balances performance, maintainability, and scalability while providing clear boundaries between domain logic and data access concerns. The implementation supports high-throughput operations with proper transaction management, connection pooling, and query optimization strategies.

9. Asynchronous Processing & Messaging

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

In a typical e-commerce system, the decision to use asynchronous processing is driven by the need for loose coupling, scalability, and handling long-running operations without blocking the main request flow.

Synchronous flows (should remain synchronous):

  • User authentication and authorization (e.g., login, token validation)
  • Real-time payment processing (for immediate feedback)
  • Immediate inventory checks (must return quickly to prevent overbooking)

Asynchronous flows (should be async):

  • Order confirmation email sending
  • Inventory adjustment after order placement
  • Sending notifications to third-party services (e.g., shipping provider)
  • Generating analytics reports
  • Updating search index after content changes

The key principle is that any operation that can be done in the background without affecting the user's immediate experience or business logic should be asynchronous. This allows for better scalability, resilience, and improved user experience.

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

We'll use JSON Schema for message payloads due to its simplicity, human-readability, and wide adoption in microservices environments. For versioning, we'll implement a message type + version pattern where each message has a unique identifier and version number.

{
  "type": "object",
  "title": "OrderPlacedEvent",
  "version": "1.0.0",
  "properties": {
    "eventId": {
      "type": "string",
      "description": "Unique identifier for the event"
    },
    "eventType": {
      "type": "string",
      "description": "Type of the event (e.g., OrderPlaced)"
    },
    "eventVersion": {
      "type": "string",
      "description": "Version of the event schema"
    },
    "timestamp": {
      "type": "string",
      "format": "date-time"
    },
    "orderId": {
      "type": "string"
    },
    "customerId": {
      "type": "string"
    },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "productId": {
            "type": "string"
          },
          "quantity": {
            "type": "integer"
          }
        }
      }
    },
    "totalAmount": {
      "type": "number"
    }
  },
  "required": ["eventId", "eventType", "eventVersion", "timestamp", "orderId", "customerId"]
}

Dead letter queue strategy and poison message handling

For dead-letter queues (DLQ), we implement a retry-and-dlq pattern:

  1. Retry logic: Messages are retried up to 3 times with exponential backoff (5s, 10s, 20s).
  2. DLQ handling: After max retries, messages are sent to a dedicated DLQ topic.
  3. Poison message handling:
    • Messages that fail consistently (e.g., malformed data) are moved to DLQ.
    • A separate monitoring system alerts on DLQ messages for manual inspection.
    • DLQ messages are periodically reprocessed after investigation.

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

We choose at-least-once delivery because:

  1. Simplicity: Easier to implement and maintain than exactly-once semantics.
  2. Business tolerance: For most events (e.g., order confirmation emails), duplicate processing is acceptable or can be handled gracefully.
  3. Scalability: Reduces complexity in maintaining state across systems for idempotency.

For critical operations like financial transactions, we'd implement idempotency keys at the consumer level to ensure exactly-once behavior.

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

Retry policy configuration:

ParameterValue
Max attempts3
Initial backoff5 seconds
Backoff multiplier2
Max backoff60 seconds
Retryable errorsNetwork timeouts, service unavailability, transient DB errors
Non-retryable errorsInvalid input data, authorization failures, business rule violations

Backoff formula:
delay = min(initialDelay * (multiplier ^ attempt), maxDelay)

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

We do include the outbox pattern because:

  1. Event consistency: Ensures events are published only after database transactions commit.
  2. Reliability: Prevents event loss in case of system failures during transaction commits.
  3. Auditability: Provides a clear audit trail of all published events.

Implementation approach:

  • Events are written to an event_outbox table within the same database transaction as business logic.
  • A background worker periodically polls this table and publishes events to Kafka/Redis.

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant User
    participant OrderService
    participant EventPublisher
    participant Kafka
    participant NotificationService

    User->>OrderService: Place Order (POST /orders)
    OrderService->>OrderService: Validate and Create Order
    OrderService->>EventPublisher: Publish OrderPlacedEvent to Outbox
    EventPublisher->>Kafka: Commit to Kafka Topic
    Kafka->>NotificationService: Deliver OrderPlacedEvent
    NotificationService->>NotificationService: Send Email to Customer

10. Error Handling Design

Error Classification Hierarchy

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

  • Domain Errors: Business logic violations (e.g., insufficient funds, invalid state transitions)
  • Validation Errors: Input data format or constraints violations (e.g., missing required fields, invalid email format)
  • Infrastructure Errors: System-level issues (e.g., database connection failure, timeout)
  • Authorization Errors: Access control violations (e.g., insufficient permissions, invalid token)

Error Propagation Strategy

Errors are handled at their point of occurrence and propagated up the call stack with appropriate wrapping:

  1. Domain errors are wrapped in custom domain exceptions and re-thrown
  2. Validation errors are converted to standardized validation exceptions
  3. Infrastructure errors are wrapped in service layer exceptions with retry context
  4. Authorization errors are immediately converted to HTTP 403 responses

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, a retry mechanism is implemented:

public class RetryableException extends RuntimeException {
    private final int maxRetries;
    private final long baseDelayMs;
    private final int currentRetry;

    public RetryableException(String message, int maxRetries, long baseDelayMs, int currentRetry) {
        super(message);
        this.maxRetries = maxRetries;
        this.baseDelayMs = baseDelayMs;
        this.currentRetry = currentRetry;
    }

    public long getDelayWithJitter() {
        long delay = (long) Math.pow(2, currentRetry) * baseDelayMs;
        return delay + ThreadLocalRandom.current().nextLong(-500, 500);
    }

    public boolean shouldRetry() {
        return currentRetry < maxRetries;
    }
}

Parameters:

  • Max retries: 3 attempts
  • Base delay: 100ms
  • Jitter range: ±500ms to prevent thundering herd

Circuit Breaker Design

For external service calls, a circuit breaker pattern is implemented using the Resilience4j library:

public class CircuitBreakerService {
    private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("external-service");

    public <T> T executeWithCircuitBreaker(Supplier<T> operation) {
        return circuitBreaker.executeSupplier(operation);
    }

    public void onCallSuccess() {
        circuitBreaker.recordSuccess();
    }

    public void onCallFailure() {
        circuitBreaker.recordFailure();
    }
}

Configuration:

  • Failure rate threshold: 50%
  • Minimum required calls: 10
  • Slow call duration threshold: 2s
  • Slow call rate threshold: 50%
  • Timeout: 3s
  • Half-open state timeout: 10s

Fallback Behavior Design

Each external service call has a fallback strategy:

public class ExternalServiceFallback {
    public List<Feature> getFeaturesFallback(String userId, Throwable cause) {
        // Log the fallback scenario
        logger.warn("Falling back to cached features for user: {}", userId, cause);
        
        // Return cached or default features
        return featureCache.getOrDefault(userId, Collections.emptyList());
    }
}

Error Logging Format

Structured logging is implemented with consistent fields:

public class ErrorLogEntry {
    private final String traceId;
    private final String spanId;
    private final String userId;
    private final String entityId;
    private final String errorCode;
    private final String errorMessage;
    private final String errorType;
    private final long timestamp;
    private final Map<String, Object> additionalContext;

    // Constructor and getters
}

Required fields:

  • trace_id: Distributed tracing identifier
  • span_id: Current operation span
  • user_id: Authenticated user identifier (masked in logs)
  • entity_id: Affected business entity ID
  • error_code: Standardized error code (e.g., "VALIDATION_ERROR_001")
  • error_type: Classification (DOMAIN, VALIDATION, INFRASTRUCTURE)
  • timestamp: UTC timestamp
  • additional_context: Structured error metadata

Alerting Thresholds

Critical alerts are configured for:

MetricThresholdAlert Level
5xx errors rate (5m)> 1% of total requestsCritical
Circuit breaker half-open transitions> 5 per hourWarning
Retry failures rate (5m)> 0.5% of total requestsWarning
Timeout rate (5m)> 1% of external callsCritical

On-call escalation path:

  1. Alert triggers → Slack notification
  2. If no response within 5 minutes → On-call engineer escalation
  3. If no resolution within 15 minutes → Manager notification

11. Security Design

Input Validation and Sanitization Strategy

Input validation is implemented at multiple layers:

  • API Layer: Using Jakarta Bean Validation (JSR 380) with custom constraints for business logic validation
  • Service Layer: Additional domain-specific validations using Apache Commons Validator for complex rules
  • Database Layer: Column-level constraints and triggers for data integrity
public class UserRequest {
    @NotNull
    @Size(min = 3, max = 50)
    @Pattern(regexp = "^[a-zA-Z0-9_]+$")
    private String username;
    
    @Email
    @NotNull
    private String email;
    
    @Min(18)
    @Max(120)
    private Integer age;
}

SQL Injection Prevention

All database queries are constructed using parameterized prepared statements to prevent SQL injection attacks:

// ❌ Vulnerable to SQL injection
String query = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);

// ✅ Secure parameterized query
String query = "SELECT * FROM users WHERE username = ?";
PreparedStatement pstmt = connection.prepareStatement(query);
pstmt.setString(1, username);
ResultSet rs = pstmt.executeQuery();

Authentication Middleware Design

Authentication is handled via JWT middleware that:

  • Validates token signature using asymmetric RSA keys
  • Checks expiration and audience claims
  • Extracts user context from token payload
@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 && jwtService.validateToken(token)) {
            String username = jwtService.getUsernameFromToken(token);
            Authentication auth = createAuthentication(username);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        chain.doFilter(request, response);
    }
}

Authorization Enforcement

RBAC (Role-Based Access Control) is enforced at service layer using Spring Security annotations:

@PreAuthorize("hasRole('ADMIN') or @userSecurityService.canAccessUser(principal, userId)")
public User getUserById(Long userId) {
    return userRepository.findById(userId);
}

@PreAuthorize("hasRole('USER') and @userSecurityService.isOwner(principal, userId)")
public void updateUserProfile(Long userId, UserProfileUpdateRequest request) {
    // Update logic here
}

Sensitive Data Encryption

At Rest: AES-256-GCM encryption for sensitive fields like PII and passwords using AWS KMS or HashiCorp Vault In Transit: TLS 1.3 with HSTS header enforcement

@Service
public class DataEncryptionService {
    
    @Value("${encryption.key}")
    private String encryptionKey;
    
    public String encrypt(String plaintext) {
        // AES-256-GCM encryption logic
        return encryptor.encrypt(plaintext, encryptionKey);
    }
    
    public String decrypt(String ciphertext) {
        // AES-256-GCM decryption logic
        return encryptor.decrypt(ciphertext, encryptionKey);
    }
}

PII Handling and Masking Strategy

PII fields are masked in logs and responses:

  • Email addresses: j***@example.com
  • Phone numbers: +1 (555) ***-****
  • SSNs: ***-**-1234
public class PiiMasker {
    public static String maskEmail(String email) {
        if (email == null || email.isEmpty()) return email;
        String[] parts = email.split("@");
        if (parts.length != 2) return email;
        
        String user = parts[0];
        String domain = parts[1];
        
        return user.substring(0, 1) + "****@" + domain;
    }
}

Secrets Management

Secrets are managed using AWS Secrets Manager with:

  • Automatic rotation every 90 days
  • IAM-based access control with least privilege principle
  • Encrypted storage using KMS keys
@Component
public class SecretManager {
    
    private final AWSSecretsManager secretsManager;
    
    public String getDatabasePassword() {
        GetSecretValueRequest request = GetSecretValueRequest.builder()
            .secretId("prod/db/password")
            .build();
            
        GetSecretValueResponse response = secretsManager.getSecretValue(request);
        return response.secretString();
    }
}

Audit Logging Design

All security-relevant operations are logged with structured JSON format:

{
  "timestamp": "2023-12-01T10:30:00Z",
  "userId": "user-123",
  "operation": "USER_LOGIN",
  "ipAddress": "192.168.1.100",
  "userAgent": "Mozilla/5.0...",
  "status": "SUCCESS",
  "metadata": {
    "sessionId": "session-abc",
    "authMethod": "JWT"
  }
}

Retention policy: Audit logs retained for 3 years with daily backups to secure S3 bucket.

12. Testing Strategy

Unit Testing Scope and Coverage Targets

Unit tests will cover 85%+ of business logic, with a minimum of 90% code coverage for critical paths. Each service class and utility method will have dedicated unit tests ensuring:

  • Happy path scenarios
  • Error condition handling
  • Boundary value testing
  • Null input validation

Integration Testing Strategy

Integration tests will validate end-to-end flows using:

  • Testcontainers for database and cache integration (PostgreSQL, Redis)
  • Embedded databases for fast local testing (H2 in-memory DB)
  • WireMock for external service stubbing (e.g., payment gateway, notification service)
  • Test fixtures to ensure consistent test data across environments

Contract Testing

Contract testing will be implemented using Pact to ensure API compatibility between:

  • Frontend services and backend APIs
  • Microservices within the system
  • Third-party integrations (e.g., payment providers, analytics services)

Each contract will define:

  • Request/response schemas
  • HTTP status codes
  • Error response formats
  • Version compatibility rules

Performance Testing Tool and Key Scenarios

Performance testing will use k6 to simulate realistic load patterns:

  • Concurrent user load: 1000 concurrent users with 50% read, 50% write operations
  • Peak load simulation: 5000 concurrent users during peak hours
  • Latency targets: p50 < 200ms, p95 < 500ms, p99 < 1000ms
  • Throughput targets: 2000 requests/sec for write operations, 5000 requests/sec for read operations

Key performance scenarios include:

  • Order placement under load
  • User authentication and session management
  • Real-time data updates (e.g., inventory changes)
  • Batch processing operations

Test Data Management Strategy

Test data will be managed through:

  • Factory patterns for generating test entities (User, Order, Product)
  • Fixtures for predefined data sets (e.g., product catalog, user roles)
  • Database seeding scripts to populate test environments with realistic data
  • Test data anonymization to ensure PII compliance in non-production environments

Test data lifecycle management includes:

  • Automated cleanup of test data after each test suite run
  • Data versioning to maintain consistency across environments
  • Schema validation to ensure data integrity in test fixtures
// Example of factory pattern usage in unit tests
@Test
public void testPlaceOrder_Success() {
    // Given
    OrderRequest request = OrderFactory.createValidOrderRequest();
    User user = UserFactory.createActiveUser();
    
    // When
    OrderResult result = orderService.placeOrder(request, user);
    
    // Then
    assertThat(result.getOrderStatus()).isEqualTo(OrderStatus.CREATED);
    assertThat(result.getOrderId()).isNotNull();
}

13. Observability Design

Structured Logging Schema

All logs must follow a structured JSON schema to enable programmatic parsing and filtering. The schema includes:

{
  "timestamp": "2023-10-27T14:30:00.000Z",
  "level": "INFO|WARN|ERROR",
  "service": "booking-service",
  "trace_id": "123e4567-e89b-12d3-a456-426614174000",
  "span_id": "abcdef1234567890",
  "user_id": "user-123",
  "entity_id": "booking-456",
  "request_id": "req-789",
  "method": "POST",
  "path": "/api/v2/bookings",
  "status_code": 201,
  "duration_ms": 150,
  "error_code": "BOOKING_CREATION_FAILED",
  "error_message": "Failed to create booking due to invalid seat ID",
  "pii_masked": true,
  "payload": {
    "booking_request": "{...}"
  }
}

PII fields (user_id, email, phone) are masked in logs using a configurable redaction pattern. For example, user-123 becomes user-***.

Distributed Tracing Instrumentation

We use OpenTelemetry for distributed tracing with the following custom spans:

  • http.server.request - Captures incoming HTTP requests with attributes:

    • http.method
    • http.path
    • http.status_code
    • http.user_agent
  • service.operation - Custom spans for key business operations:

    • booking.create
    • payment.process
    • notification.send

Each span includes:

  • trace_id, span_id for correlation
  • user_id and booking_id as attributes for filtering
  • error attribute when exceptions occur

Metrics Design at Service Level

We follow the RED method for service-level metrics:

Metric NameTypeDescriptionLabels
http_requests_totalCounterTotal count of HTTP requestsmethod, path, status_code
http_request_duration_secondsHistogramRequest latency in secondsmethod, path, status_code
http_requests_errors_totalCounterCount of failed requestsmethod, path, error_code
service_operations_totalCounterCount of service operationsoperation, status
service_operations_duration_secondsHistogramOperation latency in secondsoperation, status
cache_hits_totalCounterCache hit counttype, operation
cache_misses_totalCounterCache miss counttype, operation

Health Check Endpoint Design

We implement three health endpoints:

1. /health

  • Checks liveness and readiness of the service
  • Returns 200 OK when all dependencies are healthy
  • Response format:
{
  "status": "healthy",
  "timestamp": "2023-10-27T14:30:00.000Z",
  "service": "booking-service"
}

2. /ready

  • Verifies that the service is ready to serve traffic
  • Checks database connectivity, cache availability, and external API health
  • Returns 200 OK when ready or 503 Service Unavailable if not

3. /live

  • Simple liveness check (service is running)
  • Returns 200 OK when service is up and responding to pings

Alerting Thresholds per Key Metric

MetricAlert LevelThresholdAction
http_request_duration_seconds p95Warning> 500msLog alert
http_request_duration_seconds p95Critical> 1000msAlert on-call
http_requests_errors_total rate (5m)Warning> 10 errors/minLog alert
http_requests_errors_total rate (5m)Critical> 50 errors/minAlert on-call
service_operations_duration_seconds p95Warning> 300msLog alert
service_operations_duration_seconds p95Critical> 600msAlert on-call
cache_misses_total rate (5m)Warning> 1000 misses/minLog alert
cache_misses_total rate (5m)Critical> 5000 misses/minAlert on-call

On-call escalation path:

  1. Warning: Log alert to internal Slack channel
  2. Critical: SMS alert to on-call engineer and Slack notification with full stack trace

For critical alerts, the system automatically triggers a service status page update to inform users of ongoing issues.

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Versioning strategy selection: The team must decide whether to use header-based, path-based, or query parameter-based versioning. Each approach has different implications for client migration, API discoverability, and URL hygiene.
  • Backward compatibility policy: How strictly do we enforce backward compatibility? Will we support multiple versions simultaneously or deprecate older versions after a grace period?
  • Client communication strategy: How do we communicate breaking changes to existing clients? Do we use a changelog, automated migration tools, or manual notifications?

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Versioning approachPath-based (/v1/users)Header-based (Accept: application/vnd.api.v1+json)Path-based is more discoverable and easier to debug, but header-based offers cleaner URLs and better separation of concerns
Version retention policyKeep all versions indefinitelyRetire versions after 12 monthsIndefinite retention increases storage and maintenance overhead; 12-month policy balances support with operational efficiency
API evolution strategyMajor version bumps for breaking changesMinor version bumps for breaking changesMajor versions better signal breaking changes to clients, but minor versions reduce friction in evolution

Risks and mitigation strategies

  • Client migration delay: Clients may not update their API calls in a timely manner, leading to deprecated version usage.

    • Mitigation: Implement deprecation warnings in API responses with clear migration paths and estimated retirement dates.
  • Version drift: Different services within a system may evolve at different rates, causing API version mismatches.

    • Mitigation: Establish a centralized versioning policy and conduct regular cross-service compatibility reviews.
  • Increased operational complexity: Managing multiple API versions increases system complexity and maintenance overhead.

    • Mitigation: Implement automated version validation tests, centralized versioning configuration, and clear documentation for each version.
  • Cache pollution: Caching strategies may become inefficient when multiple versions exist with similar data structures.

    • Mitigation: Implement version-aware cache keys and TTL policies to ensure proper cache invalidation.

Technical debt acknowledged

  • API surface bloat: Supporting multiple versions increases codebase size and complexity without proportional business value.
  • Testing overhead: Each version requires its own test suite, increasing maintenance burden and potential for inconsistency.
  • Documentation maintenance: Version-specific documentation must be maintained and kept in sync with actual implementations, creating a potential source of drift.
  • Client confusion: Too many version options may confuse API consumers, leading to suboptimal usage patterns or incorrect version selection.
  • Legacy code accumulation: Older versions may accumulate as technical debt, with no clear path to removal or deprecation.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    Image Processing Pipeline Design and Implementation

    Comprehensive low-level design for a scalable image processing pipeline handling uploads, transformations, and delivery across distributed systems.

  • Backend LLD

    CQRS Framework Design and Implementation

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

  • Backend LLD

    Schema Migration Framework Design and Implementation

    Comprehensive low-level design for a robust database schema migration system supporting version control, rollback capabilities, and concurrent deployment across environments.

Stay in the Loop

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