DEV Community

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

Posted on

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

Flutter Service Architecture: Why We Chose Singletons Over Provider/Bloc

When building RentFox, our peer-to-peer rental marketplace, we made a controversial architectural decision that goes against everything the Flutter community preaches: we ditched Provider and Bloc for old-school singleton services.

Before you close this tab in horror, hear me out. After 6 months of development and thousands of lines of production Flutter code, our singleton-based architecture has proven to be more maintainable, testable, and performant than the recommended state management solutions.

Here's why we made this choice and how it's working out.

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<Listing> _listings = [];
  bool _isLoading = false;

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

  Future<void> 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<ListingsProvider>(
      builder: (context, provider, child) {
        if (provider.isLoading) {
          return CircularProgressIndicator();
        }
        return ListView.builder(/* ... */);
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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(/* ... */),
)
Enter fullscreen mode Exit fullscreen mode

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<ListingsProvider>.value(
          value: mockListingsProvider,
        ),
        ChangeNotifierProvider<AuthProvider>.value(
          value: mockAuthProvider,
        ),
        // Mock every provider the widget might access
      ],
      child: MaterialApp(
        home: ListingCard(listing: testListing),
      ),
    ),
  );

  expect(find.text(testListing.title), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

3. Unnecessary Reactivity

Here's the kicker: 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? These 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

Here's our new service architecture:

// 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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',
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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<Map<String, dynamic>> 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 = <String, dynamic>{
        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', queryParams: queryParams);

      return {
        'success': true,
        'listings': response.data['listings'],
        'hasMore': response.data['hasMore'],
        'total': response.data['total'],
      };
    } catch (e) {
      return {
        'success': false,
        'error': 'Failed to load listings',
      };
    }
  }

  /// Create new listing
  Future<Map<String, dynamic>> createListing(Map<String, dynamic> listingData) async {
    try {
      final response = await _api.post('/api/listings', data: listingData);

      return {
        'success': true,
        'listing': response.data['listing'],
      };
    } catch (e) {
      return {
        'success': false,
        'error': 'Failed to create listing',
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

How We Use It in Widgets

Instead of Provider's Consumer pattern, we call services directly:

class ListingsScreen extends StatefulWidget {
  @override
  _ListingsScreenState createState() => _ListingsScreenState();
}

class _ListingsScreenState extends State<ListingsScreen> {
  final ListingService _listingService = ListingService();
  List<Listing> _listings = [];
  bool _isLoading = false;

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

  Future<void> _fetchListings() async {
    setState(() => _isLoading = true);

    final result = await _listingService.getListings();

    if (mounted) {
      setState(() {
        _isLoading = false;
        if (result['success'] == true) {
          _listings = (result['listings'] as List)
              .map((json) => Listing.fromJson(json))
              .toList();
        } else {
          // Handle error
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(result['error'] ?? 'Error loading listings')),
          );
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    return ListView.builder(
      itemCount: _listings.length,
      itemBuilder: (context, index) => ListingCard(listing: _listings[index]),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When We DO Use Riverpod

We didn't completely abandon reactive state management. For UI state that needs to be shared across widgets, we use Riverpod:

// Theme state - UI-only, needs reactivity
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
  return ThemeNotifier();
});

// Current user - Authentication state that triggers UI changes
final currentUserProvider = StateProvider<User?>((ref) => null);

// Search filters - UI state for search screen
final searchFiltersProvider = StateProvider<SearchFilters>((ref) {
  return SearchFilters();
});
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: If it needs to trigger UI rebuilds across multiple widgets, use Riverpod. If it's business logic that returns data, use singleton services.

The Results

Six months later, here's what our architecture looks like:

Simplified App Structure

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: Consumer(
        builder: (context, ref, child) {
          final themeMode = ref.watch(themeProvider);

          return MaterialApp(
            theme: AppTheme.lightTheme,
            darkTheme: AppTheme.darkTheme,
            themeMode: themeMode,
            home: SplashScreen(),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

No more MultiProvider hell. Just a single ProviderScope for UI state.

Cleaner Tests

Testing business logic is now trivial:

void main() {
  group('AuthService', () {
    late AuthService authService;

    setUp(() {
      authService = AuthService();
    });

    test('should send OTP successfully', () async {
      final result = await authService.sendOTP('+1234567890');

      expect(result['success'], isTrue);
      expect(result['code'], isA<String>());
    });

    test('should verify OTP and save tokens', () async {
      final result = await authService.verifyOTP('+1234567890', '123456');

      expect(result['success'], isTrue);
      expect(result['user'], isNotNull);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

No provider mocking, no widget setup. Just plain Dart unit tests.

Better Performance

Singletons mean we're not creating new service instances every time we need them. The AuthService() factory returns the same instance, reducing memory overhead and improving cache hit rates.

Easier Debugging

When something goes wrong, we know exactly where to look. Authentication issues? Check AuthService. Listing problems? Check ListingService. No more hunting through provider trees or reactive streams.

When NOT to Use This Pattern

Our approach isn't perfect for every use case. Don't use singletons when:

  1. You need reactivity: If your UI needs to rebuild when data changes, stick with Riverpod/Provider
  2. You're building a simple app: For basic apps, setState() is enough
  3. You have complex state dependencies: If services need to listen to each other, reactive patterns work better
  4. Your team prefers reactive programming: Consistency is more important than architectural purity

Trade-offs We Accepted

No Automatic UI Updates

With Provider, the UI updates automatically when data changes. With our approach, you need to manually call setState() or refresh data when needed.

For RentFox, this was acceptable because most of our data is fetched on-demand rather than real-time. Listings don't change frequently enough to need live updates.

Global State

Singletons create global state, which can make testing harder if not managed properly. We mitigate this by:

  • Keeping services stateless (they don't hold cached data)
  • Making all methods async and returning fresh data
  • Using dependency injection for testing

Memory Usage

Since singletons live for the app's lifetime, they can potentially increase memory usage. However, our services are lightweight and don't cache large amounts of data, so this hasn't been an issue.

Lessons Learned

After six months with this architecture:

  1. Business logic and UI state are different problems requiring different solutions
  2. Not everything needs to be reactive - sometimes a simple async function is better
  3. Architecture should serve your use case, not follow trends
  4. Simpler code is easier to debug and maintain
  5. Testing pure Dart code is easier than testing widget/provider interactions

What's Next

In our next article, we'll dive into how we implemented lightning-fast location-based search using MongoDB's geospatial queries. We'll cover 2dsphere indexes, distance calculations, and achieving sub-100ms query times for millions of listings.

Want to see more of our unconventional Flutter choices? Follow along as we build RentFox and share every architectural decision, mistake, and victory.


Building a marketplace app and need help with architecture decisions? Drop us a line - we love talking Flutter architecture and sharing what we've learned.

Top comments (0)