DEV Community

Cover image for Building a Robust Local Storage Service in Flutter
Abdur Rafay Saleem
Abdur Rafay Saleem

Posted on

1

Building a Robust Local Storage Service in Flutter

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:

  1. KeyValueStorageBase: A low-level base class that directly interfaces with storage plugins
  2. KeyValueStorageService: A high-level service that provides typed, domain-specific storage operations

Why Two Layers?

This separation serves several important purposes:

  1. 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
  2. Single Responsibility

    • Base class: How to store and retrieve data
    • Service class: What to store and when as well as managing the keys.
  3. Dependency Isolation

    • Only the base class knows about SharedPreferences and FlutterSecureStorage
    • Application code only interacts with the service layer

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();
Enter fullscreen mode Exit fullscreen mode

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

  1. 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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?
     };
   }
Enter fullscreen mode Exit fullscreen mode
  1. Error Handling
   try {
     return _secureStorage!.read(key: key);
   } on PlatformException catch (ex) {
     appLogger.debug('$ex');
     return Future<String?>.value();
   }
Enter fullscreen mode Exit fullscreen mode

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

  1. Domain-Specific Operations

    • Methods map directly to business needs
    • Handles serialization/deserialization
    • Provides type safety at the domain level
  2. Centralized Key Management

   static const _authTokenKey = 'authToken';
   static const _authUserKey = 'authUserKey';
Enter fullscreen mode Exit fullscreen mode
  1. Intelligent Storage Decisions

    • Sensitive data uses encrypted storage
    • Regular data uses shared preferences
  2. Bulk Operations

   void clearUserData() {
     _keyValueStorage
       ..removeCommon(_authUserKey)
       ..removeCommon(_firstMapLoadKey)
       ..removeEncrypted(_authTokenKey);
   }
Enter fullscreen mode Exit fullscreen mode

Benefits of This Architecture

  1. Type Safety

    • Compile-time type checking for stored values
    • Domain models are properly serialized/deserialized
  2. Security

    • Clear separation between secure and non-secure storage
    • Encrypted storage for sensitive data
    • Centralized security decisions
  3. Maintainability

    • Single source of truth for storage operations
    • Easy to add new storage operations
    • Consistent error handling
  4. Testability

    • Base class can be mocked for testing
    • Service layer can be tested independently
    • Clear boundaries for unit tests
  5. 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();
Enter fullscreen mode Exit fullscreen mode

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.

Sentry blog image

The Visual Studio App Center’s retiring

But sadly….you’re not. See how to make the switch to Sentry for all your crash reporting needs.

Read more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay