Skip to main content

Coupon/Discount Engine Design: Scalable Backend System for Dynamic Promotions

Comprehensive low-level design for a scalable coupon and discount engine supporting complex promotion rules, real-time validation, and high-throughput processing.

A robust backend system design for managing coupon codes and discount rules with support for complex business logic, concurrent processing, and real-time validation across high-traffic e-commerce platforms.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the expected scale of the system?

    • How many concurrent users and active coupons are expected?
    • What is the peak transaction rate (e.g., 10k orders/sec)?
  2. What consistency requirements exist for coupon usage?

    • Should coupons be applied atomically with orders?
    • Is eventual consistency acceptable for some use cases?
  3. What are the key entities and their relationships?

    • Are coupons tied to specific users, products, or categories?
    • Do coupons have expiration dates and usage limits?
  4. What is the read/write ratio for coupon data?

    • Is it primarily used during checkout (high write) or for analytics (read-heavy)?
  5. What are the latency sensitivity requirements?

    • Should coupon validation happen within 10ms for real-time checkout?
    • Are there different SLAs for different coupon types?
  6. What authentication and authorization model is in place?

    • Will users authenticate via JWT or session tokens?
    • Are there role-based permissions for coupon creation and management?
  7. What external systems integrate with the coupon engine?

    • Does it integrate with payment gateways, inventory systems, or analytics platforms?

Functional requirements

  • Coupon creation and management: Create, update, delete, and activate/deactivate coupons with various types (percentage, fixed amount, free shipping).
  • Coupon application: Validate and apply coupons to orders during checkout.
  • Usage tracking: Track coupon redemption count, user usage limits, and validity period.
  • Coupon eligibility rules: Apply business logic to determine if a coupon can be used (e.g., minimum order value, product category restrictions).
  • Coupon code generation: Generate unique codes for promotional campaigns.
  • Coupon redemption: Deduct usage count and apply discount to eligible orders.

Non-functional requirements

  • Latency targets:

    • p50: <5ms for coupon validation
    • p95: <10ms for coupon validation
    • p99: <20ms for coupon validation
  • Throughput: Support at least 10,000 concurrent coupon validations per second.

  • Availability: 99.9% uptime for the coupon engine service.

Constraints and assumptions

  • Assumptions:

    • Coupon validation happens synchronously during order placement.
    • Coupons are valid for a fixed period (e.g., 30 days).
    • Users can have multiple coupons but each coupon has a usage limit per user.
    • Coupon engine is part of a larger e-commerce platform.
  • Constraints:

    • No external dependencies for coupon validation (e.g., no database lookups in critical path).
    • Coupon engine must support high-concurrency reads and writes with minimal latency.

Out of scope

  • Coupon analytics dashboard: Detailed reporting on coupon performance and usage trends.
  • Coupon distribution channels: Integration with email, SMS, or push notification systems for coupon delivery.
  • Coupon bundling or stacking: Allowing multiple coupons to be applied to a single order (unless explicitly required).
  • Coupon redemption in third-party systems: Integration with external loyalty or reward programs.
  • Coupon expiration notifications: Sending alerts to users before coupon expiry.

2. API Design

API Style Selection

gRPC with Protocol Buffers is selected for the Coupon/Discount engine due to:

  • Strong typing and schema validation
  • Efficient binary serialization for high-throughput scenarios
  • Built-in support for streaming and bidirectional communication
  • Native support for versioning through proto file evolution
  • Better performance characteristics than REST for complex operations

Endpoint Definitions

MethodPathDescriptionAuthRate Limit
CreateCouponPOST /v1/couponsCreate a new coupon with rules and constraintsJWT + Admin Role100 req/min
GetCouponGET /v1/coupons/{id}Retrieve coupon details by IDJWT + Admin/Viewer Role1000 req/min
ApplyCouponPOST /v1/coupons/{id}/applyApply coupon to cart/orderJWT + User Role50 req/min
ListCouponsGET /v1/couponsList coupons with filters and paginationJWT + Admin/Viewer Role500 req/min
UpdateCouponPUT /v1/coupons/{id}Update existing coupon metadataJWT + Admin Role100 req/min
DeleteCouponDELETE /v1/coupons/{id}Soft delete coupon (mark inactive)JWT + Admin Role50 req/min
ValidateCouponPOST /v1/coupons/validateValidate coupon eligibility without applyingJWT + User Role200 req/min

Request and Response Schemas

CreateCouponRequest

message CreateCouponRequest {
  string code = 1;
  string description = 2;
  CouponType type = 3;
  oneof discount_value {
    double percentage = 4;
    double fixed_amount = 5;
  }
  string currency = 6;
  int32 max_uses = 7;
  int32 max_uses_per_user = 8;
  string start_time = 9;
  string end_time = 10;
  repeated string product_categories = 11;
  repeated string excluded_product_categories = 12;
  string user_segment = 13;
  bool is_active = 14;
}

CreateCouponResponse

message CreateCouponResponse {
  string id = 1;
  string code = 2;
  string status = 3; // "created"
  int64 created_at = 4;
}

ApplyCouponRequest

message ApplyCouponRequest {
  string coupon_code = 1;
  string user_id = 2;
  string cart_id = 3;
  double subtotal = 4;
  repeated CartItem items = 5;
}

ApplyCouponResponse

message ApplyCouponResponse {
  string coupon_id = 1;
  string discount_code = 2;
  double discount_amount = 3;
  double final_total = 4;
  string currency = 5;
  repeated AppliedDiscount applied_discounts = 6;
}

HTTP Status Codes and Error Response Format

Status CodeDescription
200Success
201Created
400Bad Request - Invalid input
401Unauthorized - Missing or invalid auth
403Forbidden - Insufficient permissions
404Not Found - Coupon not found
429Too Many Requests - Rate limit exceeded
500Internal Server Error - Unexpected server error

Standardized error response:

{
  "error": {
    "code": "INVALID_INPUT",
    "message": "Coupon code must be between 5 and 20 characters",
    "details": {
      "field": "code",
      "value": "ab"
    }
  }
}

API Versioning Strategy

Versioned using path prefix: /v1/ with future versions as /v2/, etc.

Pagination Design

Cursor-based pagination chosen because:

  • More stable than offset-based when data changes
  • Better performance for large datasets
  • Consistent results across page boundaries

Sample request:

GET /v1/coupons?limit=20&cursor=eyJpZCI6IjEyMzQ1In0=

Authentication and Authorization

  • JWT Bearer tokens for all endpoints
  • Role-based access control (RBAC):
    • Admin role: Full CRUD on coupons
    • User role: Apply and validate coupons only
    • Viewer role: Read-only access to coupons

Rate Limiting Strategy

  • Token bucket algorithm with configurable limits per endpoint
  • Per-user rate limiting for sensitive endpoints (apply, validate)
  • Global rate limiting for administrative endpoints

Idempotency Design

For mutation endpoints:

  • Idempotency-Key header required
  • Store idempotency keys in Redis with TTL of 24 hours
  • Return 200 for duplicate requests with same key
