Skip to main content

Repository Pattern Implementation in Backend Systems

Comprehensive low-level design for implementing the Repository pattern to decouple data access logic from business logic in scalable backend architectures.

The Repository pattern serves as a crucial abstraction layer that decouples business logic from data access concerns, enabling testability, maintainability, and scalability in modern backend systems.

1. Problem Statement & Requirements

Clarifying questions

  1. What scale and throughput requirements are expected for this repository?
    • Is it a high-volume system handling millions of operations per second, or more modest scale?
  2. What consistency requirements exist for reads and writes?
    • Are eventual consistency models acceptable, or is strong consistency required?
  3. What are the key entities and their relationships in the system?
    • Is this a single domain or multi-domain repository with cross-entity references?
  4. What is the read/write ratio for typical operations?
    • Is the system primarily read-heavy or write-heavy?
  5. What are the latency sensitivity requirements?
    • Are sub-100ms response times expected for all operations?
  6. What authentication and authorization model is in place?
    • Is the repository used internally or exposed via APIs with external access?
  7. Are there any external integrations or dependencies?
    • Does the repository integrate with other systems like message brokers, external databases, or microservices?

Functional requirements

  • Support CRUD (Create, Read, Update, Delete) operations for domain entities
  • Provide transactional consistency guarantees where required
  • Allow querying of entities by various criteria (filters, sorting, pagination)
  • Support bulk operations for efficient data processing
  • Enable soft-delete and audit trail capabilities
  • Allow for read-replica support to offload read traffic from primary DB

Non-functional requirements

  • Latency targets:
    • p50: <50ms
    • p95: <200ms
    • p99: <500ms
  • Throughput:
    • 10,000 RPS (reads) and 5,000 RPS (writes) under load
  • Availability:
    • 99.9% uptime (5-minute SLA) with graceful degradation capability
  • Scalability:
    • Horizontal scaling support via sharding or partitioning

Constraints and assumptions

  • Assumption: The system uses a relational database (e.g., PostgreSQL or MySQL) as the primary data store
  • Assumption: The application is written in Java (or compatible with Java ecosystem)
  • Constraint: No use of ORM frameworks like Hibernate or JPA for core repository logic (to allow fine-grained control)
  • Constraint: Repository must support both read and write operations in a single transactional boundary where needed
  • Assumption: The system has a well-defined domain model and entities with clear ownership

Out of scope

  • Authentication and authorization logic (handled at service layer or API gateway)
  • Caching strategy (handled in separate caching design section)
  • Asynchronous processing and event publishing (covered in messaging section)
  • Data migration or schema evolution (handled by migration tooling)
  • Integration with external systems (e.g., Kafka, Redis) — this is a pure data access layer concern

2. API Design

API Style Selection

For this repository pattern design, I'll select REST as the primary API style because:

  • It provides clear, discoverable endpoints that align well with repository operations
  • REST is widely understood and supported across teams and tools
  • It enables proper HTTP semantics (GET, POST, PUT, DELETE) for CRUD operations
  • It's well-suited for the typical repository use cases (create, read, update, delete entities)

Complete Endpoint Definitions

MethodPathDescriptionAuth
GET/api/v1/repositoriesList all repositoriesJWT
POST/api/v1/repositoriesCreate new repositoryJWT
GET/api/v1/repositories/{id}Get specific repository by IDJWT
PUT/api/v1/repositories/{id}Update existing repositoryJWT
DELETE/api/v1/repositories/{id}Delete repository by IDJWT
GET/api/v1/repositories/{id}/commitsList commits in repositoryJWT
POST/api/v1/repositories/{id}/commitsCreate new commitJWT
GET/api/v1/repositories/{id}/commits/{commitId}Get specific commitJWT

Request and Response Schemas

Create Repository Request:

{
  "name": "my-repo",
  "description": "A sample repository",
  "visibility": "public"
}

Create Repository Response:

{
  "id": "uuid-12345",
  "name": "my-repo",
  "description": "A sample repository",
  "visibility": "public",
  "createdAt": "2023-10-01T10:00:00Z",
  "updatedAt": "2023-10-01T10:00:00Z"
}

Get Repository Response:

{
  "id": "uuid-12345",
  "name": "my-repo",
  "description": "A sample repository",
  "visibility": "public",
  "createdAt": "2023-10-01T10:00:00Z",
  "updatedAt": "2023-10-01T10:00:00Z",
  "commits": [
    {
      "id": "commit-123",
      "message": "Initial commit",
      "author": "user@example.com",
      "timestamp": "2023-10-01T10:00:00Z"
    }
  ]
}

HTTP Status Codes and Error Response Format

Standard Status Codes:

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

Standardized Error Response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Repository name is required",
    "details": [
      {
        "field": "name",
        "message": "Name is required"
      }
    ],
    "timestamp": "2023-10-01T10:00:00Z"
  }
}

API Versioning Strategy

Version will be handled via path prefix: /api/v1/ with future version paths like /api/v2/. This approach:

  • Is simple to implement and understand
  • Allows for parallel support of multiple versions
  • Makes versioning explicit in the URL structure
  • Follows REST best practices

Pagination Design

Using cursor-based pagination because:

  • It's more stable across data changes
  • Provides better performance for large datasets
  • Handles concurrent modifications gracefully
  • Avoids issues with offset-based pagination when items are inserted/deleted

Pagination Parameters:

?page=1&page_size=20&cursor=abc123

Response Format:

{
  "data": [...],
  "pagination": {
    "cursor": "next_cursor",
    "hasMore": true,
    "count": 20
  }
}

Authentication and Authorization

All endpoints require JWT-based authentication with the following scopes:

  • repositories:read - Read access to repositories
  • repositories:write - Write access to repositories

Authorization Logic:

@PreAuthorize("hasPermission(#repositoryId, 'repositories', 'write')")
public void updateRepository(String repositoryId, RepositoryUpdateRequest request) {
    // Implementation
}

Rate Limiting

Rate Limits by Endpoint:

EndpointLimitPeriod
POST /api/v1/repositories100/hourHour
GET /api/v1/repositories1000/hourHour
PUT /api/v1/repositories/{id}50/hourHour

Implementation Strategy:

  • Use Redis-based rate limiting with sliding window algorithm
  • Apply to authenticated user or IP address (depending on use case)
  • Return 429 Too Many Requests with Retry-After header

Idempotency Design

Idempotency Key Strategy:

  • Clients send X-Idempotency-Key header with each request
  • Server stores the operation result using this key as a cache key
  • If same key is used, return cached result instead of reprocessing

Implementation Pattern:

@PostMapping("/api/v1/repositories")
public ResponseEntity<Repository> createRepository(
    @RequestHeader("X-Idempotency-Key") String idempotencyKey,
    @RequestBody CreateRepositoryRequest request) {
    
    // Use idempotencyKey to check if operation already completed
    return ResponseEntity.ok(repositoryService.createRepository(request, idempotencyKey));
}

3. Data Model Design

Entity-Relationship diagram (Mermaid)

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

    ORDER {
        string id PK
        string user_id FK
        decimal total_amount
        string status
        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
        decimal price
        integer stock_quantity
        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

User table

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT TRUE,
    
    INDEX idx_users_email (email),
    INDEX idx_users_created_at (created_at)
);

