Skip to main content

Policy Engine Design and Implementation

Comprehensive low-level design for a flexible, scalable policy engine system supporting rule-based access control and business logic enforcement.

A policy engine is a system component that enforces organizational policies and business rules by evaluating conditions and making decisions based on predefined rules, commonly used for access control, compliance checking, and automated decision-making in enterprise applications.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the scale of policies and policy evaluation? Are we talking about hundreds of thousands of policies or millions, with potentially millions of evaluations per second?
  2. What are the consistency requirements for policy evaluation? Should all evaluations be strongly consistent, or can eventual consistency be acceptable?
  3. What types of entities and attributes are being evaluated? Is this for access control (RBAC/ABAC), pricing rules, or business logic validation?
  4. What is the read/write ratio for policies? Is policy creation/updates frequent, or mostly read-heavy?
  5. What are the latency sensitivity requirements? Are policy evaluations expected to be sub-millisecond, or can they tolerate higher latencies?
  6. What authentication and authorization model is in place? Is it JWT-based, OAuth2, or another system?
  7. Are there any external systems that need to be integrated with the policy engine? For example, integrating with identity providers or external rule repositories.

Functional requirements

  • Policy Creation and Management: Create, update, delete, and query policies with version control.
  • Policy Evaluation: Evaluate policies against input data to determine if access or action is allowed.
  • Policy Validation: Validate policy syntax and semantics before storing or evaluating.
  • Policy Inheritance: Support for hierarchical policy structures where child policies inherit rules from parent policies.
  • Policy Groups: Group related policies for easier management and application.
  • Audit Trail: Log all policy evaluations with input, output, and metadata for compliance.

Non-functional requirements

  • Latency Targets:
    • p50: < 10ms
    • p95: < 50ms
    • p99: < 200ms
  • Throughput: Support at least 10,000 policy evaluations per second with concurrent access.
  • Availability: 99.9% uptime (43.2 minutes of downtime per year).
  • Scalability: Support horizontal scaling to handle peak loads.
  • Compliance: Support audit logging and data retention policies for regulatory compliance.

Constraints and assumptions

  • Assumptions:
    • Policy definitions will be written in a structured rule language (e.g., Rego, JSONPath).
    • Policies will be evaluated in a sandboxed environment to prevent malicious code execution.
    • The system will support policy versioning and rollback capability.
    • Policy evaluation will be synchronous for most use cases, with asynchronous support for batch evaluations.
  • Constraints:
    • The system must not introduce significant latency in the core application flow.
    • Policy engine must be decoupled from the main application to allow independent scaling.
    • The system will not support real-time policy updates during evaluation cycles.

Out of scope

  • External integrations: Integration with external identity providers or rule repositories is out of scope.
  • Complex policy composition: Advanced policy composition or policy merging logic is not part of this design.
  • UI/Console: A web UI or console for managing policies is not included in this scope.
  • Machine learning models: Integration with ML models or AI for policy generation or optimization is not in scope.
  • Cross-service policy evaluation: Policy evaluation across multiple services or microservices is not part of this design.
  • Policy migration tools: Tools to migrate policies from other systems or formats are out of scope.

2. API Design

API Style Selection

gRPC with Protocol Buffers is selected for the Policy Engine due to:

  • Strong typing and schema validation at compile time
  • Efficient binary serialization for high-throughput operations
  • Native support for streaming and bidirectional communication
  • Excellent tooling ecosystem (protoc, grpcurl, OpenTelemetry integration)
  • Built-in support for versioning and evolution of service contracts

Endpoint Definitions

MethodPathDescriptionAuth
EvaluatePolicy/policy.v1.PolicyService/EvaluatePolicyEvaluate a policy against input dataJWT
CreatePolicy/policy.v1.PolicyService/CreatePolicyCreate a new policy definitionJWT
UpdatePolicy/policy.v1.PolicyService/UpdatePolicyUpdate existing policy definitionJWT
DeletePolicy/policy.v1.PolicyService/DeletePolicySoft-delete policy definitionJWT
ListPolicies/policy.v1.PolicyService/ListPoliciesList policies with paginationJWT
GetPolicy/policy.v1.PolicyService/GetPolicyRetrieve policy details by IDJWT
ValidatePolicy/policy.v1.PolicyService/ValidatePolicyValidate policy syntax and structureJWT

Request and Response Schemas

Request Schema:

// PolicyEvaluationRequest
message PolicyEvaluationRequest {
  string policy_id = 1;
  google.protobuf.Struct input_data = 2;
  repeated string context_tags = 3;
}

// PolicyCreationRequest
message PolicyCreationRequest {
  string name = 1;
  string description = 2;
  string expression = 3;
  PolicyType type = 4;
  repeated string tags = 5;
  google.protobuf.Struct metadata = 6;
}

// PolicyUpdateRequest
message PolicyUpdateRequest {
  string policy_id = 1;
  optional string name = 2;
  optional string description = 3;
  optional string expression = 4;
  optional PolicyType type = 5;
  repeated string tags = 6;
}

// PolicyListRequest
message PolicyListRequest {
  int32 page_size = 1;
  string page_token = 2;
  repeated string tags = 3;
  optional PolicyType type = 4;
  optional string search_query = 5;
}

// PolicyValidationRequest
message PolicyValidationRequest {
  string expression = 1;
  optional string policy_type = 2;
}

Response Schema:

// PolicyEvaluationResponse
message PolicyEvaluationResponse {
  bool is_allowed = 1;
  string policy_id = 2;
  string decision_reason = 3;
  google.protobuf.Struct evaluated_data = 4;
  repeated string matched_rules = 5;
  google.protobuf.Timestamp evaluation_timestamp = 6;
}

// PolicyCreationResponse
message PolicyCreationResponse {
  string policy_id = 1;
  string name = 2;
  google.protobuf.Timestamp created_at = 3;
  google.protobuf.Timestamp updated_at = 4;
}

// PolicyListResponse
message PolicyListResponse {
  repeated Policy policies = 1;
  string next_page_token = 2;
}

// PolicyValidationResponse
message PolicyValidationResponse {
  bool is_valid = 1;
  string error_message = 2;
  repeated ValidationIssue issues = 3;
}

HTTP Status Codes and Error Response

Status CodeDescription
200Success
400Bad Request - Invalid input or validation error
401Unauthorized - Missing or invalid auth token
403Forbidden - Insufficient permissions
404Not Found - Policy not found
429Too Many Requests - Rate limit exceeded
500Internal Server Error - Unexpected server error
503Service Unavailable - System temporarily unavailable

Standardized error response format:

{
  "error": {
    "code": "POLICY_EVALUATION_FAILED",
    "message": "Failed to evaluate policy due to syntax error",
    "details": {
      "policy_id": "policy-123",
      "error_type": "SYNTAX_ERROR"
    },
    "timestamp": "2023-10-15T14:30:00Z"
  }
}

API Versioning Strategy

Versioned using semantic versioning in the service path:

  • /policy.v1.PolicyService/... for v1
  • /policy.v2.PolicyService/... for v2 (when breaking changes occur)

Pagination Design

Cursor-based pagination selected because:

  • More efficient than offset-based for large datasets
  • Consistent results even when data changes during pagination
  • Better performance for deep pagination

Authentication and Authorization

All endpoints require JWT tokens with scopes:

  • policy:read - for read operations (EvaluatePolicy, GetPolicy, ListPolicies)
  • policy:write - for write operations (CreatePolicy, UpdatePolicy, DeletePolicy)
  • policy:validate - for validation operations

Rate Limiting

Rate limiting per endpoint:

  • EvaluatePolicy: 1000 requests/minute
  • CreatePolicy, UpdatePolicy, DeletePolicy: 100 requests/minute
  • ListPolicies, GetPolicy: 500 requests/minute

Enforcement using Redis-based token bucket algorithm with sliding window.

Idempotency Design

For mutation endpoints (CreatePolicy, UpdatePolicy, DeletePolicy):

  • Accept Idempotency-Key header with UUID
  • Store operation metadata in Redis for 24 hours
  • If same key is used within window, return stored result
  • Use optimistic locking with version numbers for updates

Example Request/Response

Evaluate Policy Request:

{
  "policy_id": "policy-123",
  "input_data": {
    "user_role": "admin",
    "resource_type": "document",
    "action": "read"
  },
  "context_tags": ["production", "us-east"]
}

Evaluate Policy Response:

{
  "is_allowed": true,
  "policy_id": "policy-123",
  "decision_reason": "User role admin has read access to documents",
  "evaluated_data": {
    "user_role": "admin",
    "resource_type": "document",
    "action": "read"
  },
  "matched_rules": ["rule-1", "rule-2"],
  "evaluation_timestamp": "2023-10-15T14:30:00Z"
}

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    POLICY {
        string id PK
        string name
        string description
        string condition
        string action
        string status
        timestamp created_at
        timestamp updated_at
        string created_by
        string updated_by
    }

    POLICY_VERSION {
        string id PK
        string policy_id FK
        integer version_number
        string condition
        string action
        timestamp created_at
        string created_by
    }

    POLICY_EVALUATION {
        string id PK
        string policy_id FK
        string context_data
        string result
        timestamp evaluated_at
        string evaluator_id
    }

    POLICY_EVALUATION_DETAIL {
        string id PK
        string evaluation_id FK
        string attribute_name
        string attribute_value
        string operator
        string expected_value
        boolean matches
    }

    POLICY_GROUP {
        string id PK
        string name
        string description
        timestamp created_at
    }

    POLICY_GROUP_MEMBER {
        string policy_id FK
        string group_id FK
        primary key (policy_id, group_id)
    }

    POLICY_AUDIT_LOG {
        string id PK
        string policy_id FK
        string action_type
        string details
        timestamp logged_at
        string user_id
    }

    POLICY --o{ POLICY_VERSION : "has"
    POLICY --o{ POLICY_EVALUATION : "evaluated"
    POLICY_EVALUATION --o{ POLICY_EVALUATION_DETAIL : "has_details"
    POLICY --o{ POLICY_GROUP_MEMBER : "belongs_to"
    POLICY_GROUP_MEMBER --o{ POLICY_GROUP : "in_group"
    POLICY --o{ POLICY_AUDIT_LOG : "logged"

Table / Collection Schema Definitions

Policies Table

CREATE TABLE policies (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    condition TEXT NOT NULL, -- JSON or DSL string
    action TEXT NOT NULL, -- JSON or DSL string
    status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    created_by VARCHAR(255) NOT NULL,
    updated_by VARCHAR(255),
    
    INDEX idx_policies_status (status),
    INDEX idx_policies_created_at (created_at),
    INDEX idx_policies_name (name)
);

Policy Versions Table

CREATE TABLE policy_versions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
    version_number INTEGER NOT NULL,
    condition TEXT NOT NULL,
    action TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    created_by VARCHAR(255) NOT NULL,
    
    UNIQUE(policy_id, version_number),
    INDEX idx_policy_versions_policy_id (policy_id),
    INDEX idx_policy_versions_version_number (version_number)
);

Policy Evaluations Table

CREATE TABLE policy_evaluations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
    context_data JSONB NOT NULL,
    result TEXT NOT NULL, -- 'ALLOW', 'DENY', 'NOT_APPLICABLE'
    evaluated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    evaluator_id VARCHAR(255),
    
    INDEX idx_policy_evaluations_policy_id (policy_id),
    INDEX idx_policy_evaluations_evaluated_at (evaluated_at),
    INDEX idx_policy_evaluations_result (result)
);

Policy Evaluation Details Table

CREATE TABLE policy_evaluation_details (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    evaluation_id UUID NOT NULL REFERENCES policy_evaluations(id) ON DELETE CASCADE,
    attribute_name VARCHAR(255) NOT NULL,
    attribute_value TEXT,
    operator VARCHAR(50) NOT NULL, -- 'EQUALS', 'CONTAINS', etc.
    expected_value TEXT NOT NULL,
    matches BOOLEAN NOT NULL,
    
    INDEX idx_policy_evaluation_details_evaluation_id (evaluation_id),
    INDEX idx_policy_evaluation_details_attribute_name (attribute_name)
);

Policy Groups Table

CREATE TABLE policy_groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL UNIQUE,
    description TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    
    INDEX idx_policy_groups_name (name)
);

Policy Group Members Table

CREATE TABLE policy_group_members (
    policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
    group_id UUID NOT NULL REFERENCES policy_groups(id) ON DELETE CASCADE,
    
    PRIMARY KEY (policy_id, group_id)
);

Policy Audit Logs Table

CREATE TABLE policy_audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
    action_type VARCHAR(100) NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE', 'EVALUATE'
    details JSONB,
    logged_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    user_id VARCHAR(255),
    
    INDEX idx_policy_audit_logs_policy_id (policy_id),
    INDEX idx_policy_audit_logs_action_type (action_type),
    INDEX idx_policy_audit_logs_logged_at (logged_at)
);

Primary Key Strategy

UUIDs are used as primary keys for the following reasons:

  • Scalability: UUIDs can be generated without database coordination, enabling horizontal scaling across distributed systems.
  • Security: UUIDs provide better security than auto-incremented IDs by preventing enumeration attacks.
  • Data Migration: UUIDs make it easier to merge data from different sources or databases without ID conflicts.
  • Global Uniqueness: UUIDs ensure uniqueness across all systems and databases.

Index Design

Index NameFieldsTypePurpose
idx_policies_statusstatusStandardFast filtering by policy status
idx_policies_created_atcreated_atStandardEfficient time-based queries
idx_policies_namenameStandardFast policy name lookups
idx_policy_versions_policy_idpolicy_idStandardFast version retrieval by policy
idx_policy_versions_version_numberversion_numberStandardFast version number lookups
idx_policy_evaluations_policy_idpolicy_idStandardFast evaluation filtering by policy
idx_policy_evaluations_evaluated_atevaluated_atStandardTime-based evaluation analytics
idx_policy_evaluations_resultresultStandardFast filtering by evaluation results
idx_policy_evaluation_details_evaluation_idevaluation_idStandardFast detail retrieval
idx_policy_evaluation_details_attribute_nameattribute_nameStandardFast attribute-based filtering
idx_policy_audit_logs_policy_idpolicy_idStandardFast audit log lookups
idx_policy_audit_logs_action_typeaction_typeStandardFast action type filtering
idx_policy_audit_logs_logged_atlogged_atStandardTime-based audit analysis

Data Partitioning / Sharding Strategy

Time-based sharding on the policy_evaluations table:

  • Partition by month/year to enable efficient time-based analytics
  • Use PostgreSQL's built-in partitioning feature or application-level sharding with consistent hashing

Policy-based sharding for policies:

  • Shard by policy type or business unit to distribute load
  • Use a consistent hash function on policy ID or category field

Soft Delete vs. Hard Delete Decision

Soft delete is used for the following reasons:

  • Auditability: Historical context of policy changes and deletions is preserved
  • Recovery capability: Policies can be restored if deleted accidentally
  • Compliance: Required for regulatory compliance in many industries
  • Analytics: Historical evaluation data is preserved for analysis

Audit Trail Design

Audit Log Fields:

  • id: UUID for unique identification
  • policy_id: Foreign key to the policy being audited
  • action_type: Enum indicating type of action (CREATE, UPDATE, DELETE, EVALUATE)
  • details: JSONB containing specific changes or evaluation context
  • logged_at: Timestamp of when the audit event occurred
  • user_id: Identifier of the user who performed the action

Data Archival Strategy and Retention Policy

Archival Strategy:

  • Policy Evaluations: Archive evaluations older than 1 year to a separate archive table or data lake
  • Policy Versions: Keep all versions but mark older ones as archived (status field)
  • Audit Logs: Archive logs older than 5 years to cold storage

Retention Policy:

Data TypeRetention PeriodArchive Location
Policy Evaluations1 yearData lake (S3, BigQuery)
Policy VersionsIndefiniteDatabase
Audit Logs5 yearsCold storage (S3 Glacier)
Active PoliciesIndefiniteDatabase
// Example of policy evaluation archival logic in Java
public class PolicyArchivalService {
    private final PolicyEvaluationRepository evaluationRepo;
    private final AuditLogRepository auditLogRepo;
    
    public void archiveOldEvaluations() {
        LocalDateTime cutoffDate = LocalDateTime.now().minusYears(1);
        
        // Archive old evaluations
        evaluationRepo.findEvaluationsOlderThan(cutoffDate)
            .forEach(evaluation -> {
                // Move to archive table
                archiveEvaluation(evaluation);
                // Soft delete from main table
                evaluationRepo.softDelete(evaluation.getId());
            });
            
        // Archive old audit logs
        auditLogRepo.findLogsOlderThan(cutoffDate)
            .forEach(log -> {
                // Move to archive table
                archiveAuditLog(log);
                // Soft delete from main table
                auditLogRepo.softDelete(log.getId());
            });
    }
}

4. Class Design & Service Layer

classDiagram
    class PolicyEngine {
        +evaluate(PolicyContext) PolicyResult
        +registerPolicy(Policy)
        +unregisterPolicy(String)
    }
    
    class Policy {
        -id: String
        -name: String
        -conditions: List~Condition~
        -actions: List~Action~
        -priority: int
        -isActive: boolean
        +evaluate(PolicyContext) PolicyResult
    }
    
    class Condition {
        -field: String
        -operator: Operator
        -value: Object
        +evaluate(PolicyContext) boolean
    }
    
    class Action {
        -type: ActionType
        -parameters: Map~String, Object~
        +execute(PolicyContext)
    }
    
    class PolicyContext {
        -data: Map~String, Object~
        -user: User
        -request: Request
        +get(String)
        +set(String, Object)
    }
    
    class PolicyResult {
        -policyId: String
        -isMatched: boolean
        -actions: List~Action~
        -score: double
    }
    
    class Operator {
        +evaluate(Object, Object) boolean
    }
    
    class ActionType {
        +execute(PolicyContext, Map~String, Object~)
    }
    
    class PolicyEvaluator {
        +evaluate(PolicyContext, List~Policy~) List~PolicyResult~
    }
    
    PolicyEngine --> PolicyEvaluator
    PolicyEngine --> Policy
    Policy --> Condition
    Policy --> Action
    PolicyEvaluator --> Policy
    PolicyContext --> PolicyResult
    Condition --> Operator
    Action --> ActionType
stateDiagram-v2
    [*] --> Active
    Active --> Inactive : deactivate()
    Inactive --> Active : activate()
    Active --> Deleted : delete()
    Deleted --> [*]

Service Class Structure and Responsibilities

The policy engine system is built around several core service classes:

  1. PolicyEngineService - Main entry point for policy evaluation and management
  2. PolicyEvaluationService - Handles the actual policy matching and execution logic
  3. PolicyRepositoryService - Manages CRUD operations for policies in storage
  4. PolicyValidationService - Validates policy definitions and conditions before registration

Interface / Abstract Class Design

public interface PolicyEvaluator {
    List<PolicyResult> evaluate(PolicyContext context, List<Policy> policies);
}

public interface PolicyCondition {
    boolean evaluate(PolicyContext context);
}

public interface PolicyAction {
    void execute(PolicyContext context);
}

public enum OperatorType {
    EQUALS, NOT_EQUALS, GREATER_THAN, LESS_THAN, CONTAINS, IN
}

public enum ActionType {
    LOG, EMAIL, BLOCK, ALLOW, MODIFY
}

Business Logic Encapsulation Patterns

We implement the Strategy Pattern for handling different operator types and action types:

public class OperatorStrategy {
    private final Map<OperatorType, PolicyCondition> strategies = new HashMap<>();
    
    public OperatorStrategy() {
        strategies.put(OperatorType.EQUALS, new EqualsCondition());
        strategies.put(OperatorType.GREATER_THAN, new GreaterThanCondition());
        strategies.put(OperatorType.CONTAINS, new ContainsCondition());
    }
    
    public PolicyCondition getStrategy(OperatorType type) {
        return strategies.getOrDefault(type, new DefaultCondition());
    }
}

public class ActionStrategy {
    private final Map<ActionType, PolicyAction> strategies = new HashMap<>();
    
    public ActionStrategy() {
        strategies.put(ActionType.LOG, new LogAction());
        strategies.put(ActionType.BLOCK, new BlockAction());
        strategies.put(ActionType.ALLOW, new AllowAction());
    }
    
    public PolicyAction getStrategy(ActionType type) {
        return strategies.getOrDefault(type, new DefaultAction());
    }
}

Critical-Path Method Implementation

@Transactional
public List<PolicyResult> evaluatePolicies(PolicyContext context) {
    // 1. Fetch all active policies from cache or DB
    List<Policy> activePolicies = policyRepository.findActivePolicies();
    
    // 2. Sort policies by priority (higher first)
    activePolicies.sort(Comparator.comparing(Policy::getPriority).reversed());
    
    // 3. Evaluate policies in order of priority
    List<PolicyResult> results = new ArrayList<>();
    
    // Use pessimistic locking to prevent concurrent policy modification
    try (Lock lock = policyLockManager.acquireLock("policy-eval")) {
        for (Policy policy : activePolicies) {
            // Check if policy matches conditions
            boolean isMatched = evaluatePolicyConditions(context, policy);
            
            if (isMatched) {
                // Create result with matched policy
                PolicyResult result = new PolicyResult(
                    policy.getId(),
                    true,
                    policy.getActions(),
                    calculateMatchScore(policy, context)
                );
                
                // Execute actions
                executePolicyActions(context, result.getActions());
                
                results.add(result);
                
                // If policy has blocking action, stop evaluation
                if (hasBlockingAction(policy)) {
                    break;
                }
            }
        }
    } catch (LockException e) {
        throw new PolicyEvaluationException("Failed to acquire policy evaluation lock", e);
    }
    
    // 4. Publish evaluation event
    eventPublisher.publish(new PolicyEvaluationEvent(context, results));
    
    return results;
}

private boolean evaluatePolicyConditions(PolicyContext context, Policy policy) {
    return policy.getConditions().stream()
        .allMatch(condition -> condition.evaluate(context));
}

private void executePolicyActions(PolicyContext context, List<Action> actions) {
    for (Action action : actions) {
        PolicyAction policyAction = actionStrategy.getStrategy(action.getType());
        try {
            policyAction.execute(context);
        } catch (Exception e) {
            logger.error("Failed to execute action: {}", action.getType(), e);
            // Continue with other actions but log error
        }
    }
}

Transaction Boundary Design

A single policy evaluation operation constitutes one transaction:

  • Fetch all active policies from database/cache
  • Evaluate each policy in sequence with pessimistic locking
  • Execute actions (if any)
  • Publish evaluation event

Service Interface Contracts

public interface PolicyEngineService {
    List<PolicyResult> evaluate(PolicyContext context);
    void registerPolicy(Policy policy);
    void updatePolicy(String policyId, Policy updatedPolicy);
    void deletePolicy(String policyId);
    void activatePolicy(String policyId);
    void deactivatePolicy(String policyId);
}

public interface PolicyRepositoryService {
    List<Policy> findActivePolicies();
    Optional<Policy> findById(String id);
    void save(Policy policy);
    void delete(String id);
}

Saga / Orchestration Pattern

For complex policy operations involving external systems:

  • Policy creation involves validation, storage, and cache invalidation
  • Use saga pattern to ensure eventual consistency across systems
  • Each step is idempotent and can be retried

Domain Object / Value Object Design

public class Policy {
    private final String id;
    private final String name;
    private final List<Condition> conditions;
    private final List<Action> actions;
    private final int priority;
    private final boolean isActive;
    
    // Constructor, getters, equals, hashCode
}

public class Condition {
    private final String field;
    private final OperatorType operator;
    private final Object value;
    
    // Constructor, getters
}

public class Action {
    private final ActionType type;
    private final Map<String, Object> parameters;
    
    // Constructor, getters
}

public class PolicyContext {
    private final Map<String, Object> data;
    private final User user;
    private final Request request;
    
    // Constructor, getters, utility methods
}

public class PolicyResult {
    private final String policyId;
    private final boolean isMatched;
    private final List<Action> actions;
    private final double score;
    
    // Constructor, getters
}

5. Concurrency & Thread Safety

Concurrency Model

The policy engine will operate in a multi-threaded environment where concurrent policy evaluations and rule updates may occur. The system will use a thread pool with fixed-size worker threads to handle incoming policy evaluation requests, ensuring predictable resource usage and avoiding thread starvation. Each request will be processed by a dedicated worker thread from the pool, allowing for concurrent evaluation of multiple policies without blocking.

Race Condition Prevention Strategy

Race conditions will be prevented through:

  1. Immutable policy objects - Once a policy is loaded into memory, it cannot be modified
  2. Read-copy-update (RCU) pattern - For policy updates, a new version is created and swapped atomically
  3. Synchronized access to shared mutable state - Using read-write locks for policy cache and rule registry
  4. Thread-local storage for temporary evaluation context to prevent cross-thread contamination

Optimistic vs. Pessimistic Locking Selection

Optimistic locking will be used for policy updates and rule modifications because:

  • Policy evaluation is the primary operation, which should not be blocked by update operations
  • Update frequency is expected to be low compared to evaluation frequency
  • Conflicts are rare and can be handled gracefully with retry logic
  • Reduces contention and improves throughput for concurrent evaluations

Deadlock Prevention Strategy

Deadlock prevention will be achieved through:

  1. Lock ordering - All threads acquire locks in a consistent order (e.g., by policy ID, then rule ID)
  2. Timeout-based acquisition - Maximum wait time for locks to prevent indefinite blocking
  3. Hierarchical lock management - Policy-level locks before rule-level locks to maintain consistent hierarchy
  4. Deadlock detection - Periodic checks for lock cycles in the system

Idempotency Implementation for Concurrent Duplicate Requests

For concurrent duplicate policy evaluation requests:

  1. Request deduplication - Use request ID or hash to identify and reject duplicate requests
  2. Caching with TTL - Cache evaluation results with short TTL to avoid re-computation
  3. Read-only operations - Policy evaluation is a read operation, so concurrent reads are safe
  4. Idempotent result storage - Store evaluation results with unique identifiers to prevent reprocessing
// Example implementation of concurrent policy evaluation with idempotency
public class PolicyEngine {
    private final Map<String, CompletableFuture<PolicyEvaluationResult>> evaluationCache = 
        new ConcurrentHashMap<>();
    private final ReadWriteLock policyLock = new ReentrantReadWriteLock();
    
    public CompletableFuture<PolicyEvaluationResult> evaluatePolicyAsync(
            PolicyRequest request, String requestId) {
        
        // Idempotency check - return cached result if available
        CompletableFuture<PolicyEvaluationResult> cached = evaluationCache.get(requestId);
        if (cached != null) {
            return cached;
        }
        
        // Create new evaluation task
        CompletableFuture<PolicyEvaluationResult> future = CompletableFuture.supplyAsync(() -> {
            try {
                // Acquire read lock for policy access
                policyLock.readLock().lock();
                try {
                    return evaluatePolicyInternal(request);
                } finally {
                    policyLock.readLock().unlock();
                }
            } catch (Exception e) {
                throw new RuntimeException("Policy evaluation failed", e);
            }
        });
        
        // Cache the future for idempotency
        evaluationCache.put(requestId, future);
        
        // Remove from cache after TTL
        CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS)
            .execute(() -> evaluationCache.remove(requestId));
            
        return future;
    }
    
    private PolicyEvaluationResult evaluatePolicyInternal(PolicyRequest request) {
        // Actual policy evaluation logic here
        return new PolicyEvaluationResult();
    }
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant U as User/Client
    participant API as PolicyEngineAPI
    participant Auth as AuthMiddleware
    participant Service as PolicyService
    participant DB as Database
    participant Cache as RedisCache
    participant EventPublisher as EventPublisher

    U->>API: POST /policies/evaluate
    API->>Auth: Validate JWT
    Auth-->>API: Valid token
    API->>Service: evaluatePolicy(policyId, context)
    Service->>Cache: get(policyId)
    Cache-->>Service: cached policy or miss
    alt cache miss
        Service->>DB: fetchPolicy(policyId)
        DB-->>Service: policy data
        Service->>Cache: put(policyId, policy)
    end
    Service->>Service: evaluateConditions(context)
    Service->>EventPublisher: publish(policyEvaluation, context)
    EventPublisher-->>Service: event published
    Service-->>API: response (allowed/denied)
    API-->>U: 200 OK (allowed) or 403 Forbidden (denied)
sequenceDiagram
    participant U as User/Client
    participant API as PolicyEngineAPI
    participant Auth as AuthMiddleware
    participant Service as PolicyService
    participant DB as Database
    participant EventPublisher as EventPublisher

    U->>API: POST /policies/validate
    API->>Auth: Validate JWT
    Auth-->>API: Valid token
    API->>Service: validatePolicy(policy)
    Service->>DB: savePolicy(policy)
    DB-->>Service: policyId
    Service->>EventPublisher: publish(policyCreated, policy)
    EventPublisher-->>Service: event published
    Service-->>API: response (policyId)
    API-->>U: 201 Created with policyId
sequenceDiagram
    participant U as User/Client
    participant API as PolicyEngineAPI
    participant Auth as AuthMiddleware
    participant Service as PolicyService
    participant DB as Database
    participant EventPublisher as EventPublisher
    participant AsyncHandler as AsyncPolicyHandler

    U->>API: POST /policies/evaluateAsync
    API->>Auth: Validate JWT
    Auth-->>API: Valid token
    API->>Service: queueEvaluation(policyId, context)
    Service->>EventPublisher: publish(policyEvaluationRequested, payload)
    EventPublisher-->>Service: event published
    Service-->>API: response (queueId)
    API-->>U: 202 Accepted with queueId
    EventPublisher->>AsyncHandler: consume policyEvaluationRequested
    AsyncHandler->>Service: evaluatePolicy(policyId, context)
    Service->>DB: fetch policy
    Service->>Service: evaluate conditions
    AsyncHandler->>EventPublisher: publish(policyEvaluationCompleted, result)
    EventPublisher-->>AsyncHandler: event published

7. Caching Design

Cache Strategy Selection

For policy engine, we'll use cache-aside pattern with write-through behavior for critical policy data. This approach provides the best balance between consistency and performance for a policy engine where correctness is paramount.

Cache Key Design

Cache keys will follow a namespace-based pattern:

policy:{policyId}:{version}
policy:rule:{ruleId}
policy:category:{categoryId}
policy:effective:{userId}:{policyId}

TTL Strategy

Data TypeTTLReason
Policy definitions1 hourPolicies change infrequently, but need to be refreshed
Rule definitions24 hoursRules are stable once defined
Effective policies5 minutesUser-specific policy evaluation needs frequent refresh
Policy categories1 weekCategories are very stable
User policy cache30 secondsReal-time user policy evaluation requires freshness

Cache Eviction Policy

Use LRU (Least Recently Used) with TTL-based expiration as the primary eviction strategy. This ensures:

  • Frequently accessed policies stay in cache
  • Memory is reclaimed for stale data
  • Predictable cache behavior under load

Cache Stampede Prevention

Implement probabilistic early expiry with 20% random jitter:

public class CacheStampedePrevention {
    private static final double JITTER_FACTOR = 0.2;
    
    public String getWithStampedeProtection(String key, Supplier<String> loader) {
        String cached = cache.get(key);
        if (cached != null) {
            return cached;
        }
        
        // Use distributed lock to prevent stampede
        String lockKey = "lock:" + key;
        if (distributedLock.tryAcquire(lockKey, 100)) {
            try {
                // Double-check pattern
                cached = cache.get(key);
                if (cached != null) {
                    return cached;
                }
                
                // Load fresh data
                String value = loader.get();
                
                // Add jitter to TTL (e.g., 5min ± 1min)
                long ttl = calculateTTLWithJitter(300000);
                cache.put(key, value, ttl);
                
                return value;
            } finally {
                distributedLock.release(lockKey);
            }
        }
        
        // Fallback to cached value or wait
        return cache.get(key);
    }
    
    private long calculateTTLWithJitter(long baseTTL) {
        return (long) (baseTTL * (1 + Math.random() * JITTER_FACTOR));
    }
}

Distributed Cache Architecture

Use Redis Cluster with:

  • 3 master nodes for policy data
  • 3 replica nodes for read redundancy
  • Redis Sentinel for high availability
  • Cache sharding by policy category hash

Cache Invalidation Strategy

Implement event-driven invalidation:

  1. When policy is updated → invalidate all related cache entries
  2. When user role changes → invalidate effective policy cache for that user
  3. When rule is updated → invalidate rule cache and dependent policies
@Component
public class PolicyCacheInvalidator {
    
    @EventListener
    public void handlePolicyUpdated(PolicyUpdatedEvent event) {
        String policyId = event.getPolicyId();
        
        // Invalidate policy definition
        cache.evict("policy:" + policyId);
        
        // Invalidate effective policies for users affected by this policy
        Set<String> affectedUsers = getAffectedUsers(policyId);
        affectedUsers.forEach(user -> {
            cache.evict("policy:effective:" + user + ":" + policyId);
        });
        
        // Invalidate rule cache if rule was part of this policy
        Set<String> ruleIds = getRuleIdsFromPolicy(policyId);
        ruleIds.forEach(ruleId -> {
            cache.evict("policy:rule:" + ruleId);
        });
    }
}

Cache Read-Through Pattern

@Service
public class PolicyCacheService {
    
    private final RedisTemplate<String, String> redisTemplate;
    
    public PolicyDefinition getPolicy(String policyId) {
        String cacheKey = "policy:" + policyId;
        
        // Try cache first
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return deserializePolicy(cached);
        }
        
        // Cache miss - load from database
        PolicyDefinition policy = policyRepository.findById(policyId);
        if (policy != null) {
            // Cache with TTL
            redisTemplate.opsForValue()
                .set(cacheKey, serializePolicy(policy), 1, TimeUnit.HOURS);
        }
        
        return policy;
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain logic and persistence concerns. Each entity will have its own repository interface with standardized CRUD operations.

public interface PolicyRepository {
    Optional<Policy> findById(UUID id);
    List<Policy> findAll();
    Policy save(Policy policy);
    void delete(UUID id);
    Page<Policy> findAll(Pageable pageable);
}

public interface PolicyRuleRepository {
    List<PolicyRule> findByPolicyId(UUID policyId);
    void deleteByPolicyId(UUID policyId);
}

ORM vs. Raw Query Decisions

For the policy engine, we use JPA/Hibernate as the ORM for most entities due to its maturity, type safety, and integration with Spring ecosystem. However, for complex policy evaluation queries that require performance optimization or custom logic, raw SQL is used via @NamedNativeQuery or direct JDBC.

Read vs. Write Separation

We implement CQRS (Command Query Responsibility Segregation) for the policy engine to separate read and write operations:

// Write-side repository - handles mutations
public interface PolicyWriteRepository {
    Policy save(Policy policy);
    void delete(UUID id);
}

// Read-side repository - optimized for queries
public interface PolicyReadRepository {
    PolicyProjection findByPolicyId(UUID policyId);
    List<PolicySummary> findActivePolicies();
    Page<PolicyProjection> searchPolicies(SearchCriteria criteria, Pageable pageable);
}

Connection Pool Configuration

Using HikariCP for connection pooling with optimized settings:

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

N+1 Query Prevention Strategy

To prevent N+1 queries, we use eager loading with JOIN FETCH and batch size configuration:

@Query("SELECT p FROM Policy p LEFT JOIN FETCH p.rules WHERE p.id = :id")
Optional<Policy> findWithRulesById(@Param("id") UUID id);

@Query("SELECT p FROM Policy p LEFT JOIN FETCH p.rules r WHERE p.status = :status")
List<Policy> findActivePoliciesWithRules(@Param("status") PolicyStatus status);

Batch Operation Design

Batch operations are implemented for bulk policy creation and updates:

public class PolicyBatchRepository {
    
    @Modifying
    public void batchCreate(List<Policy> policies) {
        String sql = "INSERT INTO policies (id, name, description, status, created_at) VALUES (?, ?, ?, ?, ?)";
        // Batch execution using JDBC template
    }
    
    @Modifying
    public void batchUpdateStatus(List<UUID> policyIds, PolicyStatus newStatus) {
        String sql = "UPDATE policies SET status = ? WHERE id IN ";
        // Build IN clause dynamically with proper parameter binding
    }
}

Repository Implementation Example

@Repository
public class PolicyJpaRepository implements PolicyRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public Optional<Policy> findById(UUID id) {
        return Optional.ofNullable(entityManager.find(Policy.class, id));
    }
    
    @Override
    public Policy save(Policy policy) {
        if (policy.getId() == null) {
            entityManager.persist(policy);
        } else {
            entityManager.merge(policy);
        }
        return policy;
    }
    
    @Override
    public void delete(UUID id) {
        Policy policy = entityManager.find(Policy.class, id);
        if (policy != null) {
            entityManager.remove(policy);
        }
    }
    
    @Override
    public Page<Policy> findAll(Pageable pageable) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Policy> query = cb.createQuery(Policy.class);
        Root<Policy> root = query.from(Policy.class);
        
        query.select(root).orderBy(cb.desc(root.get("createdAt")));
        
        TypedQuery<Policy> typedQuery = entityManager.createQuery(query);
        typedQuery.setFirstResult((int) pageable.getOffset());
        typedQuery.setMaxResults(pageable.getPageSize());
        
        return new PageImpl<>(typedQuery.getResultList(), pageable, countAll());
    }
    
    private long countAll() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
        countQuery.select(cb.count(countQuery.from(Policy.class)));
        return entityManager.createQuery(countQuery).getSingleResult();
    }
}

