Skip to main content

Unit of Work Pattern Design and Implementation

Comprehensive low-level design for the Unit of Work pattern including class structure, transaction management, and database interaction strategies.

The Unit of Work pattern is a fundamental design pattern that tracks changes to domain objects and coordinates their persistence to the database in a single transactional unit. This design ensures data consistency, provides clean separation of concerns, and enables efficient database operations through proper transaction management and object state tracking.

1. Problem Statement & Requirements

Clarifying questions

  1. What scale and throughput expectations are we targeting?

    • Are we designing for millions of concurrent operations or more modest scale?
    • What are the target latencies (e.g., p50/p95/p99) for read/write operations?
  2. What consistency requirements are in scope?

    • Are we aiming for strong consistency or eventual consistency?
    • Do we need to support distributed transactions across multiple databases?
  3. What are the key entities and their relationships in scope?

    • Is this a single domain or multi-domain UoW?
    • Are there specific entities like User, Order, Product that are central to the design?
  4. What is the read/write ratio and latency sensitivity?

    • Are we optimizing for write-heavy or read-heavy workloads?
    • What is the acceptable latency for transactional operations?
  5. What authentication and authorization model is in place?

    • Is this a monolithic or microservices architecture?
    • Are there existing auth systems (e.g., OAuth2, JWT) that the UoW should integrate with?
  6. Are there external integrations or third-party services involved?

    • Will the UoW interact with external systems (e.g., payment gateways, email services)?
    • How should we handle failures in external dependencies?
  7. What are the out-of-scope boundaries of this design?

    • Are we designing for a single database or multi-database setup?
    • Is persistence layer abstraction or ORM support out of scope?

Functional requirements

  • Transaction boundary management: The UoW must define and manage a clear transactional boundary for a set of related data operations.
  • Entity tracking: The UoW must track entities that are created, modified, or deleted within the transaction.
  • Change detection: The UoW must detect and capture changes to entities for persistence.
  • Persistence coordination: The UoW must coordinate the persistence of all tracked entities within a single transaction.
  • Rollback support: The UoW must support rollback of all changes if a failure occurs.
  • Idempotency: The UoW must be able to handle idempotent operations without side effects.

Non-functional requirements

  • Latency targets:
    • p50: < 10ms for read operations, < 50ms for write operations
    • p95: < 50ms for read, < 200ms for write
    • p99: < 200ms for read, < 500ms for write
  • Throughput: Support at least 10,000 concurrent UoW operations per second.
  • Availability: UoW must be available 99.9% of the time under normal load.
  • Isolation: Ensure proper isolation levels (e.g., READ_COMMITTED) to prevent dirty reads and phantom reads.
  • Scalability: Support horizontal scaling of UoW instances with minimal coordination overhead.

Constraints and assumptions

  • Single database: The UoW is designed to work within a single database or a tightly coupled database cluster.
  • ACID compliance: Assumes underlying storage supports ACID transactions.
  • Synchronous processing: UoW operations are handled synchronously within the same request context.
  • No distributed transactions: Distributed transaction support (e.g., 2PC) is out of scope.
  • No external dependencies: The UoW does not handle external service calls or async operations directly.

Out of scope

  • Multi-database support: Handling transactions across multiple databases or data stores.
  • Distributed transaction coordination: Support for XA transactions or two-phase commit.
  • External service integration: Integration with external systems like message queues, payment gateways, or third-party APIs.
  • Async UoW processing: Background processing of UoW operations (e.g., using a queue or event-driven architecture).
  • Cross-service UoW: UoW spanning multiple microservices or bounded contexts.
  • UoW persistence across sessions: The UoW is not designed to persist state between separate application sessions or user sessions.

2. API Design

API Style Selection

REST is selected for this design due to its widespread adoption, clear semantics, and compatibility with existing infrastructure. REST provides well-defined HTTP methods (GET, POST, PUT, DELETE) that map naturally to CRUD operations and supports caching through HTTP headers. While GraphQL offers flexibility in data fetching, the use case here is more about managing unit of work boundaries rather than flexible querying.

Endpoint Definitions

MethodPathDescriptionAuth
POST/workflowsCreate new workflow instanceJWT
GET/workflows/{id}Get workflow detailsJWT
PUT/workflows/{id}Update workflow metadataJWT
DELETE/workflows/{id}Delete workflowJWT
POST/workflows/{id}/actionsSubmit action to workflowJWT
GET/workflows/{id}/actionsList actions in workflowJWT
POST/workflows/{id}/commitCommit all pending changesJWT
POST/workflows/{id}/rollbackRollback all pending changesJWT

Request and Response Schemas

Create Workflow Request:

{
  "name": "string",
  "description": "string",
  "metadata": {
    "key": "value"
  }
}

Create Workflow Response:

{
  "id": "uuid",
  "name": "string",
  "description": "string",
  "status": "active",
  "createdAt": "2023-01-01T00:00:00Z",
  "updatedAt": "2023-01-01T00:00:00Z"
}

Workflow Action Request:

{
  "operation": "create|update|delete",
  "entityType": "user|order|product",
  "payload": {
    "id": "uuid",
    "data": {}
  }
}

HTTP Status Codes and Error Format

Standard HTTP status codes are used:

  • 200 OK - Successful GET, PUT, PATCH
  • 201 Created - Successful POST
  • 400 Bad Request - Invalid input or malformed request
  • 401 Unauthorized - Missing or invalid authentication
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Resource does not exist
  • 500 Internal Server Error - Unexpected server error

Standardized error response format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input provided",
    "details": [
      {
        "field": "name",
        "message": "Name is required"
      }
    ]
  }
}

API Versioning Strategy

Versioning via URI path prefix (/v1/workflows) to support backward compatibility and allow independent evolution of API versions without breaking existing clients.

Pagination Design

Cursor-based pagination is chosen over offset-based because:

  • More efficient for large datasets
  • Consistent results even when data changes during pagination
  • Better performance characteristics

Sample cursor response:

{
  "items": [/* workflow items */],
  "nextCursor": "cursor_value"
}

Authentication and Authorization

JWT tokens are used for authentication with scopes:

  • workflow:read - Read access to workflows
  • workflow:write - Write access to workflows and actions

Rate Limiting Strategy

Rate limiting per endpoint:

  • /workflows: 100 requests/minute
  • /workflows/{id}/actions: 50 requests/minute
  • /workflows/{id}/commit: 20 requests/minute

Rate limiting enforced via Redis with sliding window algorithm.

Idempotency Design

Idempotency keys are supported via X-Idempotency-Key header for mutation endpoints:

// Example usage in controller
@Post("/workflows/{id}/actions")
public ResponseEntity<WorkflowAction> createAction(
    @Header("X-Idempotency-Key") String idempotencyKey,
    @PathVariable String id,
    @RequestBody WorkflowActionRequest request) {
    return ResponseEntity.ok(workflowService.executeWithIdempotency(id, idempotencyKey, request));
}

The system maintains a cache of recent idempotency keys with TTL to prevent memory leaks.

3. Data Model Design

Entity-Relationship diagram (Mermaid)

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

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

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

    PRODUCT {
        string id PK
        string name
        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

users 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,
    
    INDEX idx_users_email (email),
    INDEX idx_users_created_at (created_at)
);

