Skip to main content

Data Access Layer Abstraction Design Patterns and Implementation

Comprehensive low-level design for building scalable, maintainable data access layer abstractions with database abstraction, caching, and transaction management.

A robust data access layer abstraction serves as the critical bridge between application logic and persistent storage, enabling clean separation of concerns while providing flexibility for database migration, caching strategies, and transaction management across diverse backend architectures.

1. Problem Statement & Requirements

Clarifying questions

  1. What scale and throughput expectations are we targeting?
    • Are we designing for millions of concurrent database connections or for a smaller-scale internal tool?
  2. What consistency requirements are there?
    • Do we need strong consistency across all reads/writes, or can eventual consistency be acceptable for some operations?
  3. What are the key entities and their relationships?
    • Is this a monolithic system or microservices architecture? What are the main domain entities (e.g., User, Order, Product)?
  4. What is the read/write ratio?
    • Is this primarily a write-heavy or read-heavy system? How does that impact caching and data access patterns?
  5. What latency sensitivity is expected?
    • Are sub-100ms response times critical, or can we tolerate higher latencies for less time-sensitive operations?
  6. What authentication and authorization model is in place?
    • Are we integrating with OAuth2, JWTs, or API keys? Do we need to enforce RBAC or ABAC?
  7. Are there any external integrations or third-party data sources?
    • Do we need to support data from other databases, cloud storage services, or external APIs?

Functional requirements

  • Support CRUD operations (Create, Read, Update, Delete) for domain entities
  • Provide abstraction over multiple data sources (SQL, NoSQL, in-memory)
  • Allow for query composition and filtering
  • Support transactional operations across multiple entities
  • Enable data hydration (e.g., loading related entities in one operation)
  • Support soft-deletes and audit logging
  • Allow for data partitioning (sharding) and horizontal scaling

Non-functional requirements

  • Latency targets:
    • p50: <50ms
    • p95: <100ms
    • p99: <200ms
  • Throughput:
    • 10,000 RPS (reads + writes) for the data access layer
  • Availability:
    • 99.9% uptime (99.99% for critical paths)
  • Scalability:
    • Support horizontal scaling of data access layer components

Constraints and assumptions

  • Assumption: The system will use a mix of relational (PostgreSQL) and document-based (MongoDB) databases
  • Assumption: The application is built in Java with Spring Boot framework
  • Constraint: Must support both synchronous and asynchronous data access patterns
  • Constraint: Must be compatible with existing ORM tooling (e.g., Hibernate, Spring Data JPA)
  • Assumption: The system will use a shared database connection pool across services

Out of scope

  • Database schema migration management (e.g., Flyway, Liquibase)
  • Data encryption at rest or in transit
  • Real-time data streaming (e.g., Kafka integration)
  • Complex analytics or aggregation queries (e.g., windowed aggregates, full-text search)
  • Multi-tenancy support for data isolation
  • Data deduplication or conflict resolution logic

2. API Design

API Style Selection

For this Data Access Layer abstraction, I'll select gRPC as the primary API style with JSON over HTTP as a fallback for client compatibility and debugging purposes.

Justification:

  • gRPC provides strong typing, code generation, and efficient binary serialization
  • Built-in support for streaming and bidirectional communication
  • First-class support for versioning through proto files
  • Better performance characteristics than REST for high-throughput scenarios
  • Native support for authentication and authorization mechanisms

API Endpoints

MethodPathDescriptionAuth
GET/v1/data-access/entities/{entityType}Fetch entities by type with filtersJWT
POST/v1/data-access/entitiesCreate new entitiesJWT
PUT/v1/data-access/entities/{id}Update existing entity by IDJWT
DELETE/v1/data-access/entities/{id}Delete entity by IDJWT
GET/v1/data-access/entities/{id}/relationsFetch entity relationsJWT
POST/v1/data-access/entities/{id}/relationsCreate relation between entitiesJWT
GET/v1/data-access/queryExecute complex queries with filters and aggregationsJWT
POST/v1/data-access/batchBatch operations on entitiesJWT

Request and Response Schemas

Create Entity Request:

{
  "entityType": "user",
  "data": {
    "name": "John Doe",
    "email": "john@example.com"
  }
}

Create Entity Response:

{
  "id": "uuid-12345",
  "entityType": "user",
  "data": {
    "name": "John Doe",
    "email": "john@example.com"
  },
  "createdAt": "2023-01-01T10:00:00Z",
  "updatedAt": "2023-01-01T10:00:00Z"
}

Query Request:

{
  "entityType": "user",
  "filters": [
    {
      "field": "email",
      "operator": "EQUALS",
      "value": "john@example.com"
    }
  ],
  "sort": [
    {
      "field": "createdAt",
      "direction": "DESC"
    }
  ],
  "limit": 10,
  "offset": 0
}

Query Response:

{
  "entities": [
    {
      "id": "uuid-12345",
      "entityType": "user",
      "data": {
        "name": "John Doe",
 "email": "john@example.com"
      }
    }
  ],
  "total": 1,
  "hasMore": false
}

HTTP Status Codes and Error Format

Standard Status Codes:

  • 200 OK - Successful GET, PUT, DELETE
  • 201 Created - Successful POST
  • 400 Bad Request - Invalid input or malformed request
  • 401 Unauthorized - Missing or invalid auth token
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Entity not found
  • 429 Too Many Requests - Rate limit exceeded
  • 500 Internal Server Error - Server-side error

Standardized Error Response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid email format",
    "details": [
      {
        "field": "email",
        "message": "Email must be in valid format"
      }
    ],
    "timestamp": "2023-01-01T10:00:00Z",
    "requestId": "req-12345"
  }
}

API Versioning Strategy

Versioning will be implemented through the path prefix:

  • /v1/data-access/... - Current stable version
  • Future versions will use /v2/data-access/... for breaking changes

Versioning will be enforced at the service level, with automatic migration paths documented in release notes.

Pagination Design

Cursor-based pagination will be used for better performance and consistency:

Pagination Parameters:

  • cursor - Base64 encoded cursor for next page
  • limit - Number of items to return (default 50, max 1000)
  • direction - ASC/DESC for ordering (default ASC)

Justification:

  • Cursor-based pagination is more stable and handles data changes better than offset-based
  • Provides consistent results even when data is modified during pagination
  • Better performance for large datasets

Authentication and Authorization

Authentication:

  • JWT tokens with HS256 signing algorithm
  • Token expiration of 24 hours
  • Refresh token support with 7-day validity

Authorization:

  • RBAC model with roles (ADMIN, USER, READ_ONLY)
  • Entity-level permissions based on user role and ownership
  • Resource-based access control for sensitive operations

Rate Limiting

Rate Limits:

  • 1000 requests per hour per user (hard limit)
  • 50 requests per second per user (soft limit)
  • 10000 requests per hour per IP (for unauthenticated access)

Enforcement Strategy:

  • Distributed rate limiting using Redis with token bucket algorithm
  • Leaky bucket algorithm for burst handling
  • Immediate rejection of requests exceeding limits

Idempotency Design

Idempotency Key Strategy:

  • Client-provided idempotency-key header for all write operations
  • Server stores operation metadata with key as primary identifier
  • If same key is used, return cached result instead of re-executing
  • Key expiration after 24 hours

Implementation:

public class IdempotencyService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public ResponseEntity<?> executeWithIdempotency(
        String idempotencyKey, 
        Supplier<ResponseEntity<?>> operation) {
        
        String cacheKey = "idempotency:" + idempotencyKey;
        String cachedResult = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachedResult != null) {
            return ResponseEntity.ok().body(cachedResult);
        }
        
        ResponseEntity<?> result = operation.get();
        redisTemplate.opsForValue().set(
            cacheKey, 
            result.getBody().toString(), 
            Duration.ofHours(24)
        );
        
        return result;
    }
}

3. Data Model Design

Entity-Relationship diagram (Mermaid)

erDiagram
    USER ||--o{ ORDER : places
    USER ||--o{ PAYMENT : makes
    ORDER ||--o{ ORDER_ITEM : contains
    ORDER_ITEM ||--o{ PRODUCT : references
    PRODUCT ||--o{ INVENTORY : has
    ORDER ||--o{ SHIPPING : uses
    SHIPPING ||--o{ ADDRESS : delivers_to

    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 FK
        integer quantity
        decimal price
        timestamp created_at
    }

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

    PAYMENT {
        string id PK
        string order_id FK
        string provider
        string status
        timestamp created_at
    }

    INVENTORY {
        string product_id PK FK
        integer quantity
        timestamp updated_at
    }

    SHIPPING {
        string id PK
        string order_id FK
        string tracking_number
        string status
        timestamp created_at
    }

    ADDRESS {
        string id PK
        string user_id FK
        string street
        string city
        string country
        string postal_code
        timestamp created_at
    }

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,
    
    CONSTRAINT chk_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);

Order Table

CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    total_amount DECIMAL(10,2) NOT NULL,
    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 UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    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,
    description TEXT,
    price DECIMAL(10,2) NOT NULL CHECK (price >= 0),
    stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_products_name (name),
    INDEX idx_products_price (price)
);

Payment Table

CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    provider VARCHAR(50) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_payments_order_id (order_id),
    INDEX idx_payments_status (status)
);

Inventory Table

CREATE TABLE inventory (
    product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
    quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0),
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_inventory_product_id (product_id)
);

Shipping Table

CREATE TABLE shipping (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    tracking_number VARCHAR(255),
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_shipping_order_id (order_id),
    INDEX idx_shipping_status (status)
);

Address Table

CREATE TABLE addresses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    street VARCHAR(255) NOT NULL,
    city VARCHAR(255) NOT NULL,
    country VARCHAR(255) NOT NULL,
    postal_code VARCHAR(20),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_addresses_user_id (user_id)
);

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

Using UUID as primary key for all tables. Justification:

  • Global uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination
  • No single point of failure: Unlike auto-increment, UUIDs don't require centralized sequence generators
  • Scalability: No need to coordinate increments across multiple database shards or replicas
  • Security: UUIDs don't reveal information about data volume or insertion order

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

  1. Orders by user_id and status: INDEX idx_orders_user_id (user_id), INDEX idx_orders_status (status)

    • For efficient user order listing and status filtering
    • Composite index needed for multi-column queries
  2. Orders by creation time: INDEX idx_orders_created_at (created_at)

    • For chronological order queries and analytics
  3. Order items by order_id: INDEX idx_order_items_order_id (order_id)

    • For retrieving all items in an order
  4. Product name search: INDEX idx_products_name (name)

    • For product search and filtering by name
  5. Payment status lookup: INDEX idx_payments_status (status)

    • For payment reconciliation and status monitoring
  6. Inventory lookup: INDEX idx_inventory_product_id (product_id)

    • For stock availability checks

Data partitioning / sharding strategy

Partition orders by user_id hash to achieve horizontal sharding:

  • Shard key: user_id % 100 (assuming 100 shards)
  • Partitioning strategy: Hash-based sharding to ensure even distribution
  • Cross-shard queries: Handled via application-level joins or denormalized data
  • Order ID generation: UUIDs maintain global uniqueness across shards

Soft delete vs. hard delete decision with justification

Soft delete is used for the users and orders tables:

  • Audit trail: Historical data retention for compliance and analytics
  • Data recovery: Ability to restore deleted records if needed
  • Referential integrity: Maintains foreign key relationships in related tables

Hard delete for order_items and payments:

  • Data cleanup: These are direct references that can be safely deleted when parent is removed
  • Storage efficiency: Reduces storage overhead for temporary data

Audit trail design (who, what, when)

All tables have created_at and updated_at timestamps. For audit logging:

CREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    table_name VARCHAR(100) NOT NULL,
    record_id UUID NOT NULL,
    operation VARCHAR(20) NOT NULL, -- INSERT/UPDATE/DELETE
    old_values JSONB,
    new_values JSONB,
    user_id UUID REFERENCES users(id),
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_audit_logs_table_record (table_name, record_id),
    INDEX idx_audit_logs_timestamp (timestamp)
);

Data archival strategy and retention policy

  1. Orders older than 5 years: Archived to separate storage (S3, BigQuery)
  2. Payment data: Retained for 7 years for tax and compliance purposes
  3. User data: Retained per privacy policy (typically 2-5 years after inactivity)
  4. Archival process: Scheduled nightly job that moves data to archive storage and soft-deletes from primary tables

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class DataAccessException {
        <<exception>>
        +String message
        +Throwable cause
    }
    
    class Repository {
        <<interface>>
        +<T> T findById(String id)
        +<T> List~T~ findAll()
        +<T> T save(T entity)
        +<T> T update(T entity)
        +boolean delete(String id)
    }
    
    class RepositoryImpl {
        -DataSource dataSource
        -EntityMapper mapper
        +<T> T findById(String id)
        +<T> List~T~ findAll()
        +<T> T save(T entity)
        +<T> T update(T entity)
        +boolean delete(String id)
    }
    
    class RepositoryFactory {
        +Repository createRepository(String entityType)
    }
    
    class EntityMapper {
        <<interface>>
        +<T> T mapToEntity(Map~String, Object~ data)
        +Map~String, Object~ mapToMap(T entity)
    }
    
    class EntityMapperImpl {
        +<T> T mapToEntity(Map~String, Object~ data)
        +Map~String, Object~ mapToMap(T entity)
    }
    
    class TransactionManager {
        +void executeInTransaction(TransactionCallback callback)
        +void beginTransaction()
        +void commit()
        +void rollback()
    }
    
    class TransactionCallback {
        <<interface>>
        +void execute()
    }
    
    class QueryBuilder {
        +String buildSelectQuery(String table, Map~String, Object~ conditions)
        +String buildUpdateQuery(String table, Map~String, Object~ updates, Map~String, Object~ conditions)
        +String buildDeleteQuery(String table, Map~String, Object~ conditions)
    }
    
    class DataLayerConfig {
        -DataSource dataSource
        -TransactionManager transactionManager
        -QueryTimeoutMs queryTimeoutMs
    }
    
    Repository <|-- RepositoryImpl
    RepositoryFactory --> Repository : creates
    EntityMapper <|-- EntityMapperImpl
    TransactionManager --> TransactionCallback : executes
    RepositoryImpl --> DataLayerConfig : uses
    RepositoryImpl --> QueryBuilder : builds queries
    RepositoryImpl --> EntityMapper : maps entities
    TransactionManager --> DataLayerConfig : uses
    
    class User {
        +String id
        +String name
        +String email
        +LocalDateTime createdAt
    }
    
    class Order {
        +String id
        +String userId
        +BigDecimal amount
        +OrderStatus status
    }
    
    class OrderStatus {
        PENDING
        CONFIRMED
        CANCELLED
    }
    
    User "1" --> "*" RepositoryImpl : manages
    Order "1" --> "*" RepositoryImpl : manages

State Machine Diagram for Order Entity

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