Performance Considerations

  1. Query Optimization: All policy queries use proper indexing and avoid SELECT * patterns
  2. Read Replicas: For read-heavy operations, we route queries to read replicas using database routing
  3. Query Caching: Frequently accessed policy metadata is cached at the repository level
  4. Pagination Strategy: Cursor-based pagination is implemented for large result sets to avoid OFFSET operations

Transaction Management

Repository methods are wrapped in appropriate transaction boundaries:

@Transactional
public Policy createPolicy(Policy policy) {
    // Transaction boundary includes save and related rule creation
    return policyRepository.save(policy);
}

@Transactional(readOnly = true)
public List<Policy> getActivePolicies() {
    return policyReadRepository.findActivePolicies();
}

Data Access Layer Testing Strategy

Unit tests for repositories mock database interactions, while integration tests use embedded H2 databases to verify actual query execution:

@ExtendWith(MockitoExtension.class)
public class PolicyRepositoryTest {
    
    @Mock
    private EntityManager entityManager;
    
    @InjectMocks
    private PolicyJpaRepository policyRepository;
    
    @Test
    public void testFindByIdReturnsOptional() {
        // Mock behavior and verify correct Optional return
    }
}

9. Asynchronous Processing & Messaging

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

The policy engine will process policies that may involve complex validations or external system integrations. For example, when a policy is created or updated, we need to validate it against external systems like fraud detection services or compliance checkers. These validations can be time-consuming and should not block the main request flow.

  • Synchronous: Policy creation/update operations that require immediate validation and response to the client.
  • Asynchronous: Complex policy validation workflows, policy change notifications to downstream systems (e.g., billing engine), and audit trail updates.

