๐Ÿ† Consistent Riverpod Service Architecture

This document outlines the standardized patterns for all Riverpod services, providers, and bridges in the FlipDare application. Following these patterns ensures consistency, maintainability, and testability across the entire codebase.

๐Ÿ“‹ Table of Contents

  1. Naming Conventions
  2. File Structure
  3. Service Pattern
  4. State Management
  5. Testing Pattern
  6. Bridge Pattern
  7. Provider Initialization
  8. Usage Examples
  9. Migration Guide

๐ŸŽฏ Naming Conventions

Services and Providers

// Service class names: XxxService
class AuthService extends _$AuthService { }
class PledgeService extends _$PledgeService { }
class UserService extends _$UserService { }

// Generated provider names: xxxServiceProvider
final authServiceProvider = ...;
final pledgeServiceProvider = ...;
final userServiceProvider = ...;

State Classes

// State class names: XxxServiceState
class AuthServiceState { }
class PledgeServiceState { }
class UserServiceState { }

Convenience Providers

// Naming pattern: xxxIsLoading, xxxError, xxxData, etc.
@riverpod
bool authIsLoading(Ref ref) { }

@riverpod
String? authError(Ref ref) { }

@riverpod
bool pledgeIsLoading(Ref ref) { }

@riverpod
List<PledgeModel> pledgesList(Ref ref) { }

Bridge Classes

// Bridge class names: XxxBridge
class DareBridge extends BridgeService<DareModel> { }
class PledgeBridge extends BridgeService<PledgeModel> { }
class UserBridge extends BridgeService<UserModel> { }

// Generated provider names: xxxBridgeProvider
final dareBridgeProvider = ...;
final pledgeBridgeProvider = ...;
final userBridgeProvider = ...;

๐Ÿ“ File Structure

packages/services/lib/
โ”œโ”€โ”€ provider/
โ”‚   โ”œโ”€โ”€ auth/
โ”‚   โ”‚   โ”œโ”€โ”€ auth_service.dart           # Main service
โ”‚   โ”‚   โ”œโ”€โ”€ auth_service_state.dart     # State class
โ”‚   โ”‚   โ”œโ”€โ”€ auth_event_data.dart        # Event data
โ”‚   โ”‚   โ””โ”€โ”€ auth_service.g.dart         # Generated file
โ”‚   โ”œโ”€โ”€ pledge/
โ”‚   โ”‚   โ”œโ”€โ”€ pledge_service.dart         # Main service
โ”‚   โ”‚   โ”œโ”€โ”€ pledge_service_state.dart   # State class
โ”‚   โ”‚   โ””โ”€โ”€ pledge_service.g.dart       # Generated file
โ”‚   โ”œโ”€โ”€ user/
โ”‚   โ”‚   โ”œโ”€โ”€ user_service.dart           # Main service
โ”‚   โ”‚   โ”œโ”€โ”€ user_service_state.dart     # State class
โ”‚   โ”‚   โ””โ”€โ”€ user_service.g.dart         # Generated file
โ”‚   โ”œโ”€โ”€ current_user_service/
โ”‚   โ”‚   โ”œโ”€โ”€ current_user_service.dart
โ”‚   โ”‚   โ”œโ”€โ”€ current_user_service_state.dart
โ”‚   โ”‚   โ””โ”€โ”€ current_user_service.g.dart
โ”‚   โ”œโ”€โ”€ _service_template.dart          # Template file
โ”‚   โ””โ”€โ”€ _provider_pattern.dart          # Pattern documentation
โ”œโ”€โ”€ provider_bridge/
โ”‚   โ”œโ”€โ”€ bridge_registration.dart        # All bridge providers
โ”‚   โ”œโ”€โ”€ bridge_service.dart             # Base bridge interface
โ”‚   โ”œโ”€โ”€ dare_bridge.dart                # Dare bridge implementation
โ”‚   โ”œโ”€โ”€ pledge_bridge.dart              # Pledge bridge implementation
โ”‚   โ””โ”€โ”€ user_service.dart               # User bridge implementation
โ””โ”€โ”€ test/unit/provider/
    โ”œโ”€โ”€ auth_service_unit.dart          # Auth service tests
    โ”œโ”€โ”€ pledge_service_unit.dart        # Pledge service tests
    โ”œโ”€โ”€ user_service_unit.dart          # User service tests
    โ””โ”€โ”€ _service_test_template.dart     # Test template

๐Ÿ—๏ธ Service Pattern

1. State Class Structure

All state classes follow this pattern:

class ServiceState {
  final bool isLoading;
  final String? error;
  final DateTime? lastUpdated;
  // Add service-specific properties here

  const ServiceState({
    this.isLoading = false,
    this.error,
    this.lastUpdated,
  });

  ServiceState copyWith({
    bool? isLoading,
    String? error,
    DateTime? lastUpdated,
  }) {
    return ServiceState(
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
      lastUpdated: lastUpdated ?? this.lastUpdated,
    );
  }

  // Convenience getters - always include these
  bool get hasError => error != null;
  bool get isIdle => !isLoading && error == null;
  bool get isReady => !isLoading && error == null;
}

2. Service Class Structure

All service classes follow this pattern:

@riverpod
class Service extends _$Service {
  @override
  ServiceState build() {
    return const ServiceState();
  }

  // =============================================================================
  // PUBLIC API METHODS - Always async with consistent error handling
  // =============================================================================

  Future<void> performAction() async {
    state = state.copyWith(isLoading: true, error: null);

    try {
      // Business logic here

      state = state.copyWith(
        isLoading: false,
        lastUpdated: DateTime.now(),
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        error: e.toString(),
      );
    }
  }

  // =============================================================================
  // UTILITY METHODS - Standard helper methods all services should have
  // =============================================================================

  void clearError() {
    state = state.copyWith(error: null);
  }

  void reset() {
    state = const ServiceState();
  }

  Future<void> refresh() async {
    // Implement service-specific refresh logic
  }

  // =============================================================================
  // CONVENIENCE GETTERS - Easy access to common state properties
  // =============================================================================

  bool get isLoading => state.isLoading;
  String? get error => state.error;
  bool get hasError => state.hasError;
  bool get isIdle => state.isIdle;
}

3. Convenience Providers

Each service should provide these standard convenience providers:

@riverpod
bool serviceIsLoading(Ref ref) {
  return ref.watch(serviceProvider).isLoading;
}

@riverpod
String? serviceError(Ref ref) {
  return ref.watch(serviceProvider).error;
}

@riverpod
bool serviceIsReady(Ref ref) {
  return ref.watch(serviceProvider).isReady;
}

๐Ÿ”„ State Management

Loading States

// Start loading
state = state.copyWith(isLoading: true, error: null);

// Complete successfully
state = state.copyWith(
  isLoading: false,
  lastUpdated: DateTime.now(),
);

// Handle error
state = state.copyWith(
  isLoading: false,
  error: e.toString(),
);

Error Handling

try {
  // Risky operation
} catch (e) {
  state = state.copyWith(
    isLoading: false,
    error: e.toString(),
  );
  // Optional: rethrow if caller needs to handle
}

๐Ÿงช Testing Pattern

Standard Test Structure

void runServiceUnitTests() {
  group('Service Tests (Standardized Pattern)', () {
    late ProviderContainer container;

    setUp(() {
      container = ProviderContainer(
        overrides: [
          // Add any required overrides
        ],
      );
    });

    tearDown(() {
      container.dispose();
    });

    group('Initialization Tests', () {
      test('should initialize with default state', () {
        final state = container.read(serviceProvider);

        expect(state.isLoading, false);
        expect(state.error, null);
        expect(state.hasError, false);
      });

      test('should support ProviderScope', () {
        expect(() => ProviderScope(child: Container()), returnsNormally);
      });

      test('should support UncontrolledProviderScope', () {
        final testContainer = ProviderContainer();
        expect(() => UncontrolledProviderScope(
          container: testContainer,
          child: Container()
        ), returnsNormally);
        testContainer.dispose();
      });
    });

    // More test groups...
  });
}

Testing Utilities

class ServiceTestHelper {
  static ProviderContainer createTestContainer({
    List<Override> overrides = const [],
  }) {
    return ProviderContainer(overrides: overrides);
  }

  static ProviderContainer createMockContainer() {
    final mockService = MockService();
    return ProviderContainer(
      overrides: [
        serviceProvider.overrideWith(() => mockService),
      ],
    );
  }
}

