Architecture Analysis: State Management & Persistence

Current Architecture Issues

1. Too Many Layers

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

2. AppCacheManager is Problematic

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

Proposed Simplified Architecture

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

Option B: Mixin Pattern for Persistence ⭐⭐

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

Option C: Cache Manager via Events 🎯

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

Testing Comparison

Current Architecture (Complex)

setUp(() {
  AppStore.instance = TestAppStore();
  container = ProviderContainer(overrides: []);
});

Option A: Direct AppStore (Same)

setUp(() {
  AppStore.instance = TestAppStore();
  container = ProviderContainer(overrides: []);
});

Option B: Mixin (Same, but cleaner code)

setUp(() {
  AppStore.instance = TestAppStore();
  container = ProviderContainer(overrides: []);
});

Option C: Events (Need to clean up streams)

setUp(() {
  AppStore.instance = TestAppStore();
  container = ProviderContainer(overrides: []);
});

tearDown(() {
  cacheEventBus.close(); // Need to manage stream
});

Recommendation: Option A + B 🎯

Immediate Steps:

  1. Delete FilterPersistenceService

    • It’s redundant - AppStore already does this
    • Move the 3 methods directly into HomeFilterService
  2. Keep AppCacheManager but 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);
      // ...
    }
    
  3. Use the Mixin pattern for future services

    • Provides consistency
    • Reduces boilerplate
    • Easy to test

Migration Plan

Phase 1: Simplify Current Code (Low Risk)

  1. ✅ Remove FilterPersistenceService class
  2. ✅ Move persistence logic directly into HomeFilterService
  3. ✅ Update tests (minimal changes needed)
  4. ⏱️ Time: 30 minutes

Phase 2: Create Mixin (Medium Risk)

  1. ✅ Create PersistableState mixin
  2. ✅ Refactor HomeFilterService to use mixin
  3. ✅ Apply to other services (StripeOnboardingService, etc.)
  4. ⏱️ Time: 1-2 hours

Phase 3: Fix Cache Manager (Optional)

  1. ⚠️ Implement registration pattern OR event bus
  2. ⚠️ Remove hardcoded service references
  3. ⏱️ Time: 1 hour

Example: Fully Simplified HomeFilterService

@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.


Summary

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)?