Service Class Structure and Responsibilities

The Data Access Layer abstraction consists of the following core components:

  1. Repository Pattern Implementation: Provides a clean abstraction over data access operations with consistent interfaces across different entity types.

  2. Transaction Management: Ensures atomicity and consistency across related database operations.

  3. Entity Mapping: Handles conversion between database records and domain objects.

  4. Query Building: Generates parameterized SQL queries to prevent injection attacks.

  5. Configuration Management: Centralized configuration for data sources, timeouts, and transaction behavior.

Interface / Abstract Class Design

public interface Repository<T> {
    T findById(String id);
    List<T> findAll();
    T save(T entity);
    T update(T entity);
    boolean delete(String id);
}

public interface EntityMapper<T> {
    T mapToEntity(Map<String, Object> data);
    Map<String, Object> mapToMap(T entity);
}

public interface TransactionCallback {
    void execute();
}

public class DataLayerConfig {
    private final DataSource dataSource;
    private final TransactionManager transactionManager;
    private final int queryTimeoutMs;
    
    public DataLayerConfig(DataSource dataSource, 
                          TransactionManager transactionManager,
                          int queryTimeoutMs) {
        this.dataSource = dataSource;
        this.transactionManager = transactionManager;
        this.queryTimeoutMs = queryTimeoutMs;
    }
    
    // Getters
}

Business Logic Encapsulation Patterns

We implement the Strategy Pattern for query building to support different database dialects and optimize performance based on data access patterns.

public interface QueryStrategy {
    String buildSelectQuery(String table, Map<String, Object> conditions);
    String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions);
    String buildDeleteQuery(String table, Map<String, Object> conditions);
}

public class PostgresQueryStrategy implements QueryStrategy {
    @Override
    public String buildSelectQuery(String table, Map<String, Object> conditions) {
        // PostgreSQL-specific query building logic
        return buildGenericQuery(table, conditions, "SELECT * FROM %s WHERE %s");
    }
    
    @Override
    public String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions) {
        // PostgreSQL-specific update logic
        return buildGenericQuery(table, conditions, "UPDATE %s SET %s WHERE %s");
    }
    
    @Override
    public String buildDeleteQuery(String table, Map<String, Object> conditions) {
        // PostgreSQL-specific delete logic
        return buildGenericQuery(table, conditions, "DELETE FROM %s WHERE %s");
    }
    
    private String buildGenericQuery(String table, Map<String, Object> conditions, String template) {
        // Implementation for PostgreSQL-specific query building
        return String.format(template, table, buildWhereClause(conditions));
    }
    
    private String buildWhereClause(Map<String, Object> conditions) {
        return conditions.entrySet().stream()
                .map(entry -> String.format("%s = ?", entry.getKey()))
                .collect(Collectors.joining(" AND "));
    }
}

public class QueryBuilder {
    private final QueryStrategy queryStrategy;
    
    public QueryBuilder(QueryStrategy queryStrategy) {
        this.queryStrategy = queryStrategy;
    }
    
    public String buildSelectQuery(String table, Map<String, Object> conditions) {
        return queryStrategy.buildSelectQuery(table, conditions);
    }
    
    public String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions) {
        return queryStrategy.buildUpdateQuery(table, updates, conditions);
    }
    
    public String buildDeleteQuery(String table, Map<String, Object> conditions) {
        return queryStrategy.buildDeleteQuery(table, conditions);
    }
}

Critical-Path Method Implementation

public class OrderRepositoryImpl implements Repository<Order> {
    private final DataSource dataSource;
    private final QueryBuilder queryBuilder;
    private final EntityMapper<Order> orderMapper;
    private final TransactionManager transactionManager;
    
    public OrderRepositoryImpl(DataSource dataSource, 
                              QueryBuilder queryBuilder,
                              EntityMapper<Order> orderMapper,
                              TransactionManager transactionManager) {
        this.dataSource = dataSource;
        this.queryBuilder = queryBuilder;
        this.orderMapper = orderMapper;
        this.transactionManager = transactionManager;
    }
    
    @Override
    public Order save(Order order) {
        return transactionManager.executeInTransaction(() -> {
            try (Connection connection = dataSource.getConnection()) {
                // Validate order status transition
                if (order.getStatus() == null) {
                    throw new DataAccessException("Order status cannot be null");
                }
                
                // Insert order with generated ID
                String insertQuery = "INSERT INTO orders (id, user_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?)";
                try (PreparedStatement stmt = connection.prepareStatement(insertQuery)) {
                    String orderId = UUID.randomUUID().toString();
                    order.setId(orderId);
                    stmt.setString(1, orderId);
                    stmt.setString(2, order.getUserId());
                    stmt.setBigDecimal(3, order.getAmount());
                    stmt.setString(4, order.getStatus().name());
                    stmt.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
                    stmt.executeUpdate();
                }
                
                // Log audit trail
                publishAuditEvent("ORDER_CREATED", order.getId(), 
                    Map.of("userId", order.getUserId(), "amount", order.getAmount()));
                
                return order;
            } catch (SQLException e) {
                throw new DataAccessException("Failed to save order", e);
            }
        });
    }
    
    @Override
    public Order findById(String id) {
        return transactionManager.executeInTransaction(() -> {
            try (Connection connection = dataSource.getConnection()) {
                String selectQuery = "SELECT * FROM orders WHERE id = ?";
                try (PreparedStatement stmt = connection.prepareStatement(selectQuery)) {
                    stmt.setString(1, id);
                    ResultSet rs = stmt.executeQuery();
                    if (rs.next()) {
                        return orderMapper.mapToEntity(resultSetToMap(rs));
                    }
                    return null;
                }
            } catch (SQLException e) {
                throw new DataAccessException("Failed to find order by ID", e);
            }
        });
    }
    
    @Override
    public Order update(Order order) {
        return transactionManager.executeInTransaction(() -> {
            try (Connection connection = dataSource.getConnection()) {
                // Validate order exists
                Order existing = findById(order.getId());
                if (existing == null) {
                    throw new DataAccessException("Order not found for update: " + order.getId());
                }
                
                // Check status transition validity
                validateStatusTransition(existing.getStatus(), order.getStatus());
                
                String updateQuery = "UPDATE orders SET amount = ?, status = ? WHERE id = ?";
                try (PreparedStatement stmt = connection.prepareStatement(updateQuery)) {
                    stmt.setBigDecimal(1, order.getAmount());
                    stmt.setString(2, order.getStatus().name());
                    stmt.setString(3, order.getId());
                    stmt.executeUpdate();
                }
                
                // Publish status change event
                publishOrderStatusChangeEvent(order.getId(), existing.getStatus(), order.getStatus());
                
                return order;
            } catch (SQLException e) {
                throw new DataAccessException("Failed to update order", e);
            }
        });
    }
    
    private void validateStatusTransition(OrderStatus from, OrderStatus to) {
        if (from == null || to == null) return;
        
        // Simple validation - in real system would be more comprehensive
        if (from == OrderStatus.CANCELLED && to != OrderStatus.CANCELLED) {
            throw new DataAccessException("Cannot change status from CANCELLED");
        }
    }
    
    private void publishAuditEvent(String eventType, String entityId, Map<String, Object> details) {
        // Publish to audit event bus or log
        // This would typically be done asynchronously with proper error handling
    }
    
    private void publishOrderStatusChangeEvent(String orderId, OrderStatus from, OrderStatus to) {
        // Publish order status change event for downstream consumers
    }
    
