Skip to main content

Thin Client Implementation Guide

⚠️ CRITICAL: ARCHITECTURAL BOUNDARY ENFORCEMENT

This guide is MANDATORY READING for all OpenLift client developers. Violating these principles will result in:

  • Inconsistent business logic across platforms
  • Difficult-to-maintain duplicated code
  • Security vulnerabilities and data integrity issues
  • Failed code reviews and deployment blocks

The Thin Client Principle

OpenLift follows a strict thin client architecture where:

  • Server: Contains ALL business logic, calculations, rules, and intelligence
  • Client: Handles ONLY display, user interaction, caching, and UI state management
┌─────────────────────────────────────────────────────────────────────────────┐
│ THIN CLIENT ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT RESPONSIBILITIES │ SERVER RESPONSIBILITIES │
│ (What you SHOULD implement) │ (What you MUST NOT duplicate) │
│ │ │
│ ✅ User Interface & Navigation │ 🚫 Business Logic & Rules │
│ ✅ Form Validation (UI-only) │ 🚫 Calculations & Analysis │
│ ✅ Loading States & Animations │ 🚫 Data Validation (Business) │
│ ✅ Offline Caching & Sync │ 🚫 Progression Algorithms │
│ ✅ User Preferences & Settings │ 🚫 Analytics & Insights │
│ ✅ Platform Integration │ 🚫 Recommendation Engines │
│ ✅ Error Handling & User Feedback │ 🚫 Performance Calculations │
│ ✅ Data Formatting for Display │ 🚫 Workout Effectiveness Logic │
│ │ 🚫 Recovery Recommendations │
│ │ 🚫 Program Logic & Scheduling │
│ │ │
└─────────────────────────────────────────────────────────────────────────────┘

What Thin Clients DO

✅ User Interface & Experience

// ✅ CORRECT: Handle UI state and user interaction
class WorkoutTimerWidget extends StatefulWidget {
@override
_WorkoutTimerWidgetState createState() => _WorkoutTimerWidgetState();
}

class _WorkoutTimerWidgetState extends State<WorkoutTimerWidget> {
int _secondsElapsed = 0;
Timer? _timer;

void _startTimer() {
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() => _secondsElapsed++);
});
}

@override
Widget build(BuildContext context) {
return Column(
children: [
Text('${_secondsElapsed ~/ 60}:${(_secondsElapsed % 60).toString().padLeft(2, '0')}'),
ElevatedButton(
onPressed: _startTimer,
child: Text('Start Timer'),
),
],
);
}
}

✅ Data Formatting & Display

// ✅ CORRECT: Format server data for display
class ExerciseDisplayHelper {
static String formatWeight(double weight, String unit) {
switch (unit) {
case 'kg':
return '${weight.toStringAsFixed(1)} kg';
case 'lbs':
return '${weight.toStringAsFixed(1)} lbs';
default:
return '${weight.toStringAsFixed(1)}';
}
}

static String formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;

if (hours > 0) {
return '${hours}h ${minutes}m ${secs}s';
} else {
return '${minutes}m ${secs}s';
}
}
}

✅ Caching & Offline Support

// ✅ CORRECT: Cache server responses for offline use
class ExerciseCacheService {
static const String _cacheKey = 'cached_exercises';

Future<List<Exercise>> getCachedExercises() async {
final prefs = await SharedPreferences.getInstance();
final cachedData = prefs.getString(_cacheKey);

if (cachedData != null) {
final List<dynamic> exerciseJson = jsonDecode(cachedData);
return exerciseJson.map((json) => Exercise.fromJson(json)).toList();
}

return [];
}

Future<void> cacheExercises(List<Exercise> exercises) async {
final prefs = await SharedPreferences.getInstance();
final exerciseJson = exercises.map((e) => e.toJson()).toList();
await prefs.setString(_cacheKey, jsonEncode(exerciseJson));
}
}

✅ Form Validation (UI-Only)

// ✅ CORRECT: UI validation for better user experience
class WorkoutFormValidator {
static String? validateExerciseName(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Exercise name is required';
}
if (value.length < 2) {
return 'Exercise name must be at least 2 characters';
}
return null; // Valid
}

static String? validateWeight(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Weight is required';
}
final weight = double.tryParse(value);
if (weight == null || weight <= 0) {
return 'Please enter a valid weight';
}
return null; // Valid - server will do business validation
}
}

What Thin Clients MUST NOT DO

❌ Business Logic & Calculations