orders 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_items 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 DEFAULT 1,
    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)
);

products table

CREATE TABLE products (
    id VARCHAR(255) PRIMARY KEY,
    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_price (price),
    INDEX idx_products_stock_quantity (stock_quantity)
);

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

We use UUIDs as primary keys for all tables:

  • Consistency across systems: UUIDs provide globally unique identifiers, essential in distributed systems where data may be replicated or merged across services.
  • No sequential patterns: UUIDs prevent predictable ID sequences that could be exploited for enumeration attacks or data leakage.
  • Scalability: UUIDs allow for horizontal scaling without ID conflicts when merging data from different sources or databases.

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

  1. Composite Indexes:

    • idx_orders_user_id on orders(user_id) - for efficient user-based order queries
    • idx_order_items_order_id on order_items(order_id) - for efficient item retrieval by order
  2. Partial Indexes:

    • idx_orders_status on orders(status) - optimized for status-based filtering (e.g., "pending orders")
    • idx_products_stock_quantity on products(stock_quantity) - optimized for low-stock alerts
  3. Covering Indexes:

    • idx_users_email on users(email) - includes email field to avoid table lookups for authentication

Data partitioning / sharding strategy

Partition orders by user_id hash to distribute load:

  • Shards: 32 shards based on user ID hash modulo 32
  • Each shard contains orders for users whose IDs hash to that bucket
  • Allows horizontal scaling of order processing and storage

Soft delete vs. hard delete decision with justification

We implement soft delete using a deleted_at timestamp field:

  • Auditability: Historical data can be retained for compliance and analytics
  • Recovery capability: Data can be restored if accidentally deleted
  • Referential integrity: Foreign key constraints are maintained during soft deletion

Audit trail design (who, what, when)

We maintain an audit_log table:

CREATE TABLE audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    operation VARCHAR(50) NOT NULL,
    table_name VARCHAR(100) NOT NULL,
    record_id UUID,
    user_id UUID REFERENCES users(id),
    changes JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Data archival strategy and retention policy

  • Archival trigger: Orders older than 2 years are archived to a separate archived_orders table
  • Retention policy:
    • 5 years for financial records (orders, payments)
    • 3 years for operational logs
    • 1 year for user activity logs
  • Archival process: Scheduled nightly job to move expired orders to archive tables with metadata preserved

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class UnitOfWork {
        -Map~Entity, Change~ changes
        -List~Entity~ entitiesToPersist
        -List~Entity~ entitiesToDelete
        -Transaction transaction
        +begin()
        +commit()
        +rollback()
        +registerDirty(Entity)
        +registerNew(Entity)
        +registerDeleted(Entity)
        +getChangeSet()
    }

    class Repository {
        -EntityManager entityManager
        +save(Entity)
        +delete(Entity)
        +findById(Id)
        +findAll()
    }

    class EntityManager {
        -Map~Class, Repository~ repositories
        -UnitOfWork unitOfWork
        +getRepository(Class)
        +flush()
    }

    class Entity {
        -Id id
        -String version
        +getId()
        +getVersion()
    }

    class Order {
        -String orderId
        -List~OrderItem~ items
        +addItem(OrderItem)
        +removeItem(OrderItem)
    }

    class OrderItem {
        -String itemId
        -Integer quantity
        +getQuantity()
        +setQuantity(Integer)
    }

    class OrderService {
        -EntityManager entityManager
        -UnitOfWork unitOfWork
        +createOrder(Order)
        +updateOrder(Order)
        +deleteOrder(String)
    }

    class OrderRepository {
        -EntityManager entityManager
        +save(Order)
        +delete(Order)
        +findById(String)
    }

    UnitOfWork --> Repository : manages
    EntityManager --> UnitOfWork : uses
    EntityManager --> Repository : creates
    OrderService --> EntityManager : uses
    OrderService --> UnitOfWork : uses
    Repository <|-- OrderRepository : implements
    Entity <|-- Order : extends
    Entity <|-- OrderItem : extends

State Machine Diagram for Order Entity

stateDiagram-v2
    [*] --> Created
    Created --> Confirmed : confirm()
    Created --> Cancelled : cancel()
    Confirmed --> Shipped : ship()
    Confirmed --> Cancelled : cancel()
    Shipped --> Delivered : deliver()
    Shipped --> Cancelled : cancel()
    Cancelled --> [*]
    Delivered --> [*]

Service Class Structure and Responsibilities

The OrderService class encapsulates business logic for order operations, coordinating between repositories and the unit of work. It handles:

  • Order creation with validation
  • Order updates with optimistic locking
  • Order deletion with proper state management
  • Transaction boundary management

Interface / Abstract Class Design

public interface Repository<T extends Entity> {
    T save(T entity);
    void delete(T entity);
    Optional<T> findById(String id);
    List<T> findAll();
}

public abstract class AbstractRepository<T extends Entity> implements Repository<T> {
    protected final EntityManager entityManager;
    
    public AbstractRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    @Override
    public T save(T entity) {
        return persist(entity);
    }
    
    protected abstract T persist(T entity);
}

public interface UnitOfWork {
    void begin();
    void commit();
    void rollback();
    void registerDirty(Entity entity);
    void registerNew(Entity entity);
    void registerDeleted(Entity entity);
    Map<Class<?>, List<Entity>> getChangeSet();
}

Business Logic Encapsulation Patterns

The Unit of Work pattern uses the State Pattern to manage transaction states and ensure proper lifecycle management:

public class UnitOfWork implements IUnitOfWork {
    private enum State {
        ACTIVE, COMMITTED, ROLLED_BACK
    }
    
    private State state = State.ACTIVE;
    private final Map<Class<?>, List<Entity>> changes = new HashMap<>();
    
    public void begin() {
        if (state != State.ACTIVE) {
            throw new IllegalStateException("UnitOfWork already started");
        }
        // Initialize transaction context
    }
    
    public void commit() {
        if (state != State.ACTIVE) {
            throw new IllegalStateException("UnitOfWork not active");
        }
        
        try {
            // Flush all changes to database
            flushChanges();
            // Commit transaction
            transaction.commit();
            state = State.COMMITTED;
        } catch (Exception e) {
            rollback();
            throw new RuntimeException("Failed to commit transaction", e);
        }
    }
    
    public void rollback() {
        if (state == State.COMMITTED || state == State.ROLLED_BACK) {
            return;
        }
        
        try {
            transaction.rollback();
            state = State.ROLLED_BACK;
        } catch (Exception e) {
            throw new RuntimeException("Failed to rollback transaction", e);
        }
    }
    
    private void flushChanges() {
        // Process all registered entities in proper order
        for (Map.Entry<Class<?>, List<Entity>> entry : changes.entrySet()) {
            Class<?> entityType = entry.getKey();
            List<Entity> entities = entry.getValue();
            
            // Apply changes to database
            for (Entity entity : entities) {
                if (entity instanceof Order order) {
                    // Handle specific entity logic
                    processOrderChange(order);
                }
            }
        }
    }
}

Critical-Path Method Implementation

public class OrderService {
    private final EntityManager entityManager;
    private final UnitOfWork unitOfWork;
    
