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
- What is the expected scale of API consumers? Are we talking about hundreds, thousands, or tens of thousands of clients using the API?
- What is the expected rate of API changes? How often do we expect breaking changes vs. backward-compatible ones?
- What is the current API versioning strategy in place? Is there an existing system that we're extending or replacing?
- What are the key stakeholders and their tolerance for breaking changes? Will they prefer gradual migration or immediate updates?
- Are there any specific compliance or regulatory requirements that impact versioning? For example, GDPR or HIPAA implications for data handling.
- What is the expected lifecycle of API versions? Is there a defined time window for supporting older versions?
- 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
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/v1/users/{userId} | Retrieve user details | JWT |
| POST | /api/v1/users | Create new user | JWT |
| PUT | /api/v1/users/{userId} | Update user details | JWT |
| DELETE | /api/v1/users/{userId} | Delete user | JWT |
| GET | /api/v1/users/{userId}/orders | List user orders | JWT |
| POST | /api/v1/users/{userId}/orders | Create order for user | JWT |
| GET | /api/v1/orders/{orderId} | Retrieve order details | JWT |
| PUT | /api/v1/orders/{orderId} | Update order status | JWT |
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, PATCH201 Created- Successful POST204 No Content- Successful DELETE400 Bad Request- Invalid request data401 Unauthorized- Missing or invalid auth403 Forbidden- Insufficient permissions404 Not Found- Resource not found422 Unprocessable Entity- Validation errors429 Too Many Requests- Rate limiting500 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:
- API gateway routing to appropriate version handlers
- Version validation in request processing pipeline
- 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:
- In-memory rate limiter for single instance
- Distributed rate limiting using Redis for multi-instance deployments
- 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:
- Clients include
X-Idempotency-Keyheader in requests - Server stores operation result for the key
- If same key is used, return stored result instead of reprocessing
- Key TTL set to 24 hours
Version Migration Strategy
When introducing new API versions:
- Maintain backward compatibility for v1 endpoints
- Gradually migrate clients to new version
- Provide clear migration guide with breaking changes documentation
- Deprecate old versions after 6 months with warning headers
- 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)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PK, NOT NULL | Unique identifier |
| VARCHAR(255) | UNIQUE, NOT NULL | User's email address | |
| name | VARCHAR(255) | NOT NULL | User's full name |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Last update timestamp |
Orders table (orders)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PK, NOT NULL | Unique identifier |
| user_id | UUID | FK to users.id, NOT NULL | Foreign key to user who placed order |
| status | VARCHAR(50) | NOT NULL, DEFAULT 'PENDING' | Order lifecycle status |
| total_amount | DECIMAL(10,2) | NOT NULL | Total order amount |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Last update timestamp |
Order Items table (order_items)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PK, NOT NULL | Unique identifier |
| order_id | UUID | FK to orders.id, NOT NULL | Foreign key to order |
| product_id | VARCHAR(100) | NOT NULL | Product identifier (external reference) |
| quantity | INTEGER | NOT NULL, DEFAULT 1 | Quantity of product ordered |
| price | DECIMAL(10,2) | NOT NULL | Price per unit at time of order |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
Products table (products)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | PK, NOT NULL | Unique identifier |
| name | VARCHAR(255) | NOT NULL | Product name |
| description | TEXT | Product description | |
| price | DECIMAL(10,2) | NOT NULL | Current price |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Last 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:
- Global Uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination.
- No Centralized ID Generation: Eliminates contention on ID generation in high-concurrency environments.
- Decoupling: Allows for easy data sharding and horizontal scaling without ID conflicts.
- 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 Name | Fields | Type | Purpose |
|---|---|---|---|
| idx_users_email | Unique | Fast lookup by email | |
| idx_orders_user_id_created_at | user_id, created_at | Composite | Efficient query of user's recent orders |
| idx_orders_status_created_at | status, created_at | Partial (where status in ('PENDING', 'PROCESSING')) | Fast filtering of active orders |
| idx_order_items_order_id | order_id | Composite | Efficient retrieval of items by order |
| idx_products_created_at | created_at | Standard | For 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_attimestamp - Orders: Soft delete by setting
deleted_attimestamp - Order Items: Soft delete by setting
deleted_attimestamp - Products: Soft delete by setting
deleted_attimestamp
Justification:
- Auditability: Allows for historical audit trails and compliance requirements
- Recovery: Enables recovery of accidentally deleted data
- Data Integrity: Prevents cascading deletes that could break referential integrity
- 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
- Orders Archival: Orders older than 5 years are archived to a separate storage system (e.g., S3)
- Order Items Archival: Items associated with archived orders are also moved to archival storage
- Audit Logs: Retained for 7 years, then archived to long-term storage
Retention Policy
| Data Type | Retention Period | Archive Location |
|---|---|---|
| Orders | 5 years | S3 bucket with lifecycle policy |
| Order Items | 5 years | S3 bucket |
| Audit Logs | 7 years | Long-term storage (e.g., Glacier) |
| Users | Indefinite | Primary 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:
- Immutable objects - Domain entities and value objects are designed to be immutable where possible
- Synchronized access - Critical sections use synchronized blocks or
ReentrantLockfor thread-safe operations - Atomic operations - Use of
java.util.concurrent.atomicclasses for counter and flag updates - 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:
- Lock ordering - All operations acquire locks in a consistent global order (e.g., by entity ID)
- Timeout configuration - Lock acquisition has a 5-second timeout to prevent indefinite waits
- Lock hierarchy - Clear definition of which locks can be acquired together vs. mutually exclusive
- Monitoring and alerts - Deadlock detection via database monitoring tools with alerting
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented using:
- Idempotency keys - Clients provide a unique
idempotency-keyheader for write operations - Cache-based tracking - Store recent operation IDs in Redis with TTL of 1 hour
- 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:
- Avoiding shared mutable state between concurrent requests
- Using thread-local storage for request-scoped data (e.g., user context)
- Implementing proper synchronization on shared resources like configuration caches
- 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:
- Optimistic concurrency control with version checks
- Batch processing for bulk operations to reduce transaction overhead
- Read-replica usage for read-heavy operations to reduce primary DB load
Performance Considerations
- Thread pool sizing - Configured based on CPU cores and I/O characteristics (typically 2x CPU cores + I/O bound threads)
- Async processing - Use of
@Asyncannotation for non-critical background tasks to free up main threads - Connection pooling - Database connections are managed via HikariCP with proper pool sizing and timeouts
Testing Concurrency Scenarios
Concurrency testing includes:
- Stress tests with JMeter or Gatling simulating 1000+ concurrent requests
- Race condition tests with synchronized unit tests using
CountDownLatch - Deadlock detection through integration testing with various operation sequences
- 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 Type | TTL | Reason |
|---|---|---|
| User Profile | 5 minutes | Frequent updates, low consistency requirements |
| Product Catalog | 1 hour | Stable data, infrequent changes |
| Order Details | 30 minutes | Critical for order processing, moderate consistency needs |
| Session Tokens | 1 hour | Security-sensitive, short-lived |
| API Rate Limits | 1 minute | High 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:
- Performance: Raw queries provide better performance with minimal overhead
- Control: Fine-grained control over SQL execution and query optimization
- Debugging: Easier to debug and profile specific queries
- 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:
- Retry logic: Messages are retried up to 3 times with exponential backoff (5s, 10s, 20s).
- DLQ handling: After max retries, messages are sent to a dedicated DLQ topic.
- 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:
- Simplicity: Easier to implement and maintain than exactly-once semantics.
- Business tolerance: For most events (e.g., order confirmation emails), duplicate processing is acceptable or can be handled gracefully.
- 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:
| Parameter | Value |
|---|---|
| Max attempts | 3 |
| Initial backoff | 5 seconds |
| Backoff multiplier | 2 |
| Max backoff | 60 seconds |
| Retryable errors | Network timeouts, service unavailability, transient DB errors |
| Non-retryable errors | Invalid 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:
- Event consistency: Ensures events are published only after database transactions commit.
- Reliability: Prevents event loss in case of system failures during transaction commits.
- Auditability: Provides a clear audit trail of all published events.
Implementation approach:
- Events are written to an
event_outboxtable 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:
- Domain errors are wrapped in custom domain exceptions and re-thrown
- Validation errors are converted to standardized validation exceptions
- Infrastructure errors are wrapped in service layer exceptions with retry context
- 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 identifierspan_id: Current operation spanuser_id: Authenticated user identifier (masked in logs)entity_id: Affected business entity IDerror_code: Standardized error code (e.g., "VALIDATION_ERROR_001")error_type: Classification (DOMAIN, VALIDATION, INFRASTRUCTURE)timestamp: UTC timestampadditional_context: Structured error metadata
Alerting Thresholds
Critical alerts are configured for:
| Metric | Threshold | Alert Level |
|---|---|---|
| 5xx errors rate (5m) | > 1% of total requests | Critical |
| Circuit breaker half-open transitions | > 5 per hour | Warning |
| Retry failures rate (5m) | > 0.5% of total requests | Warning |
| Timeout rate (5m) | > 1% of external calls | Critical |
On-call escalation path:
- Alert triggers → Slack notification
- If no response within 5 minutes → On-call engineer escalation
- 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.methodhttp.pathhttp.status_codehttp.user_agent
-
service.operation- Custom spans for key business operations:booking.createpayment.processnotification.send
Each span includes:
trace_id,span_idfor correlationuser_idandbooking_idas attributes for filteringerrorattribute when exceptions occur
Metrics Design at Service Level
We follow the RED method for service-level metrics:
| Metric Name | Type | Description | Labels |
|---|---|---|---|
http_requests_total | Counter | Total count of HTTP requests | method, path, status_code |
http_request_duration_seconds | Histogram | Request latency in seconds | method, path, status_code |
http_requests_errors_total | Counter | Count of failed requests | method, path, error_code |
service_operations_total | Counter | Count of service operations | operation, status |
service_operations_duration_seconds | Histogram | Operation latency in seconds | operation, status |
cache_hits_total | Counter | Cache hit count | type, operation |
cache_misses_total | Counter | Cache miss count | type, 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
| Metric | Alert Level | Threshold | Action |
|---|---|---|---|
http_request_duration_seconds p95 | Warning | > 500ms | Log alert |
http_request_duration_seconds p95 | Critical | > 1000ms | Alert on-call |
http_requests_errors_total rate (5m) | Warning | > 10 errors/min | Log alert |
http_requests_errors_total rate (5m) | Critical | > 50 errors/min | Alert on-call |
service_operations_duration_seconds p95 | Warning | > 300ms | Log alert |
service_operations_duration_seconds p95 | Critical | > 600ms | Alert on-call |
cache_misses_total rate (5m) | Warning | > 1000 misses/min | Log alert |
cache_misses_total rate (5m) | Critical | > 5000 misses/min | Alert on-call |
On-call escalation path:
- Warning: Log alert to internal Slack channel
- 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-off | Option 1 | Option 2 | Justification |
|---|---|---|---|
| Versioning approach | Path-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 policy | Keep all versions indefinitely | Retire versions after 12 months | Indefinite retention increases storage and maintenance overhead; 12-month policy balances support with operational efficiency |
| API evolution strategy | Major version bumps for breaking changes | Minor version bumps for breaking changes | Major 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.