// ❌ NEVER DO THIS: Calculate progression on client
class ProgressionCalculator {
static double calculateNextWeight(List<WorkoutSet> previousSets) {
// THIS IS WRONG! This logic belongs on the server
double averageRpe = previousSets.map((s) => s.rpe).reduce((a, b) => a + b) / previousSets.length;
double completionRate = previousSets.where((s) => s.completed).length / previousSets.length;

if (averageRpe < 7.5 && completionRate > 0.9) {
return previousSets.first.weight * 1.025; // 2.5% increase
}
return previousSets.first.weight;
}
}

// ✅ CORRECT: Request progression from server
class ProgressionService {
Future<ProgressionRecommendation> getNextProgression(String userId, String exerciseId) async {
const query = '''
query GetProgressionRecommendation(\$userId: ID!, \$exerciseId: ID!) {
progressionRecommendation(userId: \$userId, exerciseId: \$exerciseId) {
recommendedWeight
recommendedReps
recommendedSets
reasoning
confidenceScore
}
}
''';

// Server handles ALL progression logic
final result = await graphqlClient.query(QueryOptions(
document: gql(query),
variables: {'userId': userId, 'exerciseId': exerciseId},
));

return ProgressionRecommendation.fromJson(result.data!['progressionRecommendation']);
}
}

❌ Analytics & Performance Analysis

// ❌ NEVER DO THIS: Analyze workout performance on client
class WorkoutAnalyzer {
static WorkoutMetrics analyzeWorkout(WorkoutSession workout) {
// THIS IS WRONG! Analysis belongs on the server
double totalVolume = 0;
double averageRpe = 0;

for (final exercise in workout.exercises) {
for (final set in exercise.sets) {
totalVolume += set.weight * set.reps;
averageRpe += set.rpe;
}
}

return WorkoutMetrics(
totalVolume: totalVolume,
averageRpe: averageRpe / getTotalSets(workout),
intensity: calculateIntensity(workout), // More complex logic
);
}
}

// ✅ CORRECT: Request analysis from server
class WorkoutAnalyticsService {
Future<WorkoutAnalysis> getWorkoutAnalysis(String workoutId) async {
const query = '''
query GetWorkoutAnalysis(\$workoutId: ID!) {
workoutAnalysis(workoutId: \$workoutId) {
totalVolume
averageRpe
intensityScore
muscleGroupBreakdown
performanceInsights {
type
message
recommendation
}
}
}
''';

// Server provides complete analysis
final result = await graphqlClient.query(QueryOptions(
document: gql(query),
variables: {'workoutId': workoutId},
));

return WorkoutAnalysis.fromJson(result.data!['workoutAnalysis']);
}
}

❌ Complex Business Rules

// ❌ NEVER DO THIS: Implement program scheduling logic
class ProgramScheduler {
static List<WorkoutSession> generateWeeklySchedule(
ProgramTemplate program,
UserPreferences preferences,
List<String> availableDays
) {
// THIS IS WRONG! Scheduling logic belongs on the server
List<WorkoutSession> schedule = [];

// Complex logic for workout distribution
int workoutsPerWeek = program.workoutsPerWeek;
List<String> selectedDays = _selectOptimalDays(availableDays, workoutsPerWeek);

for (int i = 0; i < workoutsPerWeek; i++) {
schedule.add(WorkoutSession(
day: selectedDays[i],
workout: program.workouts[i % program.workouts.length],
restDays: _calculateRestDays(i, workoutsPerWeek),
));
}

return schedule;
}
}

// ✅ CORRECT: Request schedule from server
class ProgramScheduleService {
Future<WeeklySchedule> generateSchedule({
required String programId,
required String userId,
required List<String> availableDays,
}) async {
const mutation = '''
mutation GenerateWeeklySchedule(\$input: ScheduleGenerationInput!) {
generateWeeklySchedule(input: \$input) {
weekNumber
scheduledWorkouts {
dayOfWeek
workout {
id
name
estimatedDuration
}
restDaysBefore
restDaysAfter
}
alternativeOptions {
reason
alternativeSchedule
}
}
}
''';

// Server generates optimal schedule using complex algorithms
final result = await graphqlClient.mutate(MutationOptions(
document: gql(mutation),
variables: {
'input': {
'programId': programId,
'userId': userId,
'availableDays': availableDays,
}
},
));

return WeeklySchedule.fromJson(result.data!['generateWeeklySchedule']);
}
}

Implementation Patterns