    public void createOrder(Order order) {
        // Begin transaction with unit of work
        unitOfWork.begin();
        
        try {
            // Validate order
            validateOrder(order);
            
            // Set initial state
            order.setStatus("CREATED");
            
            // Register new entity with unit of work
            unitOfWork.registerNew(order);
            
            // Save to repository (will be flushed on commit)
            OrderRepository orderRepo = entityManager.getRepository(Order.class);
            Order savedOrder = orderRepo.save(order);
            
            // Publish order created event
            EventPublisher.publish(new OrderCreatedEvent(savedOrder.getId()));
            
            // Commit transaction - flushes all changes to DB
            unitOfWork.commit();
            
        } catch (Exception e) {
            // Rollback on failure
            unitOfWork.rollback();
            throw new OrderCreationException("Failed to create order", e);
        }
    }
    
    private void validateOrder(Order order) {
        if (order.getItems() == null || order.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        
        // Validate each item
        for (OrderItem item : order.getItems()) {
            if (item.getQuantity() <= 0) {
                throw new IllegalArgumentException("Item quantity must be positive");
            }
        }
    }
}

Transaction Boundary Design

A single transaction in this system represents a complete business operation:

  • For order creation: entire order object graph (order + items)
  • For order update: specific order and its related entities
  • For order deletion: order + all dependent entities (items, payments)

Each operation begins with unitOfWork.begin() and ends with either commit() or rollback(). The unit of work ensures all changes are flushed atomically to the database.

Service Interface Contracts

public interface OrderService {
    Order createOrder(Order order);
    Order updateOrder(String orderId, OrderUpdateRequest request);
    void deleteOrder(String orderId);
    Order findOrderById(String orderId);
    List<Order> findAllOrders();
}

Saga / Orchestration Pattern for Distributed Transactions

For distributed systems, the Unit of Work pattern can be extended with a saga coordinator:

public class OrderSaga {
    private final UnitOfWork unitOfWork;
    private final EventPublisher eventPublisher;
    
    public void processOrder(Order order) {
        unitOfWork.begin();
        
        try {
            // Step 1: Create order in database
            Order createdOrder = createOrderInDatabase(order);
            
            // Step 2: Reserve inventory (external service)
            boolean inventoryReserved = reserveInventory(order.getItems());
            
            if (!inventoryReserved) {
                throw new InventoryReservationFailedException();
            }
            
            // Step 3: Process payment (external service)
            boolean paymentProcessed = processPayment(order);
            
            if (!paymentProcessed) {
                throw new PaymentProcessingFailedException();
            }
            
            // Step 4: Update order status
            createdOrder.setStatus("CONFIRMED");
            unitOfWork.commit();
            
        } catch (Exception e) {
            // Compensating actions
            rollbackOrderCreation();
            unitOfWork.rollback();
            throw new OrderProcessingFailedException("Order processing failed", e);
        }
    }
}

Domain Object / Value Object Design

public abstract class Entity {
    protected String id;
    protected String version;
    
    public String getId() {
        return id;
    }
    
    public String getVersion() {
        return version;
    }
    
    public void setVersion(String version) {
        this.version = version;
    }
}

public class Order extends Entity {
    private String status;
    private List<OrderItem> items;
    
    public void addItem(OrderItem item) {
        if (items == null) {
            items = new ArrayList<>();
        }
        items.add(item);
    }
    
    public void removeItem(OrderItem item) {
        if (items != null) {
            items.remove(item);
        }
    }
}

public class OrderItem extends Entity {
    private String productId;
    private Integer quantity;
    
    public void setQuantity(Integer quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        this.quantity = quantity;
    }
}

5. Concurrency & Thread Safety

Concurrency Model

The Unit of Work pattern operates within a single-threaded context for each request or transaction boundary, ensuring that all data operations within a unit are executed in sequence without concurrent interference. This approach aligns with the typical web application request lifecycle where each HTTP request is handled by a single thread from an application server's thread pool.

In multi-threaded environments, each thread maintains its own instance of the UnitOfWork and associated entities. The pattern does not inherently support concurrent access to the same UnitOfWork instance across multiple threads, as doing so would violate the principle of maintaining a consistent view of data within a single transactional boundary.

Race Condition Prevention Strategy

Race conditions are mitigated through:

  1. Thread-local storage: Each thread maintains its own copy of the UnitOfWork instance, preventing cross-thread contamination.
  2. Transaction isolation: Database-level transaction isolation levels (e.g., READ_COMMITTED or SERIALIZABLE) ensure that concurrent transactions do not interfere with each other's reads and writes.
  3. Optimistic locking: For entities that support versioning, concurrent updates are detected and handled gracefully using version fields or timestamps.
  4. Database constraints: Unique constraints, foreign key relationships, and check constraints in the database enforce data integrity beyond application-level logic.

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is preferred for the Unit of Work pattern because:

  • It aligns with the typical use case where entities are read and modified within a single transaction.
  • It avoids blocking operations that can occur with pessimistic locking, improving system throughput and reducing contention.
  • The pattern assumes that conflicts are rare, making optimistic locking an efficient choice.

Implementation approach:

  • Entities include a version field (e.g., @Version in JPA).
  • When updating, the system checks if the version matches the one stored in memory.
  • If not, a StaleObjectStateException is thrown to indicate a conflict.

Deadlock Prevention Strategy

Deadlock prevention includes:

  1. Consistent ordering of resource acquisition: All threads acquire locks in a consistent order (e.g., by entity ID or type).
  2. Timeout configuration: Database and application-level timeouts prevent indefinite waits.
  3. Transaction scope minimization: Keeping units of work as small as possible to reduce lock duration.
  4. Lock hierarchy enforcement: Entities are grouped and accessed in a consistent order across all operations.

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is not a core concern of the Unit of Work pattern itself but can be implemented at higher layers (e.g., API or service layer) to handle concurrent duplicate requests:

  • Idempotency keys: Clients provide a unique key for each operation.
  • Request deduplication: The system stores the result of previously executed idempotent requests and returns the cached result for matching keys.
  • Database constraints: Unique constraints on business keys prevent duplicate creation.
// Example: Idempotent operation with key-based deduplication
public class IdempotentUnitOfWork {
    private static final Map<String, String> idempotencyCache = new ConcurrentHashMap<>();

    public void executeWithIdempotency(String idempotencyKey, Runnable operation) {
        if (idempotencyCache.containsKey(idempotencyKey)) {
            return; // Already executed
        }

        try {
            operation.run();
            idempotencyCache.put(idempotencyKey, "executed");
        } catch (Exception e) {
            // Optionally remove from cache on failure to allow retry
            idempotencyCache.remove(idempotencyKey);
            throw e;
        }
    }
}

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant UnitOfWork
    participant Repository1
    participant Repository2
    participant DB

    Client->>Service: PlaceOrderRequest
    Service->>UnitOfWork: begin()
    Service->>Repository1: save(order)
    Service->>Repository2: save(payment)
    Service->>UnitOfWork: commit()
    Repository1-->>DB: INSERT order
    Repository2-->>DB: INSERT payment
    UnitOfWork-->>Service: Transaction committed
    Service-->>Client: OrderPlacedResponse

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant UnitOfWork
    participant Repository1
    participant Repository2
    participant DB

    Client->>Service: PlaceOrderRequest
    Service->>UnitOfWork: begin()
    Service->>Repository1: save(order)
    Service->>Repository2: save(payment)
    Repository2-->>DB: INSERT payment (fails - constraint violation)
    Service->>UnitOfWork: rollback()
    UnitOfWork-->>Service: Transaction rolled back
    Service-->>Client: OrderFailedResponse (with error details)

Async Flow Sequence Diagram

sequenceDiagram
    participant Client
    participant Service
    participant UnitOfWork
    participant Repository1
    participant EventPublisher
    participant MessageQueue

    Client->>Service: PlaceOrderRequest
    Service->>UnitOfWork: begin()
    Service->>Repository1: save(order)
    Service->>UnitOfWork: commit()
    Repository1-->>DB: INSERT order
    UnitOfWork-->>EventPublisher: publish(order.created)
    EventPublisher->>MessageQueue: send(order.created.event)
    MessageQueue-->>Consumer: order.created

7. Caching Design

Cache Strategy Selection

For the Unit of Work pattern, we'll use a cache-aside strategy with write-through behavior for critical consistency. This approach provides the best balance between performance and data consistency, particularly when the Unit of Work is expected to be committed or rolled back as a single atomic operation.

Cache Key Design

Cache keys will follow a structured namespace pattern:

{namespace}:{entityType}:{id}:{version}

Example:

uow:order:12345:1
uow:user:67890:2

TTL Strategy

Data TypeTTLReason
Unit of Work Metadata5 minutesShort-lived, only needed during active transaction
Entity Cache10 minutesAllows for read-heavy workloads with reasonable freshness
Transaction State30 secondsCritical state tracking for concurrent operations
Configuration Cache1 hourStable configuration data

Cache Eviction Policy

We'll implement a hybrid LRU + TTL eviction policy:

  • Primary: LRU-based eviction for memory pressure management
  • Secondary: TTL-based expiration to ensure data freshness and prevent stale reads

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic Early Expiry: Cache entries expire 20% early to prevent simultaneous refreshes
  2. Distributed Locking: Use Redis distributed locks for cache population during high-concurrency scenarios
  3. Cache Entry Validation: Validate cache entries with version numbers to ensure consistency

Distributed Cache Architecture

We'll use Redis Cluster with:

  • 3 master nodes for high availability
  • 3 replica nodes per master for read scaling
  • Redis Sentinel for automatic failover
  • Cache sharding based on entity type to distribute load

Cache Invalidation Strategy

We'll implement event-driven invalidation:

  1. Write Operations: Invalidate related cache entries when entities are modified
  2. Transaction Commit: Clear all Unit of Work associated caches upon successful commit
  3. Rollback Handling: Restore invalidated entries from database state on rollback

Cache Implementation Example (Java)

@Component
public class UnitOfWorkCache {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;
    
    public void putUnitOfWork(UnitOfWork uow) {
        String key = buildCacheKey("uow", uow.getId(), uow.getVersion());
        String json = objectMapper.writeValueAsString(uow);
        
        // Set with TTL and write-through behavior
        redisTemplate.opsForValue().set(key, json, 5, TimeUnit.MINUTES);
        
        // Cache related entities
        uow.getEntities().forEach(entity -> {
            String entityKey = buildCacheKey("entity", entity.getId(), entity.getVersion());
            redisTemplate.opsForValue().set(entityKey, entity, 10, TimeUnit.MINUTES);
        });
    }
    
    public UnitOfWork getUnitOfWork(String uowId) {
        String key = buildCacheKey("uow", uowId, null);
        String json = (String) redisTemplate.opsForValue().get(key);
        
        if (json != null) {
            try {
                return objectMapper.readValue(json, UnitOfWork.class);
            } catch (Exception e) {
                // Log and continue with DB fetch
                log.warn("Failed to deserialize cached UOW: {}", uowId);
            }
        }
        
        return null;
    }
    
    public void invalidateUnitOfWork(String uowId) {
        String key = buildCacheKey("uow", uowId, null);
        redisTemplate.delete(key);
        
        // Invalidate related entities
        String entityKeyPattern = buildCacheKey("entity", "*", null);
        Set<String> keys = redisTemplate.keys(entityKeyPattern);
        redisTemplate.delete(keys);
    }
    
    private String buildCacheKey(String namespace, String id, String version) {
        return String.format("%s:%s:%s", namespace, id, version != null ? version : "latest");
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern provides a clean abstraction between the domain layer and data access logic, promoting loose coupling and testability. In this design, repositories encapsulate all data access operations for specific entities.

public interface UserRepository extends Repository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByRole(String role);
    Page<User> findAll(Pageable pageable);
}

public interface OrderRepository extends Repository<Order, Long> {
    List<Order> findByUserId(Long userId);
    List<Order> findByStatus(OrderStatus status);
    Page<Order> findByUserIdAndStatus(Long userId, OrderStatus status, Pageable pageable);
}

ORM vs. Raw Query Decisions

For the Unit of Work pattern, we'll use JPA/Hibernate as the ORM for most entities due to its maturity and support for lazy loading, caching, and complex object relationships. Raw SQL queries will be used for performance-critical operations or when complex joins are required.

// ORM approach - good for most use cases
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);

// Raw SQL - for performance-critical queries
@Query(value = "SELECT * FROM users u WHERE u.email = ?1", nativeQuery = true)
Optional<User> findByEmailNative(String email);

Read vs. Write Separation

We implement CQRS (Command Query Responsibility Segregation) for read-heavy workloads to optimize performance:

public interface UserReadRepository {
    UserProjection findUserById(Long id);
    List<UserProjection> findAllUsers();
    UserProjection findUserByEmail(String email);
}

public interface UserWriteRepository {
    void save(User user);
    void delete(Long id);
    void update(User user);
}

Connection Pool Configuration

Connection pool is configured with:

  • Max pool size: 20
  • Min idle connections: 5
  • Connection timeout: 30 seconds
  • Validation query: SELECT 1
# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      validation-query: "SELECT 1"

N+1 Query Prevention Strategy

We implement eager loading and batch fetching to prevent N+1 queries:

@Entity
public class User {
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @BatchSize(size = 10)
    private List<Order> orders;
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "role_id")
    private Role role;
}

// In repository query with JOIN FETCH
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders o WHERE u.id = :id")
Optional<User> findWithOrders(@Param("id") Long id);

Batch Operation Design

Batch operations are implemented for bulk data processing:

public class BatchUserRepository {
    public void batchSave(List<User> users) {
        entityManager.persist(users.get(0));
        for (int i = 1; i < users.size(); i++) {
            entityManager.persist(users.get(i));
            if (i % 50 == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
    
    public void batchUpdate(List<User> users) {
        for (int i = 0; i < users.size(); i++) {
            entityManager.merge(users.get(i));
            if (i % 50 == 0) {
                entityManager.flush();
            }
        }
    }
}

Repository Implementation Example

@Repository
public class UserRepositoryImpl implements UserRepository {
    private final EntityManager entityManager;
    private final UserReadRepository userReadRepository;
    
    public UserRepositoryImpl(EntityManager entityManager, 
                             UserReadRepository userReadRepository) {
        this.entityManager = entityManager;
        this.userReadRepository = userReadRepository;
    }
    
    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(entityManager.find(User.class, id));
    }
    
    @Override
    public User save(User user) {
        if (user.getId() == null) {
            entityManager.persist(user);
        } else {
            entityManager.merge(user);
        }
        return user;
    }
    
    @Override
    public void delete(Long id) {
        User user = entityManager.find(User.class, id);
        if (user != null) {
            entityManager.remove(user);
        }
    }
    
    @Override
    public List<User> findAll() {
        return entityManager.createQuery("SELECT u FROM User u", User.class)
                           .getResultList();
    }
    
    @Override
    public Page<User> findAll(Pageable pageable) {
        TypedQuery<User> query = entityManager.createQuery("SELECT u FROM User u", User.class);
        query.setFirstResult(pageable.getOffset());
        query.setMaxResults(pageable.getPageSize());
        
        List<User> content = query.getResultList();
        long total = entityManager.createQuery("SELECT COUNT(u) FROM User u", Long.class)
                                 .getSingleResult();
        
        return new PageImpl<>(content, pageable, total);
    }
    
    public Optional<User> findByEmail(String email) {
        return entityManager.createQuery(
            "SELECT u FROM User u WHERE u.email = :email", User.class)
                           .setParameter("email", email)
                           .getResultStream()
                           .findFirst();
    }
}

Transaction Management

Repository methods are transactional with proper propagation:

@Repository
@Transactional
public class UserRepositoryImpl implements UserRepository {
    // All methods are transactional by default in this design
    
    @Transactional(readOnly = true)
    public Optional<User> findByEmail(String email) {
        return entityManager.createQuery(
            "SELECT u FROM User u WHERE u.email = :email", User.class)
                           .setParameter("email", email)
                           .getResultStream()
                           .findFirst();
    }
}

Unit of Work Integration

The repository layer integrates with the Unit of Work pattern through the EntityManager:

public class UnitOfWork {
    private final EntityManager entityManager;
    private final Map<Class<?>, List<Object>> changes = new HashMap<>();
    
    public UnitOfWork(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    public <T> void register(T entity, ChangeType type) {
        changes.computeIfAbsent(entity.getClass(), k -> new ArrayList<>()).add(entity);
    }
    
    public void commit() {
        // Flush all changes to database
        entityManager.flush();
        changes.clear();
    }
    
    public void rollback() {
        // Clear all pending changes
        changes.clear();
        entityManager.clear();
    }
}

This repository design provides:

  1. Clear separation of concerns between data access and business logic
  2. Support for both ORM and raw SQL approaches
  3. CQRS pattern for optimized read/write operations
  4. Prevention of N+1 query problems through eager loading and batch fetching
  5. Proper transaction management with Unit of Work integration
  6. Performance optimizations through connection pooling and batch operations

9. Asynchronous Processing & Messaging

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

The Unit of Work pattern is primarily a synchronization mechanism for database transactions. However, when extended to support asynchronous processing, we can leverage event-driven architecture to decouple transactional operations from side effects like notifications, audit logging, or integration with external systems.

In this design:

  • Synchronous operations: All core business logic that must succeed or fail as a unit (e.g., creating an order, updating inventory) remain synchronous and within the same transaction boundary.
  • Asynchronous operations: Events that are published after successful transaction commit but don't block the main flow (e.g., sending confirmation emails, updating analytics, triggering external webhooks) are handled asynchronously.

This separation ensures that the core business logic maintains ACID properties while allowing for scalable and resilient side-effect handling.

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

We'll use JSON schema for simplicity and flexibility, but in production, Avro or Protobuf would be preferred for type safety and schema evolution.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "UnitOfWorkEvent",
  "type": "object",
  "version": "1.0.0",
  "properties": {
    "eventId": { "type": "string" },
    "eventType": { "type": "string", "enum": ["unitOfWork.committed", "unitOfWork.failed"] },
    "timestamp": { "type": "string", "format": "date-time" },
    "unitOfWorkId": { "type": "string" },
    "entities": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "entityType": { "type": "string" },
          "operation": { "type": "string", "enum": ["create", "update", "delete"] },
          "entityId": { "type": "string" },
          "payload": { "type": "object" }
        }
      }
    },
    "metadata": {
      "type": "object",
      "properties": {
        "userId": { "type": "string" },
        "correlationId": { "type": "string" }
      }
    }
  },
  "required": ["eventId", "eventType", "timestamp", "unitOfWorkId"]
}

Versioning strategy:

  • Use semantic versioning (1.0.0, 1.0.1) for message schemas.
  • Maintain backward compatibility where possible.
  • Use schema registry (e.g., Confluent Schema Registry) to enforce versioning and validate messages.

Dead letter queue strategy and poison message handling

Poison messages are those that consistently fail processing after retries. They should be routed to a dead letter queue (DLQ) for manual inspection and recovery.

  • DLQ Configuration: Messages that exceed retry limits (e.g., 5 attempts) are moved to a DLQ topic named uow-dlq.
  • Poison Message Handling:
    • Log the error and metadata for debugging.
    • Alert on DLQ messages to trigger manual intervention.
    • Provide a dashboard or monitoring tool to inspect failed messages.

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

At-least-once delivery is chosen because:

  • The Unit of Work pattern ensures that database operations are atomic.
  • External systems can handle idempotent operations (e.g., duplicate email notifications).
  • The system is designed to be resilient to reprocessing.
  • Exactly-once delivery adds significant complexity and overhead (e.g., idempotency keys, transactional outbox) that isn't justified for this use case.

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

Retry policy configuration:

ConfigurationValue
Max retries5
Backoff formulaExponential with jitter (randomized delay)
Retry interval1s, 2s, 4s, 8s, 16s
Non-retryable errorsValidationException, AuthorizationException

Java implementation of retry logic:

public class RetryPolicy {
    private static final int MAX_RETRIES = 5;
    private static final long BASE_DELAY_MS = 1000L;

    public <T> T executeWithRetry(Supplier<T> operation) {
        Exception lastException = null;
        for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                if (shouldRetry(e)) {
                    long delay = calculateBackoff(attempt);
                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException("Interrupted during retry", ie);
                    }
                } else {
                    break;
                }
            }
        }
        throw new RuntimeException("Failed after " + MAX_RETRIES + " attempts", lastException);
    }

    private boolean shouldRetry(Exception e) {
        return !(e instanceof ValidationException || e instanceof AuthorizationException);
    }

    private long calculateBackoff(int attempt) {
        long delay = BASE_DELAY_MS * (1L << attempt);
        // Add jitter: random value between 0 and delay
        long jitter = ThreadLocalRandom.current().nextLong(delay);
        return delay + jitter;
    }
}

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

Outbox pattern is included in this design.

Justification:

  • Ensures that events are published only after the database transaction is committed.
  • Prevents event loss in case of system failures during transaction commit.
  • Provides a mechanism to retry failed events without re-executing business logic.

Implementation:

  1. Events are written to an event_outbox table within the same transaction as the business operation.
  2. A background job or consumer polls this table and publishes events to the message broker.
  3. Once successfully published, the event is marked as published.

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant Client
    participant Service
    participant Repository
    participant DB
    participant OutboxRepo
    participant EventPublisher

    Client->>Service: Place Order
    Service->>Repository: Save Order (in transaction)
    Repository->>DB: Insert Order
    Repository->>OutboxRepo: Insert Event to Outbox
    DB-->>Repository: Transaction committed
    OutboxRepo-->>EventPublisher: Poll for new events
    EventPublisher->>Broker: Publish to Topic

