Modern mobile applications often need to store various types of data locally - from user preferences to authentication tokens. While Flutter provides SharedPreferences
for basic storage and FlutterSecureStorage
for encrypted storage, managing these effectively in a large application requires careful architectural planning.
In this article, we'll explore how to build a robust key-value storage system that separates concerns, provides type safety, and makes storage operations maintainable and secure.
The Two-Layer Architecture
Our implementation uses a two-layer architecture:
-
KeyValueStorageBase
: A low-level base class that directly interfaces with storage plugins -
KeyValueStorageService
: A high-level service that provides typed, domain-specific storage operations
Why Two Layers?
This separation serves several important purposes:
-
Separation of Concerns
- Base class is more generic and handles raw storage operations
- Service class handles business logic and data transformation according to the app domain
- Clear boundary between storage mechanism and business logic
-
Single Responsibility
- Base class: How to store and retrieve data
- Service class: What to store and when as well as managing the keys.
-
Dependency Isolation
- Only the base class knows about
SharedPreferences
andFlutterSecureStorage
- Application code only interacts with the service layer
- Only the base class knows about
The Base Layer: KeyValueStorageBase
Let's look at our base class key_value_storage_base.dart
implementation:
Initialization
WidgetsBinding.ensureInitialized();
...
// For preparing the key-value mem cache
await KeyValueStorageBase.init();
...
runApp();
The init()
method is a crucial part of our base layer design. By initializing both storage systems (SharedPreferences
and FlutterSecureStorage
) at app startup, we ensure that the storage instances are ready before any part of the app tries to access them. It allows us to perform synchronous reads from SharedPreferences
throughout our app's lifecycle.
Without this initialization pattern, we'd need to handle async operations for every read operation, which would significantly complicate our storage API and force unnecessary complexity onto the rest of our application code. This "initialize once, use synchronously" pattern is particularly valuable for accessing critical data like user authentication state or app configuration during app startup.
Key Features of the Base Layer
- Unified Storage Interface
T? getCommon<T>(String key)
Future<String?> getEncrypted(String key)
Future<bool> setCommon<T>(String key, T value)
Future<bool> setEncrypted(String key, String value)
- Type-Safe Operations
T? getCommon<T>(String key) {
return switch (T) {
const (String) => _sharedPrefs!.getString(key) as T?,
const (List<String>) => _sharedPrefs!.getStringList(key) as T?,
const (int) => _sharedPrefs!.getInt(key) as T?,
const (bool) => _sharedPrefs!.getBool(key) as T?,
const (double) => _sharedPrefs!.getDouble(key) as T?,
_ => _sharedPrefs!.get(key) as T?
};
}
- Error Handling
try {
return _secureStorage!.read(key: key);
} on PlatformException catch (ex) {
appLogger.debug('$ex');
return Future<String?>.value();
}
The Service Layer: KeyValueStorageService
The service layer provides domain-specific storage operations:
Key Features of the Service Layer
-
Domain-Specific Operations
- Methods map directly to business needs
- Handles serialization/deserialization
- Provides type safety at the domain level
Centralized Key Management
static const _authTokenKey = 'authToken';
static const _authUserKey = 'authUserKey';
-
Intelligent Storage Decisions
- Sensitive data uses encrypted storage
- Regular data uses shared preferences
Bulk Operations
void clearUserData() {
_keyValueStorage
..removeCommon(_authUserKey)
..removeCommon(_firstMapLoadKey)
..removeEncrypted(_authTokenKey);
}
Benefits of This Architecture
-
Type Safety
- Compile-time type checking for stored values
- Domain models are properly serialized/deserialized
-
Security
- Clear separation between secure and non-secure storage
- Encrypted storage for sensitive data
- Centralized security decisions
-
Maintainability
- Single source of truth for storage operations
- Easy to add new storage operations
- Consistent error handling
-
Testability
- Base class can be mocked for testing
- Service layer can be tested independently
- Clear boundaries for unit tests
-
Performance
- Async operations where needed
- Synchronous operations where possible
- Background execution for non-critical writes
Usage in Application Code
The service is easily accessible through a Riverpod provider:
final keyValueStorageServiceProvider = Provider((ref) {
return KeyValueStorageService();
});
// In application code
final user = ref.read(keyValueStorageServiceProvider).getAuthUser();
However, you don't have to use riverpod
and you can easily change it to some other DI service or simply make it a singleton pattern as well.
Conclusion
This two-layer architecture provides a robust foundation for key-value storage in Flutter applications. By following this pattern you create a clean, maintainable, and secure storage system that can grow with your application.
Credit
If you like it, please also take a look at the Analytics Service article I wrote to see it in action.
Finally, please keep following me as I will be posting about many more advanced stuff like PermissionService
, LocationService
, and NotificationService
etc.
Top comments (0)