Pattern 1: Server-Driven UI

// ✅ CORRECT: UI driven by server recommendations
class ProgressionRecommendationWidget extends StatefulWidget {
final String exerciseId;

@override
_ProgressionRecommendationWidgetState createState() =>
_ProgressionRecommendationWidgetState();
}

class _ProgressionRecommendationWidgetState
extends State<ProgressionRecommendationWidget> {
ProgressionRecommendation? _recommendation;
bool _isLoading = true;

@override
void initState() {
super.initState();
_loadRecommendation();
}

Future<void> _loadRecommendation() async {
try {
final recommendation = await progressionService.getRecommendation(
userId: currentUser.id,
exerciseId: widget.exerciseId,
);

setState(() {
_recommendation = recommendation;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
_showError(e.toString());
}
}

@override
Widget build(BuildContext context) {
if (_isLoading) return const CircularProgressIndicator();
if (_recommendation == null) return const Text('No recommendations available');

return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Progression Recommendation',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),

// Display server-provided recommendation
Text('Weight: ${_recommendation!.recommendedWeight} kg'),
Text('Sets: ${_recommendation!.recommendedSets}'),
Text('Reps: ${_recommendation!.recommendedReps}'),

const SizedBox(height: 8),
Text(
'Reasoning: ${_recommendation!.reasoning}',
style: Theme.of(context).textTheme.bodySmall,
),

const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => _acceptRecommendation(),
child: const Text('Accept'),
),
TextButton(
onPressed: () => _rejectRecommendation(),
child: const Text('Not Today'),
),
],
),
],
),
),
);
}

Future<void> _acceptRecommendation() async {
await progressionService.acceptRecommendation(_recommendation!.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Progression applied!')),
);
}

Future<void> _rejectRecommendation() async {
await progressionService.rejectRecommendation(
_recommendation!.id,
reason: 'user_declined',
);
Navigator.of(context).pop();
}
}

Pattern 2: Data Transformation Only

// ✅ CORRECT: Transform server data for UI display only
class WorkoutSummaryWidget extends StatelessWidget {
final WorkoutAnalysis analysis; // From server

@override
Widget build(BuildContext context) {
return Column(
children: [
_buildMetricCard(
'Total Volume',
_formatVolume(analysis.totalVolume),
Icons.fitness_center,
),
_buildMetricCard(
'Average RPE',
_formatRpe(analysis.averageRpe),
Icons.trending_up,
),
_buildMetricCard(
'Intensity Score',
_formatIntensity(analysis.intensityScore),
Icons.local_fire_department,
),

// Display server-generated insights
if (analysis.insights.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Performance Insights',
style: Theme.of(context).textTheme.titleMedium,
),
...analysis.insights.map((insight) =>
ListTile(
leading: Icon(_getInsightIcon(insight.type)),
title: Text(insight.message),
subtitle: insight.recommendation != null
? Text(insight.recommendation!)
: null,
),
),
],
],
);
}

// ✅ CORRECT: UI formatting only
String _formatVolume(double volume) {
if (volume >= 1000) {
return '${(volume / 1000).toStringAsFixed(1)}k kg';
}
return '${volume.toStringAsFixed(0)} kg';
}

String _formatRpe(double rpe) {
return '${rpe.toStringAsFixed(1)}/10';
}

Color _getIntensityColor(double intensity) {
if (intensity >= 0.8) return Colors.red;
if (intensity >= 0.6) return Colors.orange;
if (intensity >= 0.4) return Colors.yellow;
return Colors.green;
}
}

Pattern 3: Server-Side Validation with Client Feedback

// ✅ CORRECT: Server validates, client handles UI response
class WorkoutSubmissionService {
Future<WorkoutSubmissionResult> submitWorkout(WorkoutData workoutData) async {
const mutation = '''
mutation CreateWorkoutHistory(\$input: CreateWorkoutHistoryInput!) {
createWorkoutHistory(input: \$input) {
id
validationWarnings {
field
message
severity
}
metrics {
totalVolume
averageRpe
}
}
}
''';

try {
final result = await graphqlClient.mutate(MutationOptions(
document: gql(mutation),
variables: {'input': workoutData.toJson()},
));

final workoutResult = result.data!['createWorkoutHistory'];

// ✅ CORRECT: Display server validation results
if (workoutResult['validationWarnings'].isNotEmpty) {
_showValidationWarnings(workoutResult['validationWarnings']);
}

return WorkoutSubmissionResult.success(
workoutId: workoutResult['id'],
metrics: WorkoutMetrics.fromJson(workoutResult['metrics']),
);

} on OperationException catch (e) {
// ✅ CORRECT: Handle server-side business validation errors
final error = e.graphqlErrors.first;

if (error.extensions?['code'] == 'VALIDATION_ERROR') {
return WorkoutSubmissionResult.validationError(
message: error.message,
field: error.extensions?['field'],
);
}

throw WorkoutSubmissionException(error.message);
}
}

void _showValidationWarnings(List<dynamic> warnings) {
for (final warning in warnings) {
final severity = warning['severity'];
final message = warning['message'];

// ✅ CORRECT: Display server-provided warnings
if (severity == 'WARNING') {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.orange,
),
);
}
}
}
}