10. Error Handling Design

Error Classification Hierarchy

Errors are categorized into three main types to enable appropriate handling and response:

  1. Domain Errors - Business logic violations (e.g., insufficient funds, invalid state transitions)
  2. Infrastructure Errors - System-level issues (e.g., database connection failures, network timeouts)
  3. Validation Errors - Input validation failures (e.g., missing required fields, malformed data)
public abstract class BaseException extends RuntimeException {
    private final String errorCode;
    private final int httpStatus;

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

    public String getErrorCode() { return errorCode; }
    public int getHttpStatus() { return httpStatus; }
}

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

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

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

Error Propagation Strategy

Errors are propagated up the call stack with appropriate wrapping to maintain context, but handled at the appropriate layer:

  • Service Layer: Converts infrastructure errors to domain errors where appropriate
  • Controller Layer: Translates domain/validation errors into HTTP responses with proper status codes

Retry Strategy

Retry logic is implemented using exponential backoff with jitter to prevent thundering herd:

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 (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (attempts >= MAX_RETRIES - 1) throw e;
                
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts) 
                                  * (1 + JITTER_FACTOR * (Math.random() - 0.5)));
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new InfrastructureException("Retry interrupted", "RETRY_INTERRUPTED");
                }
                attempts++;
            }
        }
        throw new InfrastructureException("Retry limit exceeded", "RETRY_LIMIT_EXCEEDED");
    }
}

Circuit Breaker Design

Circuit breaker pattern is applied to external dependencies to prevent cascading failures:

public class CircuitBreaker {
    private static final int FAILURE_THRESHOLD = 5;
    private static final long TIMEOUT_MS = 60000;
    private static final int 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:
                try {
                    T result = operation.get();
                    onSuccess();
                    return result;
                } catch (Exception e) {
                    onFailure();
                    throw e;
                }
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime > RESET_TIMEOUT_MS) {
                    state = CircuitState.HALF_OPEN;
                    return execute(operation);
                }
                throw new InfrastructureException("Circuit breaker open", "CIRCUIT_OPEN");
            case HALF_OPEN:
                try {
                    T result = operation.get();
                    state = CircuitState.CLOSED;
                    return result;
                } catch (Exception e) {
                    state = CircuitState.OPEN;
                    throw e;
                }
        }
        throw new IllegalStateException("Invalid circuit state");
    }

    private void onSuccess() {
        failureCount = 0;
    }

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

Fallback Behavior Design

Fallbacks are implemented for critical external dependencies to ensure graceful degradation:

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

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

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

Error Logging Format and Alerting Thresholds

Structured logging includes trace context, error codes, and user identifiers:

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

    public void logError(String traceId, String userId, String errorCode, 
                        Exception exception, String context) {
        logger.error("TraceId: {}, UserId: {}, ErrorCode: {}, Context: {}, Exception: {}",
            traceId, userId, errorCode, context, exception.getMessage(),
            new ErrorLogDetails(traceId, userId, errorCode, context, exception));
    }

    private static class ErrorLogDetails {
        private final String traceId;
        private final String userId;
        private final String errorCode;
        private final String context;
        private final Exception exception;

        public ErrorLogDetails(String traceId, String userId, String errorCode, 
                              String context, Exception exception) {
            this.traceId = traceId;
            this.userId = userId;
            this.errorCode = errorCode;
            this.context = context;
            this.exception = exception;
        }
    }
}

Alerting thresholds are defined as follows:

Error TypeAlert ThresholdSeverity
Infrastructure Errors> 10 errors/minuteCritical
Domain Errors> 50 errors/minuteWarning
Validation Errors> 100 errors/minuteInfo

Error Handling in Unit of Work Context

In the context of Unit of Work pattern, error handling must ensure data consistency:

public class UnitOfWork {
    private final TransactionManager transactionManager;
    private final List<UnitOfWorkCallback> callbacks = new ArrayList<>();
    private boolean isCommitted = false;

    public void executeInTransaction(Runnable operation) {
        try {
            transactionManager.begin();
            operation.run();
            transactionManager.commit();
            isCommitted = true;
            executeCallbacks();
        } catch (Exception e) {
            transactionManager.rollback();
            executeErrorCallbacks(e);
            throw new InfrastructureException("Transaction failed", "TX_FAILED");
        }
    }

    private void executeCallbacks() {
        callbacks.forEach(callback -> callback.onSuccess());
    }

    private void executeErrorCallbacks(Exception error) {
        callbacks.forEach(callback -> callback.onError(error));
    }
}

11. Security Design

Input Validation and Sanitization Strategy

All external inputs (API requests, database queries, file uploads) must undergo comprehensive validation and sanitization to prevent injection attacks and data corruption. We'll use a combination of:

  • OWASP Java HTML Sanitizer for HTML content to prevent XSS
  • Apache Commons Text for encoding/decoding operations
  • Custom validation annotations using Bean Validation (JSR-303) for structured data
  • Regular expression validation for specific formats (email, phone, UUID)
public class UserInputValidator {
    private static final HtmlSanitizer htmlSanitizer = 
        HtmlSanitizerBuilder
            .fromDefaults()
            .allowElements("p", "br", "strong", "em")
            .build();

    public static String sanitizeHtml(String input) {
        return htmlSanitizer.sanitize(input);
    }

    public static boolean isValidEmail(String email) {
        return email.matches("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
    }
}

SQL Injection Prevention

All database queries must use parameterized prepared statements exclusively. No dynamic query construction or string concatenation is allowed.

// ❌ DO NOT DO THIS
String query = "SELECT * FROM users WHERE email = '" + email + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);

// ✅ DO THIS INSTEAD
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement stmt = connection.prepareStatement(query);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();

Authentication Middleware Design

Authentication middleware will intercept all requests and validate the presence and validity of authentication tokens. JWT-based authentication will be used with a custom filter that:

  1. Extracts the Bearer token from Authorization header
  2. Validates signature using symmetric key (HS256)
  3. Verifies expiration and audience claims
  4. Sets authenticated user context in thread-local storage
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response,
                                   FilterChain filterChain) throws ServletException, IOException {
        String token = extractToken(request);
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            Authentication auth = new UsernamePasswordAuthenticationToken(
                username, null, Collections.singletonList(new SimpleGrantedAuthority("USER")));
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        return (bearerToken != null && bearerToken.startsWith("Bearer ")) 
            ? bearerToken.substring(7) : null;
    }
}

Authorization Enforcement

Fine-grained authorization will be enforced at service layer using Role-Based Access Control (RBAC) principles:

  • User roles: ADMIN, MODERATOR, USER
  • Resource permissions: READ, WRITE, DELETE
  • Permission checking will occur before any data access or modification operations
@PreAuthorize("hasRole('ADMIN') or @userPermissionChecker.canAccessUser(principal, userId)")
public void deleteUser(String userId) {
    // Implementation here
}

Sensitive Data Encryption

All sensitive data at rest will be encrypted using AES-256 with a key rotation strategy:

  1. Master key: Stored in AWS KMS (or similar HSM)
  2. Data encryption keys: Generated per record and encrypted with master key
  3. In-transit: TLS 1.3 required for all external communications
@Service
public class DataEncryptionService {
    private final AesGcmEncryption aesGcmEncryption = new AesGcmEncryption();
    
    public String encrypt(String plaintext, String keyId) {
        return aesGcmEncryption.encrypt(plaintext, keyId);
    }
    
    public String decrypt(String ciphertext, String keyId) {
        return aesGcmEncryption.decrypt(ciphertext, keyId);
    }
}

PII Handling and Masking Strategy

Personal Identifiable Information (PII) will be:

  1. Masked in logs - only first 2 characters of SSN, last 4 digits of phone numbers
  2. Redacted in responses - sensitive fields like credit card numbers will be excluded from API responses
  3. Logged with proper PII handling - using structured logging that excludes sensitive fields
public class PiiMaskingUtil {
    public static String maskSsn(String ssn) {
        return ssn.length() > 5 ? ssn.substring(0, 2) + "XXX-XX-" + ssn.substring(7) : ssn;
    }

    public static String maskEmail(String email) {
        int atIndex = email.indexOf('@');
        if (atIndex <= 1) return email;
        return email.substring(0, 1) + "****" + email.substring(atIndex - 1);
    }
}

Secrets Management

All secrets (API keys, database passwords, encryption keys) will be managed through:

  1. AWS Secrets Manager (or equivalent in cloud provider)
  2. Rotating secrets every 90 days
  3. Environment-specific secret paths (e.g., /prod/app/db-password)
  4. Application-level caching with refresh mechanism
@Service
public class SecretsManager {
    private final AWSSecretsManager secretsManager;
    private final Map<String, String> secretCache = new ConcurrentHashMap<>();

    public String getSecret(String secretName) {
        return secretCache.computeIfAbsent(secretName, key -> {
            GetSecretValueRequest request = new GetSecretValueRequest().withSecretId(key);
            return secretsManager.getSecretValue(request).getSecretString();
        });
    }
}

Audit Logging Design

All security-relevant operations will be audited with:

  1. Required fields: timestamp, user_id, operation_type, resource_id, ip_address, result_code
  2. Log format: JSON with structured fields for easy querying and analysis
  3. Retention policy: 90 days for audit logs, 1 year for security events
@Component
public class SecurityAuditLogger {
    private static final Logger logger = LoggerFactory.getLogger(SecurityAuditLogger.class);

    public void logSecurityEvent(String userId, String operation, String resourceId, 
                                String ipAddress, boolean success) {
        Map<String, Object> auditEvent = new HashMap<>();
        auditEvent.put("timestamp", Instant.now().toString());
        auditEvent.put("user_id", userId);
        auditEvent.put("operation", operation);
        auditEvent.put("resource_id", resourceId);
        auditEvent.put("ip_address", ipAddress);
        auditEvent.put("success", success);
        
        logger.info("Security audit event: {}", auditEvent);
    }
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85%+ code coverage with focus on business logic, boundary conditions, and error paths
  • Repository Layer: 90%+ coverage with emphasis on query correctness and data access patterns
  • Domain Objects: 95%+ coverage for value objects, entities, and their validation logic
  • Integration Points: 100% coverage for critical paths like transaction boundaries and event publishing
  • Edge Cases: 100% coverage for null inputs, invalid states, and concurrent access scenarios

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL/MySQL to test actual database interactions and schema compliance
  • External Dependencies: Mock external services (payment gateways, notification systems) using WireMock or Mockito
  • Transaction Boundaries: Validate rollback behavior and consistency across multiple repository operations
  • Event Publishing: Verify event payloads and message broker interactions through embedded Kafka or RabbitMQ containers

Contract Testing

  • API Contracts: Use Pact for consumer-driven contract testing between microservices
  • Event Schemas: Validate event payloads using JSON Schema or Avro schema registry with versioning support
  • External Service Contracts: Test against real service endpoints using contract testing frameworks like Spring Cloud Contract

Performance Testing Tool and Key Scenarios

  • Tool: k6 for load testing with realistic concurrent user scenarios
  • Key Scenarios:
    • High-concurrency unit of work operations (500 concurrent requests)
    • Transaction rollback under load (1000 simultaneous writes with failures)
    • Batch operation throughput (1000 entities in single UoW)
    • Memory usage under sustained load (monitor GC and heap allocation)

Test Data Management Strategy

  • Fixtures: Maintain minimal, deterministic test data sets in YAML or JSON format for predictable test runs
  • Factories: Implement factory patterns for generating valid domain objects with realistic but varied data (e.g., OrderFactory with random but valid customer IDs)
  • Seeding: Use database migrations or test data builders to populate test databases with realistic baseline data
  • Isolation: Each test runs in a separate database transaction or isolated test database to prevent cross-contamination
// Example: Unit test for UoW with multiple operations
@Test
public void shouldCommitAllOperationsWhenSuccessful() {
    // Given
    Order order = orderFactory.createValidOrder();
    Customer customer = customerFactory.createValidCustomer();
    
    // When
    uow.begin();
    orderRepository.save(order);
    customerRepository.save(customer);
    uow.commit();
    
    // Then
    assertThat(orderRepository.findById(order.getId())).isPresent();
    assertThat(customerRepository.findById(customer.getId())).isPresent();
}

@Test
public void shouldRollbackAllOperationsWhenAnyFails() {
    // Given
    Order order = orderFactory.createValidOrder();
    
    // When/Then
    uow.begin();
    orderRepository.save(order);
    // Simulate failure in second operation
    assertThrows(ValidationException.class, () -> {
        customerRepository.save(null); // Invalid operation
    });
    
    uow.rollback();
    
    // Then
    assertThat(orderRepository.findById(order.getId())).isEmpty();
}

13. Observability Design

Structured Logging Schema

All logs must follow a consistent schema to enable effective querying and analysis. The structured logging format includes:

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "level": "INFO",
  "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",
  "operation": "createBooking",
  "request_id": "req_abcde12345",
  "duration_ms": 45,
  "error_code": "BOOKING_CREATION_FAILED",
  "error_message": "Failed to create booking due to invalid passenger details",
  "stack_trace": "...",
  "payload": {
    "booking_request": {
      "passenger_name": "John Doe",
      "flight_id": "flight_123"
    }
  }
}

PII masking rules:

  • User IDs and entity IDs are logged as-is if they're UUIDs or numeric IDs
  • Personal names, email addresses, and phone numbers are masked to *** or truncated to first character + *
  • Credit card numbers and sensitive fields are fully redacted

Distributed Tracing Instrumentation

OpenTelemetry is used for distributed tracing across service boundaries. Custom spans are added to key operations:

// Example: Tracing in booking creation flow
Span span = tracer.spanBuilder("createBooking")
    .setAttribute("booking.request.user_id", userId)
    .setAttribute("booking.request.flight_id", flightId)
    .startSpan();

try (Scope scope = span.makeCurrent()) {
    // Business logic here
    bookingService.createBooking(request);
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}