    private Map<String, Object> resultSetToMap(ResultSet rs) throws SQLException {
        ResultSetMetaData metaData = rs.getMetaData();
        Map<String, Object> map = new HashMap<>();
        for (int i = 1; i <= metaData.getColumnCount(); i++) {
            String columnName = metaData.getColumnName(i);
            Object value = rs.getObject(i);
            map.put(columnName, value);
        }
        return map;
    }
    
    @Override
    public List<Order> findAll() {
        // Implementation for findAll method
        return Collections.emptyList();
    }
    
    @Override
    public boolean delete(String id) {
        // Implementation for delete method
        return false;
    }
}

Transaction Boundary Design

Transaction boundaries are defined by:

  1. Explicit transaction scope: Each repository operation that requires consistency
  2. Read-only vs read-write operations: Different transaction strategies based on operation type
  3. Nested transaction support: Using savepoints for complex operations

Service Interface Contracts

public interface OrderService {
    Order createOrder(String userId, BigDecimal amount);
    Order updateOrderStatus(String orderId, OrderStatus newStatus);
    Order getOrderById(String orderId);
    List<Order> getAllOrders();
    boolean cancelOrder(String orderId);
}

Saga / Orchestration Pattern

For distributed transactions involving multiple services:

  1. Saga pattern is implemented at the service layer to coordinate cross-service operations
  2. Compensating actions are registered for rollback in case of failure
  3. Event-driven coordination ensures eventual consistency

Domain Object / Value Object Design

public class Order {
    private String id;
    private String userId;
    private BigDecimal amount;
    private OrderStatus status;
    private LocalDateTime createdAt;
    
    // Constructors, getters, setters
}

public enum OrderStatus {
    PENDING,
    CONFIRMED,
    CANCELLED,
    SHIPPED,
    DELIVERED,
    RETURNED
}

5. Concurrency & Thread Safety

Concurrency Model

The backend system uses a thread pool model with Java's ExecutorService for handling concurrent database operations. Each request is processed in a dedicated thread from the application's thread pool, ensuring that I/O-bound database operations don't block the main request handling thread. For high-throughput scenarios, we leverage reactive programming with Project Reactor or RxJava to handle asynchronous database operations without blocking 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 cycles
  2. Optimistic locking with version fields in entities to detect and prevent concurrent modifications
  3. Idempotency checks for operations that can be safely retried without side effects

Optimistic vs. Pessimistic Locking Selection

We use optimistic locking for most entities due to:

  • Lower system overhead compared to pessimistic locking
  • Better scalability in read-heavy workloads
  • Reduced risk of deadlocks in concurrent systems

Pessimistic locking is applied selectively for:

  • Critical write operations where data consistency is paramount
  • Batch processing scenarios with high contention

Deadlock Prevention Strategy

Deadlock prevention includes:

  1. Consistent lock ordering - All transactions acquire locks in the same order across entities
  2. Lock timeout configuration (e.g., 5 seconds) to prevent indefinite waits
  3. Transaction isolation level set to READ_COMMITTED or REPEATABLE_READ depending on consistency needs
  4. Application-level retry logic with exponential backoff for deadlock-recovered transactions

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is handled through:

  1. Idempotency keys in request headers (e.g., X-Idempotency-Key)
  2. Cache-based deduplication for operations within a time window (e.g., 5 minutes)
  3. Database uniqueness constraints on idempotency keys for critical operations
// Example: Idempotent operation with cache-based deduplication
public class IdempotentOperationService {
    private final Map<String, CompletableFuture<OperationResult>> operationCache = new ConcurrentHashMap<>();
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

    public CompletableFuture<OperationResult> executeWithIdempotency(
            String idempotencyKey, 
            Supplier<CompletableFuture<OperationResult>> operation) {
        
        return operationCache.computeIfAbsent(idempotencyKey, key -> {
            CompletableFuture<OperationResult> future = operation.get();
            
            // Remove from cache after TTL
            scheduler.schedule(() -> operationCache.remove(key), 5, TimeUnit.MINUTES);
            
            return future;
        });
    }
}

Thread Safety in Repository Layer

The repository layer ensures thread safety through:

  1. Immutable entity objects with proper encapsulation to prevent concurrent modification
  2. Thread-local database connections in connection pools to avoid cross-thread contamination
  3. Synchronized access to shared resources like query builders or prepared statements when necessary

Transaction Isolation and Consistency

  • Read Committed isolation level for most operations to prevent dirty reads
  • Repeatable Read for operations requiring consistent snapshot views (e.g., reporting)
  • Serializable for critical financial or audit operations where strict consistency is required

Concurrency Control in Batch Operations

Batch operations use:

  1. Partitioned processing to reduce contention across batches
  2. Lock-free data structures for intermediate state management (e.g., ConcurrentLinkedQueue)
  3. Batch size limiting to prevent overwhelming database connections

Performance Considerations

  • Thread pool sizing tuned based on database connection pool size and I/O characteristics
  • Async database drivers (e.g., async PostgreSQL driver) to minimize thread blocking
  • Connection pool monitoring and adaptive sizing based on concurrent load patterns

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant Database

    Client->>Service: GET /users/123
    Service->>Repository: findById(123)
    Repository->>Database: SELECT * FROM users WHERE id = 123
    Database-->>Repository: User object
    Repository-->>Service: User object
    Service-->>Client: 200 OK, User JSON

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant Database

    Client->>Service: GET /users/999
    Service->>Repository: findById(999)
    Repository->>Database: SELECT * FROM users WHERE id = 999
    Database-->>Repository: Empty result
    Repository-->>Service: EntityNotFoundException
    Service-->>Client: 404 Not Found, Error JSON

Async Flow Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant EventPublisher
    participant MessageQueue
    participant EventHandler

    Client->>Service: POST /users
    Service->>Repository: save(user)
    Repository->>Database: INSERT INTO users (name, email) VALUES (?, ?)
    Database-->>Repository: User ID generated
    Repository->>EventPublisher: publish(userCreatedEvent)
    EventPublisher->>MessageQueue: send(userCreatedEvent)
    MessageQueue-->>EventHandler: userCreatedEvent
    EventHandler->>Service: processUserCreated(user)
    Service-->>Client: 201 Created, User JSON

7. Caching Design

Cache Strategy Selection

For this data access layer abstraction, we'll use cache-aside pattern with a distributed Redis cluster as the primary cache store. This approach provides:

  • Explicit control over cache population
  • Clear separation between application logic and caching concerns
  • Support for cache warming and invalidation strategies
  • Horizontal scalability across multiple cache nodes

Cache Key Design

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

{namespace}:{entity_type}:{entity_id}:{version}:{field_name?}

Example cache keys:

  • user:12345:v1:profile
  • order:67890:v2:items
  • product:abc123:v1:metadata

TTL Strategy

Data TypeTTLReason
User Profile5 minutesFrequent updates, low consistency needs
Order Details10 minutesModerate update frequency, high read volume
Product Catalog1 hourStable data, infrequent updates
Session Tokens24 hoursLong-lived, security-sensitive
Configuration Data30 minutesDynamic config, acceptable staleness

Cache Eviction Policy

We'll implement LRU (Least Recently Used) eviction policy with a TTL-based fallback:

  • Primary eviction: LRU algorithm to maintain hot data
  • Fallback: TTL expiration for stale entries that haven't been accessed recently
  • Cache size limits enforced at Redis cluster level (5GB per node)

Cache Stampede Prevention