This approach ensures that the core business logic remains responsive while allowing long-running or potentially failing operations to run in the background without blocking user experience.

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

We'll use JSON Schema for its simplicity and wide adoption in microservices environments. Versioning will be handled via a version field in the message header.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "PolicyEvent",
  "version": "1.0",
  "type": "object",
  "properties": {
    "eventId": { "type": "string" },
    "eventType": { "type": "string", "enum": ["POLICY_CREATED", "POLICY_UPDATED", "POLICY_VALIDATION_FAILED"] },
    "policyId": { "type": "string" },
    "policyData": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "name": { "type": "string" },
        "conditions": { "type": "array", "items": { "type": "object" } },
        "rules": { "type": "array", "items": { "type": "object" } }
      },
      "required": ["id", "name"]
    },
    "timestamp": { "type": "string", "format": "date-time" },
    "source": { "type": "string" }
  },
  "required": ["eventId", "eventType", "policyId", "timestamp"]
}

Dead letter queue strategy and poison message handling

Poison messages (messages that consistently fail processing) will be routed to a dead letter queue (DLQ) after a configurable number of retries. The DLQ will store failed messages for manual inspection and potential reprocessing.

  • Retry limit: 3 attempts
  • DLQ topic: policy-engine-dlq
  • Message retention: 7 days
  • Alerting: Alert on DLQ message count exceeding threshold (e.g., >10 messages in 5 minutes)

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