Order table

CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_orders_user_id (user_id),
    INDEX idx_orders_status (status),
    INDEX idx_orders_created_at (created_at)
);

Order Item table

CREATE TABLE order_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id VARCHAR(255) NOT NULL,
    quantity INTEGER NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_order_items_order_id (order_id),
    INDEX idx_order_items_product_id (product_id)
);

Product table

CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock_quantity INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_products_name (name),
    INDEX idx_products_stock_quantity (stock_quantity)
);

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

Using UUID as primary key for all tables because:

  1. Global uniqueness - UUIDs ensure uniqueness across distributed systems without coordination
  2. No single point of failure - No need for a centralized ID generator service
  3. Scalability - Can scale horizontally without worrying about ID collisions
  4. Security - UUIDs don't expose sequential information that could be exploited

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

  1. Users email index: idx_users_email - Essential for user lookups by email, which is a frequent operation in authentication flows
  2. Orders user_id index: idx_orders_user_id - Critical for fetching orders by user, supporting user dashboard and history views
  3. Orders status index: idx_orders_status - Needed for order status filtering and reporting operations
  4. Orders created_at index: idx_orders_created_at - Required for time-based queries and analytics
  5. Order Items order_id index: idx_order_items_order_id - Needed for fetching all items in an order
  6. Order Items product_id index: idx_order_items_product_id - Required for product-based analytics and inventory tracking
  7. Products name index: idx_products_name - Needed for product search and filtering
  8. Products stock_quantity index: idx_products_stock_quantity - Critical for inventory management and low-stock alerts

Data partitioning / sharding strategy

Partition orders by created_at date range (monthly) to enable:

  • Faster historical query performance
  • Easier data lifecycle management
  • Reduced I/O on hot partitions

Partition users by id hash modulo 100 to support horizontal sharding across database instances.

Soft delete vs. hard delete decision with justification

Soft delete is used for the users and orders tables to:

  1. Compliance - Maintain audit trail for regulatory requirements
  2. Data recovery - Allow for accidental deletion recovery
  3. Analytics - Preserve historical data for reporting and analysis

Hard delete is used for order_items since they're always tied to a specific order and can be safely removed when the parent order is deleted.

Audit trail design (who, what, when)

All tables have created_at and updated_at timestamps to track creation and modification times. For enhanced audit capability, a separate audit_log table would be added with:

  • table_name (string)
  • record_id (UUID)
  • operation (INSERT/UPDATE/DELETE)
  • old_values (JSONB)
  • new_values (JSONB)
  • user_id (UUID, nullable)
  • timestamp (timestamp)

Data archival strategy and retention policy

  1. Orders older than 5 years are archived to cold storage (S3 or similar)
  2. User data is retained for 7 years after last activity
  3. Order items are archived with their parent orders
  4. Audit logs are retained for 3 years in primary storage, then moved to long-term archival

Archived data is marked with a archived_at timestamp and can be restored via a dedicated API endpoint.

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class Repository {
        <<Interface>>
        +save(T entity)
        +findById(ID id)
        +findAll()
        +deleteById(ID id)
        +existsById(ID id)
    }
    
    class UserRepository {
        +save(User user)
        +findById(Long id)
        +findAll()
        +deleteById(Long id)
        +existsById(Long id)
        +findByEmail(String email)
        +findActiveUsers()
    }
    
    class OrderRepository {
        +save(Order order)
        +findById(Long id)
        +findAll()
        +deleteById(Long id)
        +existsById(Long id)
        +findByUserId(Long userId)
        +findRecentOrders()
    }
    
    class User {
        -id: Long
        -email: String
        -status: UserStatus
        -createdAt: LocalDateTime
        -updatedAt: LocalDateTime
    }
    
    class Order {
        -id: Long
        -userId: Long
        -status: OrderStatus
        -amount: BigDecimal
        -createdAt: LocalDateTime
        -updatedAt: LocalDateTime
    }
    
    class UserStatus {
        ACTIVE
        INACTIVE
        SUSPENDED
    }
    
    class OrderStatus {
        PENDING
        CONFIRMED
        CANCELLED
        SHIPPED
    }
    
    class UserService {
        +createUser(CreateUserRequest request)
        +getUserById(Long id)
        +updateUserStatus(Long userId, UserStatus status)
        +deleteUser(Long id)
        +findActiveUsers()
        +getUserOrders(Long userId)
    }
    
    class OrderService {
        +createOrder(CreateOrderRequest request)
        +getOrderById(Long id)
        +updateOrderStatus(Long orderId, OrderStatus status)
        +cancelOrder(Long orderId)
        +findRecentOrders()
    }
    
    class CreateOrderRequest {
        userId: Long
        amount: BigDecimal
        items: List~OrderItem~
    }
    
    class CreateOrderRequest {
        userId: Long
        amount: BigDecimal
        items: List~OrderItem~
    }
    
    class OrderItem {
        productId: Long
        quantity: Integer
        price: BigDecimal
    }
    
    Repository <|-- UserRepository
    Repository <|-- OrderRepository
    UserRepository --> User : aggregates
    OrderRepository --> Order : aggregates
    
    UserService --> UserRepository : uses
    OrderService --> OrderRepository : uses
    
    UserService --> User : creates/updates
    OrderService --> Order : creates/updates

State Machine Diagram for Order Entity

stateDiagram-v2
    [*] --> PENDING
    PENDING --> CONFIRMED : payment_received
    PENDING --> CANCELLED : payment_failed
    PENDING --> SHIPPED : shipped
    CONFIRMED --> SHIPPED : shipped
    CONFIRMED --> CANCELLED : cancelled_by_user
    SHIPPED --> DELIVERED : delivered
    SHIPPED --> RETURNED : returned
    CANCELLED --> [*]
    DELIVERED --> [*]
    RETURNED --> [*]

Service Class Structure and Responsibilities

The service layer provides business logic encapsulation and orchestrates interactions between repositories, external services, and domain objects.

User Service Responsibilities:

  • User creation with validation
  • Status management (activation, suspension)
  • User profile retrieval and updates
  • Active user listing for dashboard features

Order Service Responsibilities:

  • Order placement with payment validation
  • Order status transitions (confirm, ship, cancel)
  • Order history and recent orders retrieval
  • Business rule enforcement (e.g., order cancellation window)

Interface / Abstract Class Design

// Repository interface - generic contract for data access operations
public interface Repository<T, ID> {
    T save(T entity);
    Optional<T> findById(ID id);
    List<T> findAll();
    void deleteById(ID id);
    boolean existsById(ID id);
}

// Specific repository interfaces extending the generic one
public interface UserRepository extends Repository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findActiveUsers();
}

public interface OrderRepository extends Repository<Order, Long> {
    List<Order> findByUserId(Long userId);
    List<Order> findRecentOrders();
}

// Service interfaces for business logic contracts
public interface UserService {
    User createUser(CreateUserRequest request);
    User getUserById(Long id);
    User updateUserStatus(Long userId, UserStatus status);
    void deleteUser(Long id);
    List<User> findActiveUsers();
    List<Order> getUserOrders(Long userId);
}