To prevent thundering herd on cache misses:

  1. Probabilistic Early Expiry: Cache entries expire 20% before TTL to allow for early refresh
  2. Distributed Locking: Use Redis distributed locks (SET key value NX EX ttl) for cache population
  3. Cache Backfill Strategy: Pre-warm hot keys during low-traffic periods

Distributed Cache Architecture

We'll implement a Redis Cluster with:

  • 3 master nodes (for data partitioning)
  • 3 replica nodes (for high availability)
  • Automatic failover capability
  • Client-side sharding using consistent hashing

Cache Invalidation Strategy

We'll use event-driven invalidation:

  1. Write Operations: Invalidate cache entries when data is modified
  2. Batch Operations: Use cache tags to invalidate groups of related entities
  3. Explicit Purge: Support for manual cache clearing via admin endpoints

Cache Implementation in Java

public class RedisCacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final String cacheNamespace = "data-access-layer";
    
    public <T> T get(String key, Class<T> type) {
        String cacheKey = buildCacheKey(key);
        return (T) redisTemplate.opsForValue().get(cacheKey);
    }
    
    public <T> void put(String key, T value, Duration ttl) {
        String cacheKey = buildCacheKey(key);
        redisTemplate.opsForValue().set(cacheKey, value, ttl);
    }
    
    public boolean lock(String key, String lockValue, Duration ttl) {
        String lockKey = buildLockKey(key);
        return redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, ttl);
    }
    
    public void unlock(String key, String lockValue) {
        String lockKey = buildLockKey(key);
        String storedValue = (String) redisTemplate.opsForValue().get(lockKey);
        if (lockValue.equals(storedValue)) {
            redisTemplate.delete(lockKey);
        }
    }
    
    private String buildCacheKey(String key) {
        return String.format("%s:%s", cacheNamespace, key);
    }
    
    private String buildLockKey(String key) {
        return String.format("%s:lock:%s", cacheNamespace, key);
    }
}

Cache Integration with Repository Pattern

public class CachedRepository<T> implements Repository<T> {
    private final Repository<T> delegate;
    private final RedisCacheService cacheService;
    
    @Override
    public T findById(String id) {
        String cacheKey = "entity:" + id;
        T cached = cacheService.get(cacheKey, (Class<T>) entityClass);
        
        if (cached != null) {
            return cached;
        }
        
        // Acquire distributed lock to prevent cache stampede
        String lockKey = "lock:" + id;
        String lockValue = UUID.randomUUID().toString();
        
        if (cacheService.lock(lockKey, lockValue, Duration.ofSeconds(5))) {
            try {
                T result = delegate.findById(id);
                if (result != null) {
                    cacheService.put(cacheKey, result, Duration.ofMinutes(5));
                }
                return result;
            } finally {
                cacheService.unlock(lockKey, lockValue);
            }
        }
        
        // Fallback to cache if lock acquisition fails
        return cacheService.get(cacheKey, (Class<T>) entityClass);
    }
    
    @Override
    public void save(T entity) {
        delegate.save(entity);
        // Invalidate cache for this entity
        String cacheKey = "entity:" + getEntityId(entity);
        cacheService.delete(cacheKey);
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented as a clean abstraction layer between the service layer and data storage, providing a collection-like interface for data access operations. This pattern enables loose coupling, testability, and supports various data sources.

// Repository interface - defines contract for data access operations
public interface UserRepository extends Repository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByStatus(UserStatus status);
    Page<User> findByStatusWithPagination(UserStatus status, Pageable pageable);
    List<User> findActiveUsersWithRoles(List<Role> roles);
}

// Generic Repository base interface
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    T save(T entity);
    void delete(T entity);
    boolean existsById(ID id);
}

// Repository implementation for concrete data source
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 Optional<User> findById(Long id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        try {
            return Optional.ofNullable(jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper()));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
    
    @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 (name, email, status, created_at) VALUES (?, ?, ?, ?)";
        KeyHolder keyHolder = new GeneratedKeyHolder();
        
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, user.getName());
            ps.setString(2, user.getEmail());
            ps.setString(3, user.getStatus().name());
            ps.setTimestamp(4, Timestamp.valueOf(user.getCreatedAt()));
            return ps;
        }, keyHolder);
        
        user.setId(keyHolder.getKey().longValue());
        return user;
    }
    
    private User update(User user) {
        String sql = "UPDATE users SET name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?";
        jdbcTemplate.update(sql, 
            user.getName(), 
            user.getEmail(), 
            user.getStatus().name(),
            Timestamp.valueOf(user.getUpdatedAt()),
            user.getId());
        return user;
    }
    
    @Override
    public void delete(User user) {
        String sql = "DELETE FROM users WHERE id = ?";
        jdbcTemplate.update(sql, user.getId());
    }
}

ORM vs. Raw Query Decisions

For this design, we use raw SQL queries with JDBC Template instead of full ORM like Hibernate for several reasons:

  1. Performance: Raw SQL provides better performance for complex queries and reduces object mapping overhead
  2. Control: Direct control over query execution, allowing for optimization and complex joins
  3. Maintainability: Easier to debug and tune specific queries when performance issues arise
  4. Database Portability: Clear separation between query logic and database-specific features
// Example of complex query using raw SQL with parameter binding
public List<User> findActiveUsersWithRoles(List<Role> roles) {
    String sql = """
        SELECT u.*, r.name as role_name 
        FROM users u 
        JOIN user_roles ur ON u.id = ur.user_id 
        JOIN roles r ON ur.role_id = r.id 
        WHERE u.status = ? AND r.name IN (?)
        """;
    
    // Build dynamic IN clause for roles
    String rolePlaceholders = roles.stream()
        .map(r -> "?")
        .collect(Collectors.joining(","));
    
    // This would be built dynamically with proper parameter binding
    return jdbcTemplate.query(sql, new Object[]{UserStatus.ACTIVE.name(), rolePlaceholders}, 
        new UserWithRoleRowMapper());
}

Read vs. Write Separation

The repository pattern supports read and write separation through distinct interfaces:

// Read-only operations interface
public interface UserReadRepository {
    Optional<User> findByEmail(String email);
    List<User> findByStatus(UserStatus status);
    Page<User> findByStatusWithPagination(UserStatus status, Pageable pageable);
    long countActiveUsers();
}

// Write operations interface  
public interface UserWriteRepository {
    User save(User user);
    void delete(User user);
    void batchInsert(List<User> users);
}

// Combined repository interface
public interface UserRepository extends UserReadRepository, UserWriteRepository {
    // Combined operations
}

Connection Pool Configuration

Connection pool is configured with optimal settings for high-concurrency environments:

@Configuration
public class DatabaseConfig {
    
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
        config.setUsername("user");
        config.setPassword("password");
        
        // Pool configuration
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        config.setLeakDetectionThreshold(60000);
        
        // Validation settings
        config.setConnectionTestQuery("SELECT 1");
        config.setValidationTimeout(5000);
        
        return new HikariDataSource(config);
    }
}

N+1 Query Prevention Strategy

To prevent N+1 query issues, we implement eager loading with batch operations:

// Batch operation for efficient data loading
public List<User> findUsersWithRoles(List<Long> userIds) {
    if (userIds.isEmpty()) return Collections.emptyList();
    
    String sql = """
        SELECT u.*, r.name as role_name, r.id as role_id
        FROM users u 
        LEFT JOIN user_roles ur ON u.id = ur.user_id 
        LEFT JOIN roles r ON ur.role_id = r.id
        WHERE u.id IN (?)
        ORDER BY u.id
        """;
    
    // Use batch query with proper parameter binding
    return jdbcTemplate.query(sql, new Object[]{buildInClause(userIds)}, 
        new UserWithRoleBatchMapper());
}