We will implement at-least-once delivery strategy. This is acceptable because:

  1. Policy engine operations are idempotent where possible.
  2. The downstream systems (e.g., billing, compliance) can handle duplicate events gracefully.
  3. Implementing exactly-once delivery adds significant complexity and cost.

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

Retry policy uses exponential backoff with jitter:

  • Max attempts: 3
  • Backoff formula: delay = baseDelay * (2^attempt) + random(0, 1000ms)
  • Non-retryable errors:
    • Invalid policy data (400)
    • Unauthorized access (401/403)
    • Resource not found (404)
public class RetryPolicy {
    private static final int MAX_ATTEMPTS = 3;
    private static final long BASE_DELAY_MS = 1000;

    public static boolean shouldRetry(int attempt, Exception error) {
        if (attempt >= MAX_ATTEMPTS) return false;
        
        // Do not retry on client errors
        if (error instanceof ClientException) {
            return false;
        }

        return true;
    }

    public static long getDelay(int attempt) {
        long delay = BASE_DELAY_MS * (1L << attempt);
        return delay + ThreadLocalRandom.current().nextInt(0, 1000);
    }
}

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

We will NOT use the outbox pattern for this design because:

  1. The policy engine is a single-service system with direct database access.
  2. Events are published to a message broker that supports transactional publishing.
  3. The system can tolerate eventual consistency in event delivery for most use cases.

