Skip to content

Latest commit

 

History

History
81 lines (64 loc) · 3.67 KB

File metadata and controls

81 lines (64 loc) · 3.67 KB

Architecture Overview

Hexagonal (Ports & Adapters) – Strict Rules

  • src/Domain → 100% pure PHP, no Symfony, no Doctrine, no attributes
  • src/Application → Use Cases, DTOs, Interfaces (TransactionManager, Clock, UserProvider)
  • src/Infrastructure → Symfony Controllers, Doctrine Repos, Redis adapters, Messenger handlers

Core Decisions

Transactional Outbox

Symfony Messenger with doctrine transport is the official outbox implementation:

  • Domain events are dispatched to outbox bus (doctrine transport)
  • Written atomically to messenger_messages table in the same transaction
  • Dedicated worker: bin/console messenger:consume outbox --limit=1000 --time-limit=55
  • Worker publishes to RabbitMQ (or other brokers)
  • Deduplication: consumer side with Redis key outbox:processed:{message_id} (UUID v7), TTL 8 days

Idempotency (Mandatory for all POST/PUT/PATCH)

  • Header: Idempotency-Key → UUID v4, lowercase, no hyphens (e.g. a0eebc999c0b4ef8bb6d6bb9bd380a11)
  • Redis key: idempotency:{key} → Hash (status + response)
  • TTL: 48 hours
  • States:
    • Missing → acquire lock idempotency:lock:{key} NX EX 30s → process
    • processing → 409 IDEMPOTENCY_KEY_IN_USE
    • completed → return cached response

Concurrency & Zero Overselling

  • Pessimistic Write Locking (SELECT ... FOR UPDATE SKIP LOCKED where possible)
  • All write transactions in SERIALIZABLE isolation
  • Lock timeout: exactly 8 seconds (SET LOCAL lock_timeout = '8s')
  • Automatic retry with exponential backoff + full jitter (max 6 attempts)

Transaction Retry Wrapper

$transactionManager->transactional(fn() => $useCase->execute(...), maxAttempts: 6);

Delay formula: min(1000ms, random_between(0.5, 1.5) × (50ms × 2^attempt))

Transaction Retry Semantics

The TransactionManager MUST only retry on transient database lock errors.

  • Max attempts: 6
  • Per-attempt behavior:
    • Attempt 1 runs with SET LOCAL lock_timeout = '8s'
    • If the transaction fails with a lock-timeout or deadlock error, the manager applies the backoff delay and retries
    • Validation errors, unique constraint violations, and business rule violations MUST NOT be retried

Error mapping:

  • If all 6 attempts fail due to lock timeout or deadlock:
    • The use case MUST throw a domain-level exception that maps to HTTP 503 with error code LOCK_TIMEOUT
  • On the first non-retryable error:
    • The manager MUST stop retrying and bubble up the exception

Example timeline (lock contention):

  • T0: Attempt 1 → lock timeout at ~8s → backoff delay
  • T1: Attempt 2 → quick success or another lock timeout
  • Tn: After 6 failed attempts → final LOCK_TIMEOUT error returned to API layer

Redis Key Convention

Key Pattern Type TTL Purpose
idempotency:{key} HASH 48 hours Idempotency response cache
idempotency:lock:{key} String 30 seconds Distributed lock
rate_limit:user:{user_id}:{minute} Int sliding Per-user limiter (10/min)
rate_limit:global:{minute} Int sliding Global limiter (1000/min)
outbox:processed:{message_id} SET 8 days Deduplication of relayed messages

Rate Limiting

Symfony RateLimiter with Redis adapter:

  • Per user (X-Mock-User-Id): 10 requests/minute
  • Global (IP): 1000 requests/minute

Reservation Expiry Scheduler

@Scheduled(cron="*/30 * * * *")

  • Batch size: 200
  • Query uses FOR UPDATE SKIP LOCKED
  • Publishes ReservationExpired to outbox bus