Key spans to capture:

  • createBooking - full booking creation flow
  • validatePassengerDetails - validation sub-operation
  • reserveSeat - seat allocation operation
  • updateInventory - inventory management
  • publishBookingCreatedEvent - async event publishing

Metrics Design

RED (Rate, Errors, Duration) metrics are collected for all service endpoints:

Service-Level Metrics

Metric NameTypeLabelsDescription
http_requests_totalCountermethod, path, status_codeTotal HTTP requests
http_request_duration_secondsHistogrammethod, pathRequest processing time
database_query_duration_secondsHistogramoperation, tableDB query execution time
active_transactionsGaugetypeActive UoW transactions
cache_hits_totalCountertype, operationCache hit count
error_count_totalCountererror_type, componentError occurrences

UoW-Specific Metrics

Metric NameTypeLabelsDescription
uow_transaction_count_totalCounterstatus, typeTransaction lifecycle events
uow_commit_duration_secondsHistogramsuccess, typeCommit operation duration
uow_rollback_count_totalCounterreason, typeRollback occurrences
repository_operations_totalCounteroperation, entity_typeRepository method calls

Health Check Endpoint Design

Health checks are implemented as separate endpoints to support different readiness levels:

@RestController
@RequestMapping("/health")
public class HealthController {
    
    @GetMapping("/live")
    public ResponseEntity<String> live() {
        return ResponseEntity.ok("OK");
    }
    
    @GetMapping("/ready")
    public ResponseEntity<String> ready() {
        // Check DB connectivity, cache availability, external service status
        if (isDatabaseReady() && isCacheReady()) {
            return ResponseEntity.ok("READY");
        }
        return ResponseEntity.status(503).body("UNREADY");
    }
    
    @GetMapping("/startup")
    public ResponseEntity<String> startup() {
        // Validate configuration, required dependencies
        return ResponseEntity.ok("STARTUP_COMPLETE");
    }
}

Alerting Thresholds

Critical alerts are configured with thresholds that trigger on-call escalation:

MetricWarning LevelCritical LevelAlerting Channel
http_request_duration_secondsp95 > 500msp99 > 2sSlack/Email
database_query_duration_secondsp95 > 100msp99 > 1sSlack/Email
error_count_total> 5 errors/min> 20 errors/minPagerDuty
active_transactions> 50 concurrent> 100 concurrentSlack/Email
cache_miss_rate> 30%> 50%Slack/Email

Log Rotation and Retention

  • Logs are rotated daily with 30-day retention
  • Error logs are retained for 90 days for forensic analysis
  • Structured logs are sent to centralized logging system (e.g., ELK stack)
  • Log aggregation includes filtering for sensitive fields and PII masking

Service Map and Dependency Tracing

OpenTelemetry service maps are generated to visualize service dependencies, allowing for quick identification of performance bottlenecks and failure propagation paths. The service map includes:

  • Service-to-service call relationships
  • Latency distribution per endpoint
  • Error rate per service and operation
  • Transaction flow visualization for UoW operations

Performance Monitoring

Key performance indicators are monitored:

  • Database query latency distribution (p50, p95, p99)
  • Transaction commit success rate
  • Cache hit ratio and miss rate
  • Memory usage and GC pressure
  • Concurrent request handling capacity

Audit Trail Integration

All UoW operations that modify data are logged with audit trail information:

public class AuditLogger {
    public void logUnitOfWorkOperation(String operation, String userId, String entityId) {
        logger.info("UOW_OPERATION", 
            "operation", operation,
            "user_id", userId,
            "entity_id", entityId,
            "timestamp", Instant.now(),
            "trace_id", getCurrentTraceId()
        );
    }
}

This ensures that all UoW-based data modifications can be traced back to their origin, supporting compliance and forensic analysis requirements.

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Transaction boundary granularity: Should Unit of Work span entire business operations or be scoped to individual domain actions? This affects consistency vs. performance trade-offs and requires alignment on business logic boundaries.
  • Event publishing strategy: Whether to publish events within the same transaction or defer them to an outbox pattern. This impacts eventual consistency guarantees and system complexity.
  • CQRS vs. CTO approach: Whether to maintain separate read/write models or use a unified model with read replicas, particularly for high-read workloads.
  • Cross-service transaction handling: Whether to use Saga orchestration or distributed transactions (e.g., 2PC) for cross-service operations, requiring coordination across teams.

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Transaction scopeSingle UoW per business operationMultiple UoWs in sequenceOption 1 provides stronger consistency but can increase latency and reduce throughput. Option 2 allows better performance isolation but introduces complexity in managing distributed state
Event publishing timingSynchronous event publishingAsynchronous with outbox patternOption 1 provides immediate consistency but can cause performance bottlenecks. Option 2 decouples systems but introduces eventual consistency and complexity
Caching strategyCache-aside with TTL-based invalidationWrite-through with cache warmingOption 1 reduces cache inconsistency but increases read latency. Option 2 improves read performance but requires more complex cache management
Read model refreshImmediate sync with write operationsBatched async refreshOption 1 ensures strong consistency but increases write latency. Option 2 improves write performance but introduces eventual consistency

Risks and mitigation strategies

  • Risk: Transaction timeout under high load
    Mitigation: Implement adaptive transaction timeouts, use read-only transactions where possible, and provide circuit breaker patterns for long-running operations.

  • Risk: Memory leak in UoW lifecycle management
    Mitigation: Implement strict object lifecycle management with proper cleanup in close() and dispose() methods, use weak references for cached entities, and enforce unit test coverage for UoW cleanup.

  • Risk: Inconsistent state in distributed systems
    Mitigation: Use Saga pattern for cross-service operations, implement idempotent operations, and design compensating actions for failed transactions.

  • Risk: Performance degradation from UoW overhead
    Mitigation: Profile and optimize UoW implementation, use lazy initialization for entities, and implement connection pooling to minimize database overhead.

Technical debt acknowledged

  • Debt: Tight coupling between UoW and repository layer
    Impact: Makes it harder to swap out data access strategies or implement different persistence patterns.
    Mitigation: Introduce repository abstraction and dependency injection to reduce coupling.

  • Debt: Manual transaction management in service layer
    Impact: Increases risk of inconsistent transaction boundaries and makes code harder to maintain.
    Mitigation: Consider using Spring's @Transactional or similar declarative transaction management to reduce boilerplate.

  • Debt: Lack of automatic rollback on exceptions
    Impact: Requires manual error handling in every UoW usage, increasing chance of human error.
    Mitigation: Implement automatic rollback with exception handling wrappers or use AOP for transaction boundary management.

  • Debt: Limited support for nested transactions
    Impact: Complex business operations requiring sub-transactions cannot be handled cleanly.
    Mitigation: Consider implementing nested transaction support or provide clear documentation for use cases that require it.

  • Debt: No built-in audit trail capability
    Impact: Adding audit logging requires manual implementation in each UoW usage.
    Mitigation: Extend UoW to include audit logging capabilities or provide a decorator pattern for audit support.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend HLD

    Job Scheduling System Architecture

    Comprehensive backend design for a scalable job scheduling system with distributed processing and fault tolerance capabilities.

  • Backend HLD

    Workflow Engine Backend Architecture and Design Patterns

    Comprehensive high-level design document for building a scalable, fault-tolerant workflow engine with distributed processing and state 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.