// Helper method to build IN clause safely
private String buildInClause(List<Long> ids) {
    return ids.stream()
        .map(String::valueOf)
        .collect(Collectors.joining(",", "(", ")"));
}

Batch Operation Design

Batch operations are implemented to reduce database round trips:

public class UserBatchRepository {
    
    public void batchInsert(List<User> users) {
        String sql = "INSERT INTO users (name, email, status, created_at) VALUES (?, ?, ?, ?)";
        
        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                User user = users.get(i);
                ps.setString(1, user.getName());
                ps.setString(2, user.getEmail());
                ps.setString(3, user.getStatus().name());
                ps.setTimestamp(4, Timestamp.valueOf(user.getCreatedAt()));
            }
            
            @Override
            public int getBatchSize() {
                return users.size();
            }
        });
    }
    
    public void batchUpdate(List<User> users) {
        String sql = "UPDATE users SET name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?";
        
        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                User user = users.get(i);
                ps.setString(1, user.getName());
                ps.setString(2, user.getEmail());
                ps.setString(3, user.getStatus().name());
                ps.setTimestamp(4, Timestamp.valueOf(user.getUpdatedAt()));
                ps.setLong(5, user.getId());
            }
            
            @Override
            public int getBatchSize() {
                return users.size();
            }
        });
    }
}

9. Asynchronous Processing & Messaging

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

The Data Access Layer abstraction is primarily concerned with synchronous database operations. However, the abstraction layer can be extended to support asynchronous processing for specific use cases:

  1. Synchronous operations: All direct CRUD operations (create, read, update, delete) remain synchronous to ensure immediate consistency and response.
  2. Async event publishing: When entities are created, updated, or deleted, the system publishes domain events asynchronously to support eventual consistency and decoupled systems.
  3. Batch operations: Bulk data ingestion or transformation can be processed asynchronously to avoid blocking the main request path.
  4. Audit logging: Audit trail creation is handled asynchronously to prevent performance degradation of the primary operation.

The decision to make some operations asynchronous is driven by:

  • Performance isolation (avoiding blocking primary operations)
  • System decoupling (loose coupling between services)
  • Scalability (handling bursts of operations without overloading the database)

Message schema design with a sample payload

{
  "version": "1.0",
  "event_type": "user.created",
  "timestamp": "2023-10-15T14:30:00Z",
  "source": "user-service",
  "payload": {
    "user_id": "uuid-12345",
    "email": "john.doe@example.com",
    "created_at": "2023-10-15T14:30:00Z"
  },
  "correlation_id": "corr-67890",
  "tenant_id": "tenant-abc"
}

Dead letter queue strategy and poison message handling

Poison messages are handled through a dead-letter queue (DLQ) pattern:

  1. Messages that fail more than 3 times are moved to a DLQ
  2. DLQ messages are logged with full context for manual inspection
  3. A separate monitoring system alerts on DLQ queue size and error rates
  4. Failed messages are retried with exponential backoff (5s, 10s, 20s) before moving to DLQ

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

At-least-once delivery is chosen for the Data Access Layer abstraction because:

  1. Database operations are idempotent in nature (e.g., upserts, updates)
  2. The abstraction layer can handle duplicate processing gracefully
  3. Simpler implementation with better performance characteristics
  4. For most data access operations, eventual consistency is acceptable

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

public class RetryPolicy {
    private static final int MAX_RETRIES = 3;
    private static final long INITIAL_DELAY_MS = 5000;
    private static final double BACKOFF_MULTIPLIER = 2.0;
    private static final double JITTER_RANGE = 0.1;

    public static boolean shouldRetry(Exception exception) {
        return !(exception instanceof ValidationException ||
                exception instanceof AuthorizationException ||
                exception instanceof ResourceNotFoundException);
    }

    public static long calculateDelay(int attempt) {
        long baseDelay = (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1));
        long jitter = (long) (baseDelay * JITTER_RANGE * (Math.random() - 0.5));
        return baseDelay + jitter;
    }
}

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

Outbox pattern is included for the Data Access Layer abstraction because:

  1. Ensures atomicity between database writes and event publishing
  2. Prevents data loss in case of publish failures
  3. Maintains consistency between database state and event stream
  4. Provides better error handling and retry capabilities

The outbox pattern is implemented by:

  1. Writing events to an event_outbox table within the same database transaction as the data operation
  2. A separate background worker processes events from the outbox table and publishes them to the message broker
  3. Events are marked as published after successful delivery

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant Client
    participant ServiceLayer
    participant Repository
    participant Database
    participant OutboxTable
    participant EventPublisher
    participant MessageBroker

    Client->>ServiceLayer: CreateUserRequest
    ServiceLayer->>Repository: save(user)
    Repository->>Database: INSERT INTO users (...)
    Repository->>OutboxTable: INSERT INTO event_outbox (...)
    Repository-->>ServiceLayer: User saved
    ServiceLayer-->>Client: UserCreatedResponse
    loop Process Events
        EventPublisher->>OutboxTable: SELECT * FROM event_outbox WHERE published=false
        EventPublisher->>MessageBroker: PublishEvent(event)
        MessageBroker-->>EventPublisher: Acknowledge
        EventPublisher->>OutboxTable: UPDATE event_outbox SET published=true WHERE id=...
    end

10. Error Handling Design

Error Classification Hierarchy

The error handling strategy follows a hierarchical classification system to enable appropriate handling and response:

  1. Domain Errors - Business logic violations (e.g., insufficient funds, invalid state transitions)
  2. Validation Errors - Input data format or constraints violations (e.g., missing fields, invalid formats)
  3. Infrastructure Errors - System-level issues (e.g., database connection failures, timeout errors)
  4. Authorization Errors - Access control violations (e.g., insufficient permissions, invalid tokens)
  5. Internal System Errors - Unexpected system failures (e.g., unhandled exceptions, service unavailability)

Error Propagation Strategy

Errors are propagated up the call stack using a layered approach:

  • Service Layer: Converts infrastructure and domain exceptions into standardized API errors
  • Controller Layer: Maps service errors to appropriate HTTP responses with proper status codes
  • Global Exception Handler: Catches unhandled exceptions and logs them for monitoring
// Example error propagation in service layer
public class OrderService {
    public Order placeOrder(PlaceOrderRequest request) {
        try {
            // Business logic
            return orderRepository.save(order);
        } catch (DataAccessException e) {
            throw new InfrastructureException("Database operation failed", e);
        } catch (ValidationException e) {
            throw new ValidationException("Order validation failed", e);
        }
    }
}

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, a retry mechanism is implemented using exponential backoff with jitter:

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

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (true) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (attempts >= MAX_RETRIES) {
                    throw e;
                }
                
                long delay = calculateDelay(attempts);
                Thread.sleep(delay);
                attempts++;
            }
        }
    }

    private long calculateDelay(int attempt) {
        long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
        long jitter = (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
        return delay + jitter;
    }
}

Circuit Breaker Design

The circuit breaker pattern prevents cascading failures in distributed systems:

public class CircuitBreaker {
    private final int failureThreshold = 5;
    private final long timeout = 60000; // 1 minute
    private final int successThreshold = 5;
    
    private volatile CircuitState state = CircuitState.CLOSED;
    private int failureCount = 0;
    private long lastFailureTime = 0;