public interface OrderService {
    Order createOrder(CreateOrderRequest request);
    Order getOrderById(Long id);
    Order updateOrderStatus(Long orderId, OrderStatus status);
    void cancelOrder(Long orderId);
    List<Order> findRecentOrders();
}

Business Logic Encapsulation Patterns

Strategy Pattern Implementation for Order Status Transitions:

// Strategy interface for order status transitions
public interface OrderStatusTransitionStrategy {
    boolean canTransition(Order order, OrderStatus targetStatus);
    void applyTransition(Order order, OrderStatus targetStatus);
}

// Concrete strategy implementations
@Component
public class PendingToConfirmedStrategy implements OrderStatusTransitionStrategy {
    @Override
    public boolean canTransition(Order order, OrderStatus targetStatus) {
        return order.getStatus() == OrderStatus.PENDING && 
               targetStatus == OrderStatus.CONFIRMED;
    }
    
    @Override
    public void applyTransition(Order order, OrderStatus targetStatus) {
        order.setStatus(targetStatus);
        order.setUpdatedAt(Instant.now());
    }
}

@Component
public class OrderStatusTransitionContext {
    private final Map<OrderStatus, OrderStatusTransitionStrategy> strategies;
    
    public OrderStatusTransitionContext(
            List<OrderStatusTransitionStrategy> strategyList) {
        this.strategies = strategyList.stream()
            .collect(Collectors.toMap(
                strategy -> getTargetStatus(strategy),
                Function.identity()
            ));
    }
    
    private OrderStatus getTargetStatus(OrderStatusTransitionStrategy strategy) {
        // Implementation to determine the target status for each strategy
        return OrderStatus.CONFIRMED; // Simplified for example
    }
    
    public void transition(Order order, OrderStatus targetStatus) {
        var strategy = strategies.get(targetStatus);
        if (strategy == null) {
            throw new IllegalArgumentException("Invalid status transition");
        }
        
        if (!strategy.canTransition(order, targetStatus)) {
            throw new IllegalStateException("Cannot transition to " + targetStatus);
        }
        
        strategy.applyTransition(order, targetStatus);
    }
}

Critical-Path Method Implementation

@Transactional
public Order createOrder(CreateOrderRequest request) {
    // Validate input and business rules
    if (request.getAmount() == null || request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
        throw new ValidationException("Order amount must be positive");
    }
    
    // Fetch user to validate existence and status
    User user = userRepository.findById(request.getUserId())
        .orElseThrow(() -> new EntityNotFoundException("User not found"));
    
    if (user.getStatus() != UserStatus.ACTIVE) {
        throw new IllegalStateException("Cannot create order for inactive user");
    }
    
    // Create order entity
    Order order = new Order();
    order.setUserId(request.getUserId());
    order.setStatus(OrderStatus.PENDING);
    order.setAmount(request.getAmount());
    order.setItems(request.getItems());
    order.setCreatedAt(Instant.now());
    order.setUpdatedAt(Instant.now());
    
    // Save order with optimistic locking
    Order savedOrder = orderRepository.save(order);
    
    // Publish order created event for async processing
    OrderCreatedEvent event = new OrderCreatedEvent(
        savedOrder.getId(),
        savedOrder.getUserId(),
        savedOrder.getAmount(),
        savedOrder.getItems()
    );
    
    eventPublisher.publish(event);
    
    return savedOrder;
}

Transaction Boundary Design

  • Single transaction scope: The entire order creation process (user validation, order creation, event publishing) is wrapped in a single database transaction.
  • Consistency guarantee: All operations within the transaction either succeed or fail together, ensuring data integrity.
  • Isolation level: Read committed isolation to prevent dirty reads and non-repeatable reads.

Service Interface Contracts

public interface UserService {
    User createUser(CreateUserRequest request);
    User getUserById(Long id);
    User updateUserStatus(Long userId, UserStatus status);
    void deleteUser(Long id);
    List<User> findActiveUsers();
    List<Order> getUserOrders(Long userId);
}

public interface OrderService {
    Order createOrder(CreateOrderRequest request);
    Order getOrderById(Long id);
    Order updateOrderStatus(Long orderId, OrderStatus status);
    void cancelOrder(Long orderId);
    List<Order> findRecentOrders();
}

Saga / Orchestration Pattern

For distributed transactions involving external services (e.g., payment processing), we use the saga pattern with compensating actions:

public class OrderSaga {
    private final List<SagaStep> steps;
    
    public void executeOrderCreation(Order order) {
        try {
            // Step 1: Create order in database
            steps.add(new OrderCreationStep());
            
            // Step 2: Process payment
            steps.add(new PaymentProcessingStep());
            
            // Step 3: Reserve inventory
            steps.add(new InventoryReservationStep());
            
            // Execute all steps in order
            for (SagaStep step : steps) {
                step.execute();
            }
            
        } catch (Exception e) {
            // Compensate for failed steps
            compensateSteps();
            throw new OrderProcessingException("Order creation failed", e);
        }
    }
    
    private void compensateSteps() {
        // Reverse steps in reverse order
        for (int i = steps.size() - 1; i >= 0; i--) {
            steps.get(i).compensate();
        }
    }
}

Domain Object / Value Object Design

// Immutable value object for order item
public class OrderItem {
    private final Long productId;
    private final Integer quantity;
    private final BigDecimal price;
    
    public OrderItem(Long productId, Integer quantity, BigDecimal price) {
        this.productId = Objects.requireNonNull(productId);
        this.quantity = Objects.requireNonNull(quantity);
        this.price = Objects.requireNonNull(price);
    }
    
    // Getters only - immutable
    public Long getProductId() { return productId; }
    public Integer getQuantity() { return quantity; }
    public BigDecimal getPrice() { return price; }
    
    public BigDecimal getTotalPrice() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}

// Mutable domain object with business logic
public class Order {
    private Long id;
    private Long userId;
    private OrderStatus status;
    private BigDecimal amount;
    private List<OrderItem> items;
    private Instant createdAt;
    private Instant updatedAt;
    
    // Business logic methods
    public void calculateTotal() {
        this.amount = items.stream()
            .map(OrderItem::getTotalPrice)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    public boolean isEligibleForCancellation() {
        return status == OrderStatus.PENDING;
    }
    
    // Getters and setters
}

5. Concurrency & Thread Safety

Concurrency Model

The backend system uses a thread pool model with Java's ExecutorService for handling concurrent requests. Each HTTP request is processed in a separate thread from the application's thread pool, allowing for efficient handling of multiple simultaneous operations. For database operations, we utilize connection pooling with thread-safe drivers (e.g., HikariCP) to ensure that database connections can be safely shared across threads.

Race Condition Prevention Strategy

Race conditions are mitigated through:

  1. Database-level atomic operations: Using SELECT FOR UPDATE in transactions to lock rows during read-modify-write operations.
  2. Optimistic locking: Implementing version fields in entities to detect and handle concurrent modifications.
  3. Thread-local storage: For request-scoped data that should not be shared across threads.
  4. Synchronized blocks: Where necessary, for operations that require strict mutual exclusion.

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is preferred for most use cases because:

