Design a Gym & Fitness Tracking App
Building a production-grade fitness platform: workout tracking, AI form correction, wearable integration, and social challenges at scale
1. Introduction — The Fitness App Landscape
The global fitness app market was valued at approximately 1.3 billion dollars in 2024 and is projected to exceed 3.5 billion dollars by 2030. Applications like MyFitnessPal, Fitbod, Strong, JEFIT, and Strava have demonstrated that users are willing to pay premium subscriptions for intelligent workout tracking, personalized plans, and social accountability. Yet building a comprehensive gym and fitness tracking platform that handles real-time workout logging, wearable data ingestion, AI-powered form analysis, class scheduling, membership billing, and social challenges is a formidable systems-design challenge.
In this guide we design a full-featured gym and fitness tracking application from the ground up. We begin with requirements gathering and capacity estimation, walk through the data model and high-level architecture, implement core services in C#, and then dive deep into each feature domain. By the end you will have a production-grade blueprint that covers every major subsystem, complete with code examples, database schemas, Mermaid diagrams, and interview-ready answers.
Fitness apps differ from typical CRUD applications because they must handle high-frequency real-time data streams from wearables, compute aggregates over long historical windows, support offline-first mobile experiences, and integrate with third-party hardware ecosystems. The latency budget for logging a set during a workout is sub-second, while nightly analytics batch jobs may process millions of data points. This duality of real-time and batch workloads makes the architecture particularly interesting.
We will also address challenges that are unique to the fitness domain: handling timezone-aware class schedules across global gym chains, computing one-rep-max formulas in real time, managing recurring membership billing cycles with proration, and ensuring HIPAA-compliant storage of biometric health data. Each section provides both the conceptual reasoning and the production C# implementation so you can adapt the patterns to your own tech stack.
The fitness industry is undergoing a digital transformation where the physical gym experience is being augmented by intelligent software. Members expect their gym app to be as polished and responsive as the consumer apps they use daily. Personal trainers need data-driven insights to program effectively for their clients. Gym operators need operational dashboards to optimize class scheduling and equipment utilization. This guide addresses all three personas and shows how a unified platform can serve them all through well-designed microservices with clear domain boundaries.
We will examine real-world architectural decisions such as why MongoDB is preferred for workout session documents over traditional relational tables, how TimescaleDB handles billions of heart rate samples efficiently, and why Redis Sorted Sets are the perfect data structure for real-time leaderboards. Each technology choice is justified with concrete performance characteristics and cost analysis.
2. Functional & Non-Functional Requirements
Functional Requirements
- Workout Logging: Users can log exercises with sets, reps, weight, duration, distance, and RPE (Rate of Perceived Exertion). Support for supersets, drop sets, and circuit training with real-time rest timer integration.
- Exercise Library: Searchable database of 1000+ exercises with muscle group categorization, equipment tags, difficulty levels, instructional videos, and muscle activation heatmaps.
- Personalized Plans: Algorithmically generated or trainer-authored workout plans that adapt based on progress, available equipment, training history, and user goals.
- Progress Analytics: Charts showing volume load over time, estimated one-rep-max progression, body measurement trends, personal records, and muscle group balance analysis.
- Social Features: Activity feed, leaderboards, workout challenges, friend lists, gym-wide competitions, and shared workout templates.
- Wearable Integration: Sync heart rate, step count, sleep data, SpO2, and GPS routes from Apple Health, Google Fit, Garmin Connect, and Fitbit.
- Class Booking: Browse, book, waitlist, and cancel group fitness classes with real-time seat availability and recurring schedule support.
- Membership & Billing: Tiered membership plans, recurring Stripe subscriptions, freeze/pause, prorated upgrades, and annual payment options.
- Trainer Dashboard: Trainers can view assigned clients, program assignments, attendance tracking, client progress, and communicate via in-app messaging.
- Nutrition Tracking: Food logging with barcode scanning, macro breakdown, calorie targets based on TDEE, meal plan suggestions, and water intake tracking.
- Gamification: XP points, achievement badges, streaks, level progression, and unlockable content to drive long-term retention.
- Push Notifications: Workout reminders, class start alerts, friend activity, streak warnings, and personalized marketing messages.
- Video Exercise Library: HD video demonstrations with slow-motion playback, angle switching, and frame-by-frame stepping.
- AI Form Correction: Computer vision analysis of user-submitted exercise videos with real-time feedback on form deviations and injury risk scoring.
- Multi-Gym Support: Franchise chains with location-specific schedules, equipment inventories, staff management, and cross-gym access tiers.
Non-Functional Requirements
| Attribute | Target | Rationale |
|---|---|---|
| Availability | 99.95% uptime | Fitness users expect 24/7 access; peak hours are early morning and evening |
| Latency | p99 < 200ms for API reads, < 500ms for writes | Workout logging must feel instantaneous during a session |
| Throughput | 50,000 concurrent users, 500 requests/sec sustained | Mid-scale gym chain with 2 million registered users |
| Data Durability | 99.999999999% (11 nines) | Workout history is irreplaceable; users rage-quit over lost data |
| Offline Support | Full workout logging offline, sync on reconnect | Gyms often have poor connectivity in basements and parking garages |
| Privacy | HIPAA-compliant for health data, GDPR for EU users | Biometric data requires regulatory compliance |
| Scalability | Linear horizontal scaling to 10M users | Targeting growth from 2M to 10M over 3 years |
| Media Support | Video uploads up to 100 MB, streaming at 1080p | AI form correction requires high-quality video input |
3. Capacity Estimation & Back-of-Envelope
User Activity Model
Assume 2 million registered users with 400,000 daily active users. Each active user performs an average of 1 workout per day, with each workout containing roughly 6 exercises, each exercise having 3 sets. This yields approximately 400,000 workouts per day or about 4.6 workouts per second on average. During peak hours (6 AM to 9 AM and 5 PM to 8 PM), traffic spikes to roughly 3x the average, reaching approximately 14 workouts per second.
Each workout session generates approximately 18 set log entries (6 exercises × 3 sets), resulting in 7.2 million set logs per day. Each set log requires a write operation, bringing our peak write throughput to approximately 42 set logs per second. This is well within the capacity of a single PostgreSQL instance with proper indexing, but we plan for multi-region deployment from the start.
Storage Estimation
| Data Type | Size per Record | Daily Volume | Daily Storage |
|---|---|---|---|
| Workout Session | ~2 KB (JSON payload) | 400,000 | ~800 MB |
| Set Log Entry | ~200 bytes | 7,200,000 | ~1.4 GB |
| Heart Rate Sample | ~32 bytes | 345,600,000 (1/sec per active user for 1 hr) | ~11 GB |
| Exercise Video (AI) | ~5 MB | 10,000 submissions | ~50 GB |
| User Profile | ~1 KB | Incremental | ~Negligible |
| Nutrition Log | ~300 bytes | 800,000 (2 meals avg) | ~240 MB |
| Class Booking | ~150 bytes | 50,000 | ~7.5 MB |
Annual storage for structured data (excluding video) is approximately 4.5 TB. Video storage adds roughly 18 TB per year. We should budget for 25 TB total annual storage growth and implement tiered storage policies that move data older than 90 days to colder storage tiers.
Bandwidth Estimation
Assuming average API response size of 5 KB and 500 requests per second sustained, outbound bandwidth is approximately 2.5 MB/s or 216 GB/day. During peak, this reaches 7.5 MB/s. The heartbeat streaming endpoint from wearables adds approximately 128 Kbps of continuous inbound traffic per active session, which at 10,000 concurrent sessions totals about 1.28 Gbps — a significant but manageable load that benefits from dedicated streaming infrastructure with horizontal partitioning.
Cache Estimation
The exercise library (1,000 exercises) is read-heavy and changes rarely. At 2 KB per exercise document, the entire library fits in 2 MB of cache. User profiles and recent workout history should be cached with a TTL of 5 minutes, requiring approximately 800 MB of cache for 400,000 daily active users at 2 KB each. Leaderboards require approximately 200 KB per active challenge. Total Redis cache cluster should be provisioned at 4 GB minimum with 8 GB recommended for headroom.
4. Data Model & Database Design
We adopt a polyglot persistence strategy: PostgreSQL for transactional relational data, MongoDB for semi-structured workout session documents, Redis for caching and real-time leaderboards, TimescaleDB for time-series heart rate data, and S3-compatible object storage for media files. Each store is chosen for its strengths relative to the access patterns of the data it holds.
Entity-Relationship Overview
PostgreSQL Schema — Core Tables
SQL
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(512) NOT NULL,
display_name VARCHAR(100) NOT NULL,
avatar_url TEXT,
date_of_birth DATE,
gender VARCHAR(20),
height_cm DECIMAL(5,1),
weight_kg DECIMAL(5,1),
fitness_level VARCHAR(20) CHECK (fitness_level IN ('beginner','intermediate','advanced','elite')),
timezone VARCHAR(50) DEFAULT 'UTC',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE gyms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
address TEXT,
latitude DECIMAL(9,6),
longitude DECIMAL(9,6),
phone VARCHAR(30),
timezone VARCHAR(50) DEFAULT 'UTC',
max_capacity INT,
chain_id UUID REFERENCES gym_chains(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE exercises (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT,
muscle_group VARCHAR(50) NOT NULL,
secondary_muscles TEXT[],
equipment VARCHAR(50),
difficulty VARCHAR(20) CHECK (difficulty IN ('beginner','intermediate','advanced')),
exercise_type VARCHAR(30) CHECK (exercise_type IN ('strength','cardio','flexibility','balance','plyometric')),
video_url TEXT,
thumbnail_url TEXT,
met_value DECIMAL(4,2),
is_compound BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE workout_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT,
created_by UUID REFERENCES users(id),
difficulty VARCHAR(20),
duration_weeks INT,
goal VARCHAR(50),
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE workout_template_exercises (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID REFERENCES workout_templates(id) ON DELETE CASCADE,
exercise_id UUID REFERENCES exercises(id),
day_of_week INT CHECK (day_of_week BETWEEN 0 AND 6),
order_index INT NOT NULL,
target_sets INT,
target_reps VARCHAR(20),
target_weight_kg DECIMAL(6,2),
rest_seconds INT,
notes TEXT
);
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
gym_id UUID REFERENCES gyms(id),
plan_type VARCHAR(30) CHECK (plan_type IN ('basic','standard','premium','vip')),
status VARCHAR(20) CHECK (status IN ('active','paused','cancelled','expired')),
start_date DATE NOT NULL,
end_date DATE,
freeze_until DATE,
stripe_subscription_id VARCHAR(200),
monthly_price DECIMAL(8,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE class_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID REFERENCES gyms(id),
class_name VARCHAR(200) NOT NULL,
instructor_name VARCHAR(200),
trainer_id UUID REFERENCES trainers(id),
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
max_participants INT DEFAULT 30,
current_participants INT DEFAULT 0,
room VARCHAR(50),
difficulty VARCHAR(20),
recurrence VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE class_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
class_id UUID REFERENCES class_schedules(id),
status VARCHAR(20) CHECK (status IN ('confirmed','waitlisted','cancelled','attended')),
booked_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, class_id)
);
CREATE TABLE body_measurements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
measured_at DATE NOT NULL,
weight_kg DECIMAL(5,1),
body_fat_pct DECIMAL(4,1),
muscle_mass_kg DECIMAL(5,1),
chest_cm DECIMAL(5,1),
waist_cm DECIMAL(5,1),
hips_cm DECIMAL(5,1),
bicep_cm DECIMAL(5,1),
thigh_cm DECIMAL(5,1),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE nutrition_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
logged_at TIMESTAMPTZ DEFAULT NOW(),
meal_type VARCHAR(20) CHECK (meal_type IN ('breakfast','lunch','dinner','snack')),
food_name VARCHAR(200),
calories INT,
protein_g DECIMAL(5,1),
carbs_g DECIMAL(5,1),
fat_g DECIMAL(5,1),
fiber_g DECIMAL(5,1),
barcode VARCHAR(50),
serving_size VARCHAR(50)
);
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
badge_code VARCHAR(50) NOT NULL,
badge_name VARCHAR(100) NOT NULL,
description TEXT,
xp_value INT DEFAULT 0,
earned_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
data JSONB,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
MongoDB — Workout Session Document
JSON
{
"_id": "ObjectId('66a1b2c3d4e5f6a7b8c9d0e1')",
"userId": "UUID('...')",
"gymId": "UUID('...')",
"startedAt": "2026-07-13T06:30:00Z",
"endedAt": "2026-07-13T07:45:00Z",
"durationMinutes": 75,
"sessionType": "strength",
"templateId": "UUID('...')",
"mood": "energized",
"energyLevel": 8,
"notes": "Felt strong today, hit a new PR on bench",
"totalVolumeLoad": 4875.5,
"estimatedCaloriesBurned": 620,
"exercises": [
{
"exerciseId": "UUID('...')",
"exerciseName": "Barbell Bench Press",
"order": 1,
"isSuperset": false,
"sets": [
{ "setNumber": 1, "reps": 12, "weightKg": 60, "rpe": 6, "isWarmup": false, "restSeconds": 90 },
{ "setNumber": 2, "reps": 10, "weightKg": 80, "rpe": 7, "isWarmup": false, "restSeconds": 120 },
{ "setNumber": 3, "reps": 8, "weightKg": 95, "rpe": 8.5, "isWarmup": false, "restSeconds": 150 },
{ "setNumber": 4, "reps": 6, "weightKg": 105, "rpe": 9.5, "isWarmup": false, "restSeconds": 180 }
],
"notes": "Last set was a grinder, might deload next week"
}
],
"wearableData": {
"avgHeartRate": 132,
"maxHeartRate": 168,
"heartRateZones": { "zone1": 12, "zone2": 25, "zone3": 28, "zone4": 8, "zone5": 2 },
"caloriesBurned": 620,
"stepsDuringWorkout": 2400
}
}
TimescaleDB — Heart Rate Time Series
SQL
CREATE TABLE heart_rate_samples (
time TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL,
session_id UUID,
heart_rate SMALLINT NOT NULL,
source VARCHAR(30) DEFAULT 'apple_watch'
);
SELECT create_hypertable('heart_rate_samples', 'time');
CREATE INDEX idx_hr_user_time ON heart_rate_samples (user_id, time DESC);
CREATE INDEX idx_hr_session ON heart_rate_samples (session_id, time);
5. High-Level Architecture
The system follows a microservices architecture with an API Gateway pattern. Mobile clients communicate through a central gateway that handles authentication, rate limiting, and request routing. Individual services own their data stores and communicate asynchronously via RabbitMQ for event-driven workflows and synchronously via gRPC for low-latency internal calls. The architecture is designed for independent deployment and scaling of each service based on its specific load characteristics.
Service Responsibilities
| Service | Responsibility | Primary Store | Language |
|---|---|---|---|
| Auth Service | JWT issuance, refresh tokens, OAuth2, MFA | PostgreSQL | C# |
| User Service | Profile management, preferences, body measurements | PostgreSQL | C# |
| Workout Service | Session CRUD, real-time set logging, PR detection | MongoDB | C# |
| Exercise Service | Exercise catalog, search, filtering, admin CRUD | PostgreSQL + Elasticsearch | C# |
| Plan Generator | ML-powered plan generation, adaptation algorithms | PostgreSQL | C# + Python ML |
| Analytics Service | Volume load calculations, trend analysis, reports | TimescaleDB | C# |
| Social Service | Activity feed, leaderboards, friend graph | Redis + PostgreSQL | C# |
| Wearable Ingestion | HealthKit/Fit sync, heart rate stream processing | TimescaleDB + S3 | C# |
| Class Booking | Schedule management, reservations, waitlists | PostgreSQL | C# |
| Billing Service | Stripe integration, subscriptions, invoicing | PostgreSQL | C# |
| Trainer Service | Client management, program assignment, attendance | PostgreSQL | C# |
| Nutrition Service | Food logging, barcode lookup, macro computation | MongoDB | C# |
| Notification Service | Push notifications, in-app alerts, scheduling | PostgreSQL + Redis | C# |
| Video Processing | Transcoding, thumbnail generation, CDN upload | S3 | C# |
| AI Form Correction | Pose estimation, deviation scoring, feedback generation | S3 + PostgreSQL | C# + Python |
6. API Design & RESTful Endpoints
All endpoints are versioned under /api/v1/. Authentication is handled via Bearer JWT tokens. The API Gateway handles rate limiting (1000 requests per minute per user), request validation via JSON Schema, and request/response logging for observability. All timestamps use ISO 8601 format in UTC.
Workout Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/workouts/sessions | Create a new workout session |
| GET | /api/v1/workouts/sessions/{id} | Get workout session details with all exercises and sets |
| PATCH | /api/v1/workouts/sessions/{id} | Update session metadata (end time, notes, mood) |
| DELETE | /api/v1/workouts/sessions/{id} | Delete an entire workout session |
| POST | /api/v1/workouts/sessions/{id}/exercises | Add exercise to session |
| PATCH | /api/v1/workouts/sessions/{id}/exercises/{eid} | Update exercise order or superset grouping |
| POST | /api/v1/workouts/sessions/{id}/exercises/{eid}/sets | Log a set with reps, weight, RPE |
| PATCH | /api/v1/workouts/sessions/{id}/exercises/{eid}/sets/{sid} | Update an existing set |
| DELETE | /api/v1/workouts/sessions/{id}/exercises/{eid}/sets/{sid} | Delete a set |
| GET | /api/v1/workouts/history | Get paginated workout history with date filtering |
| GET | /api/v1/workouts/personal-records | Get all personal records across exercises |
| POST | /api/v1/workouts/sync | Bulk sync offline mutations |
Exercise Library Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/exercises | List exercises with filtering by muscle, equipment, type |
| GET | /api/v1/exercises/{id} | Get exercise details with videos and muscle diagrams |
| GET | /api/v1/exercises/search?q=bench | Full-text search with autocomplete suggestions |
| GET | /api/v1/exercises/muscle-groups | List all muscle groups with exercise counts |
| POST | /api/v1/exercises/{id}/video | Upload exercise demonstration video (admin only) |
Plan & Analytics Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/plans/generate | Generate AI workout plan based on goals and history |
| GET | /api/v1/plans/{id} | Get plan with daily schedules and exercise prescriptions |
| PATCH | /api/v1/plans/{id}/adapt | Trigger plan re-adaptation based on recent performance |
| GET | /api/v1/analytics/volume-load | Volume load over date range with granularity param |
| GET | /api/v1/analytics/estimations/{exerciseId} | Estimated 1RM progression chart data |
| GET | /api/v1/analytics/body-trends | Body measurement trend data with regression analysis |
| GET | /api/v1/analytics/muscle-balance | Muscle group volume distribution and balance score |
C# API Controller Example
C#
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class WorkoutSessionsController : ControllerBase
{
private readonly IWorkoutSessionService _sessionService;
private readonly IEventBus _eventBus;
private readonly ILogger<WorkoutSessionsController> _logger;
public WorkoutSessionsController(
IWorkoutSessionService sessionService,
IEventBus eventBus,
ILogger<WorkoutSessionsController> logger)
{
_sessionService = sessionService;
_eventBus = eventBus;
_logger = logger;
}
[HttpPost]
[ProducesResponseType(typeof(WorkoutSessionResponse), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateSession(
[FromBody] CreateWorkoutSessionRequest request,
CancellationToken ct)
{
var userId = GetUserId();
var session = await _sessionService.CreateSessionAsync(new CreateSessionCommand
{
UserId = userId,
GymId = request.GymId,
SessionType = request.SessionType,
TemplateId = request.TemplateId,
StartedAt = DateTime.UtcNow
}, ct);
await _eventBus.PublishAsync(new WorkoutSessionStartedEvent
{
UserId = userId,
SessionId = session.Id,
StartedAt = session.StartedAt
});
_logger.LogInformation("User {UserId} started workout session {SessionId}", userId, session.Id);
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
}
[HttpPost("{id:guid}/exercises/{exerciseId:guid}/sets")]
[ProducesResponseType(typeof(SetLogResponse), StatusCodes.Status201Created)]
public async Task<IActionResult> LogSet(
Guid id, Guid exerciseId,
[FromBody] LogSetRequest request,
CancellationToken ct)
{
var userId = GetUserId();
var setLog = await _sessionService.LogSetAsync(new LogSetCommand
{
SessionId = id,
ExerciseId = exerciseId,
UserId = userId,
SetNumber = request.SetNumber,
Reps = request.Reps,
WeightKg = request.WeightKg,
Rpe = request.Rpe,
DurationSeconds = request.DurationSeconds,
DistanceMeters = request.DistanceMeters,
IsWarmup = request.IsWarmup,
RestSeconds = request.RestSeconds,
LoggedAt = DateTime.UtcNow
}, ct);
var prCheck = await _sessionService.CheckPersonalRecordAsync(
userId, exerciseId, request.WeightKg, request.Reps, ct);
if (prCheck.IsNewPR)
{
await _eventBus.PublishAsync(new PersonalRecordAchievedEvent
{
UserId = userId,
ExerciseId = exerciseId,
PRType = prCheck.PRType,
PreviousValue = prCheck.PreviousValue,
NewValue = prCheck.NewValue
});
}
return CreatedAtAction(nameof(LogSet), new { id, exerciseId }, setLog);
}
[HttpGet("history")]
[ProducesResponseType(typeof(PaginatedResponse<WorkoutSessionSummary>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistory(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
CancellationToken ct = default)
{
var userId = GetUserId();
var result = await _sessionService.GetHistoryAsync(userId, page, pageSize, fromDate, toDate, ct);
return Ok(result);
}
private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
7. Workout Tracking & Logging
Workout logging is the core feature that defines the user experience. It must be fast, reliable, and forgiving of poor network conditions. The key architectural decision is to use an offline-first approach where the mobile app maintains a local SQLite database of the current workout session and syncs to the server when connectivity is available. This is critical because gym environments often have thick concrete walls that block cellular signals, especially in basements and parking garage workout areas.
Offline Sync Strategy
Each set logged offline is assigned a monotonically increasing local sequence number. When the app reconnects, it performs a bulk sync that sends all pending mutations in order. The server applies these idempotently using the sequence number and a client-generated UUID for each operation. This guarantees exactly-once semantics even if the client retries due to network instability.
C#
public class OfflineSyncService
{
private readonly IWorkoutSessionRepository _repository;
private readonly ISyncQueueRepository _syncQueue;
private readonly IEventBus _eventBus;
public async Task<SyncResult> SyncPendingMutationsAsync(
Guid userId, List<PendingMutation> mutations, CancellationToken ct)
{
var result = new SyncResult
{
ProcessedCount = 0,
Conflicts = new List<SyncConflict>()
};
var orderedMutations = mutations.OrderBy(m => m.SequenceNumber).ToList();
await using var transaction = await _repository.BeginTransactionAsync(ct);
try
{
foreach (var mutation in orderedMutations)
{
var isApplied = await _syncQueue.IsAlreadyAppliedAsync(
mutation.IdempotencyKey, ct);
if (isApplied)
{
result.ProcessedCount++;
continue;
}
switch (mutation.Type)
{
case MutationType.LogSet:
var setCmd = JsonSerializer.Deserialize<LogSetCommand>(mutation.Payload)!;
await _repository.LogSetAsync(setCmd, ct);
break;
case MutationType.UpdateSet:
var updateCmd = JsonSerializer.Deserialize<UpdateSetCommand>(mutation.Payload)!;
await _repository.UpdateSetAsync(updateCmd, ct);
break;
case MutationType.DeleteSet:
var deleteCmd = JsonSerializer.Deserialize<DeleteSetCommand>(mutation.Payload)!;
await _repository.DeleteSetAsync(deleteCmd, ct);
break;
case MutationType.EndSession:
var endCmd = JsonSerializer.Deserialize<EndSessionCommand>(mutation.Payload)!;
await _repository.EndSessionAsync(endCmd, ct);
break;
}
await _syncQueue.MarkAppliedAsync(mutation.IdempotencyKey, ct);
result.ProcessedCount++;
}
await transaction.CommitAsync(ct);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
throw new SyncFailedException("Bulk sync failed; client should retry", ex);
}
return result;
}
}
Real-Time Personal Record Detection
When a user logs a new set, we compute the estimated one-rep-max using the Epley formula: e1rm = weight * (1 + reps / 30). We compare against cached PRs in Redis and emit an event if a new record is set. The PR is persisted asynchronously to avoid blocking the logging path. The event triggers a notification to the user, updates the social activity feed, and may trigger achievement checks in the gamification engine.
C#
public class PersonalRecordService
{
private readonly IRedisCache _cache;
private readonly IPersonalRecordRepository _repository;
public decimal CalculateEstimatedOneRepMax(decimal weightKg, int reps)
{
if (reps == 1) return weightKg;
return Math.Round(weightKg * (1 + reps / 30m), 1);
}
public async Task<PRCheckResult> CheckAndRecordPRAsync(
Guid userId, Guid exerciseId, decimal weightKg, int reps,
CancellationToken ct)
{
var e1rm = CalculateEstimatedOneRepMax(weightKg, reps);
var cacheKey = $"pr:user:{userId}:exercise:{exerciseId}";
var currentPR = await _cache.GetAsync<PersonalRecordDto>(cacheKey, ct);
var result = new PRCheckResult();
if (currentPR == null || e1rm > currentPR.EstimatedOneRepMax)
{
var newPR = new PersonalRecord
{
UserId = userId,
ExerciseId = exerciseId,
WeightKg = weightKg,
Reps = reps,
EstimatedOneRepMax = e1rm,
AchievedAt = DateTime.UtcNow
};
await _repository.UpsertAsync(newPR, ct);
await _cache.SetAsync(cacheKey, new PersonalRecordDto
{
WeightKg = weightKg,
Reps = reps,
EstimatedOneRepMax = e1rm,
AchievedAt = DateTime.UtcNow
}, TimeSpan.FromDays(365), ct);
result.IsNewPR = true;
result.PRType = currentPR == null
? PRType.FirstRecord
: PRType.BestE1RM;
result.PreviousValue = currentPR?.EstimatedOneRepMax;
result.NewValue = e1rm;
}
return result;
}
}
Volume Load Computation
Volume load for a workout session is the sum of sets * reps * weight for every exercise. This metric is critical for tracking progressive overload — the foundational principle of strength training adaptation. We compute it both in real time (for the session summary displayed on the completion screen) and in batch (for weekly and monthly aggregate charts in the analytics dashboard).
C#
public static class VolumeLoadCalculator
{
public static decimal ComputeSessionVolume(WorkoutSession session)
{
return session.Exercises.Sum(exercise =>
exercise.Sets
.Where(s => !s.IsWarmup)
.Sum(set => set.Reps * set.WeightKg)
);
}
public static decimal ComputeExerciseVolume(ExerciseLog exercise)
{
return exercise.Sets
.Where(s => !s.IsWarmup)
.Sum(set => set.Reps * set.WeightKg);
}
public static decimal ComputeWeeklyVolume(IEnumerable<WorkoutSession> sessions)
{
return sessions.Sum(ComputeSessionVolume);
}
public static decimal ComputeAverageDailyVolume(
IEnumerable<WorkoutSession> weekSessions, int daysInPeriod)
{
var total = ComputeWeeklyVolume(weekSessions);
return Math.Round(total / daysInPeriod, 1);
}
public static Dictionary<string, decimal> ComputeMuscleGroupVolume(
IEnumerable<WorkoutSession> sessions,
Dictionary<Guid, Exercise> exerciseLookup)
{
return sessions
.SelectMany(s => s.Exercises)
.GroupBy(e =>
exerciseLookup.TryGetValue(e.ExerciseId, out var ex)
? ex.MuscleGroup : "Unknown")
.ToDictionary(
g => g.Key,
g => g.Sum(e => ComputeExerciseVolume(e))
);
}
}
8. Exercise Library
The exercise library is the foundation upon which all workout features are built. It contains 1,000+ exercises, each tagged with primary and secondary muscle groups, required equipment, difficulty level, exercise type (strength, cardio, flexibility, balance, plyometric), MET value for calorie estimation, and optional video demonstrations. The library is read-heavy (99.5% reads) and relatively static, making it ideal for aggressive caching with Redis and CDN-level caching for media assets.
Elasticsearch Index Mapping
JSON
{
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "english" },
"name_suggest": { "type": "completion" },
"muscle_group": { "type": "keyword" },
"secondary_muscles": { "type": "keyword" },
"equipment": { "type": "keyword" },
"difficulty": { "type": "keyword" },
"exercise_type": { "type": "keyword" },
"is_compound": { "type": "boolean" },
"met_value": { "type": "float" },
"description": { "type": "text", "analyzer": "english" },
"tags": { "type": "keyword" }
}
}
}
C# Exercise Search Service
C#
public class ExerciseSearchService : IExerciseSearchService
{
private readonly IElasticClient _elastic;
private readonly IDistributedCache _cache;
public async Task<PagedResult<ExerciseDto>> SearchAsync(
ExerciseSearchCriteria criteria, CancellationToken ct)
{
var cacheKey = BuildCacheKey(criteria);
var cached = await _cache.GetAsync<PagedResult<ExerciseDto>>(cacheKey, ct);
if (cached != null) return cached;
var searchDescriptor = new SearchDescriptor<ExerciseDocument>()
.Index("exercises")
.Size(criteria.PageSize)
.From((criteria.Page - 1) * criteria.PageSize);
searchDescriptor.Query(q => q
.Bool(b => b
.Must(must =>
{
var queries = new List<Func<
QueryContainerDescriptor<ExerciseDocument>,
QueryContainer>>();
if (!string.IsNullOrWhiteSpace(criteria.Query))
{
queries.Add(qq => qq.Match(mm => mm
.Field(f => f.Name)
.Query(criteria.Query)
.Fuzziness(Fuzziness.Auto)
));
}
if (!string.IsNullOrWhiteSpace(criteria.MuscleGroup))
{
queries.Add(qq => qq.Term(t => t
.Field(f => f.MuscleGroup)
.Value(criteria.MuscleGroup)
));
}
if (!string.IsNullOrWhiteSpace(criteria.Equipment))
{
queries.Add(qq => qq.Term(t => t
.Field(f => f.Equipment)
.Value(criteria.Equipment)
));
}
if (!string.IsNullOrWhiteSpace(criteria.Difficulty))
{
queries.Add(qq => qq.Term(t => t
.Field(f => f.Difficulty)
.Value(criteria.Difficulty)
));
}
if (criteria.IsCompound.HasValue)
{
queries.Add(qq => qq.Term(t => t
.Field(f => f.IsCompound)
.Value(criteria.IsCompound.Value)
));
}
return queries.Any()
? b.Must(queries.ToArray())
: null;
})
)
).Sort(s => s.Ascending(a => a.Name));
var response = await _elastic.SearchAsync<ExerciseDocument>(
searchDescriptor, ct);
var result = new PagedResult<ExerciseDto>
{
Items = response.Documents.Select(MapToDto).ToList(),
TotalCount = (int)response.Total,
Page = criteria.Page,
PageSize = criteria.PageSize
};
await _cache.SetAsync(cacheKey, result,
TimeSpan.FromMinutes(10), ct);
return result;
}
}
9. Personalized Workout Plans
Personalized workout plans are generated using a hybrid approach: a rule-based engine handles the structural scaffolding (split type, exercise selection, volume prescription) while a lightweight ML model predicts optimal sets, reps, and weight targets based on the user's training history and recovery metrics. Plans are regenerated weekly as new data arrives from completed workouts and wearable sleep data.
Plan Generation Algorithm
C#
public class WorkoutPlanGenerator : IWorkoutPlanGenerator
{
private readonly ITrainingHistoryAnalyzer _historyAnalyzer;
private readonly IExerciseRepository _exerciseRepo;
private readonly IRecoveryPredictor _recoveryPredictor;
public async Task<WorkoutPlan> GeneratePlanAsync(
PlanGenerationRequest request, CancellationToken ct)
{
var userProfile = request.UserProfile;
var history = await _historyAnalyzer
.GetRecentHistoryAsync(userProfile.UserId, weeks: 12, ct);
var split = DetermineOptimalSplit(userProfile, history);
var exercisesPerDay = SelectExercises(
split, userProfile.Goal,
userProfile.AvailableEquipment, ct);
var volumeTargets = ComputeVolumeTargets(
exercisesPerDay, userProfile.FitnessLevel, history);
var plan = new WorkoutPlan
{
UserId = userProfile.UserId,
Name = $"{split.Name} — {request.Goal}",
Goal = request.Goal,
DurationWeeks = 8,
Difficulty = userProfile.FitnessLevel,
Days = new List<PlanDay>()
};
foreach (var dayTemplate in split.Days)
{
var planDay = new PlanDay
{
DayOfWeek = dayTemplate.DayOfWeek,
Focus = dayTemplate.Focus,
Exercises = new List<PlannedExercise>()
};
foreach (var exerciseSlot in dayTemplate.Exercises)
{
var prescription = volumeTargets[exerciseSlot.ExerciseId];
var lastPerf = history.GetLastPerformance(exerciseSlot.ExerciseId);
var predictedWeight = PredictStartingWeight(
lastPerf, prescription, userProfile);
planDay.Exercises.Add(new PlannedExercise
{
ExerciseId = exerciseSlot.ExerciseId,
ExerciseName = exerciseSlot.ExerciseName,
TargetSets = prescription.TargetSets,
TargetReps = prescription.TargetReps,
TargetWeightKg = predictedWeight,
RestSeconds = prescription.RestSeconds,
RPE = prescription.TargetRPE,
Notes = GenerateCoachNotes(
exerciseSlot, prescription, lastPerf)
});
}
plan.Days.Add(planDay);
}
plan.WeeklyVolumeLoad = plan.Days
.SelectMany(d => d.Exercises)
.Sum(e => e.TargetSets * e.TargetReps * e.TargetWeightKg);
return plan;
}
private TrainingSplit DetermineOptimalSplit(
UserProfile profile, TrainingHistory history)
{
var daysPerWeek = profile.AvailableDaysPerWeek ?? 4;
var level = profile.FitnessLevel;
return (daysPerWeek, level) switch
{
(3, _) => TrainingSplit.UpperLower3Day,
(4, "beginner") => TrainingSplit.FullBody4Day,
(4, "intermediate") or (4, "advanced")
=> TrainingSplit.UpperLower4Day,
(5, _) => TrainingSplit.PushPullLegsUpperLower,
(6, _) => TrainingSplit.PushPullLegs2x,
_ => TrainingSplit.FullBody3Day
};
}
private decimal PredictStartingWeight(
ExercisePerformance? lastPerf,
VolumePrescription prescription,
UserProfile profile)
{
if (lastPerf == null)
{
return profile.FitnessLevel switch
{
"beginner" => 20m,
"intermediate" => 40m,
"advanced" => 60m,
_ => 80m
};
}
var estimated1RM = lastPerf.EstimatedOneRepMax;
var targetPct = prescription.TargetPercentageOf1RM / 100m;
var predicted1RM = estimated1RM * 1.02m;
return Math.Round(predicted1RM * targetPct / 2.5m) * 2.5m;
}
}
Plan Adaptation Engine
Every Sunday night, the adaptation engine analyzes the past week's performance data. If a user completed all prescribed sets and reps with RPE below the target threshold, the plan auto-progresses by increasing weight by 2.5 to 5 percent. If the user failed to complete sets or reported consistently high RPE values, the plan auto-regresses by reducing volume by 10 to 20 percent and may substitute exercises to address weak points. This closed-loop system ensures the user is always training within an optimal stimulus range without requiring manual plan adjustments from a trainer.
10. Progress Analytics & Charts
Progress analytics transform raw workout data into actionable insights. The analytics service runs nightly batch jobs to pre-compute aggregates and materializes them into summary tables for fast dashboard queries. Real-time queries for current-session metrics are served directly from the operational stores. The key design principle is to never compute expensive aggregations on the hot path — all historical analytics should be pre-computed and cached.
Key Metrics Computed
| Metric | Formula / Method | Computation Frequency |
|---|---|---|
| Estimated 1RM | Epley: weight * (1 + reps/30) | Real-time on each set log |
| Volume Load | SUM(sets * reps * weight) | Real-time per session, nightly aggregate |
| Training Frequency | Count of sessions per week | Nightly batch |
| Personal Records | Max weight * reps combination per exercise | Real-time detection, persisted nightly |
| Body Weight Trend | 7-day moving average of daily weigh-ins | Nightly batch |
| Body Fat Trend | Linear regression of monthly measurements | On new measurement |
| Muscle Group Volume | SUM(volume_load) grouped by muscle_group per week | Nightly batch |
| Strength Score | Weighted sum of top-5 exercise e1RMs | Nightly batch |
C# Analytics Service
C#
public class AnalyticsService : IAnalyticsService
{
private readonly IWorkoutSessionRepository _sessionRepo;
private readonly IBodyMeasurementRepository _measurementRepo;
private readonly IPersonalRecordRepository _prRepo;
private readonly IExerciseRepository _exerciseRepo;
public async Task<VolumeLoadChartDto> GetVolumeLoadOverTimeAsync(
Guid userId, DateTime fromDate, DateTime toDate,
string granularity, CancellationToken ct)
{
var sessions = await _sessionRepo.GetSessionsInRangeAsync(
userId, fromDate, toDate, ct);
var groupedData = granularity.ToLower() switch
{
"daily" => sessions.GroupBy(s => s.StartedAt.Date)
.Select(g => new VolumeDataPoint
{
Date = g.Key,
VolumeLoad = VolumeLoadCalculator
.ComputeWeeklyVolume(g),
SessionCount = g.Count()
}).OrderBy(d => d.Date).ToList(),
"weekly" => sessions
.GroupBy(s => ISOWeek.GetWeekOfMonth(s.StartedAt))
.Select(g => new VolumeDataPoint
{
Date = g.First().StartedAt,
VolumeLoad = VolumeLoadCalculator
.ComputeWeeklyVolume(g),
SessionCount = g.Count()
}).OrderBy(d => d.Date).ToList(),
"monthly" => sessions
.GroupBy(s => new { s.StartedAt.Year, s.StartedAt.Month })
.Select(g => new VolumeDataPoint
{
Date = new DateTime(g.Key.Year, g.Key.Month, 1),
VolumeLoad = VolumeLoadCalculator
.ComputeWeeklyVolume(g),
SessionCount = g.Count()
}).OrderBy(d => d.Date).ToList(),
_ => throw new ArgumentException(
$"Unsupported granularity: {granularity}")
};
return new VolumeLoadChartDto
{
DataPoints = groupedData,
TotalVolume = groupedData.Sum(d => d.VolumeLoad),
AveragePerSession = groupedData.Average(d =>
d.VolumeLoad / Math.Max(d.SessionCount, 1)),
Trend = CalculateTrend(
groupedData.Select(d => d.VolumeLoad).ToList())
};
}
public async Task<E1RMProgressionDto> GetE1RMProgressionAsync(
Guid userId, Guid exerciseId, CancellationToken ct)
{
var sessions = await _sessionRepo
.GetSessionsContainingExerciseAsync(userId, exerciseId, ct);
var progressionPoints = sessions
.SelectMany(s => s.Exercises
.Where(e => e.ExerciseId == exerciseId))
.SelectMany(e => e.Sets
.Where(s => !s.IsWarmup && s.Reps > 0))
.GroupBy(s => s.LoggedAt.Date)
.Select(g => new E1RMDataPoint
{
Date = g.Key,
EstimatedOneRepMax = g.Max(s =>
PersonalRecordService
.CalculateEstimatedOneRepMax(
s.WeightKg, s.Reps)),
BestSet = g.OrderByDescending(s =>
PersonalRecordService
.CalculateEstimatedOneRepMax(
s.WeightKg, s.Reps)).First()
})
.OrderBy(p => p.Date)
.ToList();
return new E1RMProgressionDto
{
ExerciseId = exerciseId,
Points = progressionPoints,
CurrentE1RM = progressionPoints
.LastOrDefault()?.EstimatedOneRepMax ?? 0,
AllTimeBest = progressionPoints.Any()
? progressionPoints.Max(p => p.EstimatedOneRepMax)
: 0,
Improvement = progressionPoints.Count >= 2
? progressionPoints.Last().EstimatedOneRepMax
- progressionPoints.First().EstimatedOneRepMax
: 0
};
}
}
12. Wearable Device Integration (Apple Health / Google Fit)
Wearable integration is essential for automatic data collection and removing friction from the tracking experience. The system supports Apple HealthKit (iOS), Google Health Connect (Android), Garmin Connect API, and Fitbit Web API. The wearable ingestion service handles OAuth2 authorization flows, data normalization across different units and formats, duplicate detection, and conflict resolution when the same data arrives from multiple sources.
Data Flow Architecture
C# Wearable Sync Service
C#
public class WearableSyncService : IWearableSyncService
{
private readonly IHeartRateRepository _hrRepo;
private readonly ISleepDataRepository _sleepRepo;
private readonly IEventBus _eventBus;
public async Task<SyncResult> SyncHealthKitDataAsync(
Guid userId, HealthKitSyncPayload payload,
CancellationToken ct)
{
var result = new SyncResult();
if (payload.HeartRateSamples?.Any() == true)
{
var normalized = payload.HeartRateSamples
.Select(s => new HeartRateSample
{
UserId = userId,
Time = s.Timestamp,
HeartRate = (short)s.Value,
Source = s.SourceName ?? "apple_watch",
SessionId = await MatchToSessionAsync(
userId, s.Timestamp, ct)
})
.GroupBy(s => new { s.UserId, s.Time })
.Select(g => g.First())
.ToList();
await _hrRepo.BulkUpsertAsync(normalized, ct);
result.HeartRateSamplesProcessed =
normalized.Count;
}
if (payload.SleepSamples?.Any() == true)
{
var sleepRecords = payload.SleepSamples
.Select(s => new SleepRecord
{
UserId = userId,
Date = s.StartTime.Date,
BedTime = s.StartTime,
WakeTime = s.EndTime,
TotalMinutes =
(int)(s.EndTime - s.StartTime)
.TotalMinutes,
DeepMinutes = s.SleepStages?
.Where(st => st.Type == "deep")
.Sum(st => (int)st.Duration
.TotalMinutes) ?? 0,
REMMinutes = s.SleepStages?
.Where(st => st.Type == "rem")
.Sum(st => (int)st.Duration
.TotalMinutes) ?? 0,
Quality = CalculateSleepQuality(s)
}).ToList();
await _sleepRepo.BulkUpsertAsync(
sleepRecords, ct);
result.SleepRecordsProcessed =
sleepRecords.Count;
}
await _eventBus.PublishAsync(
new WearableDataSyncedEvent
{
UserId = userId,
HeartRateCount =
result.HeartRateSamplesProcessed,
SyncedAt = DateTime.UtcNow
});
return result;
}
private async Task<Guid?> MatchToSessionAsync(
Guid userId, DateTime timestamp,
CancellationToken ct)
{
var session = await _sessionRepo
.FindActiveSessionAtTimeAsync(
userId, timestamp, ct);
return session?.Id;
}
}
13. Class Booking & Scheduling
Class booking handles group fitness classes like yoga, spin, CrossFit, Zumba, HIIT, and pilates. The key challenge is handling concurrent booking requests for popular classes with limited capacity. We use optimistic concurrency control with a version column to prevent overbooking without the performance penalty of pessimistic database locks. The system also handles recurring class schedules, instructor availability, and room assignments.
Booking with Concurrency Control
C#
public class ClassBookingService : IClassBookingService
{
private readonly IClassScheduleRepository _classRepo;
private readonly IBookingRepository _bookingRepo;
private readonly INotificationService _notifService;
private const int MaxRetryAttempts = 3;
public async Task<BookingResult> BookClassAsync(
Guid userId, Guid classId, CancellationToken ct)
{
for (int attempt = 0;
attempt < MaxRetryAttempts; attempt++)
{
var schedule = await _classRepo
.GetByIdAsync(classId, ct);
if (schedule == null)
throw new ClassNotFoundException(classId);
if (schedule.Status == ClassStatus.Cancelled)
throw new ClassCancelledException(classId);
if (schedule.StartTime <= DateTime.UtcNow)
throw new ClassAlreadyStartedException(classId);
var existing = await _bookingRepo
.GetByUserAndClassAsync(userId, classId, ct);
if (existing != null)
throw new DuplicateBookingException(
userId, classId);
if (schedule.CurrentParticipants
>= schedule.MaxParticipants)
{
var waitlist = new ClassBooking
{
UserId = userId,
ClassId = classId,
Status = BookingStatus.Waitlisted,
BookedAt = DateTime.UtcNow
};
await _bookingRepo.AddAsync(waitlist, ct);
var position = await _bookingRepo
.GetWaitlistPositionAsync(classId, ct);
return new BookingResult
{
Status = BookingStatus.Waitlisted,
Position = position
};
}
try
{
schedule.CurrentParticipants++;
await _classRepo.UpdateAsync(
schedule, ct);
var booking = new ClassBooking
{
UserId = userId,
ClassId = classId,
Status = BookingStatus.Confirmed,
BookedAt = DateTime.UtcNow
};
await _bookingRepo.AddAsync(booking, ct);
return new BookingResult
{
Status = BookingStatus.Confirmed,
Position = null
};
}
catch (DbUpdateConcurrencyException)
{
if (attempt == MaxRetryAttempts - 1)
throw;
await Task.Delay(
50 * (attempt + 1), ct);
}
}
throw new BookingFailedException(
"Unable to complete booking after retries");
}
}
14. Membership & Billing
The billing service integrates with Stripe for payment processing and subscription management. It handles tiered membership plans (Basic at 29 dollars/month, Standard at 49 dollars/month, Premium at 79 dollars/month, VIP at 129 dollars/month), prorated upgrades and downgrades, membership freezes, and annual payment discounts. The service must handle webhook callbacks from Stripe for payment succeeded, payment failed, subscription cancelled, and dispute events.
C# Stripe Integration
C#
public class BillingService : IBillingService
{
private readonly StripeClient _stripe;
private readonly IMembershipRepository _membershipRepo;
private readonly IEventBus _eventBus;
public async Task<MembershipResult> CreateSubscriptionAsync(
CreateSubscriptionCommand command,
CancellationToken ct)
{
var customer = await EnsureStripeCustomerAsync(
command.UserId, command.Email, ct);
var priceId = command.PlanType switch
{
PlanType.Basic => "price_basic_monthly",
PlanType.Standard => "price_standard_monthly",
PlanType.Premium => "price_premium_monthly",
PlanType.Vip => "price_vip_monthly",
_ => throw new ArgumentException(
$"Unknown plan: {command.PlanType}")
};
var options = new SubscriptionCreateOptions
{
Customer = customer.Id,
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Price = priceId, Quantity = 1
}
},
PaymentBehavior = "default_incomplete",
Expand = new List<string>
{
"latest_invoice.payment_intent"
},
Metadata = new Dictionary<string, string>
{
{ "user_id",
command.UserId.ToString() },
{ "gym_id",
command.GymId.ToString() },
{ "plan_type",
command.PlanType.ToString() }
}
};
var subscription = await _stripe.Subscriptions
.CreateAsync(options);
var membership = new Membership
{
UserId = command.UserId,
GymId = command.GymId,
PlanType = command.PlanType,
Status = MembershipStatus.Pending,
StartDate = DateTime.UtcNow.Date,
StripeSubscriptionId = subscription.Id,
MonthlyPrice =
GetMonthlyPrice(command.PlanType)
};
await _membershipRepo.AddAsync(membership, ct);
return new MembershipResult
{
MembershipId = membership.Id,
ClientSecret =
((Invoice)subscription.LatestInvoice)
.PaymentIntent.ClientSecret,
Status = membership.Status
};
}
public async Task FreezeMembershipAsync(
Guid userId, DateTime untilDate,
CancellationToken ct)
{
var membership = await _membershipRepo
.GetActiveByUserAsync(userId, ct)
?? throw new NoActiveMembershipException(userId);
var freezeDays = (untilDate - DateTime.UtcNow).Days;
if (freezeDays > 90)
throw new FreezeTooLongException(90);
if (freezeDays < 7)
throw new FreezeTooShortException(7);
membership.Status = MembershipStatus.Paused;
membership.FreezeUntil = untilDate;
await _membershipRepo.UpdateAsync(membership, ct);
await _stripe.Subscriptions.UpdateAsync(
membership.StripeSubscriptionId,
new SubscriptionUpdateOptions
{
PauseCollection =
new SubscriptionPauseCollectionOptions
{
Behavior = "void"
}
});
}
private decimal GetMonthlyPrice(PlanType plan) =>
plan switch
{
PlanType.Basic => 29m,
PlanType.Standard => 49m,
PlanType.Premium => 79m,
PlanType.Vip => 129m,
_ => throw new ArgumentException(
$"Unknown plan: {plan}")
};
}
15. Trainer Dashboard
The trainer dashboard gives fitness professionals a comprehensive view of their assigned clients. Trainers can see client workout adherence rates, progress trends, upcoming class schedules, flag at-risk clients who are dropping off, and can assign or modify workout plans directly from the dashboard. The dashboard is optimized for tablet use with a responsive grid layout and supports real-time updates via SignalR websockets for live client activity monitoring.
Trainer Service Implementation
C#
public class TrainerService : ITrainerService
{
private readonly IClientAssignmentRepository
_assignmentRepo;
private readonly IWorkoutSessionRepository
_sessionRepo;
private readonly IWorkoutPlanRepository _planRepo;
private readonly IAnalyticsService _analyticsService;
public async Task<TrainerDashboardDto> GetDashboardAsync(
Guid trainerId, CancellationToken ct)
{
var assignments = await _assignmentRepo
.GetByTrainerAsync(trainerId, ct);
var clientSummaries =
new List<ClientSummaryDto>();
foreach (var assignment in assignments)
{
var recentSessions = await _sessionRepo
.GetRecentSessionsAsync(
assignment.UserId, days: 30, ct);
var lastSession = recentSessions.FirstOrDefault();
var thisWeekStart =
DateTime.UtcNow.StartOfWeek(DayOfWeek.Monday);
var sessionsThisWeek = recentSessions
.Count(s => s.StartedAt >= thisWeekStart);
var analytics = await _analyticsService
.GetVolumeLoadOverTimeAsync(
assignment.UserId,
DateTime.UtcNow.AddDays(-30),
DateTime.UtcNow,
"weekly", ct);
clientSummaries.Add(new ClientSummaryDto
{
UserId = assignment.UserId,
ClientName = assignment.UserName,
AssignedPlan =
assignment.CurrentPlanName,
SessionsThisWeek = sessionsThisWeek,
TargetSessionsPerWeek =
assignment.TargetSessionsPerWeek,
AdherenceRate =
assignment.TargetSessionsPerWeek > 0
? Math.Round(
(decimal)sessionsThisWeek
/ assignment.TargetSessionsPerWeek
* 100)
: 0,
LastWorkoutAt =
lastSession?.StartedAt,
WeeklyVolumeLoad =
analytics.TotalVolumeLoad,
VolumeTrend = analytics.Trend,
FlaggedIssues =
await IdentifyIssuesAsync(
assignment, recentSessions, ct)
});
}
return new TrainerDashboardDto
{
TrainerId = trainerId,
TotalClients = assignments.Count,
ActiveClients = clientSummaries
.Count(c => c.SessionsThisWeek > 0),
AtRiskClients = clientSummaries
.Count(c => c.AdherenceRate < 50),
Clients = clientSummaries
.OrderByDescending(c => c.AdherenceRate)
.ToList(),
UpcomingClasses =
await GetTrainerScheduleAsync(
trainerId, ct)
};
}
public async Task AssignPlanAsync(
Guid trainerId, Guid userId,
Guid planId, string notes,
CancellationToken ct)
{
var assignment = await _assignmentRepo
.GetByTrainerAndUserAsync(
trainerId, userId, ct)
?? throw new ClientNotAssignedException(
trainerId, userId);
var plan = await _planRepo.GetByIdAsync(planId, ct)
?? throw new PlanNotFoundException(planId);
assignment.CurrentPlanId = planId;
assignment.CurrentPlanName = plan.Name;
assignment.PlanAssignedAt = DateTime.UtcNow;
assignment.TrainerNotes = notes;
await _assignmentRepo.UpdateAsync(
assignment, ct);
await _notifService.SendAsync(new Notification
{
UserId = userId,
Type = "plan_assigned",
Title = "New Workout Plan!",
Body = $"Your trainer assigned you \"{plan.Name}\".",
Data = JsonSerializer.Serialize(new
{ PlanId = planId })
}, ct);
}
}
16. Nutrition Tracking
Nutrition tracking is essential for users with body composition goals. The system provides food logging via manual entry and barcode scanning, integrates with the Nutritionix and USDA FoodData Central APIs for comprehensive nutritional data, computes daily macro targets based on TDEE (Total Daily Energy Expenditure) using the Mifflin-St Jeor equation, and tracks adherence to personalized meal plans. Water intake tracking and supplement logging are also supported.
Calorie and Macro Calculation
C#
public static class NutritionCalculator
{
public static MacroTarget CalculateDailyTarget(
UserProfile profile, FitnessGoal goal)
{
var bmr = CalculateBMR(profile);
var tdee = bmr *
GetActivityMultiplier(profile.ActivityLevel);
var calories = goal switch
{
FitnessGoal.WeightLoss => tdee - 500,
FitnessGoal.MuscleGain => tdee + 300,
FitnessGoal.Maintenance => tdee,
FitnessGoal.Endurance => tdee + 200,
_ => tdee
};
var proteinPerKg = goal switch
{
FitnessGoal.MuscleGain => 2.2m,
FitnessGoal.WeightLoss => 2.0m,
FitnessGoal.Endurance => 1.6m,
_ => 1.8m
};
var proteinG = profile.WeightKg * proteinPerKg;
var fatPct = 0.25m;
var fatG = calories * fatPct / 9m;
var remaining =
calories - (proteinG * 4) - (fatG * 9);
var carbsG = remaining / 4m;
return new MacroTarget
{
Calories = (int)calories,
ProteinG = Math.Round(proteinG),
CarbsG = Math.Round(carbsG),
FatG = Math.Round(fatG),
FiberG = profile.Gender == "male" ? 38 : 25
};
}
private static decimal CalculateBMR(
UserProfile profile)
{
var age = profile.DateOfBirth.HasValue
? (decimal)(DateTime.Today
- profile.DateOfBirth.Value)
.TotalDays / 365.25m
: 30m;
return profile.Gender?.ToLower() switch
{
"male" => 10m * profile.WeightKg
+ 6.25m * (profile.Height_cm ?? 175m)
- 5m * age + 5m,
"female" => 10m * profile.WeightKg
+ 6.25m * (profile.Height_cm ?? 162m)
- 5m * age - 161m,
_ => 10m * profile.WeightKg
+ 6.25m * (profile.Height_cm ?? 170m)
- 5m * age + 5m
};
}
private static decimal GetActivityMultiplier(
string? level) => level?.ToLower() switch
{
"sedentary" => 1.2m,
"light" => 1.375m,
"moderate" => 1.55m,
"active" => 1.725m,
"very_active" => 1.9m,
_ => 1.55m
};
}
17. Gamification & Achievements
Gamification elements dramatically improve user retention. The system awards XP (experience points) for completing workouts, logging meals, attending classes, and maintaining streaks. Users level up as they accumulate XP, unlocking new badge tiers and cosmetic profile features. The achievement engine runs after every significant user action and evaluates all applicable achievement conditions.
Achievement Definitions
| Badge | Condition | XP Value | Tier |
|---|---|---|---|
| First Workout | Complete your first workout session | 50 | Bronze |
| Week Warrior | Work out 5 days in a single week | 200 | Silver |
| Iron Month | Work out 20+ days in a month | 500 | Gold |
| 100 Day Streak | Maintain a 100-day workout streak | 2000 | Platinum |
| PR Crusher | Set 10 personal records in a month | 300 | Gold |
| Marathon Mind | Run 42 km total distance | 250 | Silver |
| Social Butterfly | Join 5 challenges and complete them all | 400 | Gold |
| Nutrition Guru | Log meals for 30 consecutive days | 600 | Gold |
| Early Bird | Complete 50 workouts before 7 AM | 350 | Silver |
| Volume King | Accumulate 100,000 kg total volume load | 1000 | Platinum |
C# Achievement Engine
C#
public class AchievementEngine : IAchievementEngine
{
private readonly IAchievementRepository _achievementRepo;
private readonly IStreakService _streakService;
private readonly IXpService _xpService;
private readonly INotificationService _notifService;
private static readonly List<AchievementDefinition>
Definitions = new()
{
new AchievementDefinition
{
Code = "FIRST_WORKOUT",
Name = "First Workout", XP = 50,
Tier = "bronze"
},
new AchievementDefinition
{
Code = "WEEK_WARRIOR",
Name = "Week Warrior", XP = 200,
Tier = "silver"
},
new AchievementDefinition
{
Code = "IRON_MONTH",
Name = "Iron Month", XP = 500,
Tier = "gold"
},
new AchievementDefinition
{
Code = "STREAK_100",
Name = "100 Day Streak", XP = 2000,
Tier = "platinum"
},
new AchievementDefinition
{
Code = "PR_CRUSHER",
Name = "PR Crusher", XP = 300,
Tier = "gold"
},
new AchievementDefinition
{
Code = "VOLUME_KING",
Name = "Volume King", XP = 1000,
Tier = "platinum"
},
};
public async Task<List<AchievementDto>>
EvaluateAchievementsAsync(
Guid userId,
WorkoutSession completedSession,
CancellationToken ct)
{
var earned = new List<AchievementDto>();
var existing =
await _achievementRepo
.GetUserBadgesAsync(userId, ct);
var existingCodes =
existing.Select(a => a.BadgeCode).ToHashSet();
var totalWorkouts =
await _achievementRepo
.GetTotalWorkoutCountAsync(userId, ct);
var currentStreak =
await _streakService
.GetCurrentStreakAsync(userId, ct);
var prCount =
await _achievementRepo
.GetPRCountAsync(userId,
DateTime.UtcNow.Month, ct);
var totalVolume =
await _achievementRepo
.GetTotalVolumeLoadAsync(userId, ct);
var checks = new List<
Func<AchievementDefinition, bool>>
{
d => d.Code == "FIRST_WORKOUT"
&& totalWorkouts >= 1,
d => d.Code == "WEEK_WARRIOR"
&& GetSessionsThisWeek(totalWorkouts) >= 5,
d => d.Code == "STREAK_100"
&& currentStreak >= 100,
d => d.Code == "PR_CRUSHER"
&& prCount >= 10,
d => d.Code == "VOLUME_KING"
&& totalVolume >= 100_000,
};
foreach (var def in Definitions)
{
if (existingCodes.Contains(def.Code))
continue;
var matcher = checks.FirstOrDefault(
c => c(def));
if (matcher != null)
{
var achievement = new Achievement
{
UserId = userId,
BadgeCode = def.Code,
BadgeName = def.Name,
XPValue = def.XP,
EarnedAt = DateTime.UtcNow
};
await _achievementRepo.AddAsync(
achievement, ct);
await _xpService.AddXPAsync(
userId, def.XP, ct);
await _notifService.SendAsync(
new Notification
{
UserId = userId,
Type = "achievement_earned",
Title = $"Badge: {def.Name}!",
Body = $"+{def.XP} XP earned!",
Data = JsonSerializer.Serialize(
new { def.Code, def.XP })
}, ct);
earned.Add(MapToDto(achievement));
}
}
return earned;
}
}
18. Push Notifications & Reminders
Push notifications are critical for re-engagement and retention. The notification service supports scheduled reminders (daily workout at 6 AM), event-triggered alerts (friend completed a challenge, PR achieved, class starting in 30 minutes), and marketing campaigns (new features, promotions). It integrates with Apple Push Notification Service (APNs) and Firebase Cloud Messaging (FCM) with intelligent delivery scheduling based on user timezone and quiet hours preferences.
C# Notification Service
C#
public class NotificationService : INotificationService
{
private readonly IPushNotificationClient _pushClient;
private readonly INotificationRepository _notifRepo;
private readonly IUserPreferencesRepository _prefsRepo;
private readonly IBackgroundJobScheduler _jobScheduler;
public async Task SendAsync(
Notification notification,
CancellationToken ct)
{
var prefs = await _prefsRepo
.GetAsync(notification.UserId, ct);
if (prefs != null && !prefs.PushEnabled)
return;
if (prefs?.QuietHoursEnabled == true
&& IsQuietHours(prefs))
{
await ScheduleForEndOfQuietHoursAsync(
notification, prefs, ct);
return;
}
await _notifRepo.AddAsync(notification, ct);
var tokens = await _notifRepo
.GetDeviceTokensAsync(
notification.UserId, ct);
var payload = new PushPayload
{
Title = notification.Title,
Body = notification.Body,
Data = notification.Data,
Badge = await _notifRepo
.GetUnreadCountAsync(
notification.UserId, ct),
Sound = "default"
};
var targets = tokens
.Select(t => new PushTarget
{
Token = t.Token,
Platform = t.Platform
}).ToList();
var results = await _pushClient
.SendBatchAsync(payload, targets, ct);
var failed = results
.Where(r => !r.Success)
.Select(r => r.Token).ToList();
if (failed.Any())
await _notifRepo
.DeactivateTokensAsync(failed, ct);
}
public async Task ScheduleWorkoutRemindersAsync(
Guid userId, CancellationToken ct)
{
var prefs = await _prefsRepo
.GetAsync(userId, ct);
if (prefs?.WorkoutReminderEnabled != true)
return;
var reminderTime =
prefs.WorkoutReminderTime
?? new TimeOnly(18, 0);
var tz = TimeZoneInfo
.FindSystemTimeZoneById(
prefs.Timezone ?? "UTC");
var now = TimeZoneInfo
.ConvertTimeFromUtc(
DateTime.UtcNow, tz);
var scheduledTime =
now.Date.Add(reminderTime.Value.ToTimeSpan());
if (scheduledTime < now)
scheduledTime = scheduledTime.AddDays(1);
var utcScheduled = TimeZoneInfo
.ConvertTimeToUtc(scheduledTime, tz);
var activePlan = await _planRepo
.GetActivePlanAsync(userId, ct);
var tomorrow = now.AddDays(1);
var dayOfWeek = tomorrow.DayOfWeek;
var hasWorkout = activePlan?.Days
.Any(d => d.DayOfWeek == (int)dayOfWeek)
?? false;
if (!hasWorkout) return;
var exerciseCount = activePlan!.Days
.First(d => d.DayOfWeek == (int)dayOfWeek)
.Exercises.Count;
await _jobScheduler.ScheduleAsync(
new ScheduledNotification
{
UserId = userId,
Title = "Time to Crush It!",
Body = $"Tomorrow: {exerciseCount} exercises. See you at the gym!",
ScheduledAt = utcScheduled,
Type = "workout_reminder"
}, ct);
}
private bool IsQuietHours(UserPreferences prefs)
{
var now = TimeOnly.FromDateTime(
DateTime.UtcNow);
var start = prefs.QuietHoursStart
?? new TimeOnly(22, 0);
var end = prefs.QuietHoursEnd
?? new TimeOnly(7, 0);
if (start > end)
return now >= start || now <= end;
return now >= start && now <= end;
}
}
19. Video Exercise Library
The video exercise library provides high-definition demonstrations for every exercise in the catalog. Videos are stored in S3, transcoded to multiple resolutions (360p, 720p, 1080p) via a background worker using FFmpeg, and served through CloudFront CDN for low-latency global delivery. The system supports slow-motion playback, angle switching (front, side, 45-degree), and frame-by-frame stepping for detailed form study.
Video Processing Pipeline
C#
public class VideoProcessingService
: IVideoProcessingService
{
private readonly IS3Client _s3;
private readonly IFFmpegRunner _ffmpeg;
private readonly IVideoRepository _videoRepo;
public async Task ProcessVideoAsync(
VideoUploadEvent uploadEvent,
CancellationToken ct)
{
var originalKey =
$"exercise-videos/{uploadEvent.ExerciseId}"
+ $"/{uploadEvent.VideoId}/original.mp4";
var tempPath = Path.Combine(
Path.GetTempPath(),
$"{uploadEvent.VideoId}.mp4");
try
{
await _s3.DownloadAsync(
uploadEvent.BucketName,
originalKey, tempPath, ct);
var metadata = await _ffmpeg
.GetMetadataAsync(tempPath, ct);
if (metadata.Duration.TotalMinutes > 10)
throw new VideoTooLongException(10);
var resolutions = new[]
{
("360p", 640, 360),
("720p", 1280, 720),
("1080p", 1920, 1080)
};
foreach (var (label, w, h) in resolutions)
{
var outputPath = Path.Combine(
Path.GetTempPath(),
$"{uploadEvent.VideoId}_{label}.mp4");
await _ffmpeg.TranscodeAsync(
tempPath, outputPath, w, h, ct);
var s3Key =
$"exercise-videos/"
+ $"{uploadEvent.ExerciseId}/"
+ $"{uploadEvent.VideoId}/{label}.mp4";
await _s3.UploadAsync(
uploadEvent.BucketName,
s3Key, outputPath, ct);
File.Delete(outputPath);
}
var thumbPath = Path.Combine(
Path.GetTempPath(),
$"{uploadEvent.VideoId}_thumb.jpg");
await _ffmpeg.ExtractFrameAsync(
tempPath, thumbPath,
TimeSpan.FromSeconds(2), ct);
var thumbKey =
$"exercise-videos/"
+ $"{uploadEvent.ExerciseId}/"
+ $"{uploadEvent.VideoId}/thumbnail.jpg";
await _s3.UploadAsync(
uploadEvent.BucketName,
thumbKey, thumbPath, ct);
var gifPath = Path.Combine(
Path.GetTempPath(),
$"{uploadEvent.VideoId}_preview.gif");
await _ffmpeg.CreateGifAsync(
tempPath, gifPath,
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5), ct);
var gifKey =
$"exercise-videos/"
+ $"{uploadEvent.ExerciseId}/"
+ $"{uploadEvent.VideoId}/preview.gif";
await _s3.UploadAsync(
uploadEvent.BucketName,
gifKey, gifPath, ct);
var manifest = new VideoManifest
{
VideoId = uploadEvent.VideoId,
ExerciseId = uploadEvent.ExerciseId,
ThumbnailUrl =
$"https://cdn.ayodhyya.com/{thumbKey}",
PreviewGifUrl =
$"https://cdn.ayodhyya.com/{gifKey}",
Resolutions = resolutions
.Select(r => new VideoResolution
{
Label = r.Item1,
Url = $"https://cdn.ayodhyya.com/"
+ $"exercise-videos/"
+ $"{uploadEvent.ExerciseId}/"
+ $"{uploadEvent.VideoId}/"
+ $"{r.Item1}.mp4",
Width = r.Item2,
Height = r.Item3
}).ToList(),
Duration = metadata.Duration,
Status = VideoStatus.Ready,
ProcessedAt = DateTime.UtcNow
};
await _videoRepo.UpdateManifestAsync(
manifest, ct);
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
}
20. AI Form Correction
AI form correction is a differentiating feature that sets premium fitness apps apart. Users record a video of their exercise, and the system analyzes body joint angles, movement path, tempo, and range of motion to provide real-time feedback on form deviations. The system uses MediaPipe Pose for landmark detection and a fine-tuned classification model for deviation scoring. This feature requires significant compute resources and is processed asynchronously via GPU-accelerated workers.
Processing Pipeline
C# AI Form Analysis Service
C#
public class FormAnalysisService : IFormAnalysisService
{
private readonly IPoseEstimationClient _poseClient;
private readonly IDeviationClassifier _classifier;
private readonly IFeedbackGenerator _feedbackGen;
private readonly IS3Client _s3;
private readonly IFormAnalysisRepository _repo;
public async Task<FormAnalysisResult>
AnalyzeExerciseVideoAsync(
FormAnalysisRequest request,
CancellationToken ct)
{
var videoUrl = await _s3.GetPreSignedUrlAsync(
request.VideoKey,
TimeSpan.FromMinutes(30), ct);
var landmarks = await _poseClient
.ExtractLandmarksAsync(videoUrl, ct);
var frameAngles = new List<FrameAngles>();
foreach (var frame in landmarks.Frames)
{
var angles = ComputeJointAngles(
frame, request.ExerciseType);
frameAngles.Add(angles);
}
var temporalMetrics = new TemporalMetrics
{
Tempo = ComputeTempo(frameAngles),
RepCount = CountReps(
frameAngles, request.ExerciseType),
RangeOfMotion = ComputeROM(
frameAngles, request.ExerciseType),
TimeUnderTension = ComputeTUT(frameAngles),
DescentToAscentRatio =
ComputeTempoRatio(frameAngles)
};
var deviations = await _classifier
.ClassifyDeviationsAsync(
frameAngles, temporalMetrics,
request.ExerciseType, ct);
var feedback = await _feedbackGen
.GenerateAsync(new FeedbackContext
{
ExerciseType = request.ExerciseType,
Deviations = deviations,
Metrics = temporalMetrics,
UserLevel = request.UserFitnessLevel
}, ct);
var analysis = new FormAnalysis
{
Id = Guid.NewGuid(),
UserId = request.UserId,
ExerciseId = request.ExerciseId,
VideoKey = request.VideoKey,
OverallScore =
CalculateOverallScore(deviations),
Deviations = deviations,
Metrics = temporalMetrics,
Feedback = feedback,
AnalyzedAt = DateTime.UtcNow
};
await _repo.SaveAsync(analysis, ct);
return new FormAnalysisResult
{
AnalysisId = analysis.Id,
OverallScore = analysis.OverallScore,
Deviations = deviations
.Select(d => new DeviationDto
{
Type = d.Type,
Severity = d.Severity,
Description = d.Description,
Timestamp = d.Timestamp,
JointInvolved = d.JointInvolved,
Recommendation = d.Recommendation
}).ToList(),
Metrics = temporalMetrics,
Feedback = feedback
};
}
private Dictionary<string, double>
ComputeJointAngles(
PoseFrame frame, string exerciseType)
{
var angles =
new Dictionary<string, double>();
angles["knee_left"] = ComputeAngle(
frame.LeftHip, frame.LeftKnee,
frame.LeftAnkle);
angles["knee_right"] = ComputeAngle(
frame.RightHip, frame.RightKnee,
frame.RightAnkle);
angles["hip_left"] = ComputeAngle(
frame.LeftShoulder, frame.LeftHip,
frame.LeftKnee);
angles["hip_right"] = ComputeAngle(
frame.RightShoulder, frame.RightHip,
frame.RightKnee);
angles["shoulder_left"] = ComputeAngle(
frame.LeftElbow, frame.LeftShoulder,
frame.LeftHip);
angles["shoulder_right"] = ComputeAngle(
frame.RightElbow, frame.RightShoulder,
frame.RightHip);
angles["elbow_left"] = ComputeAngle(
frame.LeftShoulder, frame.LeftElbow,
frame.LeftWrist);
angles["elbow_right"] = ComputeAngle(
frame.RightShoulder, frame.RightElbow,
frame.RightWrist);
angles["spine_angle"] = ComputeAngle(
frame.Neck, frame.MidHip, frame.MidKnee);
return angles;
}
private double ComputeAngle(
Point3D a, Point3D b, Point3D c)
{
var ba = new Point3D(
a.X - b.X, a.Y - b.Y, a.Z - b.Z);
var bc = new Point3D(
c.X - b.X, c.Y - b.Y, c.Z - b.Z);
var dot = ba.X * bc.X
+ ba.Y * bc.Y + ba.Z * bc.Z;
var magBA = Math.Sqrt(
ba.X * ba.X + ba.Y * ba.Y + ba.Z * ba.Z);
var magBC = Math.Sqrt(
bc.X * bc.X + bc.Y * bc.Y + bc.Z * bc.Z);
if (magBA == 0 || magBC == 0) return 0;
var cosine = dot / (magBA * magBC);
cosine = Math.Clamp(cosine, -1.0, 1.0);
return Math.Acos(cosine) * (180.0 / Math.PI);
}
}
Common Form Deviations Detected
| Exercise | Deviation | Threshold | Feedback |
|---|---|---|---|
| Squat | Knee valgus | Knee angle < 160 at bottom | Push knees outward over toes |
| Squat | Forward lean | Spine angle < 45 from vertical | Keep chest up, engage core |
| Bench Press | Flared elbows | Elbow angle > 80 from torso | Tuck elbows at 45 degree angle |
| Deadlift | Round back | Spine deviation > 15 degrees | Brace core, maintain neutral spine |
| Overhead Press | Hyperextension | Spine > 10 past vertical | Squeeze glutes, avoid arching |
| Row | Excessive momentum | Torso swing > 20 degrees | Reduce weight, control the movement |
21. Multi-Gym Support
Franchise gym chains require multi-tenancy at the gym level. Each gym location has its own equipment inventory, class schedule, staff roster, and operational hours, while sharing a global user database and exercise library. The architecture uses a gym_id tenant discriminator across all tables with row-level security in PostgreSQL to enforce data isolation. This allows a single deployment to serve hundreds of gym locations with complete data separation.
Gym Scoping in C#
C#
public class GymScopedRepository<T>
where T : class, IGymScoped
{
private readonly ApplicationDbContext _context;
private readonly IGymContext _gymContext;
public GymScopedRepository(
ApplicationDbContext context,
IGymContext gymContext)
{
_context = context;
_gymContext = gymContext;
}
public IQueryable<T> Query()
{
return _context.Set<T>()
.Where(e =>
e.GymId == _gymContext.CurrentGymId);
}
public async Task<T?> GetByIdAsync(
Guid id, CancellationToken ct)
{
return await Query()
.FirstOrDefaultAsync(
e => e.Id == id, ct);
}
public async Task AddAsync(
T entity, CancellationToken ct)
{
entity.GymId =
_gymContext.CurrentGymId;
await _context.Set<T>()
.AddAsync(entity, ct);
}
}
public class GymContext : IGymContext
{
private readonly IHttpContextAccessor
_httpContextAccessor;
public Guid CurrentGymId
{
get
{
var claim =
_httpContextAccessor.HttpContext
?.User?.FindFirst("gym_id");
if (claim == null
|| !Guid.TryParse(
claim.Value, out var gymId))
throw new GymContextNotSetException();
return gymId;
}
}
}
Row-Level Security Policy
SQL
ALTER TABLE class_schedules
ENABLE ROW LEVEL SECURITY;
ALTER TABLE equipment
ENABLE ROW LEVEL SECURITY;
ALTER TABLE trainers
ENABLE ROW LEVEL SECURITY;
CREATE POLICY gym_isolation ON class_schedules
USING (gym_id =
current_setting('app.current_gym_id')::uuid);
CREATE POLICY gym_isolation ON equipment
USING (gym_id =
current_setting('app.current_gym_id')::uuid);
CREATE POLICY gym_isolation ON trainers
USING (gym_id =
current_setting('app.current_gym_id')::uuid);
22. Security & Compliance
Fitness apps handle sensitive health data including body measurements, heart rate, sleep patterns, and potentially medical information. Security must be defense-in-depth with encryption at rest and in transit, strict access controls, audit logging, and compliance with HIPAA (for US health data), GDPR (for EU users), and CCPA (for California residents). The security architecture follows zero-trust principles where every service-to-service call is authenticated and authorized.
Security Architecture
| Layer | Mechanism | Details |
|---|---|---|
| Transport | TLS 1.3 | All API traffic encrypted; HSTS enabled |
| Authentication | JWT + Refresh Tokens | 15-min access tokens, 30-day refresh, rotation |
| Authorization | RBAC + ABAC | Roles: user, trainer, gym_admin, platform_admin |
| Data at Rest | AES-256 | Database encryption, S3 SSE-KMS, Redis AUTH |
| PII Handling | Field-level encryption | Email, body measurements with per-user keys |
| Audit | Immutable log | All data access logged, 7-year retention |
| API Security | Rate limiting + WAF | AWS WAF rules, per-user limits, DDoS protection |
| Mobile | Certificate pinning | SSL pinning, root/jailbreak detection |
| Compliance | HIPAA + GDPR | BAA with providers, DPO appointed, DPA templates |
C# Health Data Encryption Service
C#
public class HealthDataEncryptionService
: IHealthDataEncryptionService
{
private readonly IKeyVaultClient _keyVault;
public async Task<EncryptedPayload>
EncryptHealthDataAsync(
Guid userId, object healthData,
CancellationToken ct)
{
var key = await _keyVault.GetKeyAsync(
$"health-data-key-{userId}", ct);
var plainBytes = JsonSerializer
.SerializeToUtf8Bytes(healthData);
using var aes = Aes.Create();
aes.Key = key.Key;
aes.GenerateIV();
using var encryptor =
aes.CreateEncryptor();
var encrypted =
encryptor.TransformFinalBlock(
plainBytes, 0, plainBytes.Length);
return new EncryptedPayload
{
EncryptedData =
Convert.ToBase64String(encrypted),
IV = Convert.ToBase64String(aes.IV),
KeyVersion = key.Version,
Algorithm = "AES-256-CBC",
EncryptedAt = DateTime.UtcNow
};
}
public async Task<T> DecryptHealthDataAsync<T>(
Guid userId, EncryptedPayload payload,
CancellationToken ct)
{
var key = await _keyVault.GetKeyAsync(
$"health-data-key-{userId}",
payload.KeyVersion, ct);
using var aes = Aes.Create();
aes.Key = key.Key;
aes.IV =
Convert.FromBase64String(payload.IV);
using var decryptor =
aes.CreateDecryptor();
var encrypted =
Convert.FromBase64String(
payload.EncryptedData);
var plain =
decryptor.TransformFinalBlock(
encrypted, 0, encrypted.Length);
return JsonSerializer
.Deserialize<T>(plain)!;
}
}
23. Cost Estimation
Cost estimation for a mid-scale fitness platform (2 million registered users, 400,000 daily active users) running on AWS. We assume a microservices architecture with moderate over-provisioning for reliability. All costs are estimated for the US East region and include data transfer charges.
| Service | Specification | Monthly Cost (USD) |
|---|---|---|
| EKS Cluster (3 nodes) | m5.xlarge (4 vCPU, 16 GB) | |
| RDS PostgreSQL | db.r5.xlarge, Multi-AZ, 500 GB | |
| MongoDB Atlas | M40 cluster, 100 GB | |
| ElastiCache Redis | r5.large, cluster mode, 3 shards | |
| Amazon MSK (Kafka) | kafka.m5.large, 3 brokers | |
| S3 Storage | 25 TB (video + images) | |
| CloudFront CDN | 5 TB transfer/month | |
| ALB | 2 ALBs, moderate traffic | |
| Elasticsearch | t3.large, 3 nodes | |
| Lambda (async jobs) | Video processing, analytics | |
| SQS + SNS | Notification queues | |
| CloudWatch | Logs, metrics, alarms | |
| WAF | API protection | |
| KMS | Key management | |
| Data Transfer | Inter-region + internet | |
| Total | ,015 |
Revenue Model
| Plan | Price/Month | Target Users | Monthly Revenue |
|---|---|---|---|
| Free Tier | 600,000 (30%) | ||
| Basic | .99 | 600,000 (30%) | ,994,000 |
| Premium | .99 | 500,000 (25%) | ,995,000 |
| Trainer Pro | .99 | 100,000 (5%) | ,999,000 |
| Enterprise | Custom | Gym Chains | ,000 |
| Total | ,488,000 |
With infrastructure costs of approximately 6,000 dollars per month and projected revenue exceeding 20 million dollars per month at scale, the gross margin is exceptionally healthy. Even at 10% of projected scale, the platform is profitable with infrastructure costs remaining under 3,000 dollars per month thanks to auto-scaling capabilities.
24. Testing & Deployment
Testing Strategy
The testing pyramid for the fitness platform consists of unit tests at the base (target: 80% code coverage), integration tests for database and external API interactions, contract tests for inter-service communication via Pact, and end-to-end tests using Playwright for web and Appium for mobile. Performance tests use k6 to simulate peak workout hours with realistic user behavior patterns.
C# Integration Test Example
C#
public class WorkoutSessionIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program>
_factory;
private readonly HttpClient _client;
public WorkoutSessionIntegrationTests(
WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(
builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<
IDbContextFactory<
ApplicationDbContext>>();
services.AddDbContextFactory<
ApplicationDbContext>(options =>
options.UseNpgsql(
"Host=localhost;"
+ "Database=fitness_test"));
});
});
_client = _factory.CreateClient();
_client.DefaultRequestHeaders
.Authorization =
new AuthenticationHeaderValue(
"Bearer",
TestAuthTokenGenerator.Generate());
}
[Fact]
public async Task
CreateSession_ThenLogSets_ComputesVolume()
{
var createResp = await _client
.PostAsJsonAsync(
"/api/v1/workout-sessions",
new
{
SessionType = "strength",
GymId = Guid.NewGuid()
});
createResp.EnsureSuccessStatusCode();
var session = await createResp.Content
.ReadFromJsonAsync
<WorkoutSessionResponse>();
var exerciseId =
await SeedExerciseAsync(
"Barbell Bench Press");
await _client.PostAsJsonAsync(
$"/api/v1/workout-sessions/"
+ $"{session!.Id}/exercises",
new
{
ExerciseId = exerciseId,
Order = 1
});
var sets = new[]
{
new { SetNumber = 1, Reps = 12,
WeightKg = 60m, Rpe = 6m },
new { SetNumber = 2, Reps = 10,
WeightKg = 80m, Rpe = 7m },
new { SetNumber = 3, Reps = 8,
WeightKg = 95m, Rpe = 8.5m }
};
foreach (var set in sets)
{
var resp = await _client.PostAsJsonAsync(
$"/api/v1/workout-sessions/"
+ $"{session.Id}/exercises/"
+ $"{exerciseId}/sets",
set);
resp.EnsureSuccessStatusCode();
}
var detail = await _client.GetAsync(
$"/api/v1/workout-sessions/"
+ $"{session.Id}");
detail.EnsureSuccessStatusCode();
var d = await detail.Content
.ReadFromJsonAsync
<WorkoutSessionResponse>();
var expected = (12 * 60) + (10 * 80)
+ (8 * 95);
Assert.Equal(
(decimal)expected,
d!.TotalVolumeLoad);
}
}
CI/CD Pipeline
25. Interview Q&A
Q1: How would you handle workout logging when the user has no internet connection?
Answer: Use an offline-first architecture. The mobile app maintains a local SQLite database that mirrors the server schema for the active workout session. All mutations are written locally first and queued in an outbox table with monotonically increasing sequence numbers. When connectivity resumes, a bulk sync endpoint accepts the entire mutation list. The server applies operations idempotently using client-generated UUIDs and sequence numbers, guaranteeing exactly-once semantics. Conflict resolution is straightforward because workout data is user-partitioned. Last-writer-wins per set based on the sequence number is acceptable for this domain.
Q2: How do you design the real-time leaderboard for a fitness challenge?
Answer: Redis Sorted Sets are the ideal data structure. The challenge ID becomes the key, user IDs are members, and scores represent the challenge metric. When a user logs a workout, an event triggers a score update via ZINCRBY which is O(log N). Ranking queries use ZREVRANK for O(log N) lookups. For the top-N display, ZREVRANGE gives O(log N + M) where M is the returned count. For millions of users, this handles sub-millisecond latency. To prevent stale data, we set a 30-second TTL on cached leaderboard snapshots. The backing store is PostgreSQL for durability with Redis as the fast-path cache.
Q3: How would you scale heart rate data ingestion from 10,000 concurrent wearables?
Answer: Each wearable sends approximately one heart rate sample per second. With 10,000 concurrent users, that is 10,000 samples per second or approximately 864 million per day. We use Kafka as the ingestion buffer with a partitioned topic keyed on user ID. This ensures ordered processing per user while distributing load across consumers. Each partition handles approximately 1,000 samples per second, well within a single consumer's capacity. The stream processor computes rolling averages, detects anomalies, and batches writes to TimescaleDB. TimescaleDB compression policies automatically compress data older than 7 days, reducing storage by 90%.
Q4: How would you handle the AI form correction feature at scale with GPU requirements?
Answer: AI form correction is inherently asynchronous and compute-intensive. We use a dedicated GPU worker pool (AWS g4dn instances) behind an SQS queue. The mobile app uploads the video to S3 and publishes a processing request. The queue decouples upload from processing, allowing us to scale GPU instances independently. We implement priority queuing so premium users get faster processing. The average video is 30 seconds long and takes approximately 15 seconds to process on a T4 GPU. With 10 g4dn.xlarge instances, we can process approximately 40 videos per minute, which handles 10,000 daily submissions with room for burst traffic.
Q5: How do you ensure data consistency across microservices when a workout is completed?
Answer: We use the Saga pattern with compensating transactions. When a workout session ends, the Workout Service publishes a WorkoutCompleted event. The Analytics Service updates aggregates, the Gamification Service checks for new achievements, the Social Service broadcasts to the activity feed, and the Notification Service sends a summary. If any step fails, we use a dead letter queue for manual retry. We accept eventual consistency for analytics and social features (acceptable delay of seconds to minutes) while keeping the workout data itself strongly consistent within the Workout Service's MongoDB transactions.
Q6: How would you design the system to support both a single gym and a 500-location franchise?
Answer: Multi-gym support uses a gym_id tenant discriminator across all tables with row-level security in PostgreSQL. Each API request carries the gym context from the JWT token. The data model is designed with gym_id as a foreign key on location-specific tables (classes, equipment, trainers, staff) but not on global tables (exercises, workout templates, user profiles). For franchise-wide analytics, we query across gym_ids with appropriate aggregation. The architecture supports a single-tenant deployment (one gym) by simply not configuring multi-tenancy, keeping the operational complexity proportional to the deployment size.
Q7: How do you handle prorated membership upgrades and downgrades?
Answer: When a user upgrades, we calculate the remaining days in their current billing cycle and apply a prorated credit. For example, if a user on a 49 dollar/month plan upgrades to a 79 dollar/month plan with 10 days remaining, they receive a credit of 49 * 10/30 = 16.33 dollars and are immediately charged the new rate. Downgrades take effect at the next billing cycle to avoid disruption. Stripe handles the proration calculation natively via SubscriptionUpdateOptions with ProrationBehavior set to create_prorations. We also support mid-cycle plan changes for annual subscriptions with proportional adjustments.
11. Social Features & Challenges
Social features drive retention by creating accountability loops. Research shows that users who connect with friends are 3.5x more likely to maintain their workout streak beyond 90 days. The social service manages the friend graph, activity feed, leaderboards, and challenge system. It uses a combination of PostgreSQL for the relational friend graph and Redis for real-time leaderboard rankings and feed caching.
Challenge System
Challenges are time-bound competitions with measurable goals. Types include total volume load, most workouts completed, longest streak, highest average RPE, and most calories burned. Leaderboards are computed in real time using Redis Sorted Sets for sub-millisecond ranking queries. Challenges can be global (open to all users), gym-specific, or friend-group only.
Activity Feed Design
The activity feed uses a fan-out-on-write approach for users with fewer than 500 followers and fan-out-on-read for users with more than 500 followers. This hybrid approach balances write amplification against read latency. Activities include workout completed, PR achieved, challenge milestone, achievement earned, and class attended. The feed is stored as a Redis list per user with a 30-day TTL, backed by PostgreSQL for long-term persistence. Users can filter their feed by activity type, date range, or specific friends.