// Example for ApplyCoupon endpoint
public ApplyCouponResponse applyCoupon(String idempotencyKey, ApplyCouponRequest request) {
    String cacheKey = "idempotency:" + idempotencyKey;
    if (redisTemplate.hasKey(cacheKey)) {
        return redisTemplate.opsForValue().get(cacheKey, ApplyCouponResponse.class);
    }
    
    ApplyCouponResponse response = processApplyCoupon(request);
    redisTemplate.opsForValue().set(cacheKey, response, 24, TimeUnit.HOURS);
    return response;
}

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    COUPON {
        string id PK
        string code
        string description
        string type
        string status
        integer value
        integer max_uses
        integer used_count
        timestamp start_date
        timestamp end_date
        string created_by
        timestamp created_at
        timestamp updated_at
    }

    COUPON_APPLICABILITY {
        string coupon_id FK
        string category_id FK
        string product_id FK
        string user_segment_id FK
    }

    USER_COUPON {
        string user_id PK
        string coupon_id PK
        string status
        timestamp used_at
        integer times_used
    }

    COUPON_RULE {
        string id PK
        string coupon_id FK
        string rule_type
        string rule_value
        integer priority
    }

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

    PRODUCT {
        string id PK
        string name
        string category_id FK
        integer price
    }

    CATEGORY {
        string id PK
        string name
    }

    COUPON_APPLICABILITY ||--o{ COUPON : "has"
    COUPON ||--o{ USER_COUPON : "redeemed_by"
    COUPON ||--o{ COUPON_RULE : "has_rules"
    COUPON_RULE ||--o{ USER_COUPON : "applied_to"
    USER ||--o{ USER_COUPON : "redeemed"
    PRODUCT ||--o{ COUPON_APPLICABILITY : "applicable_to"
    CATEGORY ||--o{ COUPON_APPLICABILITY : "applicable_to"

Table Schema Definitions

coupons table

CREATE TABLE coupons (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    code VARCHAR(50) UNIQUE NOT NULL,
    description TEXT,
    type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount', 'free_shipping'
    status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active', 'expired', 'disabled'
    value INTEGER NOT NULL, -- percentage (0-100) or fixed amount in cents
    max_uses INTEGER DEFAULT NULL,
    used_count INTEGER DEFAULT 0,
    start_date TIMESTAMP NOT NULL,
    end_date TIMESTAMP NOT NULL,
    created_by VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_coupons_status ON coupons(status);
CREATE INDEX idx_coupons_code ON coupons(code);
CREATE INDEX idx_coupons_dates ON coupons(start_date, end_date);

coupon_applicability table

CREATE TABLE coupon_applicability (
    coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
    category_id UUID REFERENCES categories(id),
    product_id UUID REFERENCES products(id),
    user_segment_id UUID REFERENCES user_segments(id),
    PRIMARY KEY (coupon_id, category_id, product_id, user_segment_id)
);

CREATE INDEX idx_coupon_applicability_product ON coupon_applicability(product_id);
CREATE INDEX idx_coupon_applicability_category ON coupon_applicability(category_id);

user_coupons table

CREATE TABLE user_coupons (
    user_id UUID NOT NULL,
    coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
    status VARCHAR(20) NOT NULL DEFAULT 'available', -- 'available', 'used', 'expired'
    used_at TIMESTAMP,
    times_used INTEGER DEFAULT 0,
    PRIMARY KEY (user_id, coupon_id)
);

CREATE INDEX idx_user_coupons_status ON user_coupons(status);
CREATE INDEX idx_user_coupons_used_at ON user_coupons(used_at);

coupon_rules table

CREATE TABLE coupon_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
    rule_type VARCHAR(50) NOT NULL, -- 'min_cart_amount', 'max_discount', 'user_segment'
    rule_value TEXT NOT NULL,
    priority INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_coupon_rules_priority ON coupon_rules(coupon_id, priority);

Primary Key Strategy

UUIDs are used as primary keys for all tables to support:

  • Distributed system compatibility (no coordination needed for ID generation)
  • Better sharding capabilities across multiple database instances
  • Improved security (no sequential ID exposure)

Index Design

TableIndex NameColumnsPurpose
couponsidx_coupons_statusstatusFast filtering by status
couponsidx_coupons_codecodeFast lookup by coupon code
couponsidx_coupons_datesstart_date, end_dateEfficient date range queries
coupon_applicabilityidx_coupon_applicability_productproduct_idFast product-based filtering
coupon_applicabilityidx_coupon_applicability_categorycategory_idFast category-based filtering
user_couponsidx_user_coupons_statusstatusFast filtering by redemption status
user_couponsidx_user_coupons_used_atused_atEfficient usage tracking
coupon_rulesidx_coupon_rules_prioritycoupon_id, priorityFast rule evaluation in priority order

Data Partitioning / Sharding Strategy

Shard by user_id hash modulo N for user_coupons table to distribute load across database instances. This ensures that users with similar IDs are distributed across shards, preventing hotspots.

Soft Delete vs. Hard Delete

Soft delete is used for coupons table to:

  • Maintain historical data for audit and analytics
  • Support potential reactivation of coupons
  • Preserve referential integrity in user_coupons table

Audit Trail Design

All data changes are tracked via:

  • created_at, updated_at timestamps
  • created_by field for user identification
  • Database triggers or application-level logging to capture:
    • Who made the change
    • What changed (before/after values)
    • When it happened

Data Archival Strategy and Retention Policy

  • Active coupons: Retained indefinitely
  • Expired coupons: Archived after 30 days
  • Used coupons: Retained for 90 days for analytics and dispute resolution
  • User redemption data: Retained for 2 years (compliance requirement)
  • Archive storage: S3 or similar object storage with lifecycle policies
// Example of soft delete pattern in Java
public class Coupon {
    private String id;
    private String code;
    private boolean deleted = false;
    
    public void softDelete() {
        this.deleted = true;
        // Optionally set status to 'deleted'
    }
    
    public boolean isDeleted() {
        return deleted;
    }
}

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class CouponService {
        +createCoupon(CouponRequest) Coupon
        +applyCoupon(String, String, BigDecimal) ApplyResult
        +validateCoupon(String, String) ValidationResult
        +redeemCoupon(String, String) RedeemResult
        +getActiveCoupons() List~Coupon~
    }
    
    class Coupon {
        -id: UUID
        -code: String
        -type: CouponType
        -value: BigDecimal
        -currency: String
        -maxRedemptions: Integer
        -redemptionCount: Integer
        -validFrom: Instant
        -validTo: Instant
        -status: CouponStatus
        -conditions: List~Condition~
        -createdBy: String
        -createdAt: Instant
        -updatedAt: Instant
        +isActive() boolean
        +isRedeemable() boolean
        +canBeRedeemed() boolean
    }
    
    class Condition {
        -type: ConditionType
        -value: String
        -operator: Operator
    }
    
    class ApplyResult {
        -success: boolean
        -discountAmount: BigDecimal
        -finalAmount: BigDecimal
        -appliedCoupon: Coupon
    }
    
    class ValidationResult {
        -valid: boolean
        -reason: String
    }
    
    class RedeemResult {
        -success: boolean
        -appliedAmount: BigDecimal
        -remainingRedemptions: Integer
    }
    
    class CouponRepository {
        +save(Coupon) Coupon
        +findById(UUID) Optional~Coupon~
        +findByCode(String) Optional~Coupon~
        +findActiveCoupons() List~Coupon~
        +updateRedemptionCount(UUID, Integer) void
    }
    
    class EventPublisher {
        +publish(CouponRedeemedEvent) void
    }
    
    class CouponValidator {
        +validate(Coupon, String, BigDecimal) ValidationResult
    }
    
    class DiscountCalculator {
        +calculateDiscount(Coupon, BigDecimal) BigDecimal
    }
    
    class CouponType {
        +STATIC_PERCENTAGE
        +STATIC_FIXED
        +DYNAMIC_PERCENTAGE
    }
    
    class CouponStatus {
        +ACTIVE
        +EXPIRED
        +USED_UP
        +INACTIVE
    }
    
    class ConditionType {
        +CATEGORY
        +MINIMUM_PURCHASE
        +USER_SEGMENT
    }
    
    class Operator {
        +EQUALS
        +GREATER_THAN
        +IN
    }
    
    CouponService --> Coupon : uses
    CouponService --> CouponRepository : persists
    CouponService --> EventPublisher : publishes
    CouponService --> CouponValidator : validates
    CouponService --> DiscountCalculator : calculates
    Coupon --> Condition : has many
    CouponValidator --> Coupon : validates
    DiscountCalculator --> Coupon : calculates

State Machine Diagram

stateDiagram-v2
    [*] --> ACTIVE
    ACTIVE --> EXPIRED : validTo < now
    ACTIVE --> USED_UP : redemptionCount >= maxRedemptions
    ACTIVE --> INACTIVE : admin_deactivation
    EXPIRED --> [*]
    USED_UP --> [*]
    INACTIVE --> [*]

Service Class Structure and Responsibilities

The CouponService is the primary orchestrator that handles all coupon operations. It encapsulates business logic for creating, validating, applying, and redeeming coupons while delegating persistence to CouponRepository and event publishing to EventPublisher.

Key responsibilities:

  • Validate coupon eligibility before redemption
  • Calculate discounts based on coupon type and conditions
  • Manage redemption counts and status transitions
  • Publish events for audit and analytics
  • Handle concurrent access with proper locking

Interface / Abstract Class Design

public interface CouponRepository {
    Coupon save(Coupon coupon);
    Optional<Coupon> findById(UUID id);
    Optional<Coupon> findByCode(String code);
    List<Coupon> findActiveCoupons();
    void updateRedemptionCount(UUID id, Integer count);
}

public interface EventPublisher {
    void publish(CouponRedeemedEvent event);
}

public interface CouponValidator {
    ValidationResult validate(Coupon coupon, String userId, BigDecimal purchaseAmount);
}

Business Logic Encapsulation Pattern

Strategy Pattern is applied for discount calculation to support different coupon types:

public interface DiscountCalculationStrategy {
    BigDecimal calculateDiscount(Coupon coupon, BigDecimal purchaseAmount);
}

@Component
public class StaticPercentageStrategy implements DiscountCalculationStrategy {
    @Override
    public BigDecimal calculateDiscount(Coupon coupon, BigDecimal purchaseAmount) {
        return purchaseAmount.multiply(coupon.getValue()).divide(BigDecimal.valueOf(100));
    }
}

@Component
public class StaticFixedStrategy implements DiscountCalculationStrategy {
    @Override
    public BigDecimal calculateDiscount(Coupon coupon, BigDecimal purchaseAmount) {
        return coupon.getValue();
    }
}

@Component
public class DynamicPercentageStrategy implements DiscountCalculationStrategy {
    @Override
    public BigDecimal calculateDiscount(Coupon coupon, BigDecimal purchaseAmount) {
        // Dynamic logic based on user segment or other factors
        return purchaseAmount.multiply(coupon.getValue()).divide(BigDecimal.valueOf(100));
    }
}

@Service
public class DiscountCalculator {
    private final Map<CouponType, DiscountCalculationStrategy> strategies;
    
    public DiscountCalculator(List<DiscountCalculationStrategy> strategyList) {
        this.strategies = strategyList.stream()
            .collect(Collectors.toMap(
                this::getStrategyType,
                Function.identity()
            ));
    }
    
    public BigDecimal calculateDiscount(Coupon coupon, BigDecimal purchaseAmount) {
        DiscountCalculationStrategy strategy = strategies.get(coupon.getType());
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported coupon type: " + coupon.getType());
        }
        return strategy.calculateDiscount(coupon, purchaseAmount);
    }
    
    private CouponType getStrategyType(DiscountCalculationStrategy strategy) {
        // Implementation to determine strategy type
        return null;
    }
}

Critical-Path Method Implementation

@Transactional
public ApplyResult applyCoupon(String couponCode, String userId, BigDecimal purchaseAmount) {
    // 1. Load coupon with pessimistic locking to prevent race conditions
    Coupon coupon = couponRepository.findByCode(couponCode)
        .orElseThrow(() -> new CouponNotFoundException("Coupon not found: " + couponCode));
    
    // 2. Validate coupon status and eligibility
    ValidationResult validation = validator.validate(coupon, userId, purchaseAmount);
    if (!validation.isValid()) {
        return new ApplyResult(false, null, null, null, validation.getReason());
    }
    
    // 3. Calculate discount amount
    BigDecimal discountAmount = calculator.calculateDiscount(coupon, purchaseAmount);
    
    // 4. Apply the discount to get final amount
    BigDecimal finalAmount = purchaseAmount.subtract(discountAmount);
    
    // 5. Attempt to redeem the coupon (this is where concurrency matters)
    try {
        // Use optimistic locking with version field or explicit row locking
        int updatedRows = couponRepository.updateRedemptionCount(coupon.getId(), 
            coupon.getRedemptionCount() + 1);
        
        if (updatedRows == 0) {
            throw new ConcurrentModificationException("Coupon already redeemed by another process");
        }
        
        // 6. Publish event for audit and analytics
        eventPublisher.publish(new CouponRedeemedEvent(
            coupon.getId(),
            userId,
            purchaseAmount,
            discountAmount,
            finalAmount
        ));
        
        return new ApplyResult(true, discountAmount, finalAmount, coupon);
    } catch (Exception e) {
        // Handle any database or concurrency issues
        throw new CouponRedemptionException("Failed to redeem coupon: " + couponCode, e);
    }
}

Transaction Boundary Design

A single transaction encompasses:

  1. Coupon loading with pessimistic locking to prevent race conditions during redemption
  2. Validation and discount calculation (read-only operations)
  3. Redemption count update with optimistic locking
  4. Event publishing (can be moved to async if needed)

Service Interface Contracts

public interface CouponService {
    Coupon createCoupon(CouponRequest request);
    ApplyResult applyCoupon(String couponCode, String userId, BigDecimal purchaseAmount);
    ValidationResult validateCoupon(String couponCode, String userId, BigDecimal purchaseAmount);
    RedeemResult redeemCoupon(String couponCode, String userId);
    List<Coupon> getActiveCoupons();
}

Saga / Orchestration Pattern

For distributed transactions involving external systems (e.g., inventory, loyalty points), a saga pattern would be implemented using a state machine that manages compensating actions.

Domain Object / Value Object Design

public class Coupon {
    private final UUID id;
    private final String code;
    private final CouponType type;
    private final BigDecimal value;
    private final String currency;
    private final Integer maxRedemptions;
    private final Integer redemptionCount;
    private final Instant validFrom;
    private final Instant validTo;
    private final CouponStatus status;
    private final List<Condition> conditions;
    private final String createdBy;
    private final Instant createdAt;
    private final Instant updatedAt;
    
    // Constructor, getters, and immutable methods
    public boolean isActive() {
        return status == CouponStatus.ACTIVE && 
               Instant.now().isAfter(validFrom) && 
               Instant.now().isBefore(validTo);
    }
    
    public boolean isRedeemable() {
        return isActive() && (maxRedemptions == null || redemptionCount < maxRedemptions);
    }
    
    public boolean canBeRedeemed() {
        return isRedeemable() && conditions.stream().allMatch(condition -> condition.isSatisfied());
    }
}

public class Condition {
    private final ConditionType type;
    private final String value;
    private final Operator operator;
    
    public boolean isSatisfied() {
        // Implementation depends on condition type and operator
        return true;
    }
}

5. Concurrency & Thread Safety

Concurrency Model

The coupon engine will operate in a multi-threaded environment with the following concurrency model:

  • Thread pool managed by the application server (e.g., Tomcat, Spring Boot) for handling concurrent API requests
  • Async processing for non-blocking operations using CompletableFuture or reactive streams
  • Database transactions isolated at the connection level with proper locking mechanisms

Race Condition Prevention Strategy

Race conditions in coupon redemption will be prevented through:

  1. Optimistic locking using version fields or timestamps in the coupon entity
  2. Database-level atomic operations for quantity decrement and status updates
  3. Application-level synchronization for critical sections where necessary

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is selected for the following reasons:

  • High read-to-write ratio in coupon systems (most operations are validation/redeeming)
  • Low contention scenarios where concurrent updates are infrequent
  • Better performance characteristics under normal load conditions
  • Eliminates lock wait times and reduces system-wide blocking

Implementation pattern:

// Example optimistic locking in coupon redemption
public class CouponRedemptionService {
    @Transactional
    public RedemptionResult redeemCoupon(String couponId, String userId) {
        Coupon coupon = couponRepository.findById(couponId);
        
        // Validate redemption conditions
        if (coupon.getRedemptionCount() >= coupon.getMaxRedemptions()) {
            throw new InsufficientRedemptionException("Coupon max redemptions reached");
        }
        
        // Atomic update with version check
        int updatedRows = couponRepository.updateRedemptionCount(
            couponId, 
            coupon.getRedemptionCount() + 1,
            coupon.getVersion()
        );
        
        if (updatedRows == 0) {
            throw new ConcurrentModificationException("Coupon was modified by another process");
        }
        
        // Record redemption
        Redemption redemption = new Redemption(couponId, userId);
        redemptionRepository.save(redemption);
        
        return new RedemptionResult(true, "Coupon successfully redeemed");
    }
}

Deadlock Prevention Strategy

Deadlock prevention mechanisms:

  1. Consistent lock ordering - All operations acquire locks in the same order (e.g., by coupon ID)
  2. Timeout configuration - Set reasonable timeout limits on database locks (5-10 seconds)
  3. Retry logic with exponential backoff - Retry failed optimistic lock operations up to 3 times
  4. Avoid nested transactions - Flatten transaction scope where possible

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is implemented through:

  1. Idempotency keys in request headers (e.g., X-Idempotency-Key)
  2. Request deduplication cache with TTL (5 minutes) to prevent duplicate processing
  3. Database uniqueness constraints on redemption records (couponId + userId)
public class IdempotentCouponRedemptionService {
    private final Map<String, String> idempotencyCache = new ConcurrentHashMap<>();
    private final int idempotencyTTLSeconds = 300; // 5 minutes
    
    public RedemptionResult redeemCouponWithIdempotency(
            String idempotencyKey, 
            String couponId, 
            String userId) {
        
        // Check if this request was already processed
        String existingResult = idempotencyCache.get(idempotencyKey);
        if (existingResult != null) {
            return parseResult(existingResult);
        }
        
        try {
            RedemptionResult result = redeemCoupon(couponId, userId);
            
            // Cache the result for idempotency
            idempotencyCache.put(idempotencyKey, serializeResult(result));
            
            // Schedule cache cleanup
            ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
            scheduler.schedule(
                () -> idempotencyCache.remove(idempotencyKey),
                idempotencyTTLSeconds,
                TimeUnit.SECONDS
            );
            
            return result;
        } catch (Exception e) {
            // Don't cache failed requests
            throw e;
        }
    }
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant C as Customer
    participant API as CouponServiceAPI
    participant Auth as AuthMiddleware
    participant Service as CouponService
    participant DB as Database
    participant Cache as RedisCache
    participant EventPublisher as EventPublisher

    C->>API: ApplyCouponRequest(couponCode, cartItems)
    API->>Auth: Validate JWT token
    Auth-->>API: Validated user ID
    API->>Service: applyCoupon(userId, couponCode, cartItems)
    Service->>Cache: getCouponByCode(couponCode)
    Cache-->>Service: CouponEntity
    alt Coupon exists and is valid
        Service->>DB: validateCouponUsage(userId, couponCode)
        DB-->>Service: UsageCount
        alt Usage limit not reached
            Service->>DB: updateCouponUsage(userId, couponCode)
            DB-->>Service: Updated usage count
            Service->>EventPublisher: publishCouponApplied(userId, couponCode)
            EventPublisher-->>Service: Event acknowledged
            Service-->>API: CouponAppliedResponse(discountAmount, appliedCoupon)
            API-->>C: Success response with discount
        else Usage limit reached
            Service-->>API: CouponUsageExceededError
            API-->>C: Error response
        end
    else Coupon not found or invalid
        Service-->>API: InvalidCouponError
        API-->>C: Error response
    end
sequenceDiagram
    participant C as Customer
    participant API as CouponServiceAPI
    participant Auth as AuthMiddleware
    participant Service as CouponService
    participant DB as Database
    participant Cache as RedisCache
    participant EventPublisher as EventPublisher

    C->>API: ApplyCouponRequest(couponCode, cartItems)
    API->>Auth: Validate JWT token
    Auth-->>API: Validated user ID
    API->>Service: applyCoupon(userId, couponCode, cartItems)
    Service->>Cache: getCouponByCode(couponCode)
    Cache-->>Service: Cache miss
    Service->>DB: getCouponByCode(couponCode)
    DB-->>Service: CouponEntity
    Service->>Cache: cacheCoupon(couponCode, CouponEntity)
    Cache-->>Service: Cached
    alt Coupon exists and is valid
        Service->>DB: validateCouponUsage(userId, couponCode)
        DB-->>Service: UsageCount
        alt Usage limit not reached
            Service->>DB: updateCouponUsage(userId, couponCode)
            DB-->>Service: Updated usage count
            Service->>EventPublisher: publishCouponApplied(userId, couponCode)
            EventPublisher-->>Service: Event acknowledged
            Service-->>API: CouponAppliedResponse(discountAmount, appliedCoupon)
            API-->>C: Success response with discount
        else Usage limit reached
            Service-->>API: CouponUsageExceededError
            API-->>C: Error response
        end
    else Coupon not found or invalid
        Service-->>API: InvalidCouponError
        API-->>C: Error response
    end
sequenceDiagram
    participant Admin as AdminUser
    participant API as CouponServiceAPI
    participant Auth as AuthMiddleware
    participant Service as CouponService
    participant DB as Database
    participant Cache as RedisCache
    participant EventPublisher as EventPublisher

    Admin->>API: CreateCouponRequest(couponCode, discountType, discountValue, validFrom, validTo, maxUsage)
    API->>Auth: Validate Admin JWT token
    Auth-->>API: Validated admin ID
    API->>Service: createCoupon(adminId, couponRequest)
    Service->>DB: saveCoupon(couponEntity)
    DB-->>Service: Saved CouponEntity
    Service->>Cache: cacheCoupon(couponCode, CouponEntity)
    Cache-->>Service: Cached
    Service->>EventPublisher: publishCouponCreated(couponCode, adminId)
    EventPublisher-->>Service: Event acknowledged
    Service-->>API: CouponCreatedResponse(couponCode)
    API-->>Admin: Success response with coupon code

7. Caching Design

Cache Strategy Selection

For the coupon/discount engine, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for a system where coupons have time-based validity and usage constraints.

Cache Key Design

Cache keys will follow a structured namespace pattern:

coupon:{couponId}:details
coupon:{couponId}:eligibility:{userId}
coupon:{couponId}:usage:{userId}:{orderId}

TTL Strategy

Data TypeTTLReason
Coupon details5 minutesFrequent updates to validity, discounts
User eligibility1 minuteUser session and cart changes
Usage tracking30 minutesPrevents race conditions in concurrent usage
Active coupons list1 hourReduces database load for coupon discovery

Cache Eviction Policy

We'll implement LRU (Least Recently Used) eviction with a time-based expiration. This ensures that:

  1. Frequently accessed coupons stay in cache
  2. Expired or unused coupons are automatically evicted
  3. Memory pressure is managed proactively

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic early expiry: Set cache TTL to 75% of actual expiration time
  2. Distributed locks: Use Redis distributed locks for cache population
  3. Cache warming: Pre-warm popular coupons during low-traffic periods

Distributed Cache Architecture

We'll use Redis Cluster with:

  • 3 master nodes for high availability
  • 3 replica nodes per shard for read scaling
  • Redis Sentinel for automatic failover
  • Memory policy set to allkeys-lru with maxmemory configured at 8GB per node

Cache Invalidation Strategy

We'll use event-driven invalidation for optimal consistency:

@Component
public class CouponCacheInvalidator {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final EventPublisher eventPublisher;
    
    @EventListener
    public void handleCouponUpdated(CouponUpdatedEvent event) {
        String cacheKey = "coupon:" + event.getCouponId() + ":details";
        redisTemplate.delete(cacheKey);
        
        // Invalidate all user eligibility caches for this coupon
        String pattern = "coupon:" + event.getCouponId() + ":eligibility:*";
        Set<String> keys = redisTemplate.keys(pattern);
        redisTemplate.delete(keys);
    }
    
    @EventListener
    public void handleCouponUsage(CouponUsageEvent event) {
        String cacheKey = "coupon:" + event.getCouponId() + ":usage:" + 
                         event.getUserId() + ":" + event.getOrderId();
        redisTemplate.delete(cacheKey);
    }
}

Cache Read-Through Pattern

@Service
public class CouponService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponRepository couponRepository;
    
    public CouponDetails getCouponDetails(String couponId) {
        String cacheKey = "coupon:" + couponId + ":details";
        
        // Try cache first
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return (CouponDetails) cached;
        }
        
        // Cache miss - fetch from database
        CouponDetails details = couponRepository.findById(couponId);
        if (details != null) {
            // Cache for 5 minutes
            redisTemplate.opsForValue().set(cacheKey, details, 5, TimeUnit.MINUTES);
        }
        
        return details;
    }
}

Cache Write-Through Considerations

We'll avoid write-through caching for the coupon usage tracking due to:

  1. High write volume (per transaction)
  2. Complex invalidation requirements
  3. Risk of cache pollution from concurrent writes

Instead, we'll use a write-behind pattern with Redis streams for eventual consistency.

Cache Hit/Miss Metrics

We'll track cache performance using Prometheus metrics:

@MicrometerCounter(name = "coupon_cache_hits", description = "Number of cache hits")
private Counter cacheHits;

@MicrometerCounter(name = "coupon_cache_misses", description = "Number of cache misses")
private Counter cacheMisses;

@MicrometerGauge(name = "coupon_cache_hit_rate", description = "Cache hit rate percentage")
private Gauge cacheHitRate;

Cache Size and Memory Management

  • Cache size: 8GB per Redis node
  • Memory allocation: 70% for data, 30% for metadata and overhead
  • Monitoring: Alert when cache utilization exceeds 85%
  • Warm-up: Preload top 100 most used coupons during service startup

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 database operations. Each entity has its dedicated repository interface with common CRUD operations and specialized queries.

public interface CouponRepository {
    Optional<Coupon> findById(UUID id);
    List<Coupon> findByCode(String code);
    List<Coupon> findActiveCouponsForCategory(String category, LocalDateTime now);
    List<Coupon> findCouponsByUser(UUID userId, LocalDateTime now);
    Coupon save(Coupon coupon);
    void delete(UUID id);
    boolean existsById(UUID id);
}

public interface CouponUsageRepository {
    Optional<CouponUsage> findByUserIdAndCouponId(UUID userId, UUID couponId);
    List<CouponUsage> findByCouponId(UUID couponId);
    void save(CouponUsage usage);
}

ORM vs Raw Query Decisions

For the Coupon engine, we use JPA/Hibernate as the ORM for its strong typing and ease of development. However, complex analytical queries (e.g., usage analytics) are executed via raw SQL to ensure optimal performance and avoid N+1 issues.

@Query("SELECT c FROM Coupon c WHERE c.category = :category AND c.status = 'ACTIVE' AND c.validFrom <= :now AND c.validTo >= :now")
List<Coupon> findActiveCouponsForCategory(@Param("category") String category, @Param("now") LocalDateTime now);

Read vs Write Separation

The system implements CQRS (Command Query Responsibility Segregation) for the Coupon domain. Read operations (e.g., fetching available coupons) use a separate read model optimized for queries, while write operations (e.g., creating/updating coupons) use the primary domain model.

// Read model for optimized queries
@Query("SELECT new com.example.coupon.dto.CouponReadModel(c.id, c.code, c.discountType, c.discountValue) FROM Coupon c WHERE c.status = 'ACTIVE'")
List<CouponReadModel> findActiveCouponsForDisplay();

Connection Pool Configuration

Connection pooling is configured using HikariCP with optimized settings for the coupon engine:

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

N+1 Query Prevention Strategy

To prevent N+1 queries, we use eager loading for related entities and batch operations:

@Query("SELECT c FROM Coupon c JOIN FETCH c.userUsages u WHERE c.id IN :ids")
List<Coupon> findCouponsWithUsage(@Param("ids") List<UUID> ids);

@Query("SELECT u FROM CouponUsage u WHERE u.couponId IN :couponIds")
List<CouponUsage> findUsagesByCouponIds(@Param("couponIds") List<UUID> ids);

Batch Operation Design

Batch operations are implemented for bulk coupon creation and usage tracking:

public class BatchCouponRepository {
    public void batchCreateCoupons(List<Coupon> coupons) {
        String sql = "INSERT INTO coupons (id, code, discount_type, discount_value, valid_from, valid_to) VALUES (?, ?, ?, ?, ?, ?)";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            for (Coupon coupon : coupons) {
                stmt.setObject(1, coupon.getId());
                stmt.setString(2, coupon.getCode());
                stmt.setString(3, coupon.getDiscountType().name());
                stmt.setBigDecimal(4, coupon.getDiscountValue());
                stmt.setTimestamp(5, Timestamp.valueOf(coupon.getValidFrom()));
                stmt.setTimestamp(6, Timestamp.valueOf(coupon.getValidTo()));
                
                stmt.addBatch();
            }
            
            stmt.executeBatch();
        } catch (SQLException e) {
            throw new DataAccessException("Failed to batch create coupons", e);
        }
    }
}

Read-Write Splitting

Read operations are routed to read replicas while write operations go to the primary database:

@Repository
public class ReadWriteCouponRepository {
    @PersistenceContext
    private EntityManager entityManager;
    
    @PersistenceContext(unitName = "readReplica")
    private EntityManager readReplicaEntityManager;
    
    @Transactional(readOnly = true)
    public Optional<Coupon> findByIdForRead(UUID id) {
        return readReplicaEntityManager.find(Coupon.class, id);
    }
    
    @Transactional
    public Coupon save(Coupon coupon) {
        entityManager.persist(coupon);
        return coupon;
    }
}

Transaction Management

All repository operations are wrapped in proper transaction boundaries with appropriate isolation levels:

@Repository
public class CouponRepositoryImpl {
    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Coupon save(Coupon coupon) {
        entityManager.persist(coupon);
        entityManager.flush(); // Ensure immediate persistence
        return coupon;
    }
    
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean existsById(UUID id) {
        String jpql = "SELECT COUNT(c) FROM Coupon c WHERE c.id = :id";
        TypedQuery<Long> query = entityManager.createQuery(jpql, Long.class);
        query.setParameter("id", id);
        return query.getSingleResult() > 0;
    }
}

Soft Delete Implementation

Coupons are soft-deleted to maintain audit trails and allow for recovery:

@Entity
@Table(name = "coupons")
public class Coupon {
    @Id
    private UUID id;
    
    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;
    
    @Column(name = "is_deleted")
    private boolean deleted = false;
    
    public void softDelete() {
        this.deleted = true;
        this.deletedAt = LocalDateTime.now();
    }
    
    @Query("SELECT c FROM Coupon c WHERE c.id = :id AND c.deleted = false")
    Optional<Coupon> findByIdActive(@Param("id") UUID id);
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The Coupon/Discount engine will use a hybrid synchronous/asynchronous approach. The core business logic (coupon validation, application, and redemption) will be handled synchronously to ensure immediate consistency for user-facing operations. However, several secondary operations will be decoupled and handled asynchronously:

  1. Coupon usage tracking - updating analytics dashboards and user activity logs
  2. Coupon expiration notifications - sending alerts to admins or users before expiration
  3. Batch coupon generation - for bulk creation of coupons with large volumes
  4. Coupon audit trail logging - maintaining detailed logs for compliance and analytics

The decision to use async processing for these operations is based on the fact that they don't block the primary user experience and can be processed with eventual consistency.

Message schema design

{
  "version": "1.0",
  "event_type": "coupon.used",
  "timestamp": "2023-10-15T14:30:00Z",
  "correlation_id": "txn-uuid-12345",
  "payload": {
    "coupon_id": "COUPON-123456789",
    "user_id": "USER-987654321",
    "order_id": "ORDER-555555555",
    "discount_amount": 15.00,
    "currency": "USD",
    "used_at": "2023-10-15T14:30:00Z"
  }
}

Dead letter queue strategy and poison message handling

For failed message processing, we implement a dead letter queue (DLQ) strategy with:

  • Maximum retry attempts: 3
  • Retry backoff: Exponential (1s, 4s, 9s) with jitter
  • DLQ topic: coupon-dlq-topic
  • Message TTL: 7 days for poison messages

Poison messages (messages that fail consistently) will be moved to the DLQ with metadata indicating failure reason and retry count. These messages will require manual inspection and intervention by the operations team.

At-least-once vs. exactly-once delivery

We use at-least-once delivery for coupon events because:

  1. Idempotency is handled at the service layer - duplicate events are detected and ignored
  2. User experience is more important than perfect consistency - a user shouldn't lose their discount due to duplicate event processing
  3. Simpler implementation - avoids complex transactional systems

For critical operations like coupon redemption, we implement idempotency keys to ensure that duplicate requests don't result in multiple redemptions.

Retry policy for consumers

Retry configuration:

  • Max attempts: 3
  • Backoff strategy: Exponential with jitter (1s, 4s, 9s)
  • Retryable errors: Network timeouts, service unavailability
  • Non-retryable errors: Invalid coupon state, user permission denied

Retry policy implementation:

public class RetryPolicy {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    
    public boolean shouldRetry(Exception exception) {
        return exception instanceof NetworkException || 
               exception instanceof ServiceUnavailableException;
    }
    
    public long getDelay(int attempt) {
        return (long) Math.pow(BASE_DELAY_MS, attempt) + 
               ThreadLocalRandom.current().nextLong(0, 1000);
    }
}

Outbox pattern for reliable event publishing

We implement the outbox pattern to ensure event publishing is atomic with database operations:

@Service
public class CouponService {
    
    @Transactional
    public void applyCoupon(String userId, String couponId, String orderId) {
        // 1. Validate and apply coupon
        Coupon coupon = validateAndApplyCoupon(userId, couponId, orderId);
        
        // 2. Save to database (coupon usage record)
        saveCouponUsage(coupon, userId, orderId);
        
        // 3. Publish event via outbox pattern
        publishCouponEvent(coupon, userId, orderId);
        
        // 4. Commit transaction - ensures event is published only if DB save succeeds
    }
    
    private void publishCouponEvent(Coupon coupon, String userId, String orderId) {
        // Create event in outbox table
        OutboxEvent event = new OutboxEvent(
            "coupon.used",
            objectMapper.writeValueAsString(eventPayload),
            Instant.now()
        );
        outboxRepository.save(event);
        
        // Publish to message broker in separate transaction
        eventPublisher.publish(event);
    }
}

Sequence diagram for async flows

sequenceDiagram
    participant User
    participant APIGateway
    participant CouponService
    participant Database
    participant OutboxRepository
    participant MessageBroker
    participant EventConsumer
    
    User->>APIGateway: Apply Coupon Request
    APIGateway->>CouponService: validateAndApplyCoupon()
    CouponService->>Database: saveCouponUsage()
    CouponService->>OutboxRepository: saveEventToOutbox()
    CouponService-->>APIGateway: Success Response
    APIGateway-->>User: Coupon Applied
    
    OutboxRepository->>MessageBroker: Publish Event
    MessageBroker->>EventConsumer: Deliver Event
    
    EventConsumer->>AnalyticsService: Update Usage Metrics
    EventConsumer->>NotificationService: Send Expiration Alert
    
    Note over EventConsumer,AnalyticsService: Async processing - eventual consistency

10. Error Handling Design

Error Classification Hierarchy

graph TD
    A[Error] --> B[Domain Error]
    A --> C[Infrastructure Error]
    A --> D[Validation Error]
    
    B --> E[InvalidCouponException]
    B --> F[ExpiredCouponException]
    B --> G[InsufficientRedemptionException]
    
    C --> H[DatabaseException]
    C --> I[ExternalServiceException]
    
    D --> J[InvalidInputException]
    D --> K[MissingFieldException]

Error Propagation Strategy

  • Domain errors: Bubble up to service layer for centralized handling
  • Infrastructure errors: Handle at repository level and rethrow as domain exceptions
  • Validation errors: Caught early in API layer and returned as 400 responses

Retry Strategy with Exponential Backoff and Jitter

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

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (DatabaseException 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 RuntimeException("Retry interrupted", ie);
                }
                attempts++;
            }
        }
        throw new RuntimeException("Max retries exceeded");
    }
}

Circuit Breaker Design

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 <T> T execute(Supplier<T> operation) {
        switch (state) {
            case CLOSED:
                try {
                    T result = operation.get();
                    onSuccess();
                    return result;
                } catch (Exception e) {
                    onFailure(e);
                    throw e;
                }
                
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime > timeoutMs) {
                    state = CircuitState.HALF_OPEN;
                    return operation.get();
                }
                throw new CircuitBreakerOpenException("Circuit is open");
                
            case HALF_OPEN:
                try {
                    T result = operation.get();
                    state = CircuitState.CLOSED;
                    failureCount = 0;
                    return result;
                } catch (Exception e) {
                    state = CircuitState.OPEN;
                    throw e;
                }
        }
        throw new IllegalStateException("Invalid circuit state");
    }
    
    private void onSuccess() {
        if (state == CircuitState.HALF_OPEN) {
            failureCount = 0;
        }
    }
    
    private void onFailure(Exception e) {
        if (state == CircuitState.CLOSED) {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();
            if (failureCount >= failureThreshold) {
                state = CircuitState.OPEN;
            }
        } else if (state == CircuitState.HALF_OPEN) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

public class CouponService {
    private final ExternalCouponProvider externalProvider;
    
    public CouponResponse applyCouponWithFallback(String couponCode, Order order) {
        try {
            return externalProvider.fetchCoupon(couponCode);
        } catch (ExternalServiceException e) {
            // Fallback to local cache or default behavior
            return new CouponResponse(
                "DEFAULT_COUPON", 
                0.10, 
                "Applied default 10% discount"
            );
        }
    }
}

Error Logging Format and Alerting Thresholds

public class ErrorLogger {
    private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
    
    public void logError(String operation, Exception error, Map<String, Object> context) {
        String traceId = MDC.get("trace_id");
        String userId = MDC.get("user_id");
        
        Map<String, Object> logEntry = new HashMap<>();
        logEntry.put("timestamp", Instant.now().toString());
        logEntry.put("trace_id", traceId);
        logEntry.put("user_id", userId);
        logEntry.put("operation", operation);
        logEntry.put("error_type", error.getClass().getSimpleName());
        logEntry.put("error_message", error.getMessage());
        logEntry.put("context", context);
        
        logger.error("{}", logEntry);
        
        // Alert if error rate exceeds threshold
        if (shouldAlert(error)) {
            alertService.sendAlert("High error rate detected", logEntry);
        }
    }
    
    private boolean shouldAlert(Exception error) {
        // Example: Alert if 5+ database errors in last minute
        return error instanceof DatabaseException && 
               getRecentDatabaseErrorsCount() > 5;
    }
}

Alerting Thresholds

MetricWarning LevelCritical LevelEscalation Path
Database connection failures5/minute20/minuteOn-call DBA
External service timeouts10/minute50/minuteOn-call SRE
Coupon validation errors100/minute500/minuteOn-call backend team
Circuit breaker open1/minute5/minuteOn-call SRE

Error Response Format

{
  "error": {
    "code": "COUPON_EXPIRED",
    "message": "The coupon has expired and cannot be applied.",
    "details": {
      "couponId": "COUPON_12345",
      "expiryDate": "2023-12-31T23:59:59Z"
    },
    "timestamp": "2023-12-01T10:30:00Z",
    "traceId": "abc-123-def-456"
  }
}

11. Security Design

Input Validation and Sanitization Strategy

All API inputs are validated using a combination of:

  • Custom validation annotations (e.g., @ValidCouponCode, @ValidDiscountAmount) to enforce business rules
  • OWASP Java HTML Sanitization Library for any user-provided text fields to prevent XSS attacks
  • Regex patterns for coupon code format validation (^[A-Z0-9]{8,12}$)
  • Range checks for discount values (e.g., 0.01 to 99.99 for percentage discounts)

SQL Injection Prevention

  • All database queries use parameterized prepared statements exclusively (no string concatenation)
  • ORM framework (Hibernate) is configured with strict validation to prevent raw SQL injection
  • Stored procedures are avoided in favor of parameterized queries

Authentication Middleware Design

  • JWT-based authentication for API access with 24-hour expiration
  • OAuth2 integration for third-party applications (e.g., partner integrations)
  • Middleware intercepts all requests to validate JWT tokens and extract user context
  • Token refresh mechanism with sliding window (renewal window: 12 hours)

Authorization Enforcement

  • Role-Based Access Control (RBAC) model with roles: ADMIN, MARKETING_MANAGER, PARTNER_API
  • Permission-based checks at service layer for sensitive operations:
    • COUPON_CREATE for marketing managers and admins
    • COUPON_REVOKE only for admins
  • Resource-level authorization using a permission matrix:
    public class AuthorizationService {
        private static final Map<String, Set<String>> PERMISSIONS = Map.of(
            "ADMIN", Set.of("COUPON_CREATE", "COUPON_REVOKE", "COUPON_VIEW"),
            "MARKETING_MANAGER", Set.of("COUPON_CREATE", "COUPON_VIEW")
        );
        
        public boolean hasPermission(String userRole, String requiredPermission) {
            return PERMISSIONS.getOrDefault(userRole, Set.of()).contains(requiredPermission);
        }
    }

Sensitive Data Encryption

  • At-rest encryption: AES-256 encryption using AWS KMS for sensitive coupon data (e.g., redemption codes)
  • In-transit encryption: TLS 1.3 enforced for all API endpoints (minimum requirement)
  • PII masking: Coupon redemption logs only store masked identifiers (e.g., XXXXXX1234)

PII Handling and Masking Strategy

  • Coupon redemption logs do not store full user identifiers; only masked IDs and session tokens
  • Audit trail records only necessary fields (user_id hash, operation type, timestamp)
  • Data retention policy: PII logs are automatically deleted after 30 days

Secrets Management

  • AWS Secrets Manager for storing:
    • Database credentials (rotated every 90 days)
    • Third-party API keys (e.g., partner integration tokens)
  • Environment variables for non-sensitive configuration (e.g., cache host, port)

Audit Logging Design

  • Event types logged: COUPON_CREATED, COUPON_REDEEMED, COUPON_REVOKED
  • Log format:
    {
      "timestamp": "2023-10-27T14:30:45.123Z",
      "user_id": "hashed_user_id",
      "operation": "COUPON_REDEEMED",
      "coupon_code": "masked_code",
      "amount": 15.0,
      "ip_address": "masked_ip"
    }
  • Retention policy: Audit logs retained for 90 days with daily backups to S3
  • Compliance: Logs are encrypted at rest and accessible only via IAM roles with audit logging enabled

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage, focusing on business logic and edge cases
  • Repository Layer: 90% coverage for CRUD operations and query validation
  • Domain Objects: 100% coverage for value objects, validation logic, and state transitions
  • Event Handlers: 80% coverage for async processing logic and error handling paths
  • API Controllers: 75% coverage for request/response validation and error responses

Integration Testing Strategy

  • Database Integration: Use testcontainers with PostgreSQL 13+ to test actual database interactions, including transaction boundaries and constraint validation
  • External Service Mocks: WireMock for third-party payment gateway integration testing, with realistic response payloads and error scenarios
  • Event System: Embedded Kafka or RabbitMQ for testing event-driven flows, including dead-letter queue handling
  • Cache Integration: Redis test container to validate cache behavior and invalidation logic

Contract Testing

  • API Contract Testing: Use Pact to define consumer-driven contracts between coupon engine and dependent services (e.g., cart service, order service)
  • Event Schema Contract: Define Avro schemas with versioning strategy and validate consumer-consumer compatibility using schema registry
  • External API Contracts: Mock external coupon providers (e.g., loyalty systems) to ensure contract compliance for integration points

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based load testing) with Grafana for visualization and alerting
  • Key Scenarios:
    • Concurrent coupon redemption (500+ concurrent users)
    • High-volume coupon generation and validation (10,000+ coupons/sec)
    • Coupon eligibility calculation under load (500 concurrent users with complex rules)
    • Cache hit/miss ratio testing with realistic traffic patterns
  • Metrics Tracked: p50/p95/p99 response times, throughput, error rates, and cache hit ratios

Test Data Management Strategy

  • Fixtures: Use factory patterns to generate realistic test data for coupons, users, and products with valid constraints
  • Data Seeding: Use Liquibase or similar for database schema and initial data setup in test environments
  • Test Data Isolation: Use database transactional isolation with rollback strategy to ensure clean test state
  • Data Generation Patterns:
    • Coupon codes: UUID-based with prefix patterns (e.g., "SAVE2023_")
    • User segments: Random distribution across predefined groups (VIP, Regular, New)
    • Product categories: Hierarchical structure with realistic price ranges and discounts
  • Test Data Rotation: Implement test data cleanup scripts to prevent long-term pollution of test databases
// Example unit test for coupon validation logic
@Test
public void testCouponValidation_ExpiredCoupon() {
    Coupon expiredCoupon = new CouponBuilder()
        .withCode("EXPIRED_123")
        .withExpiryDate(Instant.now().minusSeconds(3600))
        .build();
    
    assertThatThrownBy(() -> couponService.validateCoupon(expiredCoupon))
        .isInstanceOf(CouponExpiredException.class)
        .hasMessageContaining("expired");
}

13. Observability Design

Structured Logging Schema

All logs must follow a structured JSON format with consistent fields to enable efficient querying and analysis:

{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "level": "INFO",
  "service": "coupon-engine",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user_12345",
  "entity_id": "coupon_abcde",
  "request_id": "req_98765",
  "event_type": "coupon_applied",
  "duration_ms": 45,
  "error_code": null,
  "error_message": null,
  "payload": {
    "coupon_code": "SAVE100",
    "discount_amount": 100,
    "applied_at": "2023-10-15T14:30:45.123Z"
  }
}

PII fields (user_id, email, etc.) must be masked or excluded from logs. The logging library should support redaction of sensitive fields.

Distributed Tracing Instrumentation

Using OpenTelemetry, we'll instrument key operations with custom spans:

Key Spans to Instrument:

  1. coupon_engine.validate_coupon - Validate coupon eligibility and rules
  2. coupon_engine.apply_discount - Apply discount to cart/order
  3. coupon_engine.check_usage_limit - Check max usage constraints
  4. coupon_engine.publish_event - Publish coupon events to message queue

Span Attributes:

Span currentSpan = Span.current();
currentSpan.setAttribute("coupon.code", "SAVE100");
currentSpan.setAttribute("cart.id", "cart_12345");
currentSpan.setAttribute("user.id", "user_12345");
currentSpan.setAttribute("discount.amount", 100.0);

Metrics Design

RED Method Metrics (Rate, Errors, Duration)

Metric NameTypeLabelsDescription
coupon_engine_requests_totalCounterendpoint, method, status_codeTotal request count
coupon_engine_errors_totalCounterendpoint, error_typeError count by type
coupon_engine_request_duration_secondsHistogramendpoint, methodRequest latency distribution
coupon_engine_active_coupons_countGaugestatus, typeActive coupon count
coupon_engine_usage_countCountercoupon_code, user_idCoupon usage count

Example Histogram Configuration:

Histogram requestDuration = OpenTelemetry.get().getMeter("coupon-engine")
    .histogramBuilder("coupon_engine_request_duration_seconds")
    .setDescription("Request duration in seconds")
    .setUnit("s")
    .build();

// Sample histogram with labels
requestDuration.record(0.123, 
    AttributeKey.stringKey("endpoint").string("applyCoupon"),
    AttributeKey.stringKey("method").string("POST")
);

Health Check Endpoint Design

/health - Readiness Check

{
  "status": "healthy",
  "checks": {
    "database": "healthy",
    "redis_cache": "healthy",
    "message_queue": "healthy"
  },
  "timestamp": "2023-10-15T14:30:45.123Z"
}

/ready - Liveness Check

{
  "status": "ready",
  "services": {
    "database": true,
    "cache": true,
    "queue": true
  },
  "timestamp": "2023-10-15T14:30:45.123Z"
}

/live - System Health Check

{
  "status": "healthy",
  "uptime_seconds": 3600,
  "memory_used_mb": 128,
  "cpu_percent": 15.2,
  "timestamp": "2023-10-15T14:30:45.123Z"
}

Alerting Thresholds

MetricWarning LevelCritical LevelAlerting Frequency
coupon_engine_errors_total> 5 errors/minute> 20 errors/minuteEvery 5 minutes
coupon_engine_request_duration_seconds (p99)> 500ms> 2sEvery minute
coupon_engine_requests_total (rate)< 100 req/sec< 50 req/secEvery 5 minutes
coupon_engine_active_coupons_count (decrease)> 50% drop in 1 hour> 80% drop in 1 hourEvery 15 minutes

Escalation Path:

  1. Warning: Slack channel notification
  2. Critical: On-call engineer page, SMS alert to team lead
  3. Persistent Issues: Incident management system creation with rollback plan

Service-Level Metrics Collection

Prometheus Exporter Configuration:

# metrics.yml
scrape_configs:
  - job_name: 'coupon-engine'
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: '/metrics'

Sample Metrics Output:

# HELP coupon_engine_requests_total Total number of requests handled by the coupon engine
# TYPE coupon_engine_requests_total counter
coupon_engine_requests_total{endpoint="applyCoupon",method="POST",status_code="200"} 1234.0
coupon_engine_requests_total{endpoint="validateCoupon",method="GET",status_code="404"} 56.0

# HELP coupon_engine_request_duration_seconds Histogram of request durations in seconds
# TYPE coupon_engine_request_duration_seconds histogram
coupon_engine_request_duration_seconds_bucket{endpoint="applyCoupon",method="POST",le="0.1"} 234.0
coupon_engine_request_duration_seconds_bucket{endpoint="applyCoupon",method="POST",le="0.5"} 1234.0
coupon_engine_request_duration_seconds_bucket{endpoint="applyCoupon",method="POST",le="+Inf"} 1234.0
coupon_engine_request_duration_seconds_sum{endpoint="applyCoupon",method="POST"} 123.45
coupon_engine_request_duration_seconds_count{endpoint="applyCoupon",method="POST"} 1234.0

Observability Tooling Stack

  • Tracing: OpenTelemetry Collector + Jaeger or Zipkin
  • Metrics: Prometheus + Grafana dashboards
  • Logging: ELK stack (Elasticsearch, Logstash, Kibana) or CloudWatch + Loki
  • Alerting: Alertmanager + PagerDuty integration

Observability Configuration Sample:

# observability-config.yaml
tracing:
  enabled: true
  service_name: "coupon-engine"
  endpoint: "http://otel-collector:4317"

metrics:
  enabled: true
  port: 8080
  path: "/metrics"

logging:
  level: "INFO"
  format: "json"
  redact_fields:
    - "user_id"
    - "email"

Data Retention and Archiving

  • Metrics: 30 days retention with hourly aggregation
  • Traces: 7 days retention for detailed debugging, 30 days for compliance
  • Logs: 90 days for operational debugging, 1 year for audit trails

Monitoring Dashboard Design

Key dashboards to build:

  1. Coupon Engine Overview - Request rate, error rate, latency
  2. Coupon Usage Trends - Active coupons, usage distribution by type
  3. Performance Metrics - Latency by endpoint, database query times
  4. Error Analysis - Error types, frequency, user impact
  5. System Health - Database connection pool, cache hit rates, queue depths

Each dashboard will include:

  • Time range selectors (last hour, day, week)
  • Alert status indicators
  • Drill-down capability to individual request traces or error logs

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Coupon redemption concurrency model: Should we allow concurrent redemptions for the same coupon, or enforce strict sequential processing? This impacts both performance and business logic consistency.
  • Coupon stacking policy: Should multiple coupons be allowed to stack (e.g., apply both a 10% discount and a $5 off coupon)? This affects validation complexity and business rules.
  • Coupon expiration behavior: Should expired coupons be soft-deleted or immediately removed from system? This affects data retention and audit requirements.
  • Redemption cap enforcement: How strictly should we enforce per-user and global redemption limits? Should we allow exceptions or overrides for specific users?

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Validation vs PerformanceValidate all coupon conditions at redemption timePre-validate and cache valid couponsOption 1 provides stronger consistency but increases latency; Option 2 improves performance but risks stale validation
Flexibility vs SimplicitySupport complex coupon rules engine (e.g., conditional logic)Simple rule-based system with fixed conditionsOption 1 offers maximum flexibility but increases complexity and maintenance cost; Option 2 is simpler to implement and maintain
Real-time vs Batch ProcessingReal-time coupon validation on every transactionBatch validation with periodic updatesOption 1 provides immediate correctness but increases system load; Option 2 reduces load but introduces potential inconsistency window
Soft-delete vs Hard-deleteSoft-delete coupons with tombstone recordsHard-delete expired couponsOption 1 preserves audit trail and allows recovery but increases storage costs; Option 2 reduces storage overhead but loses historical data
Global vs Per-User LimitsEnforce global redemption limits onlyEnforce both global and per-user limitsOption 1 simplifies logic but may not meet business requirements; Option 2 provides better control but increases complexity

Risks and mitigation strategies

  • Race condition in coupon redemption: Multiple concurrent requests could over-apply a coupon, leading to negative balance or over-discounting.

    • Mitigation: Implement optimistic locking with version fields and retry logic in the redemption process.
  • Coupon validation bypass: Malicious actors could exploit API endpoints to bypass coupon validation.

    • Mitigation: Implement comprehensive input validation, rate limiting, and authentication checks at all endpoints.
  • Performance degradation under high load: Coupon redemption might become a bottleneck during peak traffic.

    • Mitigation: Use caching for frequently accessed coupons, implement read replicas, and design with horizontal scaling in mind.
  • Data inconsistency between coupon state and redemption logs: Coupon status might not accurately reflect actual redemptions.

    • Mitigation: Use distributed transactions or event-driven consistency patterns to ensure atomicity between coupon state and redemption logs.
  • Audit trail gaps: Historical audit data might be incomplete or corrupted.

    • Mitigation: Implement robust logging with structured formats and regular data integrity checks.

Technical debt acknowledged

  • Coupon rule engine complexity: Current design assumes simple rules but may require future expansion to support complex conditional logic, which could introduce significant technical debt if not properly abstracted.
  • Hardcoded business logic: Some validation and calculation logic is currently hardcoded, which makes it difficult to adapt to changing business requirements without code changes.
  • Limited extensibility: The current service layer design may not easily accommodate new coupon types or validation rules without significant refactoring.
  • Eventual consistency in async flows: Some asynchronous processes might introduce eventual consistency issues that could impact user experience or business logic accuracy.
  • Limited support for multi-currency: The current design assumes single currency, which may not scale to international or multi-currency use cases without major refactoring.

Was this article helpful?

Topics in this article

More from Backend LLD

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.