  • It provides better scalability under moderate concurrent loads.
  • It avoids blocking threads during read operations.
  • It's more suitable for systems where conflicts are rare.

Pessimistic locking is used selectively for:

  • High-concurrency write scenarios where conflicts are expected.
  • Operations that must ensure data consistency at the point of modification.

Deadlock Prevention Strategy

Deadlock prevention is achieved through:

  1. Consistent ordering of resource acquisition: All database operations acquire locks in a consistent order (e.g., by entity ID).
  2. Timeout configuration: Setting reasonable timeouts for lock acquisition to prevent indefinite blocking.
  3. Deadlock detection and retry logic: Implementing exponential backoff with circuit breaker pattern for retrying failed operations.

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is handled at the service layer using a combination of:

  1. Idempotency keys: Clients provide unique identifiers for requests that should be treated as idempotent.
  2. Database-level uniqueness constraints: Enforcing uniqueness at the database level for operations like order creation.
  3. In-memory cache with TTL: Caching recent operation results for a short time window to avoid reprocessing.
public class IdempotencyService {
    private final Map<String, CompletableFuture<OperationResult>> pendingOperations = new ConcurrentHashMap<>();
    private final Cache<String, OperationResult> resultCache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(5))
            .build();

    public CompletableFuture<OperationResult> executeWithIdempotency(
            String idempotencyKey,
            Supplier<CompletableFuture<OperationResult>> operation) {
        return pendingOperations.computeIfAbsent(idempotencyKey, key -> {
            CompletableFuture<OperationResult> future = operation.get();
            future.thenAccept(result -> resultCache.put(key, result));
            return future;
        });
    }
}

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant Database

    Client->>Service: CreateOrderRequest(order)
    Service->>Repository: save(order)
    Repository->>Database: INSERT INTO orders (id, status, created_at) VALUES (?, ?, ?)
    Database-->>Repository: success
    Repository-->>Service: Order(order)
    Service-->>Client: OrderResponse(order)

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant Database

    Client->>Service: CreateOrderRequest(order)
    Service->>Repository: save(order)
    Repository->>Database: INSERT INTO orders (id, status, created_at) VALUES (?, ?, ?)
    Database-->>Repository: duplicate key error
    Repository-->>Service: DuplicateKeyException
    Service-->>Client: 409 Conflict: Order already exists

Async Flow Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant EventPublisher
    participant MessageQueue

    Client->>Service: CreateOrderRequest(order)
    Service->>Repository: save(order)
    Repository->>Database: INSERT INTO orders (id, status, created_at) VALUES (?, ?, ?)
    Repository->>EventPublisher: publish(order.created)
    EventPublisher->>MessageQueue: send(order.created.event)
    MessageQueue-->>EventPublisher: queued
    EventPublisher-->>Repository: success
    Repository-->>Service: Order(order)
    Service-->>Client: OrderResponse(order)

7. Caching Design

Cache Strategy Selection

For this system, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for a typical web application serving read-heavy workloads. The cache-aside pattern allows explicit control over when data is loaded into cache and provides clear separation between application logic and caching concerns.

Cache Key Design

Cache keys will follow a namespace-based structure to avoid key collisions and enable efficient cache invalidation:

{namespace}:{entity_type}:{entity_id}:{version}

Example:

user:profile:12345:v1
order:status:67890:v2

Cache TTL Strategy

Data TypeTTLReason
User Profile5 minutesFrequent updates, acceptable staleness
Order Status2 minutesCritical for real-time operations
Product Catalog1 hourInfrequently updated, high read volume
Session Token30 minutesSession timeout alignment
Search Results10 minutesExpensive to compute, moderate freshness needs

Cache Eviction Policy

We'll implement LRU (Least Recently Used) eviction policy with a secondary TTL-based cleanup. This ensures that:

  1. Frequently accessed items remain in cache
  2. Items that haven't been accessed recently are evicted
  3. Items that exceed their TTL are cleaned up

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic Early Expiry: 20% of TTL is used for early expiration
  2. Distributed Locks: For high-traffic keys, we'll use Redis distributed locks to ensure only one request computes and populates the cache
  3. Cache Entry Validation: Cache entries include a timestamp to detect stale data

Distributed Cache Architecture

We'll use Redis Cluster with:

  • 3 master nodes for data distribution
  • 3 replica nodes for high availability
  • Automatic failover capability
  • Cross-datacenter replication for disaster recovery

Cache Invalidation Strategy

We'll implement event-driven invalidation using a publish-subscribe model:

@Component
public class CacheInvalidator {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ApplicationEventPublisher eventPublisher;

    @EventListener
    public void handleEntityUpdated(EntityUpdatedEvent event) {
        String entityType = event.getEntityType();
        String entityId = event.getEntityId();
        
        // Invalidate specific entity cache
        String keyPattern = String.format("%s:%s:*", entityType, entityId);
        Set<String> keys = redisTemplate.keys(keyPattern);
        
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
        
        // Invalidate related caches (e.g., user's orders)
        invalidateRelatedCaches(entityType, entityId);
    }
}

Cache Read-Through Pattern Implementation

@Service
public class CachedUserService {
    private static final String USER_PROFILE_CACHE_KEY = "user:profile:%s";
    private static final String USER_ORDERS_CACHE_KEY = "user:orders:%s";
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    public UserProfile getUserProfile(String userId) {
        String cacheKey = String.format(USER_PROFILE_CACHE_KEY, userId);
        
        // Try to get from cache first
        UserProfile cachedProfile = (UserProfile) redisTemplate.opsForValue().get(cacheKey);
        if (cachedProfile != null) {
            return cachedProfile;
        }
        
        // Cache miss - fetch from database
        UserProfile profile = userRepository.findById(userId);
        if (profile != null) {
            // Cache the result with TTL
            redisTemplate.opsForValue().set(cacheKey, profile, 5, TimeUnit.MINUTES);
        }
        
        return profile;
    }
}

Cache Write-Through Pattern Implementation

@Service
public class CachedOrderService {
    private static final String ORDER_STATUS_CACHE_KEY = "order:status:%s";
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Transactional
    public Order updateOrderStatus(String orderId, String newStatus) {
        // Update database first (ACID guarantee)
        Order order = orderRepository.findById(orderId);
        order.setStatus(newStatus);
        Order updatedOrder = orderRepository.save(order);
        
        // Then update cache
        String cacheKey = String.format(ORDER_STATUS_CACHE_KEY, orderId);
        redisTemplate.opsForValue().set(cacheKey, updatedOrder.getStatus(), 2, TimeUnit.MINUTES);
        
        return updatedOrder;
    }
}

Cache Hit/Miss Metrics

We'll track cache performance with the following metrics:

  • Cache hit ratio (target: >90%)
  • Cache miss ratio
  • Average cache lookup time
  • Eviction rate
@Component
public class CacheMetricsCollector {
    private final MeterRegistry meterRegistry;
    
    public void recordCacheHit(String cacheName) {
        Counter.builder("cache.hits")
            .tag("cache", cacheName)
            .register(meterRegistry)
            .increment();
    }
    
    public void recordCacheMiss(String cacheName) {
        Counter.builder("cache.misses")
            .tag("cache", cacheName)
            .register(meterRegistry)
            .increment();
    }
}

Cache Warming Strategy

For high-traffic entities, we'll implement cache warming during application startup:

@Component
public class CacheWarmer {
    private final RedisTemplate<String, Object> redisTemplate;
    private final UserRepository userRepository;
    
    @EventListener
    public void onApplicationStarted(ApplicationReadyEvent event) {
        // Warm up frequently accessed user profiles
        List<UserProfile> profiles = userRepository.findPopularProfiles();
        for (UserProfile profile : profiles) {
            String key = String.format("user:profile:%s", profile.getId());
            redisTemplate.opsForValue().set(key, profile, 5, TimeUnit.MINUTES);
        }
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and persistence concerns. This pattern enables loose coupling, testability, and supports various data sources.

// Repository interface - defines contract for data access operations
public interface UserRepository {
    User findById(UUID id);
    List<User> findAll();
    User save(User user);
    void delete(UUID id);
    Optional<User> findByEmail(String email);
    List<User> findByStatus(UserStatus status);
    Page<User> findAll(Pageable pageable);
}

// Repository implementation with database access
@Repository
public class UserRepositoryImpl implements UserRepository {
    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;
    
    public UserRepositoryImpl(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
        this.jdbcTemplate = jdbcTemplate;
        this.objectMapper = objectMapper;
    }
    
    @Override
    public User findById(UUID id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        return jdbcTemplate.queryForObject(sql, new Object[]{id}, this::mapRowToUser);
    }
    
    @Override
    public List<User> findAll() {
        String sql = "SELECT * FROM users ORDER BY created_at DESC";
        return jdbcTemplate.query(sql, this::mapRowToUser);
    }
    
    @Override
    public User save(User user) {
        if (user.getId() == null) {
            return create(user);
        } else {
            return update(user);
        }
    }
    
    private User create(User user) {
        String sql = """
            INSERT INTO users (id, email, name, status, metadata, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """;
        
        UUID id = UUID.randomUUID();
        user.setId(id);
        user.setCreatedAt(Instant.now());
        user.setUpdatedAt(Instant.now());
        
        jdbcTemplate.update(sql, 
            id,
            user.getEmail(),
            user.getName(),
            user.getStatus().name(),
            objectMapper.writeValueAsString(user.getMetadata()),
            user.getCreatedAt(),
            user.getUpdatedAt()
        );
        
        return user;
    }
    
    private User update(User user) {
        String sql = """
            UPDATE users 
            SET email = ?, name = ?, status = ?, metadata = ?, updated_at = ?
            WHERE id = ?
            """;
        
        user.setUpdatedAt(Instant.now());
        jdbcTemplate.update(sql,
            user.getEmail(),
            user.getName(),
            user.getStatus().name(),
            objectMapper.writeValueAsString(user.getMetadata()),
            user.getUpdatedAt(),
            user.getId()
        );
        
        return user;
    }
    
    @Override
    public void delete(UUID id) {
        String sql = "DELETE FROM users WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }
    
    @Override
    public Optional<User> findByEmail(String email) {
        String sql = "SELECT * FROM users WHERE email = ?";
        try {
            User user = jdbcTemplate.queryForObject(sql, new Object[]{email}, this::mapRowToUser);
            return Optional.of(user);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
    
    @Override
    public List<User> findByStatus(UserStatus status) {
        String sql = "SELECT * FROM users WHERE status = ? ORDER BY created_at DESC";
        return jdbcTemplate.query(sql, new Object[]{status.name()}, this::mapRowToUser);
    }
    
    @Override
    public Page<User> findAll(Pageable pageable) {
        String countSql = "SELECT COUNT(*) FROM users";
        int total = jdbcTemplate.queryForObject(countSql, Integer.class);
        
        String sql = """
            SELECT * FROM users 
            ORDER BY created_at DESC 
            LIMIT ? OFFSET ?
            """;
        
        List<User> content = jdbcTemplate.query(sql, 
            new Object[]{pageable.getPageSize(), pageable.getOffset()}, 
            this::mapRowToUser);
        
        return new PageImpl<>(content, pageable, total);
    }
    
    private User mapRowToUser(ResultSet rs, int rowNum) throws SQLException {
        return User.builder()
            .id(UUID.fromString(rs.getString("id")))
            .email(rs.getString("email"))
            .name(rs.getString("name"))
            .status(UserStatus.valueOf(rs.getString("status")))
            .metadata(objectMapper.readValue(rs.getString("metadata"), Map.class))
            .createdAt(rs.getTimestamp("created_at").toInstant())
            .updatedAt(rs.getTimestamp("updated_at").toInstant())
            .build();
    }
}

ORM vs Raw Query Decisions

The implementation uses raw JDBC queries with JdbcTemplate for several reasons:

  1. Performance: Raw SQL provides better performance than ORM-generated queries, especially for complex operations
  2. Control: Direct control over query execution and optimization
  3. Debugging: Easier to debug and profile queries when they're explicit
  4. Flexibility: Ability to use database-specific features without ORM limitations

For simple CRUD operations, the ORM approach might be acceptable, but for complex queries and performance-critical paths, raw SQL is preferred.

Read vs Write Separation

The current implementation uses a single repository for both read and write operations. For high-scale systems, we would implement CQRS (Command Query Responsibility Segregation):

// Read-only repository for queries
public interface UserReadRepository {
    User findById(UUID id);
    List<User> findByStatus(UserStatus status);
    Page<User> findAll(Pageable pageable);
}

// Write-only repository for mutations
public interface UserWriteRepository {
    User save(User user);
    void delete(UUID id);
}

Connection Pool Configuration

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

N+1 Query Prevention Strategy

The current implementation prevents N+1 queries by:

  1. Using eager loading in batch operations
  2. Implementing proper JOINs in complex queries
  3. Using @BatchSize annotation for collections
// Example of batch loading to prevent N+1
@Query("SELECT u FROM User u WHERE u.status = :status")
List<User> findUsersByStatus(@Param("status") UserStatus status);

@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.status = :status")
List<User> findUsersWithOrdersByStatus(@Param("status") UserStatus status);

Batch Operation Design

public class BatchUserRepository {
    private final JdbcTemplate jdbcTemplate;
    
    public void batchCreate(List<User> users) {
        String sql = """
            INSERT INTO users (id, email, name, status, metadata, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """;
            
        List<Object[]> batchArgs = users.stream()
            .map(user -> new Object[]{
                user.getId(),
                user.getEmail(),
                user.getName(),
                user.getStatus().name(),
                objectMapper.writeValueAsString(user.getMetadata()),
                user.getCreatedAt(),
                user.getUpdatedAt()
            })
            .collect(Collectors.toList());
            
        jdbcTemplate.batchUpdate(sql, batchArgs);
    }
    
    public void batchUpdate(List<User> users) {
        String sql = """
            UPDATE users 
            SET email = ?, name = ?, status = ?, metadata = ?, updated_at = ?
            WHERE id = ?
            """;
            
        List<Object[]> batchArgs = users.stream()
            .map(user -> new Object[]{
                user.getEmail(),
                user.getName(),
                user.getStatus().name(),
                objectMapper.writeValueAsString(user.getMetadata()),
                user.getUpdatedAt(),
                user.getId()
            })
            .collect(Collectors.toList());
            
        jdbcTemplate.batchUpdate(sql, batchArgs);
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The repository pattern itself is primarily a synchronous data access abstraction. However, when integrated into a larger system, it can participate in asynchronous workflows through event-driven architecture. The key decision point is:

  • Synchronous operations (like save(), findById()) remain within the same transactional boundary and are handled directly by the repository layer.
  • Asynchronous operations occur when a repository operation triggers side effects that need to be processed independently, such as:
    • Publishing domain events (e.g., UserCreatedEvent) after a user is created
    • Triggering downstream workflows (e.g., sending welcome emails, updating analytics)
    • Handling audit trail logging asynchronously to avoid blocking the main operation

These asynchronous flows are decoupled from the primary data access logic and are managed through a messaging system.

Message schema design with sample payload

We use Protocol Buffers (protobuf) for type-safe message contracts to ensure compatibility across services and versions.

// UserCreatedEvent.proto
syntax = "proto3";

package events;

message UserCreatedEvent {
  string user_id = 1;
  string email = 2;
  string full_name = 3;
  int64 created_at = 4;
  string source_service = 5;
}

Versioning strategy:

  • Major version bumps (e.g., v1 → v2) when breaking changes occur
  • Minor versions (e.g., v1.1, v1.2) for backward-compatible additions
  • Schema evolution handled via schema registry (e.g., Confluent Schema Registry)

Dead letter queue strategy and poison message handling

Poison messages are those that repeatedly fail to process due to malformed data or transient errors.

  • Dead Letter Queue (DLQ): Messages that exceed retry limits are moved to a DLQ topic for manual inspection.
  • Retry policy: Max 3 retries with exponential backoff (1s, 2s, 4s) and jitter to prevent thundering herd.
  • Error classification: Non-retryable errors (e.g., validation failures) are immediately sent to DLQ.
  • Monitoring: Alerts on DLQ queue size and processing delays.

At-least-once vs. exactly-once delivery

For repository operations, we choose at-least-once delivery because:

  • The primary concern is ensuring that data mutations are persisted
  • Event consumers are designed to be idempotent (e.g., duplicate welcome emails should not occur)
  • The system can tolerate duplicates in most business contexts

Exactly-once semantics would require additional complexity (e.g., idempotency keys, distributed transactions) which is not justified for typical use cases in this context.

Retry policy for consumers

Retry configuration:

  • Max retries: 3
  • Backoff formula: delay = baseDelay * 2^retryCount + random(0, 1000)
  • Retryable errors: Network timeouts, transient DB connection issues
  • Non-retryable errors: Invalid user data, malformed payloads

Outbox pattern for reliable event publishing

We implement the outbox pattern to ensure that events are published reliably even if the database write fails.

  • When a repository operation completes successfully, it stores an event in an outbox table.
  • A separate background job (or consumer) polls the outbox table and publishes events to Kafka/Redis.
  • The outbox entry is marked as published once successfully delivered.

This ensures that event publishing happens within the same transactional boundary as the data change, preventing partial writes.

Sequence diagram for async flows

sequenceDiagram
    participant Service
    participant Repository
    participant DB
    participant Outbox
    participant MessageBroker

    Service->>Repository: save(user)
    Repository->>DB: insert user
    Repository->>Outbox: insert event
    Outbox-->>MessageBroker: publish user.created event
    MessageBroker->>Consumer: consume user.created
    Consumer->>Service: process email send

10. Error Handling Design

Error Classification Hierarchy

The error handling system follows a three-tier classification:

  1. Domain Errors - Business logic violations (e.g., InsufficientFundsException, ResourceNotFoundException)
  2. Infrastructure Errors - System-level issues (e.g., DatabaseConnectionException, ExternalServiceTimeoutException)
  3. Validation Errors - Input validation failures (e.g., InvalidInputException, ConstraintViolationException)
public abstract class BaseApplicationException extends RuntimeException {
    private final String errorCode;
    private final int httpStatus;

    public BaseApplicationException(String errorCode, int httpStatus, String message) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
    }

    // Getters
}

public class DomainException extends BaseApplicationException {
    public DomainException(String errorCode, String message) {
        super(errorCode, 400, message);
    }
}

public class InfrastructureException extends BaseApplicationException {
    public InfrastructureException(String errorCode, String message) {
        super(errorCode, 500, message);
    }
}

public class ValidationException extends BaseApplicationException {
    public ValidationException(String errorCode, String message) {
        super(errorCode, 400, message);
    }
}

Error Propagation Strategy

Errors are propagated up the call stack using a "bubble up" pattern for domain and infrastructure errors, while validation errors are handled at the service boundary to provide rich error responses.

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, a retry mechanism is implemented:

public class RetryableOperation {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    private static final double JITTER_FACTOR = 0.2;

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (true) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (++attempts >= MAX_RETRIES) {
                    throw e;
                }
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts - 1));
                delay += (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new InfrastructureException("Retry interrupted", "RETRY_INTERRUPTED");
                }
            }
        }
    }
}

Circuit Breaker Design

For external service calls, a circuit breaker pattern prevents cascading failures:

public class CircuitBreaker {
    private static final int FAILURE_THRESHOLD = 5;
    private static final long TIMEOUT_MS = 60000;
    private static final long RESET_TIMEOUT_MS = 30000;

