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
-
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)?
-
What consistency requirements exist for coupon usage?
- Should coupons be applied atomically with orders?
- Is eventual consistency acceptable for some use cases?
-
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?
-
What is the read/write ratio for coupon data?
- Is it primarily used during checkout (high write) or for analytics (read-heavy)?
-
What are the latency sensitivity requirements?
- Should coupon validation happen within 10ms for real-time checkout?
- Are there different SLAs for different coupon types?
-
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?
-
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
| Method | Path | Description | Auth | Rate Limit |
|---|---|---|---|---|
| CreateCoupon | POST /v1/coupons | Create a new coupon with rules and constraints | JWT + Admin Role | 100 req/min |
| GetCoupon | GET /v1/coupons/{id} | Retrieve coupon details by ID | JWT + Admin/Viewer Role | 1000 req/min |
| ApplyCoupon | POST /v1/coupons/{id}/apply | Apply coupon to cart/order | JWT + User Role | 50 req/min |
| ListCoupons | GET /v1/coupons | List coupons with filters and pagination | JWT + Admin/Viewer Role | 500 req/min |
| UpdateCoupon | PUT /v1/coupons/{id} | Update existing coupon metadata | JWT + Admin Role | 100 req/min |
| DeleteCoupon | DELETE /v1/coupons/{id} | Soft delete coupon (mark inactive) | JWT + Admin Role | 50 req/min |
| ValidateCoupon | POST /v1/coupons/validate | Validate coupon eligibility without applying | JWT + User Role | 200 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 Code | Description |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Missing or invalid auth |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found - Coupon not found |
| 429 | Too Many Requests - Rate limit exceeded |
| 500 | Internal 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
| Table | Index Name | Columns | Purpose |
|---|---|---|---|
coupons | idx_coupons_status | status | Fast filtering by status |
coupons | idx_coupons_code | code | Fast lookup by coupon code |
coupons | idx_coupons_dates | start_date, end_date | Efficient date range queries |
coupon_applicability | idx_coupon_applicability_product | product_id | Fast product-based filtering |
coupon_applicability | idx_coupon_applicability_category | category_id | Fast category-based filtering |
user_coupons | idx_user_coupons_status | status | Fast filtering by redemption status |
user_coupons | idx_user_coupons_used_at | used_at | Efficient usage tracking |
coupon_rules | idx_coupon_rules_priority | coupon_id, priority | Fast 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_couponstable
Audit Trail Design
All data changes are tracked via:
created_at,updated_attimestampscreated_byfield 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:
- Coupon loading with pessimistic locking to prevent race conditions during redemption
- Validation and discount calculation (read-only operations)
- Redemption count update with optimistic locking
- 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
CompletableFutureor 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:
- Optimistic locking using version fields or timestamps in the coupon entity
- Database-level atomic operations for quantity decrement and status updates
- 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:
- Consistent lock ordering - All operations acquire locks in the same order (e.g., by coupon ID)
- Timeout configuration - Set reasonable timeout limits on database locks (5-10 seconds)
- Retry logic with exponential backoff - Retry failed optimistic lock operations up to 3 times
- Avoid nested transactions - Flatten transaction scope where possible
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented through:
- Idempotency keys in request headers (e.g.,
X-Idempotency-Key) - Request deduplication cache with TTL (5 minutes) to prevent duplicate processing
- 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 Type | TTL | Reason |
|---|---|---|
| Coupon details | 5 minutes | Frequent updates to validity, discounts |
| User eligibility | 1 minute | User session and cart changes |
| Usage tracking | 30 minutes | Prevents race conditions in concurrent usage |
| Active coupons list | 1 hour | Reduces database load for coupon discovery |
Cache Eviction Policy
We'll implement LRU (Least Recently Used) eviction with a time-based expiration. This ensures that:
- Frequently accessed coupons stay in cache
- Expired or unused coupons are automatically evicted
- Memory pressure is managed proactively
Cache Stampede Prevention
To prevent cache stampedes:
- Probabilistic early expiry: Set cache TTL to 75% of actual expiration time
- Distributed locks: Use Redis distributed locks for cache population
- 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-lruwithmaxmemoryconfigured 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:
- High write volume (per transaction)
- Complex invalidation requirements
- 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:
- Coupon usage tracking - updating analytics dashboards and user activity logs
- Coupon expiration notifications - sending alerts to admins or users before expiration
- Batch coupon generation - for bulk creation of coupons with large volumes
- 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:
- Idempotency is handled at the service layer - duplicate events are detected and ignored
- User experience is more important than perfect consistency - a user shouldn't lose their discount due to duplicate event processing
- 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
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
| Database connection failures | 5/minute | 20/minute | On-call DBA |
| External service timeouts | 10/minute | 50/minute | On-call SRE |
| Coupon validation errors | 100/minute | 500/minute | On-call backend team |
| Circuit breaker open | 1/minute | 5/minute | On-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_CREATEfor marketing managers and adminsCOUPON_REVOKEonly 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:
coupon_engine.validate_coupon- Validate coupon eligibility and rulescoupon_engine.apply_discount- Apply discount to cart/ordercoupon_engine.check_usage_limit- Check max usage constraintscoupon_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 Name | Type | Labels | Description |
|---|---|---|---|
coupon_engine_requests_total | Counter | endpoint, method, status_code | Total request count |
coupon_engine_errors_total | Counter | endpoint, error_type | Error count by type |
coupon_engine_request_duration_seconds | Histogram | endpoint, method | Request latency distribution |
coupon_engine_active_coupons_count | Gauge | status, type | Active coupon count |
coupon_engine_usage_count | Counter | coupon_code, user_id | Coupon 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
| Metric | Warning Level | Critical Level | Alerting Frequency |
|---|---|---|---|
coupon_engine_errors_total | > 5 errors/minute | > 20 errors/minute | Every 5 minutes |
coupon_engine_request_duration_seconds (p99) | > 500ms | > 2s | Every minute |
coupon_engine_requests_total (rate) | < 100 req/sec | < 50 req/sec | Every 5 minutes |
coupon_engine_active_coupons_count (decrease) | > 50% drop in 1 hour | > 80% drop in 1 hour | Every 15 minutes |
Escalation Path:
- Warning: Slack channel notification
- Critical: On-call engineer page, SMS alert to team lead
- 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:
- Coupon Engine Overview - Request rate, error rate, latency
- Coupon Usage Trends - Active coupons, usage distribution by type
- Performance Metrics - Latency by endpoint, database query times
- Error Analysis - Error types, frequency, user impact
- 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-off | Option 1 | Option 2 | Justification |
|---|---|---|---|
| Validation vs Performance | Validate all coupon conditions at redemption time | Pre-validate and cache valid coupons | Option 1 provides stronger consistency but increases latency; Option 2 improves performance but risks stale validation |
| Flexibility vs Simplicity | Support complex coupon rules engine (e.g., conditional logic) | Simple rule-based system with fixed conditions | Option 1 offers maximum flexibility but increases complexity and maintenance cost; Option 2 is simpler to implement and maintain |
| Real-time vs Batch Processing | Real-time coupon validation on every transaction | Batch validation with periodic updates | Option 1 provides immediate correctness but increases system load; Option 2 reduces load but introduces potential inconsistency window |
| Soft-delete vs Hard-delete | Soft-delete coupons with tombstone records | Hard-delete expired coupons | Option 1 preserves audit trail and allows recovery but increases storage costs; Option 2 reduces storage overhead but loses historical data |
| Global vs Per-User Limits | Enforce global redemption limits only | Enforce both global and per-user limits | Option 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.