    public <T> T execute(Supplier<T> operation) {
        switch (state) {
            case CLOSED:
                try {
                    T result = operation.get();
                    onSuccess();
                    return result;
                } catch (Exception e) {
                    onFailure();
                    throw e;
                }
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime > timeout) {
                    state = CircuitState.HALF_OPEN;
                    return operation.get();
                }
                throw new CircuitBreakerOpenException("Circuit is open");
            case HALF_OPEN:
                try {
                    T result = operation.get();
                    state = CircuitState.CLOSED;
                    return result;
                } catch (Exception e) {
                    state = CircuitState.OPEN;
                    throw e;
                }
        }
        return null;
    }

    private void onSuccess() {
        failureCount = 0;
        if (state == CircuitState.HALF_OPEN) {
            state = CircuitState.CLOSED;
        }
    }

    private void onFailure() {
        failureCount++;
        lastFailureTime = System.currentTimeMillis();
        
        if (failureCount >= failureThreshold) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

Fallbacks are implemented for critical dependencies to maintain system availability:

public class FallbackService {
    public Order getOrderByFallback(String orderId) {
        // Return cached order or default value
        return orderCache.get(orderId);
    }

    public List<Order> getOrdersByFallback(String customerId) {
        // Return cached orders or empty list
        return orderCache.getOrdersByCustomer(customerId);
    }
}

Error Logging Format and Alerting Thresholds

Structured logging includes trace context and error details:

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

    public void logError(String operation, Exception error, Map<String, String> context) {
        Map<String, Object> logContext = new HashMap<>();
        logContext.put("trace_id", MDC.get("trace_id"));
        logContext.put("span_id", MDC.get("span_id"));
        logContext.put("operation", operation);
        logContext.put("error_type", error.getClass().getSimpleName());
        logContext.put("error_message", error.getMessage());
        logContext.put("context", context);
        
        logger.error("Operation failed", new Exception(error.getMessage(), error));
    }
}

Alerting thresholds:

  • Critical: >100 errors per minute in the past 5 minutes (escalate to on-call)
  • Warning: >50 errors per minute in the past 5 minutes (notify team)
  • Info: >10 errors per minute in the past 5 minutes (monitoring dashboard)

11. Security Design

Input Validation and Sanitization Strategy

All API inputs are validated using a combination of:

  • Library: Apache Commons Validator for standard validations (email, URL, phone)
  • Custom Rules: Regex patterns for specific formats like ID patterns, name sanitization
  • Sanitization: HTML escaping using OWASP Java HTML Sanitizer for any user-provided content that might be rendered in UI
public class InputValidator {
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
    
    public static boolean isValidEmail(String email) {
        return EMAIL_PATTERN.matcher(email).matches();
    }
    
    public static String sanitizeHtml(String input) {
        return HtmlSanitizer.sanitize(input);
    }
}

SQL Injection Prevention

  • ORM Usage: All database queries are constructed using Hibernate/JPA with parameterized queries to prevent SQL injection
  • No Dynamic Query Building: All query building is done through JPA Criteria API or HQL, never string concatenation
  • Whitelisting: Column and table names are validated against a predefined whitelist

Authentication Middleware Design

  • JWT-based Authentication: JWT tokens are used for stateless authentication with 24-hour expiration
  • Middleware Chain: Custom Spring Security filter chain that intercepts all requests and validates JWT tokens
  • Token Refresh: Support for refresh tokens with 7-day validity

Authorization Enforcement

  • RBAC Implementation: Role-based access control where users are assigned roles (ADMIN, USER, GUEST)
  • Permission Checking: Service layer methods check permissions before execution using @PreAuthorize annotations
  • Resource Scoping: Users can only access resources they own or have explicit permissions for
@PreAuthorize("hasRole('ADMIN') or @userPermissionService.canAccessUser(#userId)")
public User getUserById(Long userId) {
    return userRepository.findById(userId);
}

Sensitive Data Encryption

  • At Rest: AES-256 encryption using AWS KMS for sensitive fields like passwords, PII, and financial data
  • In Transit: TLS 1.3 with HSTS enabled for all API communications
  • Key Management: Keys are rotated every 90 days with audit trail

PII Handling and Masking Strategy

  • Masking: Credit card numbers are masked (show only last 4 digits), email addresses are partially redacted
  • Storage: PII is stored in encrypted format and only accessible to authorized services
  • Logging: PII fields are excluded from logs or masked before logging

Secrets Management

  • Vault Integration: AWS Secrets Manager is used for managing database credentials, API keys, and encryption keys
  • Rotation: Credentials are rotated every 90 days with automatic failover
  • Access Control: Fine-grained access control using IAM policies to limit who can read secrets

Audit Logging Design

  • Events Logged: All CRUD operations on sensitive entities (users, orders, payments) are logged
  • Log Format: Structured JSON with fields: timestamp, user_id, operation, entity_type, entity_id, ip_address, user_agent
  • Retention: Audit logs are retained for 3 years with daily backups to S3
  • Alerting: Security events trigger immediate alerts to the security team
{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "user_id": "user-12345",
  "operation": "UPDATE_USER",
  "entity_type": "USER",
  "entity_id": "user-12345",
  "ip_address": "192.168.1.100",
  "user_agent": "Mozilla/5.0..."
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage, focusing on business logic and error paths
  • Repository Layer: 90% code coverage, including edge cases and query validation
  • Domain Objects: 100% for value objects, 80% for entities with complex state transitions
  • Data Access Patterns: 95% for CRUD operations and complex queries
  • Error Handling: 100% coverage of all error paths in service and repository layers

Integration Testing Strategy

  • Database Integration: Use testcontainers for PostgreSQL/MySQL to validate actual query execution and schema compliance
  • External Service Mocks: WireMock for third-party API interactions (e.g., payment gateway, notification service)
  • Cross-Service Integration: Embedded Redis and Kafka for testing async flows and event handling
  • Transaction Boundaries: Validate rollback behavior with real database transactions using rollback-only transactions in test environments

Contract Testing

  • Tool: Pact (consumer-driven contract testing)
  • Scope: All external API integrations (payment provider, email service, SMS gateway)
  • Strategy: Define contracts for each consumer and provider pair, ensuring that changes in one service don't break consumers
  • CI Integration: Run contract tests as part of CI pipeline before merging to main branch

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based load testing)
  • Scenarios:
    • Concurrent read-heavy workload (500 concurrent users, 1000 requests/sec)
    • Write-heavy workload with transactional consistency (200 concurrent users, 500 requests/sec)
    • Batch operation performance (1000 records in batch, 50 concurrent batches)
  • Metrics Tracked:
    • p50/p95/p99 response times (target: <200ms for reads, <500ms for writes)
    • Throughput (target: 1000+ req/sec for read path, 500+ req/sec for write path)
    • Error rates (target: <0.1% error rate)

Test Data Management Strategy

  • Fixtures: YAML-based fixtures for common test data (users, products, orders)
  • Factories: Factory classes using Builder pattern to generate realistic test data with proper constraints
  • Seeding Strategy:
    • Use database migrations for initial schema and seed data (e.g., default roles, categories)
    • For tests requiring specific states, use lightweight test data setup with transactional rollbacks
  • Data Isolation: Each test runs in a separate database transaction that is rolled back after completion to ensure isolation
  • Test Data Generation: Use libraries like Faker for generating realistic but non-sensitive test data
// Example: Factory pattern for generating test entities
public class TestEntityFactory {
    public static User createUser(String email) {
        return User.builder()
                .id(UUID.randomUUID())
                .email(email)
                .name("Test User")
                .role(Role.USER)
                .createdAt(Instant.now())
                .build();
    }