Instead, we will rely on transactional message publishing where the database write and event publish are part of the same atomic operation using a message broker that supports transactional semantics.

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant Client
    participant PolicyEngineService
    participant MessageBroker
    participant ValidationService
    participant BillingService

    Client->>PolicyEngineService: Create Policy (POST /policies)
    PolicyEngineService->>MessageBroker: Publish POLICY_CREATED event
    MessageBroker->>ValidationService: Async validation request
    ValidationService-->>MessageBroker: Validation result (success/failure)
    MessageBroker->>BillingService: Notify policy change
    PolicyEngineService-->>Client: Response (201 Created)

10. Error Handling Design

Error Classification Hierarchy

The policy engine implements a three-tier error classification system:

public enum ErrorType {
    VALIDATION_ERROR,      // Input validation failures
    BUSINESS_LOGIC_ERROR,  // Policy rule violations
    INFRASTRUCTURE_ERROR,  // Database, network, or external service failures
    SYSTEM_ERROR           // Internal system issues (e.g., null pointer)
}

Error Propagation Strategy

Errors follow a layered propagation pattern:

  • Validation errors: Caught and converted to structured API responses at the controller layer
  • Business logic errors: Bubble up to service layer for domain-specific handling
  • Infrastructure errors: Translated to appropriate HTTP status codes and logged at service level
  • System errors: Caught at the top-level exception handler for centralized logging and monitoring

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors (e.g., database connection timeouts, external API failures):

public class RetryConfig {
    private int maxAttempts = 3;
    private long initialDelayMs = 1000;
    private double multiplier = 2.0;
    private long maxDelayMs = 30000;
    private double jitterFactor = 0.2;

    public long getDelay(int attempt) {
        long delay = (long) (initialDelayMs * Math.pow(multiplier, attempt));
        delay = Math.min(delay, maxDelayMs);
        
        // Add jitter: ±20% of delay
        long jitter = (long) (delay * jitterFactor * (Math.random() - 0.5) * 2);
        return Math.max(100, delay + jitter);
    }
}

Circuit Breaker Design

For external dependencies and rate-limited services:

public class CircuitBreaker {
    private final int failureThreshold = 5;
    private final long timeoutMs = 60000;
    private final int successThreshold = 5;
    
    private volatile CircuitState state = CircuitState.CLOSED;
    private int failureCount = 0;
    private long lastFailureTime = 0;
    
    public enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }
    
    public boolean canExecute() {
        switch (state) {
            case CLOSED:
                return true;
            case HALF_OPEN:
                return failureCount < successThreshold;
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime > timeoutMs) {
                    state = CircuitState.HALF_OPEN;
                    return true;
                }
                return false;
        }
        return true;
    }
    
    public void recordSuccess() {
        if (state == CircuitState.HALF_OPEN) {
            failureCount = 0;
            state = CircuitState.CLOSED;
        }
    }
    
    public void recordFailure() {
        lastFailureTime = System.currentTimeMillis();
        failureCount++;
        
        if (state == CircuitState.CLOSED && failureCount >= failureThreshold) {
            state = CircuitState.OPEN;
        } else if (state == CircuitState.HALF_OPEN && failureCount >= successThreshold) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

Each external dependency has a fallback strategy:

public class PolicyEngineFallbacks {
    public static final String DEFAULT_POLICY = "default";
    
    public PolicyResult fallbackPolicyEvaluation(
        PolicyRequest request, 
        Exception cause
    ) {
        // Log the fallback event for monitoring and debugging
        logger.warn("Policy evaluation fallback triggered for request: {}", 
                   request.getPolicyId(), cause);
        
        return new PolicyResult(
            DEFAULT_POLICY,
            PolicyEvaluationStatus.FALLBACK_USED,
            "Using default policy due to system error"
        );
    }
    
    public List<Policy> fallbackPolicyLookup(
        String policyType, 
        Exception cause
    ) {
        logger.warn("Policy lookup fallback for type: {}", policyType, cause);
        return Collections.emptyList();
    }
}

Error Logging Format and Alerting Thresholds

Structured logging follows a consistent schema:

public class ErrorLogEntry {
    private String traceId;
    private String spanId;
    private String userId;
    private String entityId;
    private String errorCode;
    private String errorMessage;
    private String errorType;
    private long timestamp;
    private Map<String, Object> context;
    private boolean piiMasked = true;
}

Alerting thresholds:

Error TypeAlert ThresholdSeverity
Validation errors> 100/sec (5 min window)Warning
Business logic errors> 50/sec (5 min window)Warning
Infrastructure errors> 20/sec (5 min window)Critical
System errors> 5/sec (5 min window)Critical

Error Handling in Policy Engine Service Layer

public class PolicyEngineService {
    private final CircuitBreaker circuitBreaker = new CircuitBreaker();
    
    public PolicyResult evaluatePolicy(PolicyRequest request) {
        try {
            // Retry logic for transient failures
            return retryWithBackoff(() -> {
                if (!circuitBreaker.canExecute()) {
                    throw new CircuitBreakerOpenException("External service circuit breaker open");
                }
                
                return policyEvaluator.evaluate(request);
            }, 3, 1000);
        } catch (ValidationException e) {
            // Convert to API-friendly error
            throw new PolicyEngineValidationException(e.getMessage(), e);
        } catch (InfrastructureException e) {
            // Log and potentially trigger fallback
            logger.error("Infrastructure error during policy evaluation: {}", e.getMessage(), e);
            
            // Fallback to default policy
            return PolicyEngineFallbacks.fallbackPolicyEvaluation(request, e);
        } catch (Exception e) {
            // Log system errors for investigation
            logger.error("Unexpected error in policy evaluation", e);
            throw new SystemException("Internal system error", e);
        }
    }
    
    private <T> T retryWithBackoff(Supplier<T> operation, int maxAttempts, long initialDelay) {
        Exception lastException = null;
        
        for (int attempt = 0; attempt < maxAttempts; attempt++) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                
                if (isRetryable(e) && attempt < maxAttempts - 1) {
                    long delay = initialDelay * (1L << attempt);
                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RetryInterruptedException("Retry interrupted", ie);
                    }
                } else {
                    break;
                }
            }
        }
        
        throw new RetryExhaustedException("Operation failed after " + maxAttempts + " attempts", lastException);
    }
    
    private boolean isRetryable(Exception e) {
        return e instanceof TimeoutException || 
               e instanceof ConnectionException ||
               e instanceof RetryableInfrastructureException;
    }
}

