Skip to main content
Architecture Decision Records (ADRs) capture significant technical decisions with their context, alternatives considered, and consequences. They live in docs/architecture/adr/ in the source repo and are numbered sequentially.

Backend infrastructure

StatusAccepted
Date2026-02-16
Context: The APNs push service was creating a new HTTP/2 connection for every single push notification. For a batch of 100 device tokens, this meant 100 separate TCP+TLS handshakes (~100-200ms each), resulting in ~15s total latency per batch during live matches.Decision: Replace per-push http2.connect() calls with a persistent, lazily-initialized HTTP/2 session reused across all push requests. HTTP/2 natively supports multiplexed streams, which is exactly the model Apple designed APNs around. Key design choices include lazy singleton via getSession(), connection deduplication via a shared promise, auto-reconnection on close/error events, GOAWAY handling, and graceful shutdown with a 5-second force-destroy fallback.Consequences:
  • ~30x latency reduction for batched pushes (100 pushes: ~15s down to ~0.5s)
  • Reduced resource usage — one long-lived connection instead of hundreds
  • Simpler call sites — sendPush() callers are unaware of connection management
  • Module-level mutable state makes unit testing harder (mitigated by resetSession() for tests)
StatusAccepted
Date2026-02-16
Context: The push job was stateless — every poll re-detected events on the last 6 balls, causing duplicate alerts (the same wicket fires on every poll while it remains in the window) and missed events after downtime (balls between the last processed one and the current 6-ball window were silently dropped).Decision: Introduce a hybrid in-memory + DynamoDB storage pattern for lastProcessedBallId per match. The in-memory Map serves as the primary read path (O(1)), with DynamoDB as a backup on cache miss and for persistence across restarts. Writes go to the Map synchronously and to DynamoDB as async fire-and-forget. A 7-day TTL on DynamoDB items provides self-cleaning.Consequences:
  • Each ball processed exactly once — the cursor guarantees no duplicates and no gaps
  • Survives restarts — DynamoDB backup means deploys during live matches don’t lose progress
  • Sub-millisecond reads on the hot path
  • Assumes monotonically increasing ball IDs from SportMonks (historically true)
StatusAccepted
Date2026-03-23
Context: The LiveScoreJob polls SportMonks every 5 seconds during live matches. The hand-rolled scheduling left several failure modes unhandled: hanging requests (could expire the distributed lock), thundering herd (deterministic backoff), no circuit breaker (kept hammering a down API), and no jitter.Decision: Use cockatiel (v3) to wrap the SportMonks API call in a composed policy stack: timeout (8s) -> circuit breaker (5 failures, 30s half-open) -> retry (2 attempts, exponential backoff + jitter). Cockatiel was chosen over opossum (CB only, CJS), p-retry+p-timeout (no CB), and mollitia (too few stars for production).Consequences:
  • Hanging requests are killed after 8s instead of blocking indefinitely
  • Circuit breaker stops hammering a down API and auto-recovers via half-open test
  • Jitter eliminates thundering herd on recovery
  • Circuit state exposed via GET /admin/polling/status for CloudWatch alarms
  • New dependency (zero-dep, 5KB), but module-level policy state is process-local

DynamoDB data model

StatusSuperseded by ADR-004
Date2026-02-17
Context: The auto-follow and push-to-start Live Activities feature required tracking which matches a device is following and which team the user supports. The existing devices table had no concept of match-level following.Decision: Extend the cricket-live-devices table with a followedMatches map attribute containing per-match data (startDate, supportedTeamId). This kept the read path to a single GetCommand.Consequences: Shipped but was superseded by ADR-004 because the hot path (“who follows match X?”) required a full table scan — the partition key was userId, not matchId.
StatusAccepted (supersedes ADR-003)
Date2026-02-19
Context: The system had grown to 6 DynamoDB tables with cross-table consistency issues (following a match required writes to 2-3 tables without transactions), redundant data (team preference stored in multiple tables), and the wrong primary access pattern for the hot path (toss detection required scanning instead of querying).Decision: Consolidate to 3 tables:
  1. cricket-devices — pure device registry (PK=userId, SK=deviceToken). Stripped of match-level concerns.
  2. cricket-match-follows — follow intent only (PK=matchId, SK=userId). One record per (match, user) with 48h TTL.
  3. cricket-activity-subscriptions — Live Activity sessions per device (PK=matchId, SK=laPushToken). Separated from follows because LA tokens are per-device while follows are per-user.
Both match-level tables use PK=matchId so toss detection (“notify all followers of match X”) is a single QueryCommand.Consequences:
  • 3 tables instead of 6 — simpler to reason about
  • Hot-path optimized — toss detection and LA updates are single PK queries
  • Auto-cleanup via DynamoDB TTL — no manual cleanup jobs
  • Trade-off: “which matches does user X follow?” requires a scan (low-frequency, acceptable)
