A deep dive into our singleton service pattern - why we abandoned Provider/Bloc and how it simplified our codebase

Published: (December 9, 2025 at 04:45 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

The State Management Dilemma

Every Flutter developer knows the pain. You start with setState(), quickly outgrow it, then face the overwhelming choice between Provider, Bloc, Riverpod, GetX, MobX, and a dozen other state management solutions.

The Flutter team recommends Provider. The community loves Bloc. Everyone’s talking about Riverpod. But what if they’re all solving the wrong problem?

Our Original Approach (The “Right” Way)

Initially, RentFox followed best practices. We used Provider for dependency injection and state management:

// The recommended approach
class ListingsProvider extends ChangeNotifier {
  List _listings = [];
  bool _isLoading = false;

  List get listings => _listings;
  bool get isLoading => _isLoading;

  Future fetchListings() async {
    _isLoading = true;
    notifyListeners();

    try {
      _listings = await ApiService.getListings();
    } catch (e) {
      // Handle error
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// Providing it at the app level
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ListingsProvider()),
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        ChangeNotifierProvider(create: (_) => BookingProvider()),
        // ... 12 more providers
      ],
      child: MaterialApp(/* ... */),
    );
  }
}

// Consuming it in widgets
class ListingsScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, provider, child) {
        if (provider.isLoading) {
          return CircularProgressIndicator();
        }
        return ListView.builder(/* ... */);
      },
    );
  }
}

This is textbook Flutter. Clean, reactive, follows all the best practices. So why did we abandon it?

The Problems We Hit

1. Provider Hell

As RentFox grew, our MultiProvider became a monster with 15+ providers. Adding new services meant updating the app‑level provider tree, breaking separation of concerns.

// Our MultiProvider nightmare
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthProvider()),
    ChangeNotifierProvider(create: (_) => ListingsProvider()),
    ChangeNotifierProvider(create: (_) => BookingProvider()),
    ChangeNotifierProvider(create: (_) => MessagingProvider()),
    ChangeNotifierProvider(create: (_) => LocationProvider()),
    ChangeNotifierProvider(create: (_) => ContentModerationProvider()),
    ChangeNotifierProvider(create: (_) => S3UploadProvider()),
    ChangeNotifierProvider(create: (_) => PaymentProvider()),
    ChangeNotifierProvider(create: (_) => NotificationProvider()),
    ChangeNotifierProvider(create: (_) => UserProfileProvider()),
    ChangeNotifierProvider(create: (_) => SearchProvider()),
    ChangeNotifierProvider(create: (_) => TrustScoreProvider()),
    // ... and it kept growing
  ],
  child: MaterialApp(/* ... */),
)

2. Testing Nightmare

Every widget test needed elaborate provider setup. Testing a simple listing card required mocking half the app:

// Testing with Provider required this mess
testWidgets('should display listing info', (tester) async {
  await tester.pumpWidget(
    MultiProvider(
      providers: [
        ChangeNotifierProvider.value(
          value: mockListingsProvider,
        ),
        ChangeNotifierProvider.value(
          value: mockAuthProvider,
        ),
        // Mock every provider the widget might access
      ],
      child: MaterialApp(
        home: ListingCard(listing: testListing),
      ),
    ),
  );

  expect(find.text(testListing.title), findsOneWidget);
});

3. Unnecessary Reactivity

Most of our business logic didn’t need to be reactive. Authentication state? Sure, that should trigger UI rebuilds. But API calls, data transformation, and business rules are procedural operations that return results, not streams of state changes. We were forcing everything into a reactive paradigm when a simple async function call was more appropriate.

Our Solution: Singleton Services

After months of Provider frustration, we took a step back and asked: What if we just used services for business logic and kept Provider only for UI state?

The Singleton Pattern

AuthService – Handles authentication business logic

class AuthService {
  static final AuthService _instance = AuthService._internal();
  factory AuthService() => _instance;
  AuthService._internal();

  final ApiService _api = ApiService();

  /// Send OTP to phone number
  Future> sendOTP(String phoneNumber) async {
    try {
      final response = await _api.post('/api/auth/send-otp', data: {
        'phone': phoneNumber,
      });

      return {
        'success': response.data['success'] == true,
        'message': response.data['message'],
        'code': response.data['code'], // For development
      };
    } on DioException catch (e) {
      return {
        'success': false,
        'error': e.response?.data['error'] ?? 'Network error',
      };
    }
  }

  /// Verify OTP and login
  Future> verifyOTP(String phoneNumber, String code) async {
    try {
      final response = await _api.post('/api/auth/verify-otp', data: {
        'phone': phoneNumber,
        'code': code,
      });

      if (response.data['success'] == true) {
        // Save tokens
        await _api.saveTokens(
          response.data['accessToken'],
          response.data['refreshToken'],
        );

        return {'success': true, 'user': response.data['user']};
      } else {
        return {
          'success': false,
          'error': response.data['error'] ?? 'Verification failed',
        };
      }
    } on DioException catch (e) {
      return {
        'success': false,
        'error': e.response?.data['error'] ?? 'Network error',
      };
    }
  }
}

ListingService – Handles listing operations

class ListingService {
  static final ListingService _instance = ListingService._internal();
  factory ListingService() => _instance;
  ListingService._internal();

  final ApiService _api = ApiService();

  /// Get listings with filters
  Future> getListings({
    String? query,
    String? category,
    double? minPrice,
    double? maxPrice,
    double? latitude,
    double? longitude,
    double? maxDistance,
    int page = 1,
    int limit = 20,
  }) async {
    try {
      final queryParams = {
        if (query != null) 'q': query,
        if (category != null) 'category': category,
        if (minPrice != null) 'minPrice': minPrice,
        if (maxPrice != null) 'maxPrice': maxPrice,
        if (latitude != null) 'latitude': latitude,
        if (longitude != null) 'longitude': longitude,
        if (maxDistance != null) 'maxDistance': maxDistance,
        'page': page,
        'limit': limit,
      };

      final response = await _api.get('/api/listings', queryParameters: queryParams);
      return {'success': true, 'data': response.data};
    } on DioException catch (e) {
      return {
        'success': false,
        'error': e.response?.data['error'] ?? 'Network error',
      };
    }
  }
}
Back to Blog

Related posts

Read more »