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:
import 'dart:async'; | |
import 'package:flutter/services.dart'; | |
import 'package:shared_preferences/shared_preferences.dart'; | |
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | |
import '../../config/logging/logging.dart'; | |
/// Base class containing a unified API for key-value pairs' storage. | |
/// This class provides low level methods for storing: | |
/// - Sensitive keys using [FlutterSecureStorage] | |
/// - Insensitive keys using [SharedPreferences] | |
class KeyValueStorageBase { | |
/// Instance of shared preferences | |
static SharedPreferences? _sharedPrefs; | |
/// Instance of flutter secure storage | |
static FlutterSecureStorage? _secureStorage; | |
/// Singleton instance of KeyValueStorage Helper | |
static const instance = KeyValueStorageBase._(); | |
/// Private constructor | |
const KeyValueStorageBase._(); | |
/// Initializer for shared prefs and flutter secure storage | |
/// Should be called in main before runApp and | |
/// after WidgetsBinding.FlutterInitialized(), to allow for synchronous tasks | |
/// when possible. | |
static Future<void> init() async { | |
_sharedPrefs ??= await SharedPreferences.getInstance(); | |
const androidOptions = AndroidOptions( | |
encryptedSharedPreferences: true, | |
); | |
_secureStorage ??= const FlutterSecureStorage(aOptions: androidOptions); | |
} | |
/// Reads the value for the key from common preferences storage | |
T? getCommon<T>(String key) { | |
try { | |
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? | |
}; | |
} on PlatformException catch (ex) { | |
appLogger.debug('$ex'); | |
return null; | |
} | |
} | |
/// Reads the decrypted value for the key from secure storage | |
Future<String?> getEncrypted(String key) { | |
try { | |
return _secureStorage!.read(key: key); | |
} on PlatformException catch (ex) { | |
appLogger.debug('$ex'); | |
return Future<String?>.value(); | |
} | |
} | |
/// Sets the value for the key to common preferences storage | |
Future<bool> setCommon<T>(String key, T value) { | |
return switch (T) { | |
const (String) => _sharedPrefs!.setString(key, value as String), | |
const (List<String>) => | |
_sharedPrefs!.setStringList(key, value as List<String>), | |
const (int) => _sharedPrefs!.setInt(key, value as int), | |
const (bool) => _sharedPrefs!.setBool(key, value as bool), | |
const (double) => _sharedPrefs!.setDouble(key, value as double), | |
_ => _sharedPrefs!.setString(key, value as String) | |
}; | |
} | |
/// Sets the encrypted value for the key to secure storage | |
Future<bool> setEncrypted(String key, String value) { | |
try { | |
_secureStorage!.write(key: key, value: value); | |
return Future.value(true); | |
} on PlatformException catch (ex) { | |
appLogger.debug('$ex'); | |
return Future.value(false); | |
} | |
} | |
Future<bool> removeCommon(String key) => _sharedPrefs!.remove(key); | |
Future<void> removeEncrypted(String key) => _secureStorage!.delete(key: key); | |
/// Erases common preferences keys | |
Future<bool> clearCommon() => _sharedPrefs!.clear(); | |
/// Erases encrypted keys | |
Future<bool> clearEncrypted() async { | |
try { | |
await _secureStorage!.deleteAll(); | |
return true; | |
} on PlatformException catch (ex) { | |
appLogger.debug('$ex'); | |
return false; | |
} | |
} | |
} |
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:
// ignore_for_file: avoid_positional_boolean_parameters | |
import 'dart:convert'; | |
import 'package:hooks_riverpod/hooks_riverpod.dart'; | |
// Services | |
import '../../features/notifications/notifications.dart'; | |
import '../networking/models/token_model.codegen.dart'; | |
import 'key_value_storage_base.dart'; | |
// Helpers | |
import '../../helpers/helpers.dart'; | |
// Features | |
import '../../features/map/map.dart'; | |
import '../../features/profile/profile.dart'; | |
/// A provider used to access instance of this service | |
final keyValueStorageServiceProvider = Provider((ref) { | |
return KeyValueStorageService(); | |
}); | |
/// A service class for providing methods to store and retrieve key-value data | |
/// from common or secure storage. | |
class KeyValueStorageService { | |
/// The name of auth token key | |
static const _authTokenKey = 'authToken'; | |
/// The name of user model key | |
static const _authUserKey = 'authUserKey'; | |
/// The last map data key | |
static const _mapDataKey = 'mapDataKey'; | |
/// The name of notifications key | |
static const _notificationsKey = 'notificationsKey'; | |
/// The name of the first map load key | |
static const _firstMapLoadKey = 'firstMapLoadKey'; | |
/// The name of the stored locale key | |
static const _localeKey = 'localeKey'; | |
/// The name of the stored job alerts area key | |
static const _jobAlertsAreaKey = 'jobAlertsAreaKey'; | |
/// Instance of key-value storage base class | |
static const _keyValueStorage = KeyValueStorageBase.instance; | |
/// Returns last authenticated user | |
UserModel? getAuthUser() { | |
final user = _keyValueStorage.getCommon<String>(_authUserKey); | |
if (user == null) return null; | |
return UserModel.fromJson(jsonDecode(user) as JSON); | |
} | |
/// Returns last authentication token | |
Future<TokenModel?> getAuthToken() async { | |
final token = await _keyValueStorage.getEncrypted(_authTokenKey); | |
if (token == null) return null; | |
return TokenModel.fromJson(jsonDecode(token) as JSON); | |
} | |
/// Returns whether this is the first time the map is being loaded | |
bool isFirstMapLoad() { | |
final firstLoad = _keyValueStorage.getCommon<bool>(_firstMapLoadKey); | |
if (firstLoad == null) return true; | |
return firstLoad; | |
} | |
/// Sets the first map load to this value. Even though this method is | |
/// asynchronous, we don't care about it's completion which is why we don't | |
/// use `await` and let it execute in the background. | |
void setFirstMapLoad(bool isFirstLoad) { | |
_keyValueStorage.setCommon<bool>(_firstMapLoadKey, isFirstLoad); | |
} | |
/// Returns the stored locale | |
String? getLocale() { | |
final locale = _keyValueStorage.getCommon<String>(_localeKey); | |
return locale; | |
} | |
/// Sets the stored locale to this value. Even though this method is | |
/// asynchronous, we don't care about it's completion which is why we don't | |
/// use `await` and let it execute in the background. | |
void setLocale(String locale) { | |
_keyValueStorage.setCommon<String>(_localeKey, locale); | |
} | |
/// Sets the authenticated user to this value. Even though this method is | |
/// asynchronous, we don't care about it's completion which is why we don't | |
/// use `await` and let it execute in the background. | |
void setAuthUser(UserModel user) { | |
_keyValueStorage.setCommon<String>(_authUserKey, jsonEncode(user.toJson())); | |
} | |
/// Sets the authentication token to this value. Even though this method is | |
/// asynchronous, we don't care about it's completion which is why we don't | |
/// use `await` and let it execute in the background. | |
void setAuthToken(TokenModel token) { | |
_keyValueStorage.setEncrypted(_authTokenKey, jsonEncode(token.toJson())); | |
} | |
void clearUserData() { | |
_keyValueStorage | |
..removeCommon(_authUserKey) | |
..removeCommon(_firstMapLoadKey) | |
..removeCommon(_mapDataKey) | |
..removeCommon(_notificationsKey) | |
..removeCommon(_jobAlertsAreaKey) | |
..removeEncrypted(_authTokenKey); | |
} | |
/// Returns the last map data | |
MapDataModel? getMapData() { | |
final json = _keyValueStorage.getCommon<String>(_mapDataKey); | |
if (json == null) return null; | |
return MapDataModel.fromJson(jsonDecode(json)); | |
} | |
/// Sets the last map data. Even though this method is asynchronous, | |
/// we don't care about it's completion which is why we don't use `await` and | |
/// let it execute in the background. | |
void setMapData(MapDataModel mapData) { | |
_keyValueStorage.setCommon(_mapDataKey, jsonEncode(mapData.toJson())); | |
} | |
/// Returns the last job alerts area | |
MapDataModel? getJobAlertsArea() { | |
final json = _keyValueStorage.getCommon<String>(_jobAlertsAreaKey); | |
if (json == null) return null; | |
return MapDataModel.fromJson(jsonDecode(json)); | |
} | |
/// Sets the last job alerts area. Even though this method is asynchronous, | |
/// we don't care about it's completion which is why we don't use `await` and | |
/// let it execute in the background. | |
void setJobAlertsArea(MapDataModel mapData) { | |
_keyValueStorage.setCommon(_jobAlertsAreaKey, jsonEncode(mapData.toJson())); | |
} | |
void clearJobAlertsArea() { | |
_keyValueStorage.removeCommon(_jobAlertsAreaKey); | |
} | |
/// Returns the last notifications | |
List<NotificationModel> getNotifications() { | |
final list = _keyValueStorage.getCommon<List<String>>(_notificationsKey); | |
if (list == null) return []; | |
return list.map((e) => NotificationModel.fromJson(jsonDecode(e))).toList(); | |
} | |
/// Sets the notifications. Even though this method is asynchronous, we don't | |
/// care about it's completion which is why we don't use `await` and let it | |
/// execute in the background. | |
Future<bool> addNotification(NotificationModel notification) async { | |
final json = jsonEncode(notification.toJson()); | |
final jsonList = | |
_keyValueStorage.getCommon<List<String>>(_notificationsKey); | |
return _keyValueStorage.setCommon(_notificationsKey, [...?jsonList, json]); | |
} | |
/// Resets the authentication. Even though these methods are asynchronous, we | |
/// don't care about their completion which is why we don't use `await` and | |
/// let them execute in the background. | |
void resetKeys() { | |
_keyValueStorage | |
..clearCommon() | |
..clearEncrypted(); | |
} | |
} |
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)