Cache architecture analysis
User Code
↓
HomeFilterService (Riverpod Notifier)
↓
FilterPersistenceService (Provider)
↓
AppStore (Singleton)
↓
SharedPreferences
Problems:
- Over-engineered: 4 layers to read/write a simple key-value pair
- Confusing: FilterPersistenceService is redundant when AppStore already handles persistence
- Hard to test: Multiple moving parts, difficult to mock
- Inconsistent: Some services use persistence layer, others don’t
class AppCacheManager extends _$AppCacheManager {
Future<void> clearFilterCache() async {
final homeFilterService = ref.read(homeFilterServiceProvider.notifier);
await homeFilterService.clearPersistedState();
}
// Need to add method for every new service... 😰
// Future<void> clearUserCache() async { ... }
// Future<void> clearSettingsCache() async { ... }
// Future<void> clearEveryThingCache() async { ... }
}
Problems:
- Tightly coupled: Knows about every service in the app
- Not scalable: Need to add methods as services grow
- Single point of failure: Changes ripple everywhere
- Violates Open/Closed Principle: Must modify for every new service
Remove the middle layers entirely:
@riverpod
class HomeFilterService extends _$HomeFilterService {
late final AppStore _store;
bool _isInitialized = false;
@override
FilterState build() {
_store = AppStore.instance;
if (!_store.ensureSetup()) {
return HomeFilterState();
}
if (!_isInitialized) {
_initializeAsync();
}
return HomeFilterState();
}
Future<void> _initializeAsync() async {
if (_isInitialized) return;
try {
final json = await _store.read(AppPersistenceKey.homeFilterState.key);
if (json != null) {
state = FilterState.fromJson(jsonDecode(json));
}
} finally {
_isInitialized = true;
}
}
Future<void> updateFilterState(FilterState newState) async {
state = newState;
await _persistState();
}
Future<void> _persistState() async {
try {
final json = FilterState.toJson(state);
await _store.write(
AppPersistenceKey.homeFilterState.key,
jsonEncode(json)
);
} catch (e) {
LOG.e('Failed to persist: $e');
}
}
Future<void> clearPersistedState() async {
await _store.delete(AppPersistenceKey.homeFilterState.key);
state = HomeFilterState();
}
}
Benefits:
- ✅ Simple: Only 2 layers (Service → AppStore)
- ✅ Clear: Easy to understand what’s happening
- ✅ Self-contained: Each service manages its own persistence
- ✅ Testable: Just mock AppStore
Create a reusable mixin for services that need persistence:
// 📦 packages/presentation/lib/provider/mixins/persistable_state.dart
mixin PersistableState<T> on Notifier<T> {
AppStore get store => AppStore.instance;
String get persistenceKey;
T fromJsonString(String json);
String toJsonString(T state);
Future<T?> loadPersistedState() async {
try {
final json = await store.read(persistenceKey);
if (json != null) {
return fromJsonString(json);
}
} catch (e) {
LOG.e('Failed to load $persistenceKey: $e');
}
return null;
}
Future<void> persistState(T state) async {
try {
await store.write(persistenceKey, toJsonString(state));
} catch (e) {
LOG.e('Failed to persist $persistenceKey: $e');
}
}
Future<void> clearPersistedState() async {
await store.delete(persistenceKey);
}
}
Usage:
@riverpod
class HomeFilterService extends _$HomeFilterService
with PersistableState<FilterState> {
@override
String get persistenceKey => AppPersistenceKey.homeFilterState.key;
@override
FilterState fromJsonString(String json) =>
FilterState.fromJson(jsonDecode(json));
@override
String toJsonString(FilterState state) =>
jsonEncode(FilterState.toJson(state));
bool _isInitialized = false;
@override
FilterState build() {
if (!store.ensureSetup()) {
return HomeFilterState();
}
if (!_isInitialized) {
_initializeAsync();
}
return HomeFilterState();
}
Future<void> _initializeAsync() async {
if (_isInitialized) return;
try {
final persisted = await loadPersistedState();
if (persisted != null) {
state = persisted;
}
} finally {
_isInitialized = true;
}
}
Future<void> updateFilterState(FilterState newState) async {
state = newState;
await persistState(state);
}
}
Benefits:
- ✅ DRY: Reusable persistence logic
- ✅ Type-safe: Generic mixin works with any state type
- ✅ Consistent: Same pattern across all services
- ✅ Testable: Mock AppStore in mixin
Instead of AppCacheManager knowing about all services, use an event-based approach:
// 📦 packages/presentation/lib/provider/cache_events.dart
/// Event bus for cache clearing
final cacheEventBus = StreamController<CacheEvent>.broadcast();
enum CacheEvent {
clearAll,
clearFilters,
clearUser,
clearSettings,
}
// Each service listens to events
@riverpod
class HomeFilterService extends _$HomeFilterService {
StreamSubscription? _eventSubscription;
@override
FilterState build() {
// Listen to cache events
_eventSubscription = cacheEventBus.stream.listen((event) {
if (event == CacheEvent.clearAll || event == CacheEvent.clearFilters) {
_handleClearCache();
}
});
ref.onDispose(() => _eventSubscription?.cancel());
// ... rest of initialization
return HomeFilterState();
}
void _handleClearCache() {
clearPersistedState();
}
}
// Simple cache manager
@riverpod
class CacheManager extends _$CacheManager {
@override
void build() {}
void clearAllCache() {
cacheEventBus.add(CacheEvent.clearAll);
}
void clearFilterCache() {
cacheEventBus.add(CacheEvent.clearFilters);
}
}
Benefits:
- ✅ Decoupled: CacheManager doesn’t know about services
- ✅ Scalable: Add services without modifying CacheManager
- ✅ Flexible: Services can ignore events they don’t care about
setUp(() {
AppStore.instance = TestAppStore();
container = ProviderContainer(overrides: []);
});
setUp(() {
AppStore.instance = TestAppStore();
container = ProviderContainer(overrides: []);
});
setUp(() {
AppStore.instance = TestAppStore();
container = ProviderContainer(overrides: []);
});
setUp(() {
AppStore.instance = TestAppStore();
container = ProviderContainer(overrides: []);
});
tearDown(() {
cacheEventBus.close(); // Need to manage stream
});
Immediate Steps:
Delete
FilterPersistenceService❌- It’s redundant - AppStore already does this
- Move the 3 methods directly into
HomeFilterService
Keep
AppCacheManagerbut simplify it ⚠️- Don’t have it reference specific services
- Instead, have services register cleanup callbacks:
@riverpod class CacheRegistry extends _$CacheRegistry { final _cleanupCallbacks = <String, VoidCallback>{}; @override void build() {} void register(String key, VoidCallback cleanup) { _cleanupCallbacks[key] = cleanup; } Future<void> clearAll() async { for (final callback in _cleanupCallbacks.values) { callback(); } } } // In HomeFilterService @override FilterState build() { ref.read(cacheRegistryProvider.notifier) .register('homeFilter', clearPersistedState); // ... }Use the Mixin pattern for future services ✅
- Provides consistency
- Reduces boilerplate
- Easy to test
- ✅ Remove
FilterPersistenceServiceclass - ✅ Move persistence logic directly into
HomeFilterService - ✅ Update tests (minimal changes needed)
- ⏱️ Time: 30 minutes
- ✅ Create
PersistableStatemixin - ✅ Refactor
HomeFilterServiceto use mixin - ✅ Apply to other services (
StripeOnboardingService, etc.) - ⏱️ Time: 1-2 hours
- ⚠️ Implement registration pattern OR event bus
- ⚠️ Remove hardcoded service references
- ⏱️ Time: 1 hour
@riverpod
class HomeFilterService extends _$HomeFilterService {
late final AppStore _store;
bool _isInitialized = false;
@override
FilterState build() {
_store = AppStore.instance;
if (!_store.ensureSetup()) {
return HomeFilterState();
}
if (!_isInitialized) {
_initializeAsync();
}
return HomeFilterState();
}
Future<void> _initializeAsync() async {
if (_isInitialized) return;
try {
final jsonString = await _store.read(AppPersistenceKey.homeFilterState.key);
if (jsonString != null) {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
state = FilterState.fromJson(json);
}
} catch (e) {
LOG.e('Failed to load filter state: $e');
} finally {
_isInitialized = true;
}
}
Future<void> updateFilterState(FilterState newState) async {
state = newState;
await _persistState();
}
Future<void> _persistState() async {
try {
final json = FilterState.toJson(state);
if (json != null) {
await _store.write(
AppPersistenceKey.homeFilterState.key,
jsonEncode(json),
);
}
} catch (e) {
LOG.e('Failed to persist filter state: $e');
}
}
Future<void> clearPersistedState() async {
try {
await _store.delete(AppPersistenceKey.homeFilterState.key);
state = HomeFilterState();
} catch (e) {
LOG.e('Failed to clear filter state: $e');
}
}
// ... other methods (resetToDefault, applyFilterChange, etc.)
}
That’s it! No FilterPersistenceService, no provider for it, just simple direct calls to AppStore.
Your instincts are correct! The current architecture is over-engineered:
- ❌ FilterPersistenceService: DELETE IT - it’s just a wrapper around AppStore
- ⚠️ AppCacheManager: KEEP but REDESIGN with registration pattern
- ✅ Direct AppStore access: SIMPLIFY - services talk directly to AppStore
- ✅ Mixin for reusability: FUTURE - when you have multiple services
Next Action: Would you like me to refactor the code to implement Option A (remove FilterPersistenceService)?