StatusAccepted
Date2026-02-27
Context: Debugging revealed that when a user unfollows then re-follows a live match, the Live Activity fails to appear. Root causes included stale push-to-start tokens never cleared, orphaned device records from app reinstalls, cross-device token pollution from writing to ALL device records, and no token refresh before follow.Decision: Simplify cricket-devices to one record per user (latest device wins). registerDevice() now cleans up old records, explicitly REMOVEs pushToStartToken when not provided, and iOS refreshes the push-to-start token before every follow call.Consequences:
  • Eliminates orphan records, stale tokens, and cross-device pollution
  • Simpler code — all consumers work with DeviceRecord | null instead of arrays
  • Trade-off: no iPad + iPhone concurrent Live Activities (acceptable edge case)
StatusAccepted
Date2026-03-02
Context: The backend had 5 shell-script migrations with no tracking of which had been applied to which environment. Running migrations required manually remembering what had already been run.Decision: Track applied migrations in a per-environment DynamoDB table (cricket-migrations-{env}). A TypeScript runner checks the registry, computes pending migrations, executes each shell script in order, and records the result. Forward-only (no rollback scripts) — to undo a bad migration, deploy the previous code version.Consequences:
  • Each environment is fully independent — no risk of sandbox state leaking into production
  • Stop on first failure prevents cascading issues from dependent migrations
  • Custom over off-the-shelf because existing tools (umzug, node-migrate) don’t handle bash script migrations well
StatusAccepted
Date2026-03-24
Context: Once a match finishes, SportMonks removes it from /livescores. The poller never writes the final status, so fixtures freeze at whatever state the last poll captured — missing final status, stale resultText, missing winnerTeamId, and lingering replay fixtures.Decision: Add a sparse GSI (live-index) to the fixtures table. A liveUpdatedAt attribute is SET on every poller tick and REMOVED when the match is archived. Only items with liveUpdatedAt appear in the GSI. Each tick, the poller scans the GSI, diffs against current /livescores, and reconciles off-air matches by fetching their final state from SportMonks.Consequences:
  • Fixtures now get their final status written (Finished, Abandoned, etc.)
  • Sparse GSI contains only 10-20 items during peak — scan is under 1 RCU, under 10ms
  • Replay fixtures (negative matchIds) are cleaned up immediately instead of waiting for TTL
  • New getFixture() added to LivescoreProvider interface for reconciliation

iOS client

StatusAccepted
Date2026-03-24
Context: The schedule view used TabView(.page) for horizontal date swiping. Two bugs emerged: TabView(.page) miscalculates scroll offset when adjacent pages have different content sizes (a known Apple bug), and all pages shared a single data property causing a visual flash on swipe.Decision: Replace TabView(.page) with ScrollView(.horizontal) + .scrollTargetBehavior(.paging) (iOS 17+). Using .containerRelativeFrame(.horizontal) pins each page to the container width regardless of content size, and per-page data ownership eliminates the shared-state flash.Consequences:
  • Fixes both the misaligned pages and visual flash bugs
  • All future paged views should use this ScrollView pattern, never TabView(.page)
  • Requires iOS 17 minimum (already the deployment target)

Display and business logic

StatusAccepted
Date2026-02-27
Context: The tension meter during live chases used a naive run rate ratio (rrr / crr) that ignored wickets in hand, target difficulty, and balls remaining. A team needing 12 RPO with 8 wickets was treated identically to 12 RPO with 2 wickets.Decision: Replace the naive ratio with a polynomial logistic regression (degree 2, 5 features) trained on 1,472 recent T20I matches (2023+), achieving 83.9% accuracy. The 5 features are: balls remaining (normalized), wickets in hand, RRR differential, performance index (CRR x wickets), and target (normalized). The model ships as 31 hardcoded constants — pure arithmetic, sub-microsecond evaluation, no ML runtime.Consequences:
  • Wickets now matter — different probabilities for same run rate with different wickets in hand
  • Target-aware — chasing 220 treated differently from 130 at the same run rate
  • Zero runtime cost — one dot product, one sigmoid
  • Model is T20I-specific — may need retraining for franchise leagues
StatusAccepted
Date2026-03-24
Context: The displayMode field controlled how match cards render team identity: league mode (brand colors, no flags) vs international mode (SportMonks flags). Two large hardcoded sets mapped every league code to a mode, but in practice only IPL needed league mode — other franchise leagues had no complaints using SportMonks flags.Decision: Simplify getDisplayMode() to: IPL -> league, everything else -> international. Remove both INTERNATIONAL_CODES and LEAGUE_CODES sets.Consequences:
  • Match cards for PSL, BBL, BPL etc. now render with SportMonks flags instead of brand colors
  • If a league raises licensing concerns, adding its code back is a one-line change
  • Trivially understandable rendering logic

Decommissioned

StatusAccepted
Date2026-03-02
Context: The backend had 24 Vitest unit test files that were never enforced in CI, had drifted from production code, and relied heavily on mocked DynamoDB calls that verified SDK parameters rather than real behavior.Decision: Remove the entire Vitest test suite and related infrastructure (24 test files, 3 devDependencies, 3 npm scripts). Replace with a replay-based integration verification strategy that tests the full stack: backend APIs -> push notifications -> iOS Live Activities -> visual regression.Consequences:
  • Eliminated false sense of coverage from tests that were never gated on merge
  • Strategic investment in replay-based testing that provides higher confidence for a real-time data flow system
  • No unit tests exist during the transition period until the replay harness is complete