Error Response Format

All API errors follow a standardized JSON response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Policy ID is required",
    "details": {
      "field": "policyId",
      "value": null,
      "reason": "required_field_missing"
    },
    "timestamp": "2023-12-01T10:30:00Z",
    "traceId": "abc123"
  }
}

Key Error Types

Error CodeDescriptionHTTP StatusRetryable
VALIDATION_ERRORInput validation failed400No
BUSINESS_LOGIC_ERRORPolicy rule violation400No
POLICY_NOT_FOUNDRequested policy does not exist404No
INFRASTRUCTURE_ERRORExternal service or DB failure503Yes
SYSTEM_ERRORInternal system error500No

Retryable Infrastructure Errors

  • Database connection timeouts
  • External API rate limiting
  • Network connectivity issues
  • Temporary service unavailability

Non-Retryable Errors

  • Validation failures (400)
  • Business logic violations (400)
  • Policy not found (404)
  • System errors (500)

Error Budget Considerations

The policy engine follows a strict error budget policy:

  • Maximum 0.1% error rate for infrastructure errors
  • Alert threshold set at 0.05% to provide early warning
  • Fallback mechanisms ensure system resilience during outages

11. Security Design

Input Validation and Sanitization Strategy

All API inputs are validated using a combination of:

  • Custom validation annotations (e.g., @ValidPolicyCondition, @ValidPolicyRule)
  • JSON Schema validation for complex nested structures
  • Regular expression patterns for string fields (e.g., policy IDs must match ^[a-zA-Z0-9_-]{1,64}$)
  • Length limits for all string fields (max 1024 characters)
  • Type checking with strict type conversion
public class PolicyCondition {
    @Pattern(regexp = "^[a-zA-Z0-9_-]{1,64}$", message = "Invalid condition ID format")
    private String id;
    
    @NotNull
    @ValidPolicyCondition
    private Map<String, Object> attributes;
}

SQL Injection Prevention

All database queries are constructed using:

  • Prepared statements with parameterized queries
  • ORM layer (Hibernate) for complex object mapping
  • Whitelisted field names in dynamic queries
// Secure query construction
String query = "SELECT * FROM policy_rules WHERE policy_id = :policyId AND status = :status";
Query nativeQuery = entityManager.createNativeQuery(query);
nativeQuery.setParameter("policyId", policyId);
nativeQuery.setParameter("status", "ACTIVE");

Authentication Middleware Design

Authentication is handled through:

  • JWT-based authentication with 24-hour expiration
  • OAuth2 integration for external identity providers (Okta, Auth0)
  • API key validation for service-to-service communication
  • Middleware layer that intercepts all requests and validates tokens
@Component
public class AuthenticationFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
        throws IOException, ServletException {
        
        String token = extractTokenFromHeader(request);
        if (jwtService.validateToken(token)) {
            // Set security context
            SecurityContextHolder.getContext().setAuthentication(
                jwtService.getAuthentication(token));
        }
        chain.doFilter(request, response);
    }
}

Authorization Enforcement

RBAC (Role-Based Access Control) is implemented with:

  • User roles: ADMIN, POLICY_CREATOR, POLICY_VIEWER
  • Resource-based permissions: policy:read, policy:create, policy:update, policy:delete
  • Permission checking at service layer using @PreAuthorize annotations
@Service
public class PolicyService {
    @PreAuthorize("hasPermission(#policyId, 'policy', 'policy:update')")
    public void updatePolicy(String policyId, PolicyUpdateRequest request) {
        // Implementation
    }
}

Sensitive Data Encryption

All sensitive data is encrypted using:

  • AES-256-GCM for data at rest
  • TLS 1.3 for data in transit
  • Key rotation every 90 days with HSM integration
@Service
public class DataEncryptionService {
    private final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    
    public String encrypt(String plaintext, String key) {
        // Implementation with proper IV and authentication tag
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }
}

PII Handling and Masking Strategy

PII data is:

  • Identified using regex patterns (SSN, email, phone)
  • Masked in logs and responses using *** pattern
  • Stored encrypted with proper key management
public class PiiMasker {
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) return email;
        String[] parts = email.split("@");
        return parts[0].substring(0, 2) + "***@" + parts[1];
    }
}

Secrets Management

Secrets are managed through:

  • AWS Secrets Manager for production environments
  • Environment variables for local development
  • Automatic rotation every 30 days
  • Access control via IAM policies
@Component
public class SecretManager {
    private final AWSSecretsManager secretsManager;
    
    public String getSecret(String secretName) {
        GetSecretValueRequest request = GetSecretValueRequest.builder()
            .secretId(secretName)
            .build();
        return secretsManager.getSecretValue(request).secretString();
    }
}

Audit Logging Design

All security-relevant operations are logged with:

  • Required fields: timestamp, user_id, operation_type, resource_id, ip_address, result
  • PII masking in log messages
  • Structured JSON format for easy parsing and analysis
@EventListener
public void handlePolicyEvent(PolicyEvent event) {
    Map<String, Object> auditLog = new HashMap<>();
    auditLog.put("timestamp", Instant.now());
    auditLog.put("user_id", getCurrentUserId());
    auditLog.put("operation_type", event.getOperation());
    auditLog.put("resource_id", event.getResourceId());
    auditLog.put("ip_address", getClientIpAddress());
    auditLog.put("result", event.isSuccess() ? "SUCCESS" : "FAILURE");
    
    logger.info("AUDIT_LOG: {}", auditLog);
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85%+ line coverage, 100% branch coverage for all business logic paths
  • Domain Objects: 90%+ line coverage, focusing on validation and state transitions
  • Repository Layer: 80%+ line coverage, with focus on query correctness and edge cases
  • API Layer: 75%+ line coverage, including request/response validation and error handling
  • Policy Engine Core: 95%+ line coverage, with particular emphasis on rule evaluation logic and condition matching

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL 13+ to test actual database interactions, including transaction boundaries and constraint enforcement
  • External Service Mocks: WireMock or Mockito for simulating external policy rule sources (e.g., rule storage services, external validation APIs)
  • Event Integration: Embedded Redis or Kafka container for testing event publishing and consumption flows
  • Authentication Integration: Use embedded OAuth2 mock server to validate JWT token processing and authorization checks

Contract Testing

  • Pact Broker Integration: Implement contract testing using Pact for all external API integrations (e.g., rule engine configuration service, audit logging service)
  • Consumer-Provider Contracts: Define contracts for each external dependency to ensure API compatibility across versions
  • Versioned Contract Testing: Maintain contract versions with semantic versioning to support backward compatibility

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based load testing) with Grafana for visualization and alerting
  • Key Scenarios:
    • Policy evaluation under concurrent load (1000+ concurrent requests)
    • Batch policy evaluation processing (100 policies per batch)
    • High-frequency rule matching (5000+ rules evaluated in 1 second)
    • Memory usage under sustained load (monitor heap allocation and GC pressure)
  • Metrics Tracked: p50/p95/p99 response times, throughput (req/sec), error rates, CPU/memory utilization

Test Data Management Strategy

  • Fixtures: YAML-based fixtures for policy definitions, rule sets, and test scenarios with version control
  • Factories: Factory pattern for generating complex domain objects (Policy, Rule, Condition) with configurable parameters
  • Seeding: Database seed scripts for test environments that populate with realistic policy data sets (e.g., 10,000+ policies)
  • Data Isolation: Use database transaction rollback or separate test databases per test suite to ensure test isolation
  • Test Data Generation: Faker libraries for generating realistic but non-sensitive test data (e.g., user IDs, policy names, rule conditions)
// Example unit test for policy evaluation logic
@Test
public void testPolicyEvaluationWithMatchingConditions() {
    // Given
    Policy policy = createTestPolicy();
    Map<String, Object> context = Map.of("userRole", "admin", "resourceType", "document");
    
    // When
    boolean result = policyEngine.evaluate(policy, context);
    
    // Then
    assertTrue(result);
}