    public static Order createOrder(User user, Product product) {
        return Order.builder()
                .id(UUID.randomUUID())
                .user(user)
                .items(List.of(OrderItem.builder()
                        .product(product)
                        .quantity(1)
                        .build()))
                .status(OrderStatus.PENDING)
                .createdAt(Instant.now())
                .build();
    }
}

13. Observability Design

Structured Logging Schema

The logging system will use structured JSON format to ensure consistency and enable efficient querying. Each log entry will include the following required fields:

{
  "timestamp": "2023-10-27T14:30:00.000Z",
  "level": "INFO|WARN|ERROR",
  "service": "data-access-layer",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user_12345",
  "entity_id": "order_98765",
  "operation": "findUserById",
  "error_code": "USER_NOT_FOUND",
  "message": "User not found in database",
  "duration_ms": 15,
  "stack_trace": "...",
  "request_id": "req_abcde12345"
}

PII masking rules will be applied to sensitive fields:

  • User IDs will be masked in logs when not needed for debugging
  • Email addresses and phone numbers will be truncated or hashed
  • Full credit card numbers will never be logged

Distributed Tracing Instrumentation

OpenTelemetry will be used for distributed tracing with custom spans added to key operations:

Span Types and Attributes:

  1. Database Operation Spans

    • Span name: db.query.{table}.{operation}
    • Attributes:
      • db.operation: SELECT, INSERT, UPDATE, DELETE
      • db.table: user, order, payment
      • db.query: actual SQL or MongoDB query
      • db.connection_id: connection pool identifier
  2. Repository Method Spans

    • Span name: repository.{method}.{entity}
    • Attributes:
      • repository.method: findById, save, delete
      • entity.type: User, Order, Payment
      • db.query.duration_ms: duration of database operation
  3. Service Layer Spans

    • Span name: service.{operation}
    • Attributes:
      • service.operation: findUser, createOrder
      • user.id: user identifier when available

Metrics Design at Service Level

RED metrics will be collected for all repository operations:

Metric NameTypeLabelsDescription
db_query_countCounteroperation, table, statusNumber of database queries executed
db_query_durationHistogramoperation, tableQuery execution time in milliseconds
db_query_errorsCounteroperation, table, error_typeCount of failed database queries
repository_method_countCountermethod, entity, statusRepository method invocations
repository_method_durationHistogrammethod, entityRepository method execution time
db_connection_pool_activeGaugepool_nameActive database connections
db_connection_pool_idleGaugepool_nameIdle database connections

Health Check Endpoint Design

Health Check Endpoints:

  1. /health - Application health status

    {
      "status": "healthy|unhealthy",
      "checks": {
        "database": "healthy|unhealthy",
        "cache": "healthy|unhealthy",
        "external_api": "healthy|unhealthy"
      },
      "timestamp": "2023-10-27T14:30:00.000Z"
    }
  2. /ready - Readiness for traffic

    {
      "status": "ready|not_ready",
      "details": {
        "database_connection": "connected|failed",
        "cache_connection": "connected|failed"
      }
    }
  3. /live - Liveness check

    {
      "status": "alive",
      "uptime_seconds": 3600,
      "timestamp": "2023-10-27T14:30:00.000Z"
    }

Alerting Thresholds per Key Metric

MetricAlert LevelThresholdAction
db_query_errors (per minute)Warning> 5 errorsAlert team
db_query_errors (per minute)Critical> 50 errorsAlert on-call
db_query_duration (p99)Warning> 500msInvestigate slow queries
db_query_duration (p99)Critical> 2000msAlert on-call, investigate
repository_method_duration (p99)Warning> 200msReview repository logic
repository_method_duration (p99)Critical> 1000msAlert on-call
db_connection_pool_activeWarning< 5 active connectionsInvestigate pool exhaustion
db_connection_pool_activeCritical< 1 active connectionsAlert on-call

On-call escalation path:

  • Warning alerts → Slack channel
  • Critical alerts → SMS to on-call engineer with escalation to team lead after 15 minutes

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • ORM vs. Raw SQL trade-off: The choice between ORM and raw SQL impacts developer productivity, query optimization control, and migration complexity. This decision should be made at the organization level based on team expertise and performance requirements.
  • Database vendor selection: The choice of database (PostgreSQL, MySQL, MongoDB) affects the implementation approach and tooling stack. This decision should align with existing infrastructure and team capabilities.
  • CQRS vs. CTA pattern: Whether to implement Command-Query Responsibility Segregation or a simpler Command-Transaction-Action approach requires architectural alignment and team consensus on complexity vs. maintainability trade-offs.

Known trade-offs and justifications

Trade-offJustification
Single repository interface vs. multiple specialized repositoriesSingle interface provides simplicity but can lead to performance issues with complex queries. Multiple specialized repositories improve query performance and type safety at the cost of increased complexity in maintaining multiple interfaces.
Generic repository vs. domain-specific repository methodsGeneric repositories provide consistency but may not expose database-specific optimizations or complex query patterns. Domain-specific methods enable better performance and maintainability at the cost of increased interface complexity.
Synchronous vs. asynchronous data accessSynchronous operations provide simpler error handling and debugging but can cause performance bottlenecks under high load. Asynchronous operations improve throughput but add complexity in error handling and debugging.
Read/write separation vs. unified data accessSeparating read and write operations enables better scaling and optimization but increases system complexity and potential consistency issues. Unified access simplifies development but can create bottlenecks under read-heavy loads.

Risks and mitigation strategies

RiskMitigation
Performance degradation from generic repository patternsImplement query optimization strategies, provide performance monitoring, and allow domain-specific overrides where needed.
Complexity of supporting multiple database backendsImplement database abstraction layers with clear boundaries, use feature flags for database-specific optimizations, and maintain comprehensive test coverage.
Inconsistent error handling across repositoriesDefine strict error handling contracts, implement centralized error translation, and enforce error handling through code reviews.
Caching invalidation issuesImplement cache invalidation strategies with event-driven updates, use cache versioning, and maintain cache consistency through transactional events.
Data consistency violations in distributed systemsImplement proper transaction boundaries, use saga patterns for distributed operations, and implement idempotency checks.

Technical debt acknowledged

  • Repository interface bloat: As more domain entities are added, the repository interface may grow unwieldy. This can be mitigated by introducing domain-specific repository interfaces or using generic type parameters.
  • ORM query optimization limitations: ORM tools may not always generate optimal SQL queries, potentially leading to performance bottlenecks. This debt can be addressed through query profiling and manual SQL overrides where necessary.
  • Testing complexity: The abstraction layer adds another layer of indirection that can make unit testing more complex. This is mitigated by comprehensive integration tests and proper mocking strategies.
  • Version compatibility issues: Changes to the repository abstraction layer can break existing implementations. This is managed through strict versioning, clear migration paths, and comprehensive documentation.
  • Limited database-specific features: Generic repository patterns may not expose all database-specific capabilities, potentially limiting performance optimization opportunities. This is addressed by providing extension points for database-specific features and maintaining clear documentation of supported features.

The design intentionally balances flexibility with maintainability, acknowledging that some trade-offs are necessary to achieve a robust and scalable data access abstraction layer.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    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.

  • Backend LLD

    File Metadata Service Low-Level Design

    Comprehensive LLD for a scalable file metadata service with indexing, search, and storage optimization capabilities.

  • Backend HLD

    Live Comment System Architecture Design

    Comprehensive backend high-level design for a scalable real-time comment system with pub/sub, caching, and conflict resolution mechanisms.

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.