Assumptions:
Assume an average persisted workout record of approximately 1 KB, excluding indexes and replication.
These estimates suggest that reads dominate traffic, so caching, indexed history queries, and read replicas are valuable.
Data entities:
User
Id
Name
Workout
Id - PK, also idempotent key
userId - partition key
Source - App/Fitness Watch/ Native app
Type - Strength/Cardio
Intensity
TimeTaken
CaloriesBurned
CreatedAt
Goal
Id - PK
UserId - Partition key
GoalWeight
CurrentWeight
Type - lose, gain
PercentComplete
CompletedAt
CreatedAt
UpdatedAt
UserStats
id
UserId - Partition key
totalWorkout
TotalGoals
GoalsCompleted
CurrentWeight
CurrentHeight
APIs
Create workout
POST /{user_id}/workout
req - {
id - idempotent key
source,
time,
intensity,
type
}
res - { id }
POST /users/{userId}/workouts/batch
Accepts a batch of client-generated workout IDs and workout records. Each record is independently idempotent, so retrying a partially successful batch is safe.
Create Goal
POST /{user_id}/goal
req -
{
id,
type,
goalWeight
}
Get user stats
GET/{user_id}/stat
response -
{
userId,
totalWorkouts,
totalGoals,
GoalsCompleted,
GoalsInProgress,
}
//fetch a workout
GET /{user_id}/workout/{workout_id}
Res - Workout
Get workouts history
Get /{user_id}/workout?filters=
res - workoutList
Acts as the entry point for all client requests.
Responsibilities:
Distributes traffic across multiple instances of the Read and Write services to improve scalability and availability.
Workout Service:
Responsibilities:
Goal Service:
Responsibilities:
Stats service:
Responsibilties:
Redis:
Device Tracker Service
Kafka
SQL Database
The application must support recording workouts without a network connection.
(userId, workoutId).For device imports, we additionally deduplicate using the provider's workout ID. This prevents the same workout from being imported repeatedly during polling or webhook retries.
If the server is unavailable, the client retains unsynced workouts and retries with exponential backoff.
Important: The server acknowledges a workout as saved only after the workout record and its outbox event have committed successfully.
There are two separate failure scenarios to handle.
A naive implementation writes the workout to SQL and then publishes an event. If SQL commits but Kafka is unavailable, the workout exists while the downstream event is lost.
Use the transactional outbox pattern:
WorkoutCreated event into an outbox table.If publishing fails, the publisher retries. It may publish an event more than once if it crashes after Kafka accepts the event but before marking the outbox row as published. Consumers must therefore be idempotent.
Kafka may redeliver messages when a consumer crashes or its offset has not been committed.
Each event has a stable eventId. The consumer:
ProcessedEvents table with a unique constraint.The processed-event record and business update must be in the same transaction. This prevents a crash from recording an event as processed without applying its effects, or applying its effects without recording it as processed.
This provides reliable, effectively-once business processing for each consumer's database transaction, rather than relying on end-to-end exactly-once delivery.
Read traffic is much higher than write traffic, so we optimize dashboard and history access.
Read path:
Caching strategy:
userId → statsuserId + goalId → progressuserId → recent workoutsWhen authoritative data changes, invalidate or update affected cache entries after the database transaction commits. TTLs provide an additional safeguard against stale cache entries.
For stronger read-after-write behavior, route a user's immediate post-write read to the primary database or use a consistency mechanism that accounts for replica lag.
Database scaling:
| FailureHandling | |
| Redis unavailable | Fall back to SQL; rebuild the cache gradually. |
| SQL primary unavailable | Fail over to a healthy replica according to the database's recovery guarantees; retry failed requests safely. |
| Kafka unavailable | Persist workout and outbox event in SQL; publish when Kafka recovers. |
| Consumer crashes | Kafka redelivers uncommitted events; transactional idempotency prevents duplicate effects. |
| Poison event | Bounded retries, alerting, and replay. |
| Device provider unavailable | Retry with backoff and resume from the last successful sync checkpoint. |
| Stale statistics | Reprocess events or reconcile derived data against authoritative records. |
| Traffic spike | Scale service instances, apply rate limits, use caching, and monitor database and queue saturation. |
I would start with SQL because we need durable transactional workout writes, uniqueness constraints for idempotency, and well-defined consistency for goals and stats.
At this scale, I would add indexing, partitioning, read replicas, and possibly sharding as needed. A distributed NoSQL database could be appropriate if access patterns and throughput requirements eventually justify its trade-offs, but it is not automatically better for storing a billion records.