Code Review Checklist

Before submitting any client-side code, verify:

✅ Thin Client Compliance

  • No business logic calculations (progression, analytics, scoring)
  • No complex validation beyond UI convenience
  • No workout effectiveness algorithms
  • No recommendation generation
  • No performance analysis or insights generation

✅ Proper Server Integration

  • All business decisions come from server APIs
  • Complex data processing happens server-side
  • Validation errors come from server with proper handling
  • Recommendations and insights fetched from server

✅ Data Flow Patterns

  • Data flows: Server → Client for display
  • User input flows: Client → Server for processing
  • No client-side data manipulation beyond formatting
  • Cache server responses, don't recalculate

Common Violations & Fixes

Violation: Client-Side RPE Analysis

// ❌ WRONG: Analyzing RPE patterns on client
double analyzeRpeTrend(List<WorkoutSet> sets) {
// Complex RPE trend analysis
return sets.map((s) => s.rpe).reduce((a, b) => a + b) / sets.length;
}

// ✅ CORRECT: Request analysis from server
Future<RpeAnalysis> getRpeAnalysis(String exerciseId) async {
const query = '''
query AnalyzeExerciseRpe(\$exerciseId: ID!) {
exerciseRpeAnalysis(exerciseId: \$exerciseId) {
averageRpe
rpeTrend
fatigueIndicators
recommendations
}
}
''';

final result = await graphqlClient.query(QueryOptions(
document: gql(query),
variables: {'exerciseId': exerciseId},
));

return RpeAnalysis.fromJson(result.data!['exerciseRpeAnalysis']);
}

Violation: Client-Side Workout Scoring

// ❌ WRONG: Calculating workout scores on client
int calculateWorkoutScore(WorkoutSession workout) {
int baseScore = workout.completedSets * 10;
double volumeBonus = workout.totalVolume * 0.1;
double rpeAdjustment = workout.averageRpe > 8 ? 1.2 : 1.0;

return (baseScore + volumeBonus * rpeAdjustment).round();
}

// ✅ CORRECT: Display server-calculated score
class WorkoutScoreWidget extends StatelessWidget {
final String workoutId;

@override
Widget build(BuildContext context) {
return FutureBuilder<WorkoutScore>(
future: workoutAnalyticsService.getWorkoutScore(workoutId),
builder: (context, snapshot) {
if (!snapshot.hasData) return CircularProgressIndicator();

final score = snapshot.data!;
return Column(
children: [
Text('Workout Score: ${score.totalScore}'),
Text('Breakdown: ${score.breakdown}'),
Text('Performance: ${score.performanceLevel}'),
if (score.improvementTips.isNotEmpty)
...score.improvementTips.map((tip) => Text('💡 $tip')),
],
);
},
);
}
}

Emergency: If You've Already Implemented Business Logic

If you've already implemented business logic on the client side:

  1. STOP: Immediately halt development of that feature
  2. DOCUMENT: List all business logic you've implemented
  3. COORDINATE: Work with the backend team to expose necessary APIs
  4. MIGRATE: Replace client logic with server API calls
  5. TEST: Verify identical behavior with server-side implementation
  6. REMOVE: Delete all client-side business logic code

Resources

  • Service Documentation: Review each service's API to understand available operations
  • GraphQL Playground: Test server-side operations before implementing client consumption
  • Code Review Guidelines: All client code must pass thin client compliance review
  • Architecture Team: Consult for any questions about client-server boundaries

Remember

The server is the source of truth for ALL business logic.

Your client's job is to make that server intelligence accessible and beautiful to users, not to duplicate or replace it.

When in doubt, ask: "Should this calculation/decision happen on every client, or once on the server?"

The answer is almost always: Once on the server.