A robust payment system architecture that leverages strategy patterns to support multiple payment providers, currencies, and processing workflows while maintaining extensibility and fault tolerance across diverse payment scenarios.
1. Problem Statement & Requirements
Clarifying questions
- What is the expected scale of transactions? What's the anticipated volume (e.g., 10K, 100K, or 1M+ transactions per second) and peak concurrency requirements?
- What consistency guarantees are required? Are strong consistency, eventual consistency, or eventual consistency with optimistic locking acceptable for payment operations?
- What are the key entities and their relationships? Is this a single payment system or does it integrate with multiple payment providers (e.g., Stripe, PayPal, etc.)?
- What's the read/write ratio? Is this predominantly a write-heavy system (e.g., payment processing) or does it support frequent reads (e.g., payment status queries)?
- What are the latency sensitivity requirements? Are payments expected to complete in <100ms for real-time transactions or is a few seconds acceptable?
- What authentication and authorization model is expected? Will payments be handled via API keys, OAuth2, or JWTs with role-based access control?
- What external systems or integrations are required? Will this system integrate with fraud detection, accounting, or reconciliation services?
Functional requirements
- Support for multiple payment methods (credit cards, ACH, digital wallets)
- Payment creation with status tracking (pending, succeeded, failed, refunded)
- Refund initiation and tracking
- Payment method validation and tokenization
- Support for recurring payments (subscriptions)
- Integration with external payment gateways (e.g., Stripe, PayPal)
- Audit trail of all payment operations
- Support for multi-currency payments
Non-functional requirements
- Latency targets:
- p50: <100ms for payment creation
- p95: <200ms for payment creation
- p99: <500ms for payment creation
- Throughput: 10K+ concurrent payments per second (peak)
- Availability: 99.9% uptime (99.99% for critical paths)
- Security: PCI-DSS compliance, end-to-end encryption of sensitive data
- Scalability: Horizontal sharding by customer or payment ID
Constraints and assumptions
- All payments must be idempotent to handle retries
- Payment data is highly sensitive and requires encryption at rest and in transit
- External payment gateway APIs are assumed to be available and stable
- Refunds must be processed within 24 hours of original payment
- All operations must support audit logging and traceability
- The system will not handle payment reconciliation or accounting directly (external systems manage that)
Out of scope
- Fraud detection and prevention logic
- Direct integration with banking systems (ACH, wire transfers)
- Real-time settlement to bank accounts
- Payment gateway configuration management (e.g., webhook setup, API key rotation)
- User-facing payment UI or mobile app integration
- Multi-region replication or disaster recovery setup (focus is on core payment processing)
- Payment method onboarding and management (e.g., adding new card types)
2. API Design
API Style Selection
gRPC with Protocol Buffers is selected for the payment system due to:
- Strong typing and automatic code generation
- Efficient binary serialization (better for transaction data)
- Native support for streaming and bidirectional communication
- First-class support for versioning and schema evolution
- Better performance characteristics for high-throughput payment operations
Endpoint Definitions
| Method | Path | Description | Auth | Rate Limit |
|---|---|---|---|---|
| CreatePayment | POST /v1/payments | Initiate a new payment | JWT | 100 req/min |
| GetPaymentStatus | GET /v1/payments/{id} | Retrieve payment status | JWT | 500 req/min |
| CancelPayment | POST /v1/payments/{id}/cancel | Cancel pending payment | JWT | 50 req/min |
| RefundPayment | POST /v1/payments/{id}/refund | Process refund | JWT | 25 req/min |
| ListPayments | GET /v1/payments | List user's payments | JWT | 100 req/min |
| GetPaymentMethods | GET /v1/payment-methods | List available payment methods | JWT | 200 req/min |
Request and Response Schemas
CreatePaymentRequest
message CreatePaymentRequest {
string user_id = 1;
double amount = 2;
string currency = 3;
string payment_method_id = 4;
string description = 5;
map<string, string> metadata = 6;
string idempotency_key = 7;
}
CreatePaymentResponse
message CreatePaymentResponse {
string payment_id = 1;
string status = 2;
double amount = 3;
string currency = 4;
string created_at = 5;
string updated_at = 6;
}
PaymentStatusResponse
message PaymentStatusResponse {
string payment_id = 1;
string status = 2;
double amount = 3;
string currency = 4;
string created_at = 5;
string updated_at = 6;
string failure_reason = 7;
repeated PaymentEvent events = 8;
}
HTTP Status Codes and Error Format
| Status Code | Usage |
|---|---|
| 200 OK | Successful GET, PUT, PATCH |
| 201 Created | Successful POST (create) |
| 400 Bad Request | Invalid request format, validation errors |
| 401 Unauthorized | Missing or invalid authentication |
| 403 Forbidden | Insufficient permissions |
| 404 Not Found | Resource not found |
| 422 Unprocessable Entity | Business logic validation errors |
| 429 Too Many Requests | Rate limit exceeded |
| 500 Internal Server Error | Unexpected server errors |
Standardized error response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Payment amount must be greater than zero",
"details": [
{
"field": "amount",
"reason": "must be greater than zero"
}
]
}
}
API Versioning Strategy
Versioned using path prefix: /v1/ with support for multiple versions:
- Current:
v1 - Future:
v2(for breaking changes) - Backward compatibility maintained for non-breaking changes
Pagination Design
Cursor-based pagination is used because:
- More consistent and predictable than offset-based
- Handles data changes during pagination gracefully
- Better performance for large datasets
message PaymentListRequest {
string cursor = 1;
int32 page_size = 2;
string sort_by = 3;
string sort_order = 4;
}
message PaymentListResponse {
repeated PaymentStatusResponse payments = 1;
string next_cursor = 2;
int32 total_count = 3;
}
Authentication and Authorization
All endpoints require JWT Bearer token with:
payment:readscope for read operationspayment:writescope for write operationspayment:refundscope for refund operations
Rate Limiting Strategy
Rate limiting implemented at API gateway level with:
- Per-user limits: 100 req/min for create, 500 req/min for read
- Per-method limits: Specific limits per endpoint type
- Token bucket algorithm with burst capacity of 2x limit
Idempotency Design
Idempotency key strategy:
- Required for all write operations (
CreatePayment,CancelPayment,RefundPayment) - Stored in database with payment ID as value
- Key format:
{user_id}:{idempotency_key} - TTL of 24 hours for cleanup
- Returns same payment ID on duplicate requests within window
// Example idempotency check in service layer
public CreatePaymentResponse createPayment(CreatePaymentRequest request) {
String idempotencyKey = buildIdempotencyKey(request.getUserId(), request.getIdempotencyKey());
// Check if this idempotency key already exists
String existingPaymentId = idempotencyRepository.get(idempotencyKey);
if (existingPaymentId != null) {
return paymentService.getPaymentStatus(existingPaymentId);
}
// Proceed with new payment creation...
String newPaymentId = paymentService.createPayment(request);
// Store the mapping
idempotencyRepository.put(idmentityKey, newPaymentId);
return paymentService.getPaymentStatus(newPaymentId);
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
PAYMENT_METHODS {
uuid id PK
uuid user_id FK
string type
string provider
string last_four_digits
boolean is_default
datetime created_at
datetime updated_at
datetime deleted_at
}
PAYMENTS {
uuid id PK
uuid user_id FK
uuid payment_method_id FK
string status
decimal amount
string currency
string description
jsonb metadata
datetime created_at
datetime updated_at
datetime completed_at
}
PAYMENT_EVENTS {
uuid id PK
uuid payment_id FK
string event_type
string status
jsonb data
datetime created_at
}
STRATEGIES {
uuid id PK
string name
string description
string implementation_class
jsonb config
boolean is_active
datetime created_at
datetime updated_at
}
PAYMENT_STRATEGY_MAPPINGS {
uuid id PK
uuid payment_method_id FK
uuid strategy_id FK
datetime created_at
}
USER {
uuid id PK
string email
string name
datetime created_at
datetime updated_at
}
PAYMENT_METHODS ||--o{ PAYMENTS : "processes"
PAYMENT_METHODS ||--o{ PAYMENT_STRATEGY_MAPPINGS : "mapped_to"
STRATEGIES ||--o{ PAYMENT_STRATEGY_MAPPINGS : "maps_to"
USER ||--o{ PAYMENT_METHODS : "owns"
PAYMENTS ||--o{ PAYMENT_EVENTS : "generates"
Table Schema Definitions
users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
payment_methods table
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'credit_card', 'paypal', 'bank_transfer'
provider VARCHAR(100), -- 'stripe', 'paypal', 'adyen'
last_four_digits VARCHAR(4),
is_default BOOLEAN DEFAULT FALSE,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
strategies table
CREATE TABLE strategies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
implementation_class VARCHAR(255) NOT NULL, -- Fully qualified class name
config JSONB,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
payment_strategy_mappings table
CREATE TABLE payment_strategy_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_method_id UUID REFERENCES payment_methods(id) ON DELETE CASCADE,
strategy_id UUID REFERENCES strategies(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
payments table
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
payment_method_id UUID REFERENCES payment_methods(id),
strategy_id UUID REFERENCES strategies(id),
status VARCHAR(50) NOT NULL, -- 'pending', 'success', 'failed', 'cancelled'
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
description TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
payment_events table
CREATE TABLE payment_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID REFERENCES payments(id) ON DELETE CASCADE,
event_type VARCHAR(100) NOT NULL, -- 'payment_initiated', 'payment_success', 'payment_failed'
status VARCHAR(50) NOT NULL,
data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Primary Key Strategy
UUIDs are used for all primary keys due to:
- Global uniqueness across distributed systems
- No need for coordination between services
- Better sharding capabilities (can be generated client-side)
- Avoids sequential ID predictability and security concerns
Index Design
| Index Name | Fields | Type | Purpose |
|---|---|---|---|
| idx_payments_user_id | user_id | Standard | Fast lookup by user |
| idx_payments_status | status | Standard | Filter payments by status |
| idx_payments_method_id | payment_method_id | Standard | Lookup payments by method |
| idx_payments_created_at | created_at | Standard | Time-based queries |
| idx_payment_events_payment_id | payment_id | Standard | Event lookup by payment |
| idx_payment_events_event_type | event_type | Standard | Filter events by type |
| idx_payment_strategy_mappings_method_id | payment_method_id | Unique composite | Ensure one strategy per method |
| idx_payment_strategy_mappings_strategy_id | strategy_id | Standard | Fast strategy lookup |
Data Partitioning Strategy
Horizontal partitioning by user_id hash:
- Partition key:
hash(user_id) % 32 - Each partition handles ~1/32 of the total payment volume
- Allows for sharding across multiple database instances or tables
Soft Delete vs. Hard Delete
Soft delete is used for:
payment_methodsandusers- to maintain referential integritypayments- to support audit trails and compliance requirements
Audit Trail Design
All tables have created_at, updated_at fields with:
created_byfield added to payment methods and payments for audit trackingupdated_byfield for tracking who made changes
Data Archival Strategy
Payment data retention policy:
- Active payments: Retained indefinitely
- Completed payments: Retained for 7 years (compliance requirement)
- Failed payments: Retained for 2 years (debugging and analytics)
- Payment events: Retained for 1 year
Archival process:
-- Move completed payments older than retention period to archive tables
INSERT INTO payments_archive SELECT * FROM payments WHERE status = 'success' AND completed_at < NOW() - INTERVAL '7 years';
DELETE FROM payments WHERE status = 'success' AND completed_at < NOW() - INTERVAL '7 years';
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class PaymentProcessor {
<<Interface>>
+processPayment(PaymentRequest) PaymentResult
+cancelPayment(String) PaymentResult
+refundPayment(String, BigDecimal) PaymentResult
}
class PaymentService {
-paymentRepository PaymentRepository
-paymentStrategyFactory PaymentStrategyFactory
-eventPublisher EventPublisher
+processPayment(PaymentRequest) PaymentResult
+cancelPayment(String) PaymentResult
+refundPayment(String, BigDecimal) PaymentResult
}
class PaymentStrategyFactory {
-strategyMap Map~String, PaymentStrategy~
+getStrategy(String) PaymentStrategy
}
class PaymentStrategy {
<<Interface>>
+execute(PaymentContext) PaymentResult
+validate(PaymentRequest) ValidationResult
}
class CreditCardPaymentStrategy {
+execute(PaymentContext) PaymentResult
+validate(PaymentRequest) ValidationResult
}
class PayPalPaymentStrategy {
+execute(PaymentContext) PaymentResult
+validate(PaymentRequest) ValidationResult
}
class PaymentRequest {
-amount BigDecimal
-currency String
-paymentMethod String
-paymentDetails PaymentDetails
-customerId String
+getAmount() BigDecimal
+getCurrency() String
+getPaymentMethod() String
}
class PaymentContext {
-request PaymentRequest
-paymentId String
-status PaymentStatus
-transactionId String
}
class PaymentResult {
-success Boolean
-paymentId String
-transactionId String
-message String
-errorCode String
}
class PaymentDetails {
-cardNumber String
-expiryDate String
-cvv String
-email String
}
class ValidationResult {
-valid Boolean
-errors List~String~
}
PaymentProcessor <|-- PaymentService
PaymentService --> PaymentStrategyFactory : uses
PaymentService --> PaymentRepository : accesses
PaymentService --> EventPublisher : publishes
PaymentStrategyFactory --> PaymentStrategy : creates
PaymentStrategy <|-- CreditCardPaymentStrategy
PaymentStrategy <|-- PayPalPaymentStrategy
PaymentRequest --> PaymentDetails : contains
PaymentContext --> PaymentRequest : contains
PaymentContext --> PaymentResult : produces
PaymentStrategy --> PaymentContext : uses
PaymentStrategy --> PaymentRequest : validates
State Machine Diagram
stateDiagram-v2
[*] --> Created
Created --> Authorized : process()
Authorized --> Captured : capture()
Authorized --> Failed : decline()
Captured --> Refunded : refund()
Captured --> Voided : void()
Failed --> [*]
Refunded --> [*]
Voided --> [*]
state "Payment Processing" {
Created
Authorized
Captured
Failed
Refunded
Voided
}
Service Class Structure and Responsibilities
The PaymentService is the core orchestrator that:
- Validates incoming payment requests
- Selects appropriate payment strategy based on method type
- Manages the complete payment lifecycle
- Handles transaction boundaries and persistence
- Publishes domain events for audit and async processing
Interface / Abstract Class Design
public interface PaymentProcessor {
PaymentResult processPayment(PaymentRequest request);
PaymentResult cancelPayment(String paymentId);
PaymentResult refundPayment(String paymentId, BigDecimal amount);
}
public interface PaymentStrategy {
PaymentResult execute(PaymentContext context);
ValidationResult validate(PaymentRequest request);
}
public abstract class BasePaymentStrategy implements PaymentStrategy {
protected final EventPublisher eventPublisher;
public BasePaymentStrategy(EventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Override
public ValidationResult validate(PaymentRequest request) {
return new ValidationResult(true, Collections.emptyList());
}
}
Business Logic Encapsulation Patterns
Strategy Pattern Implementation:
public class PaymentStrategyFactory {
private final Map<String, PaymentStrategy> strategyMap = new ConcurrentHashMap<>();
public PaymentStrategyFactory(List<PaymentStrategy> strategies) {
strategies.forEach(strategy -> {
String method = strategy.getSupportedMethod();
strategyMap.put(method, strategy);
});
}
public PaymentStrategy getStrategy(String paymentMethod) {
PaymentStrategy strategy = strategyMap.get(paymentMethod);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported payment method: " + paymentMethod);
}
return strategy;
}
}
Critical-Path Method Implementation
public PaymentResult processPayment(PaymentRequest request) {
// Transaction boundary - start new transaction
return transactionTemplate.execute(status -> {
try {
// Validate request
ValidationResult validation = validateRequest(request);
if (!validation.isValid()) {
return new PaymentResult(false, null, null,
"Validation failed: " + String.join(", ", validation.getErrors()),
"VALIDATION_ERROR");
}
// Generate unique payment ID
String paymentId = UUID.randomUUID().toString();
// Create payment context
PaymentContext context = new PaymentContext(
request,
paymentId,
PaymentStatus.CREATED,
null
);
// Select and execute strategy
PaymentStrategy strategy = paymentStrategyFactory.getStrategy(request.getPaymentMethod());
PaymentResult result = strategy.execute(context);
// Update payment record
if (result.isSuccess()) {
Payment payment = new Payment(
paymentId,
request.getCustomerId(),
request.getAmount(),
request.getCurrency(),
request.getPaymentMethod(),
PaymentStatus.CAPTURED,
result.getTransactionId(),
new Date()
);
paymentRepository.save(payment);
// Publish event
PaymentEvent event = new PaymentEvent(
paymentId,
"PAYMENT_CAPTURED",
request.getCustomerId(),
request.getAmount(),
new Date()
);
eventPublisher.publish(event);
}
return result;
} catch (Exception e) {
// Log and handle error
logger.error("Payment processing failed for request: {}", request, e);
return new PaymentResult(false, null, null,
"Payment processing failed: " + e.getMessage(),
"PROCESSING_ERROR");
}
});
}
Transaction Boundary Design
Each payment operation is wrapped in a single database transaction that:
- Ensures atomicity of payment creation and status update
- Maintains referential integrity with customer data
- Supports rollback on any failure during processing
- Uses pessimistic locking for concurrent access protection
Service Interface Contracts
public interface PaymentService {
PaymentResult processPayment(PaymentRequest request);
PaymentResult cancelPayment(String paymentId);
PaymentResult refundPayment(String paymentId, BigDecimal amount);
PaymentResult getPaymentStatus(String paymentId);
List<Payment> getCustomerPayments(String customerId, Pageable pageable);
}
Saga / Orchestration Pattern
For complex payment flows involving external systems, a saga pattern is implemented:
public class PaymentSaga {
private final List<SagaStep> steps;
public void executePaymentFlow(PaymentRequest request) {
SagaContext context = new SagaContext();
for (SagaStep step : steps) {
try {
step.execute(context);
} catch (Exception e) {
// Compensate previous steps
compensateSteps(context, step);
throw new PaymentProcessingException("Saga failed", e);
}
}
}
}
Domain Object / Value Object Design
public class Payment {
private final String id;
private final String customerId;
private final BigDecimal amount;
private final String currency;
private final String paymentMethod;
private PaymentStatus status;
private final String transactionId;
private final Date createdAt;
private Date updatedAt;
// Constructor, getters, setters
}
public enum PaymentStatus {
CREATED, AUTHORIZED, CAPTURED, FAILED, REFUNDED, VOIDED
}
public class PaymentRequest {
private final BigDecimal amount;
private final String currency;
private final String paymentMethod;
private final PaymentDetails paymentDetails;
private final String customerId;
// Constructor, getters
}
public class PaymentDetails {
private final String cardNumber;
private final String expiryDate;
private final String cvv;
private final String email;
// Constructor, getters
}
5. Concurrency & Thread Safety
Concurrency Model
The payment system will use a thread pool-based concurrency model with Java's CompletableFuture and ExecutorService for handling asynchronous operations. For synchronous operations, the system will leverage thread-safe data structures and proper synchronization mechanisms to ensure atomicity and consistency.
Race Condition Prevention Strategy
Race conditions in payment operations will be prevented through:
- Database-level atomic operations using optimistic locking with version fields
- Application-level mutexes for critical sections during payment processing
- Transaction boundaries to ensure atomicity of operations within a single payment lifecycle
Optimistic vs. Pessimistic Locking Selection
The system will use optimistic locking for payment operations because:
- Payment processing is typically read-heavy with infrequent write conflicts
- The system prioritizes throughput over strict consistency guarantees
- Payment state transitions are usually fast and have low contention
- Optimistic locking allows better scalability under concurrent load
Deadlock Prevention Strategy
Deadlock prevention will be implemented through:
- Fixed ordering of resource acquisition - all payment-related database operations will follow a consistent order (e.g., by payment ID, then by transaction ID)
- Timeout-based locking - all database locks will have a 5-second timeout to prevent indefinite blocking
- Transaction isolation level - using READ_COMMITTED isolation to prevent dirty reads and non-repeatable reads
- Lock timeout monitoring - system will log and alert on any lock wait timeouts exceeding 3 seconds
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency will be implemented using a combination of:
- Idempotency keys - Each payment request includes a unique idempotency key that is stored in the database
- Database uniqueness constraints - Payment records are uniquely identified by idempotency key and payment method
- Request deduplication cache - In-memory cache with TTL of 5 minutes to handle duplicate requests within the same window
// Example idempotency implementation in PaymentService
public class IdempotencyChecker {
private final Map<String, Payment> recentPayments = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public IdempotencyChecker() {
// Cleanup old entries every 30 seconds
scheduler.scheduleAtFixedRate(
() -> recentPayments.entrySet().removeIf(entry ->
System.currentTimeMillis() - entry.getValue().getCreatedAt().toInstant().toEpochMilli() > 300000),
30, 30, TimeUnit.SECONDS);
}
public Payment checkAndStoreIdempotentRequest(String idempotencyKey, PaymentRequest request) {
// Check if this exact request was already processed
Payment existing = recentPayments.get(idempotencyKey);
if (existing != null) {
return existing;
}
// Store new request for idempotency window
Payment payment = createPaymentFromRequest(request);
recentPayments.put(idempotencyKey, payment);
return payment;
}
}
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant C as Client
participant API as Payment API Gateway
participant S as Payment Service
participant P as Payment Processor
participant DB as Database
participant E as Event Bus
C->>API: POST /payments
API->>S: ProcessPaymentRequest
S->>DB: Create Payment Record (status: PENDING)
S->>P: Initiate Payment
P->>P: Validate Payment Method
P->>P: Process with Strategy (e.g., Stripe, PayPal)
alt Success
P->>P: Update Payment Status to SUCCESS
S->>DB: Update Payment Record (status: SUCCESS)
S->>E: Publish PaymentSuccessEvent
C->>API: 201 Created
else Failure
P->>P: Update Payment Status to FAILED
S->>DB: Update Payment Record (status: FAILED)
S->>E: Publish PaymentFailedEvent
C->>API: 400 Bad Request
end
sequenceDiagram
participant C as Client
participant API as Payment API Gateway
participant S as Payment Service
participant DB as Database
participant E as Event Bus
C->>API: POST /payments/refund
API->>S: ProcessRefundRequest
S->>DB: Fetch Original Payment
S->>S: Validate Refund Eligibility
S->>P: Initiate Refund via Strategy
P->>P: Process Refund with Payment Provider
alt Success
P->>P: Update Refund Status to COMPLETED
S->>DB: Update Refund Record (status: COMPLETED)
S->>E: Publish RefundCompletedEvent
C->>API: 200 OK
else Failure
P->>P: Update Refund Status to FAILED
S->>DB: Update Refund Record (status: FAILED)
S->>E: Publish RefundFailedEvent
C->>API: 400 Bad Request
end
sequenceDiagram
participant C as Client
participant API as Payment API Gateway
participant S as Payment Service
participant DB as Database
participant E as Event Bus
participant M as Monitoring System
C->>API: POST /payments/webhook
API->>S: HandleWebhookEvent
S->>DB: Fetch Payment by External ID
S->>S: Validate Webhook Signature
S->>S: Determine Payment Status from Provider
alt Payment Confirmed
S->>DB: Update Payment Status to CONFIRMED
S->>E: Publish PaymentConfirmedEvent
S->>M: Log Payment Success Metric
C->>API: 200 OK
else Payment Failed
S->>DB: Update Payment Status to FAILED
S->>E: Publish PaymentFailedEvent
S->>M: Log Payment Failure Metric
C->>API: 200 OK
end
7. Caching Design
Cache Strategy Selection
For the payment system, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for a payment system where data freshness is critical but not necessarily real-time.
Cache Key Design
Cache keys will follow a structured namespace pattern:
{cache_namespace}:{entity_type}:{entity_id}:{version}
Example keys:
payment:transaction:txn_123456789:v1payment:customer:cust_987654321:v1payment:card:card_112233445:v1
Cache TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Payment Transactions | 30 minutes | Recent transactions may be queried for status checks |
| Customer Profiles | 2 hours | Customer data changes infrequently |
| Card Information | 1 hour | Sensitive card data should not be cached long-term |
| Payment Methods | 24 hours | Payment methods are relatively stable |
| Fraud Detection Rules | 5 minutes | Rules may need to be updated quickly for fraud prevention |
Cache Eviction Policy
We'll implement LRU (Least Recently Used) with probabilistic early expiry to prevent cache stampedes:
- Primary eviction: LRU based on access patterns
- Secondary: 10% probabilistic early expiry to prevent thundering herd
Cache Stampede Prevention
We'll implement a distributed lock pattern for cache warming:
public class CacheLock {
private static final String LOCK_PREFIX = "payment:cache:lock:";
public boolean acquireLock(String key, int timeoutMs) {
String lockKey = LOCK_PREFIX + key;
return redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", timeoutMs, TimeUnit.MILLISECONDS);
}
public void releaseLock(String key) {
String lockKey = LOCK_PREFIX + key;
redisTemplate.delete(lockKey);
}
}
Distributed Cache Architecture
We'll use Redis Cluster with:
- 3 master nodes for high availability
- 3 replica nodes per master for read scaling
- Automatic failover with Redis Sentinel monitoring
- Cross-region replication for disaster recovery
Cache Invalidation Strategy
We'll use event-driven invalidation with the following rules:
- Payment transaction creation → invalidate customer profile cache
- Card token update → invalidate card information cache
- Customer data change → invalidate customer profile cache
- Payment status update → invalidate transaction cache
Cache Read/Write Patterns
public class PaymentCacheService {
private final RedisTemplate<String, Object> redisTemplate;
public PaymentTransaction getTransaction(String transactionId) {
String cacheKey = "payment:transaction:" + transactionId + ":v1";
// Try cache first (cache-aside pattern)
PaymentTransaction cached = (PaymentTransaction) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// Cache miss - fetch from DB and populate cache
PaymentTransaction transaction = paymentRepository.findById(transactionId);
if (transaction != null) {
redisTemplate.opsForValue().set(cacheKey, transaction, 30, TimeUnit.MINUTES);
}
return transaction;
}
public void invalidateTransaction(String transactionId) {
String cacheKey = "payment:transaction:" + transactionId + ":v1";
redisTemplate.delete(cacheKey);
}
}
Cache Hit/Miss Metrics
We'll track:
- Cache hit ratio (target: >85%)
- Cache miss rate per data type
- Average cache lookup time (target: <5ms)
- Cache warming success rate
Cache Warm-up Strategy
During system startup, we'll warm up frequently accessed payment methods and recent transactions:
@EventListener
public void handleSystemStartup(StartupEvent event) {
// Warm up recent transactions (last 24h)
List<PaymentTransaction> recentTransactions = paymentRepository.findRecentTransactions(24);
for (PaymentTransaction tx : recentTransactions) {
String key = "payment:transaction:" + tx.getId() + ":v1";
redisTemplate.opsForValue().set(key, tx, 30, TimeUnit.MINUTES);
}
}
Cache Size and Memory Management
- Redis Cluster will be configured with 8GB memory per node
- Memory usage will be monitored with alerts at 85% utilization
- Cache size will be tuned based on access patterns and hot data identification
Cache Consistency Model
We'll implement eventual consistency with:
- Read-through caching for high-frequency reads
- Write-through for critical payment data updates
- Cache invalidation on data changes to maintain consistency
Cache Performance Monitoring
We'll instrument cache performance with:
- Cache hit/miss counters per data type
- Cache latency histograms (p50/p95/p99)
- Memory usage metrics per cache namespace
- Cache warming success/failure rates
This caching strategy balances performance gains with consistency requirements while providing robust monitoring and alerting for system health.
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract data access logic and provide a clean separation between domain logic and persistence concerns. Each entity has its own repository interface and implementation.
public interface PaymentRepository {
Payment save(Payment payment);
Optional<Payment> findById(UUID id);
List<Payment> findByCustomerId(UUID customerId);
List<Payment> findByStatus(PaymentStatus status);
void updateStatus(UUID id, PaymentStatus newStatus);
void delete(UUID id);
}
public class JdbcPaymentRepository implements PaymentRepository {
private final DataSource dataSource;
private final PaymentRowMapper rowMapper = new PaymentRowMapper();
@Override
public Payment save(Payment payment) {
String sql = """
INSERT INTO payments (id, customer_id, amount, currency, status, payment_method, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, payment.getId());
stmt.setObject(2, payment.getCustomerId());
stmt.setBigDecimal(3, payment.getAmount());
stmt.setString(4, payment.getCurrency());
stmt.setString(5, payment.getStatus().name());
stmt.setString(6, payment.getPaymentMethod().name());
stmt.setTimestamp(7, Timestamp.valueOf(payment.getCreatedAt()));
stmt.setTimestamp(8, Timestamp.valueOf(payment.getUpdatedAt()));
stmt.executeUpdate();
return payment;
} catch (SQLException e) {
throw new DataAccessException("Failed to save payment", e);
}
}
@Override
public Optional<Payment> findById(UUID id) {
String sql = "SELECT * FROM payments WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return Optional.of(rowMapper.mapRow(rs));
}
return Optional.empty();
} catch (SQLException e) {
throw new DataAccessException("Failed to find payment by ID", e);
}
}
@Override
public List<Payment> findByCustomerId(UUID customerId) {
String sql = "SELECT * FROM payments WHERE customer_id = ? ORDER BY created_at DESC";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, customerId);
ResultSet rs = stmt.executeQuery();
List<Payment> payments = new ArrayList<>();
while (rs.next()) {
payments.add(rowMapper.mapRow(rs));
}
return payments;
} catch (SQLException e) {
throw new DataAccessException("Failed to find payments by customer ID", e);
}
}
@Override
public void updateStatus(UUID id, PaymentStatus newStatus) {
String sql = "UPDATE payments SET status = ?, updated_at = ? WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, newStatus.name());
stmt.setTimestamp(2, Timestamp.valueOf(Instant.now().atZone(ZoneId.systemDefault()).toLocalDateTime()));
stmt.setObject(3, id);
stmt.executeUpdate();
} catch (SQLException e) {
throw new DataAccessException("Failed to update payment status", e);
}
}
}
ORM vs. Raw Query Decisions
For this system, raw SQL queries are preferred over ORM for the following reasons:
- Performance: Raw queries provide better performance for complex operations and allow fine-tuning of database queries
- Control: Direct control over query execution plan and optimization opportunities
- Debugging: Easier to debug and profile database operations when queries are explicit
However, for simple CRUD operations, a lightweight ORM like MyBatis is used to reduce boilerplate code while maintaining performance.
Read vs. Write Separation
The system implements read-write separation using a CQRS-like approach:
public interface PaymentReadRepository {
PaymentSummary getPaymentSummary(UUID id);
List<PaymentHistory> getCustomerPaymentHistory(UUID customerId, int limit);
}
public interface PaymentWriteRepository {
Payment save(Payment payment);
void updateStatus(UUID id, PaymentStatus status);
}
// Implementation using read replica for reads
public class CQRSPaymentRepository implements PaymentRepository {
private final PaymentWriteRepository writeRepo;
private final PaymentReadRepository readRepo;
@Override
public Payment save(Payment payment) {
return writeRepo.save(payment);
}
@Override
public Optional<Payment> findById(UUID id) {
return readRepo.getPaymentSummary(id).map(this::convertToFullPayment);
}
}
Connection Pool Configuration
Connection pool is configured with optimal settings for high-throughput payment processing:
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
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 query problems, eager loading and batch operations are used:
public class PaymentBatchService {
public List<Payment> getPaymentsWithDetails(List<UUID> paymentIds) {
// Batch query to fetch all payments
String sql = "SELECT * FROM payments WHERE id IN (" +
String.join(",", Collections.nCopies(paymentIds.size(), "?")) + ")";
// Fetch all payments in single query
List<Payment> payments = jdbcTemplate.query(sql, paymentIds.toArray(), rowMapper);
// Batch fetch related data in separate queries
if (!payments.isEmpty()) {
Set<UUID> customerIds = payments.stream()
.map(Payment::getCustomerId)
.collect(Collectors.toSet());
Map<UUID, Customer> customers = customerService.getCustomersBatch(customerIds);
// Populate customer details in batch
payments.forEach(p -> p.setCustomer(customers.get(p.getCustomerId())));
}
return payments;
}
}
Batch Operation Design
Batch operations are implemented for high-volume data processing:
public class PaymentBatchRepository {
public void batchSave(List<Payment> payments) {
String sql = """
INSERT INTO payments (id, customer_id, amount, currency, status, payment_method, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (Payment payment : payments) {
stmt.setObject(1, payment.getId());
stmt.setObject(2, payment.getCustomerId());
stmt.setBigDecimal(3, payment.getAmount());
stmt.setString(4, payment.getCurrency());
stmt.setString(5, payment.getStatus().name());
stmt.setString(6, payment.getPaymentMethod().name());
stmt.setTimestamp(7, Timestamp.valueOf(payment.getCreatedAt()));
stmt.setTimestamp(8, Timestamp.valueOf(payment.getUpdatedAt()));
stmt.addBatch();
}
stmt.executeBatch();
} catch (SQLException e) {
throw new DataAccessException("Failed to batch save payments", e);
}
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The payment system will use a hybrid synchronous/asynchronous approach. The core payment processing (authorization, capture, refund) will be handled synchronously to ensure immediate feedback to the user. However, several downstream operations will be decoupled via asynchronous messaging:
- Payment confirmation notifications - Sent to user and merchant after successful payment
- Invoice generation and email - Triggered once payment is confirmed
- Audit trail logging - For compliance and monitoring purposes
- Payment status updates to external systems - Third-party integrations like fraud detection, accounting systems
- Retryable error handling - For failed external API calls
The decision to make these asynchronous is based on the need for high availability, fault tolerance, and improved user experience. Synchronous processing would block the main payment flow for potentially long-running operations.
Message schema design
{
"version": "1.0",
"event_type": "payment.confirmed",
"timestamp": "2023-10-15T14:30:00Z",
"payment_id": "pay_1234567890",
"amount": 100.00,
"currency": "USD",
"status": "confirmed",
"customer_id": "cust_0987654321",
"merchant_id": "merch_1122334455",
"payment_method": {
"type": "card",
"last_four_digits": "1234"
},
"metadata": {
"order_id": "ord_5566778899",
"source": "web"
}
}
Dead letter queue strategy and poison message handling
All failed messages will be routed to a dead-letter queue (DLQ) after reaching maximum retry attempts. The DLQ will be monitored by an alerting system that triggers manual investigation when messages accumulate beyond a threshold (e.g., 100 messages in 5 minutes).
Poison messages will be:
- Logged with full context for debugging
- Moved to a separate "poison queue" for manual processing
- Tracked in a monitoring dashboard with retry count and error details
At-least-once vs. exactly-once delivery
At-least-once delivery is selected for this system because:
- Payment operations are idempotent by design
- The system uses payment IDs as unique identifiers to prevent duplicate processing
- Business logic handles duplicate events gracefully (e.g., ignoring already processed payments)
- The complexity of exactly-once delivery would add significant overhead for minimal benefit in this domain
The system ensures idempotency through:
- Unique payment IDs from the client
- Database constraints on payment reference uniqueness
- Event deduplication in consumers
Retry policy for consumers
public class PaymentEventRetryPolicy {
private static final int MAX_RETRIES = 5;
private static final long INITIAL_DELAY_MS = 1000;
private static final double BACKOFF_MULTIPLIER = 2.0;
private static final long MAX_DELAY_MS = 30000; // 30 seconds
public RetryResult executeWithRetry(Supplier<CompletableFuture<Boolean>> operation) {
int attempt = 0;
long delay = INITIAL_DELAY_MS;
while (attempt < MAX_RETRIES) {
try {
CompletableFuture<Boolean> result = operation.get();
if (result != null && result.get(30, TimeUnit.SECONDS)) {
return RetryResult.SUCCESS;
}
} catch (Exception e) {
if (isNonRetryableError(e)) {
return RetryResult.FAILURE;
}
attempt++;
if (attempt < MAX_RETRIES) {
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return RetryResult.FAILURE;
}
delay = Math.min(delay * BACKOFF_MULTIPLIER, MAX_DELAY_MS);
}
}
}
return RetryResult.FAILURE;
}
private boolean isNonRetryableError(Exception e) {
return e instanceof ValidationException ||
e instanceof PaymentProcessingException;
}
}
Outbox pattern for reliable event publishing
The outbox pattern is implemented to ensure atomicity between database writes and message publishing.
public class PaymentOutboxService {
private final PaymentRepository paymentRepository;
private final EventPublisher eventPublisher;
private final OutboxRepository outboxRepository;
@Transactional
public void processPayment(Payment payment) {
// 1. Process the payment in database
Payment processed = paymentRepository.save(payment);
// 2. Create outbox entry for event
OutboxEvent outboxEvent = OutboxEvent.builder()
.eventId(UUID.randomUUID().toString())
.eventType("payment.confirmed")
.payload(createPaymentPayload(processed))
.status(OutboxEventStatus.PENDING)
.createdAt(Instant.now())
.build();
outboxRepository.save(outboxEvent);
// 3. Publish to message queue (this happens in same transaction)
eventPublisher.publish(outboxEvent);
// 4. Update status to PROCESSED
outboxEvent.setStatus(OutboxEventStatus.PROCESSED);
outboxRepository.save(outboxEvent);
}
}
Sequence diagram for async flows
sequenceDiagram
participant Client
participant PaymentService
participant Database
participant OutboxRepository
participant MessageBroker
participant EventConsumer
Client->>PaymentService: Create Payment Request
PaymentService->>Database: Save Payment Record
PaymentService->>OutboxRepository: Create Outbox Event
OutboxRepository->>MessageBroker: Publish to Queue
MessageBroker->>EventConsumer: Deliver Event
EventConsumer->>Database: Update Audit Log
EventConsumer->>ExternalSystem: Send Invoice Notification
10. Error Handling Design
Error Classification Hierarchy
The payment system follows a three-tier error classification:
- Domain Errors - Business logic violations (e.g., insufficient funds, invalid card)
- Infrastructure Errors - System resource issues (e.g., database timeouts, network failures)
- Validation Errors - Input format and constraint violations (e.g., malformed JSON, missing fields)
public class PaymentError extends RuntimeException {
private final ErrorType errorType;
private final String errorCode;
private final String userMessage;
public PaymentError(ErrorType errorType, String errorCode, String userMessage) {
super(errorCode + ": " + userMessage);
this.errorType = errorType;
this.errorCode = errorCode;
this.userMessage = userMessage;
}
// Getters
}
public enum ErrorType {
DOMAIN, INFRASTRUCTURE, VALIDATION
}
Error Propagation Strategy
- Domain and Validation errors: Caught at service layer and converted to PaymentError for consistent API responses
- Infrastructure errors: Bubble up to global exception handler for centralized logging and response formatting
Retry Strategy with Exponential Backoff and Jitter
Retry logic is implemented for transient infrastructure issues:
public class RetryablePaymentProcessor {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public PaymentResult processWithRetry(PaymentRequest request) {
int attempts = 0;
while (attempts < MAX_RETRIES) {
try {
return processPayment(request);
} catch (InfrastructureException e) {
if (attempts >= MAX_RETRIES - 1) throw e;
long delay = calculateExponentialDelay(attempts);
Thread.sleep(delay);
attempts++;
}
}
throw new RuntimeException("Max retries exceeded");
}
private long calculateExponentialDelay(int attempt) {
long baseDelay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
long jitter = (long) (baseDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return baseDelay + jitter;
}
}
Circuit Breaker Design
For external payment gateway integrations, a circuit breaker pattern prevents cascading failures:
public class CircuitBreaker {
private final int failureThreshold;
private final long timeoutMs;
private final AtomicInteger failureCount = new AtomicInteger(0);
private volatile CircuitState state = CircuitState.CLOSED;
private volatile long lastFailureTime = 0;
public enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public <T> T execute(Supplier<T> operation) throws Exception {
switch (state) {
case CLOSED:
return executeWithCircuitClosed(operation);
case OPEN:
if (System.currentTimeMillis() - lastFailureTime > timeoutMs) {
state = CircuitState.HALF_OPEN;
return executeWithCircuitHalfOpen(operation);
}
throw new CircuitBreakerOpenException("Circuit is open");
case HALF_OPEN:
return executeWithCircuitHalfOpen(operation);
}
throw new IllegalStateException("Invalid circuit state");
}
private <T> T executeWithCircuitClosed(Supplier<T> operation) throws Exception {
try {
T result = operation.get();
failureCount.set(0);
return result;
} catch (Exception e) {
int failures = failureCount.incrementAndGet();
lastFailureTime = System.currentTimeMillis();
if (failures >= failureThreshold) {
state = CircuitState.OPEN;
}
throw e;
}
}
private <T> T executeWithCircuitHalfOpen(Supplier<T> operation) throws Exception {
try {
T result = operation.get();
state = CircuitState.CLOSED;
failureCount.set(0);
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
lastFailureTime = System.currentTimeMillis();
throw e;
}
}
}
Fallback Behavior Design
For critical external dependencies, fallbacks provide graceful degradation:
public class PaymentService {
private final PaymentGateway gateway;
private final FallbackHandler fallback;
public PaymentResult processPayment(PaymentRequest request) {
try {
return gateway.process(request);
} catch (ExternalServiceException e) {
// Log the error for monitoring
logger.warn("Payment gateway failed, using fallback", e);
// Fallback to internal payment processing or manual review
return fallback.handlePaymentFailure(request, e);
}
}
public class FallbackHandler {
public PaymentResult handlePaymentFailure(PaymentRequest request, Exception originalError) {
// Implement fallback logic - could be:
// 1. Manual review queue placement
// 2. Internal payment method fallback
// 3. Retry with different configuration
return new PaymentResult(PaymentStatus.FAILED, "Manual review required");
}
}
}
Error Logging Format and Alerting Thresholds
Structured logging with consistent format:
public class PaymentErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(PaymentErrorLogger.class);
public void logError(String operation, String userId, String paymentId,
Exception error, Map<String, Object> additionalContext) {
Map<String, Object> logContext = new HashMap<>();
logContext.put("operation", operation);
logContext.put("userId", userId);
logContext.put("paymentId", paymentId);
logContext.put("errorType", error.getClass().getSimpleName());
logContext.put("errorCode", extractErrorCode(error));
logContext.put("errorMessage", error.getMessage());
logContext.put("timestamp", Instant.now().toString());
logContext.putAll(additionalContext);
logger.error("Payment processing error",
new ObjectMapper().valueToTree(logContext));
}
private String extractErrorCode(Exception error) {
if (error instanceof PaymentError) {
return ((PaymentError) error).getErrorCode();
}
return "UNKNOWN_ERROR";
}
}
Alerting thresholds:
| Error Type | Alert Threshold | Severity |
|---|---|---|
| Payment gateway timeouts | >5% of requests in 5 min | Critical |
| Insufficient funds errors | >100 occurrences per hour | Warning |
| Authentication failures | >50 occurrences per hour | Critical |
| Database connection errors | >10 occurrences per 15 min | Critical |
Error Response Format
Standardized JSON error response:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Payment amount exceeds available balance",
"details": {
"paymentId": "pay_1234567890",
"userId": "user_001"
},
"timestamp": "2023-10-15T14:30:00Z"
}
}
Retryable vs Non-Retryable Errors
| Error Code | Retryable | Reason |
|---|---|---|
| INSUFFICIENT_FUNDS | No | Business logic error, retry won't help |
| TIMEOUT | Yes | Transient network issue |
| INVALID_CARD | No | Input validation error, retry won't fix |
| RATE_LIMITED | Yes | Temporary throttling, can retry |
| NETWORK_ERROR | Yes | Transient infrastructure issue |
Error Budget Considerations
- 5% error budget for payment processing (500 errors + 4xx errors)
- Alert on >2% error rate for payment operations
- Fallbacks and retries are counted as part of the error budget when they fail to resolve the issue
11. Security Design
Input Validation and Sanitization Strategy
All API inputs are validated using a combination of:
- Library-based validation: Jakarta Bean Validation (JSR-380) for structured validation
- Custom validation rules: For business-specific constraints like payment amount ranges, currency codes, and card number formats
- Sanitization: HTML escaping for text fields, regex filtering for special characters in sensitive fields
public class PaymentRequest {
@NotNull
@Min(0.01)
@DecimalMax(value = "999999.99", message = "Amount must be less than 1M")
private BigDecimal amount;
@NotBlank
@Pattern(regexp = "^[A-Z]{3}$", message = "Currency must be 3-letter ISO code")
private String currency;
@ValidCardNumber
private String cardNumber;
}
SQL Injection Prevention
- Prepared statements: All database queries use parameterized prepared statements with no string concatenation
- ORM usage: Hibernate/JPA is used exclusively for data access to prevent raw SQL injection
- Input sanitization: All user inputs are properly escaped before any database interaction
Authentication Middleware Design
- JWT-based authentication: Stateless JWT tokens with 15-minute expiration for API access
- OAuth2 integration: For third-party payment providers (Stripe, PayPal)
- Token refresh mechanism: 7-day refresh tokens with sliding window expiration
- Middleware pipeline: Authentication middleware validates JWT and sets user context in request scope
Authorization Enforcement
- Role-Based Access Control (RBAC):
USER: Can view own payments, initiate new paymentsADMIN: Full access to all payment operations and reportsSERVICE: System-to-system access for payment gateway integrations
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.userId")
public Payment processPayment(@PathVariable String userId, @RequestBody PaymentRequest request) {
// Implementation
}
Sensitive Data Encryption
- At rest: AES-256 encryption for sensitive fields (card numbers, CVV, bank details) using AWS KMS
- In transit: TLS 1.3 with HSTS header enforcement for all API communications
- Key rotation: Automatic key rotation every 90 days with proper key derivation functions
PII Handling and Masking Strategy
- Card number masking: Only last 4 digits shown in logs, API responses, and reports
- Personal data masking: SSNs, email addresses, names are redacted in logs and audit trails
- Data retention policy: PII is deleted after 7 years of payment processing completion
Secrets Management
- Vault integration: HashiCorp Vault for managing API keys, payment provider credentials, and encryption keys
- Dynamic secrets: Temporary AWS credentials with 1-hour TTL for database connections
- Secret rotation: Automated rotation every 30 days with proper fallback mechanisms
Audit Logging Design
- Events logged: Payment creation, status changes, refunds, cancellations, and system failures
- Log format:
{
"timestamp": "2023-12-01T10:30:45.123Z",
"trace_id": "abc123def456",
"user_id": "user_12345",
"operation": "PAYMENT_CREATE",
"amount": 100.00,
"currency": "USD",
"card_last_four": "1234",
"status": "SUCCESS"
}
- Retention: Audit logs retained for 7 years with daily backups to secure storage
- Access control: Only admin users can access audit logs with proper audit trail permissions
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage with focus on business logic and strategy execution paths
- Domain Objects: 90% coverage including edge cases for payment methods and status transitions
- Repository Layer: 75% coverage with key query paths and error handling
- Strategy Implementations: 100% coverage for each concrete strategy (e.g., CreditCard, PayPal, BankTransfer)
- Critical Path Operations: 100% coverage for
processPayment()and related transactional flows - Test Data: All unit tests use in-memory mocks or test databases with deterministic fixtures
Integration Testing Strategy
- Database Integration: Use embedded H2 or PostgreSQL in-memory instances for end-to-end tests with real DB schema and constraints
- External Service Mocks: WireMock or Mockito for third-party payment gateways (Stripe, PayPal API)
- Event Publisher Integration: Test with embedded Redis or Kafka mock to verify event publishing and consumption
- Transaction Boundaries: Integration tests validate rollback behavior for failed payment operations
- Cross-Service Communication: Use TestContainers to spin up real instances of Redis, Kafka, and DBs for multi-service tests
Contract Testing
- Pact Broker: Use Pact for contract testing between payment service and external providers (Stripe, PayPal)
- Consumer-Provider Contracts: Define contracts for payment initiation, status updates, and refund flows
- Versioned Schema Testing: Validate that API responses match defined JSON schemas with strict validation rules
Performance Testing Tool and Key Scenarios
- Tool: k6 (JavaScript-based load testing) for high-fidelity performance simulation
- Key Scenarios:
- Concurrent payment processing (500+ requests/sec)
- Payment method switching under load (50% credit card, 30% PayPal, 20% bank transfer)
- Retry and timeout behavior under network latency (100ms to 500ms)
- High-volume refund processing with 95% success rate target
- Metrics Tracked: p95 latency for payment creation, error rates, throughput (RPS), and database connection pool saturation
Test Data Management Strategy
- Fixtures: Define payment method configurations, user roles, and test card data in YAML/JSON files
- Factories: Use Factory Boy or custom Java builders to generate realistic test data for each scenario (e.g., valid vs. invalid card numbers)
- Seeding: Use database migration scripts to seed test data for CI environments (e.g., test users, payment methods)
- Isolation: Each test runs in a clean database transaction with automatic rollback to ensure no cross-contamination
- Data Rotation: Rotate test card numbers and payment IDs in CI to prevent reuse and maintain security compliance
// Example unit test for strategy-based payment processing
@Test
public void testCreditCardPaymentStrategy() {
PaymentRequest request = PaymentRequest.builder()
.amount(BigDecimal.valueOf(100.00))
.currency("USD")
.paymentMethodType(PaymentMethodType.CREDIT_CARD)
.build();
PaymentStrategy strategy = strategyFactory.getStrategy(PaymentMethodType.CREDIT_CARD);
PaymentResult result = strategy.process(request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getPaymentId()).isNotNull();
}
13. Observability Design
Structured Logging Schema
All logs must follow a consistent schema to enable effective querying and analysis:
{
"timestamp": "2023-10-27T14:30:00.000Z",
"level": "INFO|WARN|ERROR",
"service": "payment-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user_12345",
"payment_id": "pay_98765",
"event_type": "payment_processed",
"message": "Payment processed successfully",
"error_code": "PAYMENT_SUCCESS",
"duration_ms": 150,
"request_id": "req_abcde",
"ip_address": "192.168.1.1",
"http_method": "POST",
"http_path": "/v1/payments",
"http_status_code": 200,
"tags": ["payment", "strategy", "card"]
}
PII masking rules:
- User IDs and payment IDs are logged as truncated hashes (e.g.,
user_12345→user_***) - Credit card numbers are masked (e.g.,
4111111111111111→****1111) - Email addresses are masked (e.g.,
user@example.com→u***@example.com)
Distributed Tracing Instrumentation
Using OpenTelemetry for end-to-end tracing:
Custom Spans to Add:
payment.strategy.select- Time taken to select payment strategypayment.processor.execute- Time for actual payment processingpayment.validation- Input validation durationpayment.event.publish- Event publishing latency
Attributes to Capture:
Span span = tracer.spanBuilder("payment.process")
.setAttribute("payment_strategy", "card")
.setAttribute("payment_amount", 100.0)
.setAttribute("currency", "USD")
.setAttribute("user_id", "user_12345")
.setAttribute("payment_method_type", "credit_card")
.startSpan();
Metrics Design
RED Metrics (Rate, Errors, Duration) - Service Level:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
payment_requests_total | Counter | strategy, status, method | Total payment requests |
payment_errors_total | Counter | strategy, error_type | Payment processing errors |
payment_duration_seconds | Histogram | strategy, status | Payment processing duration |
payment_amount_total | Counter | strategy, currency | Total payment amount processed |
payment_strategy_selection_time_seconds | Histogram | strategy | Strategy selection time |
payment_processor_execution_time_seconds | Histogram | strategy | Processor execution time |
Additional Gauges:
payment_active_strategies_count- Gauge counting active strategiespayment_queue_size- Gauge for async queue length
Health Check Endpoint Design
@RestController
public class HealthController {
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
Map<String, Object> response = new HashMap<>();
response.put("status", "healthy");
response.put("timestamp", Instant.now());
response.put("service", "payment-service");
response.put("dependencies", checkDependencies());
return ResponseEntity.ok(response);
}
@GetMapping("/ready")
public ResponseEntity<Map<String, Object>> readiness() {
Map<String, Object> response = new HashMap<>();
response.put("status", "ready");
response.put("timestamp", Instant.now());
response.put("database", databaseHealth());
response.put("cache", cacheHealth());
response.put("payment_providers", paymentProviderHealth());
return ResponseEntity.ok(response);
}
@GetMapping("/live")
public ResponseEntity<Map<String, Object>> liveness() {
Map<String, Object> response = new HashMap<>();
response.put("status", "alive");
response.put("timestamp", Instant.now());
response.put("uptime", System.currentTimeMillis() - startTime);
return ResponseEntity.ok(response);
}
}
Alerting Thresholds
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
payment_errors_total (rate over 5m) | > 10 errors/minute | > 50 errors/minute | On-call engineer |
payment_duration_seconds (p99) | > 2s | > 5s | On-call engineer |
payment_strategy_selection_time_seconds (p99) | > 100ms | > 500ms | Senior engineer |
payment_processor_execution_time_seconds (p99) | > 500ms | > 2s | Senior engineer |
payment_queue_size | > 100 items | > 500 items | Platform team |
database_connection_pool_utilization | > 80% | > 95% | DBA team |
Service-Level Health Checks
- Database Connection Pool: Monitor pool size, utilization, and timeout metrics
- Cache Availability: Track Redis cluster health and latency
- Payment Provider Integration: Monitor external API response times and error rates
- Event Bus Connectivity: Ensure Kafka/EventBridge is healthy for async operations
Observability Tooling Stack
- Logging: ELK stack (Elasticsearch, Logstash, Kibana) or CloudWatch + Fluentd
- Metrics: Prometheus + Grafana for service monitoring and dashboards
- Tracing: Jaeger or OpenTelemetry collector with Zipkin backend
- Alerting: Alertmanager + PagerDuty integration for on-call notifications
Log Rotation and Retention Policy
- Log Rotation: Rotate logs daily or when 100MB limit reached
- Retention: 30 days for operational logs, 90 days for audit logs
- Archive Strategy: Compress and archive logs older than 30 days to S3 or similar object storage
Audit Trail Integration
- All payment-related operations must log to audit trail with:
- User ID
- Payment ID
- Operation type (create, update, cancel)
- Timestamps
- Strategy used
- Amount and currency
- Status (success/failure)
- Error details (if applicable)
This design ensures comprehensive observability for the payment system, enabling rapid incident response and performance optimization while maintaining security and compliance standards.
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Payment method support matrix: Which payment methods (credit card, PayPal, Apple Pay, Google Pay) should be supported in v1 vs. phased rollout? This impacts the complexity of strategy implementation and testing scope.
- Currency handling strategy: Should we support multi-currency payments natively or defer to external payment providers? This affects data model complexity and compliance requirements.
- Refund policy implementation: Should refunds be handled within the system or delegated to payment providers? This influences transaction state management and audit requirements.
- Compliance framework alignment: Which regulatory frameworks (PCI DSS, PSD2, etc.) must be strictly enforced? This determines security and audit design requirements.
Known trade-offs and justifications
- Strategy pattern vs. configuration-driven approach: Strategy pattern provides type safety and extensibility but increases code complexity; configuration-based approaches offer runtime flexibility at cost of reduced type safety and more complex error handling.
- Single payment processor vs. multi-processor support: Supporting multiple processors provides redundancy and competitive advantage but increases system complexity, maintenance overhead, and potential for inconsistent behavior.
- Synchronous vs. asynchronous payment processing: Synchronous processing provides immediate feedback but can cause timeouts and degrade UX; asynchronous processing improves scalability but introduces complexity in state management and user experience.
- Full payment lifecycle vs. minimal integration: Full lifecycle control provides better user experience and error handling but requires more complex state management; minimal integration reduces scope but may leave users in inconsistent states.
Risks and mitigation strategies
- Payment provider outages: Risk of complete payment processing failure if a provider goes down. Mitigation includes fallback strategies, circuit breaker patterns, and multi-provider redundancy.
- Data consistency across systems: Risk of inconsistency between local payment records and provider states. Mitigation includes event-driven reconciliation, idempotent operations, and regular audit checks.
- Security vulnerabilities: Risk of data breaches or fraud due to improper handling of sensitive payment data. Mitigation includes end-to-end encryption, secure key management, and regular security audits.
- Compliance violations: Risk of regulatory penalties due to non-compliance with payment processing standards. Mitigation includes regular compliance audits, proper data handling protocols, and automated compliance checks.
Technical debt acknowledged
- Strategy implementation complexity: The strategy pattern introduces significant boilerplate code for each new payment method, which may require future refactoring to reduce duplication.
- Event-driven architecture complexity: The async processing layer adds complexity in debugging and maintaining consistency, particularly around event ordering and delivery guarantees.
- State management overhead: The extensive state machine design increases maintenance burden and requires careful documentation to avoid inconsistencies.
- Testing coverage gaps: The complex interactions between payment providers, strategies, and state transitions make comprehensive end-to-end testing challenging to maintain and scale.