@Test
public void testPolicyEvaluationWithNonMatchingConditions() {
    // Given
    Policy policy = createTestPolicy();
    Map<String, Object> context = Map.of("userRole", "guest", "resourceType", "document");
    
    // When
    boolean result = policyEngine.evaluate(policy, context);
    
    // Then
    assertFalse(result);
}

13. Observability Design

Structured Logging Schema

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

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "level": "INFO|WARN|ERROR",
  "service": "policy-engine",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user-123",
  "entity_id": "policy-456",
  "request_id": "req-789",
  "event_type": "policy_evaluation",
  "message": "Policy evaluation completed successfully",
  "duration_ms": 150,
  "error_code": "POLICY_EVALUATION_TIMEOUT",
  "error_details": {
    "stack_trace": "...",
    "cause": "Timeout waiting for rule engine"
  },
  "pii_masked": true
}

PII fields (user_id, email, etc.) must be masked in logs using a configurable regex pattern and logging library that supports field-level redaction.

Distributed Tracing Instrumentation

Using OpenTelemetry for distributed tracing:

Custom Spans to Add:

  1. policy_engine.evaluate - Main evaluation span
  2. policy_engine.rule_evaluation - Per-rule evaluation
  3. policy_engine.data_fetch - External data fetches (e.g., user profile, context)
  4. policy_engine.decision_publish - Event publishing

Attributes to Capture:

  • policy.id: Policy identifier
  • policy.type: Policy type (e.g., "access_control", "pricing")
  • rule.id: Rule identifier within policy
  • context.key: Key context fields (user_id, role, resource)
  • decision.outcome: "ALLOW", "DENY", "UNDECIDED"
  • evaluation.duration.ms: Milliseconds for evaluation
  • decision.reason: Reasoning for decision (e.g., "rule matched", "no matching rule")

Metrics Design

RED Method Metrics:

Metric NameTypeLabelsDescription
policy_engine_evaluations_totalCounterpolicy_type, outcome, error_codeTotal policy evaluations
policy_engine_evaluation_duration_secondsHistogrampolicy_type, outcomeEvaluation time distribution
policy_engine_active_rules_totalGaugepolicy_typeActive rule count
policy_engine_cache_hits_totalCountercache_type, hit_typeCache hit/miss counts
policy_engine_data_fetch_duration_secondsHistogramdata_source, operationExternal data fetch times
policy_engine_error_countCountererror_type, policy_typeError counts by type
policy_engine_decision_rateHistogramdecision_outcome, policy_typeDecision distribution

Label Design:

  • policy_type: access_control, pricing, compliance
  • outcome: ALLOW, DENY, UNDECIDED, ERROR
  • error_code: POLICY_NOT_FOUND, EVALUATION_TIMEOUT, INVALID_RULE

Health Check Endpoint Design

GET /health
GET /ready
GET /live
GET /startup

Response Format:

{
  "status": "healthy|unhealthy",
  "checks": {
    "database": {"status": "healthy", "details": "Connected to primary DB"},
    "cache": {"status": "healthy", "details": "Redis cluster healthy"},
    "policy_engine": {"status": "healthy", "details": "Engine ready for evaluation"}
  },
  "timestamp": "2023-10-27T14:30:45.123Z",
  "uptime_seconds": 3600
}

Checks Per Endpoint:

  • /health: Basic service status, cache connectivity
  • /ready: All dependencies ready (DB, cache, policy store)
  • /live: Process is running, no crash
  • /startup: Service-specific startup validation (policy loading)

Alerting Thresholds

MetricWarning LevelCritical LevelEscalation Path
policy_engine_evaluations_totalRate > 100/sec (5min)Rate > 500/sec (5min)On-call engineer
policy_engine_evaluation_duration_secondsp95 > 200msp95 > 1000msSRE team
policy_engine_error_count> 5 errors/minute> 20 errors/minuteSRE team
policy_engine_cache_hits_totalCache hit ratio < 80%Cache hit ratio < 60%SRE team
policy_engine_data_fetch_duration_secondsp95 > 500msp95 > 2000msSRE team

Alerting Logic:

  • Use sliding window for rate-based alerts
  • Apply exponential backoff for alert suppression
  • Include service-level SLA thresholds in alerting (e.g., 99.9% availability)
  • Implement alert deduplication and grouping for similar issues

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Policy evaluation engine selection: Should we build a custom rule engine or leverage existing open-source solutions like Drools or Janus? Custom engines offer maximum control but require significant development effort and maintenance overhead. Open-source solutions provide mature features but may introduce vendor lock-in or performance limitations.

  • DSL vs. code-based policy definition: Should policies be defined using a domain-specific language (DSL) or through code? DSLs provide business-friendly syntax and easier policy editing by non-technical stakeholders, but they require custom parser development. Code-based policies offer better tooling support and type safety but require developer expertise.

  • Real-time vs. batch policy evaluation: Should all policy evaluations happen in real-time or support batch processing? Real-time evaluation provides immediate feedback but can introduce latency. Batch processing offers better throughput but delays policy application.

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Performance vs. FlexibilityPre-compiled policy rulesRuntime rule compilationPre-compilation offers better performance but reduces flexibility in rule modification. Runtime compilation provides more flexibility but introduces compilation overhead and potential runtime errors.
Consistency vs. AvailabilityStrong consistency modelEventual consistency modelStrong consistency ensures data integrity but can impact availability during network partitions. Eventual consistency improves availability at the cost of temporary inconsistency.
Single vs. Multi-tenant ArchitectureSingle tenant per instanceShared multi-tenant architectureSingle tenant provides better isolation and security but increases infrastructure costs. Multi-tenant reduces costs but requires careful design to prevent cross-tenant data leakage and performance interference.
In-memory vs. Persistent Rule StorageIn-memory rule cachePersistent storage (DB)In-memory provides faster access but requires rehydration on restarts. Persistent storage offers durability but introduces I/O overhead and potential performance bottlenecks.

Risks and mitigation strategies

  • Policy evaluation performance degradation: As policy count increases, evaluation time may grow exponentially. Mitigation includes implementing rule indexing, caching frequently accessed policies, and using parallel evaluation where possible.

  • Security vulnerabilities from policy injection: Malicious actors could inject invalid policies or exploit rule parsing to cause system instability. Mitigation includes strict input validation, sandboxed policy evaluation environments, and comprehensive policy testing.

  • Complexity of policy conflict resolution: When multiple policies apply to the same context, determining precedence and handling conflicts becomes complex. Mitigation includes implementing clear policy ordering rules, conflict detection mechanisms, and audit trails for policy resolution decisions.

  • Scalability limitations under high concurrent load: Policy engine may become a bottleneck under heavy concurrent policy evaluation requests. Mitigation includes horizontal sharding of policies, asynchronous processing, and implementing circuit breaker patterns for downstream dependencies.

Technical debt acknowledged

  • Custom rule engine complexity: Building a custom rule engine introduces significant technical debt in terms of maintenance, testing, and documentation overhead. The engine requires ongoing attention to edge cases, performance optimizations, and compatibility with evolving business requirements.

  • DSL implementation limitations: Custom DSL parsers may not support all edge cases or complex policy constructs, leading to incomplete rule definitions and runtime failures. This debt accumulates as business requirements expand beyond initial DSL design scope.

  • Inconsistent policy versioning: Without proper version control and migration strategies, different versions of policies may coexist in the system, causing unpredictable behavior. This technical debt increases with growing policy complexity and concurrent development efforts.

  • Limited extensibility: Current design assumes specific policy types and evaluation contexts. Extending to new policy domains or evaluation patterns requires significant refactoring, creating technical debt in the service layer and data model.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend HLD

    Attribute-Based Access Control (ABAC) System Design

    Comprehensive high-level design for a scalable ABAC system with policy evaluation, attribute management, and integration capabilities.

  • Backend HLD

    Role-Based Access Control System Design

    Comprehensive high-level design for a scalable RBAC system with multi-tenant support, audit logging, and real-time permission management.

  • Backend LLD

    Authorization Service Design: RBAC and ABAC Implementation

    Comprehensive low-level design for a scalable authorization service supporting role-based and attribute-based access control with fine-grained permissions.

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.