    private volatile CircuitState state = CircuitState.CLOSED;
    private int failureCount = 0;
    private long lastFailureTime = 0;

    public enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }

    public <T> T execute(Supplier<T> operation) {
        switch (state) {
            case CLOSED:
                return executeClosed(operation);
            case OPEN:
                return executeOpen(operation);
            case HALF_OPEN:
                return executeHalfOpen(operation);
            default:
                throw new IllegalStateException("Unknown circuit state");
        }
    }

    private <T> T executeClosed(Supplier<T> operation) {
        try {
            T result = operation.get();
            failureCount = 0; // Reset on success
            return result;
        } catch (InfrastructureException e) {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();
            if (failureCount >= FAILURE_THRESHOLD) {
                state = CircuitState.OPEN;
            }
            throw e;
        }
    }

    private <T> T executeOpen(Supplier<T> operation) {
        if (System.currentTimeMillis() - lastFailureTime > RESET_TIMEOUT_MS) {
            state = CircuitState.HALF_OPEN;
            return executeHalfOpen(operation);
        }
        throw new InfrastructureException("Circuit breaker is OPEN", "CIRCUIT_OPEN");
    }

    private <T> T executeHalfOpen(Supplier<T> operation) {
        try {
            T result = operation.get();
            state = CircuitState.CLOSED;
            return result;
        } catch (InfrastructureException e) {
            state = CircuitState.OPEN;
            throw e;
        }
    }
}

Fallback Behavior Design