๐ŸŒ‰ Bridge Pattern

Bridge Registration

All bridges are registered in a single file:

// bridge_registration.dart

@riverpod
DareBridge dareBridge(Ref ref) {
  return DareBridge();
}

@riverpod
PledgeBridge pledgeBridge(Ref ref) {
  final dareBridge = ref.watch(dareBridgeProvider);
  return PledgeBridge(dareBridge: dareBridge);
}

@riverpod
UserBridge userBridge(Ref ref) {
  return UserBridge();
}

Bridge Implementation

class ServiceBridge extends BridgeService<ModelType> {
  @override
  Future<ModelType?> put(ModelType entry) async {
    // Implementation
  }

  @override
  Future<String> delete(String id) async {
    // Implementation
  }

  // Service-specific methods
  Future<List<ModelType>> fetchAll() async {
    // Implementation
  }
}

๐Ÿš€ Provider Initialization

Production (ProviderScope)

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

Testing (UncontrolledProviderScope)

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final container = await ProviderInitialization.initializeApp();

  runApp(
    UncontrolledProviderScope(
      container: container,
      child: MyApp(),
    ),
  );
}

With Dependencies

class ProviderInitialization {
  static Future<ProviderContainer> initializeApp() async {
    final sharedPreferences = await SharedPreferences.getInstance();

    final container = ProviderContainer(
      overrides: [
        sharedPreferencesProvider.overrideWithValue(sharedPreferences),
      ],
    );

    return container;
  }
}

๐Ÿ’ก Usage Examples

In Widgets

class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Watch entire state
    final serviceState = ref.watch(serviceProvider);

    // Watch specific aspects
    final isLoading = ref.watch(serviceIsLoadingProvider);
    final error = ref.watch(serviceErrorProvider);

    // Trigger actions
    final service = ref.read(serviceProvider.notifier);

    return Column(
      children: [
        if (isLoading) CircularProgressIndicator(),
        if (error != null) Text('Error: $error'),
        ElevatedButton(
          onPressed: () => service.performAction(),
          child: Text('Perform Action'),
        ),
      ],
    );
  }
}

In Tests

test('should perform action successfully', () async {
  final container = ServiceTestHelper.createTestContainer();
  final service = container.read(serviceProvider.notifier);

  await service.performAction();

  final state = container.read(serviceProvider);
  expect(state.isLoading, false);
  expect(state.hasError, false);

  container.dispose();
});

๐Ÿ”„ Migration Guide

From Old Pattern to New Pattern

  1. Update imports:

    import 'package:hooks_riverpod/hooks_riverpod.dart';
    import 'package:riverpod_annotation/riverpod_annotation.dart';
    
  2. Rename providers to follow consistent naming:

    // Old
    final isUserAuthenticatedProvider = ...;
    
    // New
    final authIsAuthenticatedProvider = ...;
    
  3. Add utility methods:

    void clearError() { /* implementation */ }
    void reset() { /* implementation */ }
    Future<void> refresh() async { /* implementation */ }
    
  4. Update tests to follow standard pattern:

    setUp(() {
      container = ProviderContainer(overrides: [...]);
    });
    
    tearDown(() {
      container.dispose();
    });
    
  5. Run code generation:

    dart run build_runner build --delete-conflicting-outputs
    

โœ… Checklist for New Services

  • Follow naming conventions
  • Implement state class with copyWith and convenience getters
  • Use @riverpod annotation for service class
  • Implement standard utility methods (clearError, reset, refresh)
  • Add convenience providers
  • Create comprehensive unit tests
  • Support both ProviderScope and UncontrolledProviderScope
  • Handle loading states and errors consistently
  • Document public API methods
  • Run code generation

๐ŸŽฏ Benefits of This Pattern

  1. Consistency: All services follow the same structure
  2. Maintainability: Easy to understand and modify
  3. Testability: Standardized testing approach
  4. Type Safety: Full type safety with code generation
  5. Performance: Efficient reactive updates
  6. Scalability: Easy to add new services
  7. Developer Experience: Clear conventions and patterns

This standardized approach ensures that all Riverpod services in the FlipDare application are consistent, maintainable, and easy to work with.