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(/* ... */);
},
);
}
}
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<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);
});
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',
};
}
}
}
// 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',
};
}
}
}
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]),
);
}
}
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();
});
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(),
);
},
),
);
}
}
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);
});
});
}
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:
- You need reactivity: If your UI needs to rebuild when data changes, stick with Riverpod/Provider
-
You're building a simple app: For basic apps,
setState()is enough - You have complex state dependencies: If services need to listen to each other, reactive patterns work better
- 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:
- Business logic and UI state are different problems requiring different solutions
- Not everything needs to be reactive - sometimes a simple async function is better
- Architecture should serve your use case, not follow trends
- Simpler code is easier to debug and maintain
- 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)