Fallbacks are implemented for external dependencies:

public class ExternalServiceFallback {
    private final CircuitBreaker circuitBreaker = new CircuitBreaker();

    public List<User> getUsersWithFallback(String userId) {
        return circuitBreaker.execute(() -> {
            // Primary operation
            return externalUserService.getUsers(userId);
        });
    }

    public List<User> getUsersFallback(String userId) {
        // Fallback logic - return cached or default data
        return Collections.emptyList();
    }
}

Error Logging Format and Alerting Thresholds

Structured logging includes trace identifiers and error context:

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

    public void logError(String traceId, String userId, String operation, Exception ex) {
        logger.error("Trace ID: {}, User ID: {}, Operation: {}, Error Code: {}, Message: {}",
                traceId, userId, operation, getErrorCode(ex), ex.getMessage(), ex);
    }

    private String getErrorCode(Exception ex) {
        if (ex instanceof BaseApplicationException) {
            return ((BaseApplicationException) ex).getErrorCode();
        }
        return "UNKNOWN_ERROR";
    }
}

Alerting thresholds:

  • Critical: >100 errors per minute for any infrastructure error
  • Warning: >50 errors per minute for any domain error
  • Info: >10 errors per minute for validation errors

Error Response Format

Standardized JSON error response:

{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Account balance is insufficient for transaction",
    "httpStatus": 400,
    "timestamp": "2023-10-15T14:30:00Z",
    "traceId": "abc123def456"
  }
}

11. Security Design

Input Validation and Sanitization Strategy

All API inputs undergo strict validation using Jakarta Bean Validation (JSR-380) with custom constraints for domain-specific rules. For example, email addresses are validated using regex patterns and domain existence checks. SQL injection prevention is achieved through parameterized queries in all database interactions, with no dynamic query construction.

SQL Injection Prevention

All database operations use prepared statements and parameterized queries exclusively. No string concatenation or dynamic SQL building occurs in the codebase. The repository layer enforces strict parameter binding for all user inputs.

Authentication Middleware Design

Authentication is handled via JWT tokens with a custom filter that validates token signatures and expiration. The middleware extracts user context from the token claims and sets it in the request thread-local for service layer access. Token refresh logic is implemented with sliding window expiration.

Authorization Enforcement

RBAC (Role-Based Access Control) is implemented at the service layer with fine-grained permission checks. Each endpoint has associated roles that must be present in the authenticated user's claims. For example, DELETE /api/users/{id} requires USER_DELETE permission.

Sensitive Data Encryption

  • At Rest: AES-256 encryption using AWS KMS for database fields containing PII (SSN, DOB, etc.)
  • In Transit: TLS 1.3 with HSTS header enforcement for all external communications
  • Secrets: All API keys and database credentials are stored in AWS Secrets Manager with automatic rotation

PII Handling and Masking Strategy

PII fields are encrypted at rest using AES-256 with key rotation every 90 days. For logging and monitoring, PII is masked using a consistent pattern (e.g., "123-**-****" for SSN). The system logs only non-sensitive user identifiers and operation metadata.

Secrets Management

All secrets (database passwords, API keys, JWT signing keys) are managed through AWS Secrets Manager with automatic rotation every 30 days. The application fetches secrets at startup and refreshes them via background task with proper error handling.

Audit Logging Design

All security-relevant events are logged to a centralized audit log table with:

  • Timestamp (UTC)
  • User ID (masked in logs)
  • IP Address
  • Operation Type (login, logout, data access, modification)
  • Success/Failure status
  • Session ID

Sample audit log entry:

{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "userId": "user_1234567890",
  "ipAddress": "192.168.1.100",
  "operation": "USER_LOGIN",
  "status": "SUCCESS",
  "sessionId": "sess_abc123"
}

The audit log is stored in a separate database table with read-only access and automatic archiving to S3 after 90 days. All audit logs are encrypted at rest using the same KMS key as other PII data.

// Example of secure input validation in repository layer
public class SecureRepository {
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
    
    public User findUserByEmail(String email) {
        // Validate input
        if (!EMAIL_PATTERN.matcher(email).matches()) {
            throw new ValidationException("Invalid email format");
        }
        
        // Use parameterized query to prevent SQL injection
        String sql = "SELECT * FROM users WHERE email = ?";
        return jdbcTemplate.queryForObject(sql, new Object[]{email}, userRowMapper);
    }
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85%+ code coverage, focusing on business logic and edge cases
  • Repository Layer: 90%+ code coverage, including all query paths and error conditions
  • Domain Objects: 100% for value objects, 80%+ for entities with complex validation logic
  • Integration Points: 100% for critical paths (e.g., order placement, payment processing)
  • Test Data: 100% of domain objects must have test factories with realistic data sets

Integration Testing Strategy

  • Database Integration Tests:

    • Use testcontainers for PostgreSQL/MySQL with real schema migration
    • Run against isolated test databases per test suite to prevent cross-contamination
    • Use embedded H2 for fast unit tests with in-memory database for validation
  • External Service Mocking:

    • WireMock or Mockito for external API dependencies (e.g., payment gateway, notification service)
    • Use Pact for contract testing between services in microservice architecture

Contract Testing

  • Pact Framework:
    • Define consumer-driven contracts for all external service integrations
    • Maintain contract repositories in version control with CI pipeline validation
    • Use Pact Broker for managing and sharing contracts across teams

Performance Testing Tool

  • Tool: k6 (JavaScript-based load testing)
  • Key Scenarios:
    • Read-heavy workload: 1000 concurrent users with 80/20 read ratio
    • Write-heavy workload: 500 concurrent users with 20/80 write ratio
    • Peak hour simulation: 5000 concurrent users with burst traffic patterns
  • Metrics Tracked:
    • p50/p95/p99 response times (target: <200ms for 95% of requests)
    • Throughput (target: 1000 RPS for read operations, 500 RPS for write operations)
    • Error rates (target: <0.1% error rate)

Test Data Management Strategy

  • Fixtures:

    • Pre-defined data sets for common test scenarios (e.g., user profiles, orders, products)
    • Version-controlled JSON/YAML files with clear naming conventions (e.g., user_with_orders.json)
  • Factories:

    • Factory pattern for generating realistic test data with configurable parameters
    • Use libraries like Java's Faker or Python's Factory Boy for realistic data generation
  • Seeding:

    • Database seed scripts for test environments with deterministic data sets
    • Use database migration tools (e.g., Flyway) to ensure test databases are in known states
// Example factory pattern for test data generation
public class TestUserFactory {
    public static User createValidUser() {
        return User.builder()
                .id(UUID.randomUUID())
                .email("test@example.com")
                .name("Test User")
                .createdAt(Instant.now())
                .build();
    }
    
    public static User createWithOrders(int orderCount) {
        User user = createValidUser();
        List<Order> orders = IntStream.range(0, orderCount)
                .mapToObj(i -> Order.builder()
                        .id(UUID.randomUUID())
                        .userId(user.getId())
                        .status(OrderStatus.PENDING)
                        .build())
                .collect(Collectors.toList());
        return user.toBuilder().orders(orders).build();
    }
}

13. Observability Design

Structured Logging Schema

All logs must follow a consistent schema to enable effective search, filtering, and analysis:

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "level": "INFO|WARN|ERROR",
  "service": "booking-service",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user_12345",
  "entity_id": "booking_98765",
  "request_id": "req_abcde12345",
  "method": "POST /bookings",
  "message": "Booking created successfully",
  "error_code": "BOOKING_CREATION_FAILED",
  "error_details": {
    "stack_trace": "...",
    "cause": "Database connection timeout"
  },
  "duration_ms": 45,
  "tags": ["booking", "payment"]
}

PII masking rules:

  • User ID and email fields are masked in logs
  • Credit card numbers are fully redacted
  • Any field containing sensitive data (SSN, phone) is either removed or masked

Distributed Tracing Instrumentation

Using OpenTelemetry for end-to-end tracing:

Custom Spans to Add:

  1. booking.create - from API entry point to DB write
  2. payment.process - when payment is attempted
  3. notification.send - when email/SMS notifications are sent

Attributes to Capture:

  • booking.id: booking identifier
  • user.id: authenticated user ID
  • payment.method: payment method used (card, PayPal)
  • notification.type: email/SMS
  • db.operation: "insert", "update", "select"
  • db.query.duration.ms: query execution time

Metrics Design at Service Level

RED Method Metrics (Rate, Errors, Duration):

Metric NameTypeLabelsDescription
booking_requests_totalCountermethod, endpoint, status_codeTotal booking requests
booking_creation_errors_totalCountererror_type, serviceErrors during booking creation
booking_db_query_duration_secondsHistogramoperation, db_typeDB query execution time
booking_api_response_time_secondsHistogrammethod, endpoint, status_codeAPI response time
booking_active_requestsGaugemethod, endpointActive concurrent requests

Sample Histogram Bucket Configuration:

booking_db_query_duration_seconds{operation="select",db_type="mysql"} 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0

Health Check Endpoint Design

Endpoints:

  1. /health - Overall service health (liveness + readiness)
  2. /ready - Service ready to serve traffic (readiness check)
  3. /live - Service alive (liveness check)
  4. /startup - Startup completion status

Response Format:

{
  "status": "healthy|unhealthy",
  "checks": {
    "database": {
      "status": "healthy",
      "message": "Connected to primary DB"
    },
    "cache": {
      "status": "healthy",
      "message": "Redis cluster connected"
    },
    "dependencies": {
      "status": "healthy",
      "message": "All external services reachable"
    }
  },
  "timestamp": "2023-10-27T14:30:45.123Z"
}

Alerting Thresholds per Key Metric

MetricWarning LevelCritical LevelEscalation Path
booking_creation_errors_total> 5 errors/minute> 20 errors/minuteOn-call engineer
booking_api_response_time_seconds (p95)> 500ms> 2sSRE team
booking_db_query_duration_seconds (p95)> 300ms> 1sDBA team
booking_requests_total (rate)< 50 req/sec< 10 req/secPlatform team
cache_hit_rate< 85%< 70%Cache team

Alerting Logic:

  • Use sliding window of 5-minute intervals for rate-based alerts
  • Apply exponential backoff for alerting to prevent spamming
  • Include contextual metadata in alerts (user ID, booking ID, error code)

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • ORM vs. Raw SQL trade-off: The choice between an ORM (like Hibernate/JPA) and raw SQL queries involves balancing developer productivity with query optimization control. While ORMs provide type safety, automatic mapping, and faster development cycles, they can introduce performance bottlenecks and complexity in complex queries. The decision should align with team expertise and performance requirements.

  • Database engine selection: Whether to use PostgreSQL, MySQL, or another RDBMS impacts query capabilities, transaction support, and scalability. This decision should be made in alignment with existing infrastructure and team familiarity.

  • CQRS vs. CTO: The choice between Command Query Responsibility Segregation and a single read/write model affects system complexity, scalability, and consistency. CQRS allows for optimized queries and better horizontal scaling but adds operational overhead.

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Repository vs. DAORepository pattern with domain-specific methodsData Access Object pattern with generic CRUDRepository provides better encapsulation of business logic and is more expressive for domain operations, while DAO offers simpler abstraction with less coupling to the persistence layer
Single vs. Multiple RepositoriesSingle repository per aggregate rootMultiple repositories per entity typeSingle repository offers better encapsulation and reduces complexity, while multiple repositories allow for more granular control but increase surface area
Synchronous vs. Asynchronous Data AccessSynchronous repository callsAsynchronous repository operationsSynchronous calls simplify error handling and debugging, while async operations improve throughput for high-volume systems
Read vs. Write SeparationUnified read/write repositorySeparate repositories for reads and writes (CQRS)Unified repositories simplify development but can become performance bottlenecks under high read loads; CQRS enables better scalability but increases system complexity

Risks and mitigation strategies

RiskImpactMitigation
N+1 Query ProblemPerformance degradation in read-heavy operationsImplement eager loading strategies, use DataLoader pattern, and monitor query execution plans
Lock ContentionDeadlocks and reduced throughput in concurrent environmentsUse optimistic locking, implement proper transaction boundaries, and design low-lock access patterns
Cache Invalidation StormSystem-wide performance degradation during cache invalidationImplement probabilistic early expiry, use distributed locks for cache invalidation, and design granular cache invalidation
Database Connection Pool ExhaustionSystem unresponsiveness under loadConfigure appropriate pool sizes, implement connection timeout and validation, and monitor pool utilization
Eventual Consistency IssuesData inconsistency in distributed systemsImplement proper saga orchestration, use database transactions where possible, and design compensating actions

Technical debt acknowledged

  • Tight coupling to specific database dialects: Repository implementations may become tightly coupled to specific SQL dialects or ORM features, making migration difficult in the future. This can be mitigated by abstracting database-specific logic behind interfaces and using generic query builders where possible.

  • Overuse of generic repository methods: Generic CRUD operations can mask business logic and make the code less expressive. The design should favor domain-specific repository methods over generic ones where appropriate.

  • Lack of query optimization awareness: Developers might not fully understand the performance implications of their queries, leading to inefficient database access patterns. This can be addressed through code reviews, query monitoring, and team training on database optimization techniques.

  • Inconsistent error handling: Repository methods might not consistently handle and propagate errors, leading to unpredictable behavior. This can be mitigated through standardized error handling patterns and comprehensive test coverage.

  • Limited support for complex queries: The repository pattern might not adequately support complex analytical or reporting queries that require custom SQL or stored procedures. This can be addressed by providing extension points for custom query execution or supporting hybrid approaches with direct SQL access where needed.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    Rule Evaluation Engine Design and Implementation

    Comprehensive low-level design for a scalable rule evaluation engine supporting complex business logic with performance optimization and extensibility.

  • Backend LLD

    Decorator-Based Feature Extension Design Pattern

    Comprehensive low-level design for implementing extensible backend systems using decorator patterns with runtime feature composition and configuration management.

  • Backend LLD

    Dependency Injection Container Design and Implementation

    Comprehensive low-level design for a production-ready dependency injection container with singleton, prototype, and lifecycle management capabilities.

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.