Skip to main content

unified_local_data 2.0.0 copy "unified_local_data: ^2.0.0" to clipboard
unified_local_data: ^2.0.0 copied to clipboard

Engine-agnostic local storage for Flutter. One API over Hive, Isar or your own engine, with typed objects, queries, reactive streams and transactions.

Platform Dart License pub.dev version

๐Ÿ“ฆ unified_local_data

A universal, engine-agnostic local database abstraction layer for Flutter.
Swap between Hive, Isar, or any future engine โ€” without changing a single line of your app logic.


Table of Contents #


Overview #

unified_local_data is a production-grade Dart package that provides a single, unified interface for all local database operations in Flutter apps. It decouples your application logic from any specific database engine using the Strategy Pattern, enabling you to switch between Hive CE and Isar Community โ€” or plug in any future engine โ€” with zero refactoring.

This package contains the API and the Hive engine, and runs on every Flutter platform, the web included. The Isar engine is a separate package, unified_local_data_isar, so apps that don't use Isar don't ship its native library.

The package offers two data access paths:

  • Generic Path โ€” Engine-agnostic put/get/putObject API using JSON serialization. Fully portable across all engines.
  • Native Path โ€” Direct access to engine-native features like Hive TypeAdapter and Isar @collection with full index, binary speed, and native query support.

Why unified_local_data? #

Pain Point Solution
Locked into one database across all projects Engine-agnostic interface โ€” pick per project
Migrating databases requires rewriting data layer Swap via a single config change
Inconsistent API between Hive and Isar One unified API for all engines
No reactive support out of the box Built-in Stream watching for keys and collections
Complex objects need boilerplate per engine Generic DataModelAdapter<T> handles serialization universally
Querying differs wildly between engines Unified QueryBuilder<T> with filter, sort, limit, offset
Adding a new engine means rewriting everything Implement UnifiedDataSource for it; code written against the interface doesn't change
Can't use Hive TypeAdapters or Isar @collection natively NativeCollectionAccessor<T> gives full engine-native power

Architecture #

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                      APPLICATION LAYER                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Generic Path (portable)     โ”‚  Native Path (per engine)     โ”‚
โ”‚                              โ”‚                               โ”‚
โ”‚  UnifiedDataSource           โ”‚  NativeCollectionAccessor<T>  โ”‚
โ”‚  put / get / putObject       โ”‚  Hive: TypeAdapter            โ”‚
โ”‚  stored as JSON              โ”‚  Isar: @collection            โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚                               โ”‚
               โ–ผ                               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              UnifiedDataSourceFactory.create()               โ”‚
โ”‚            (singleton ยท picks the engine ยท init)             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚                               โ”‚
               โ–ผ                               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  HiveDataSource             โ”‚ โ”‚  IsarDataSource              โ”‚
โ”‚  (this package)             โ”‚ โ”‚  (unified_local_data_isar)   โ”‚
โ”‚  implements                 โ”‚ โ”‚  implements                  โ”‚
โ”‚  UnifiedDataSource          โ”‚ โ”‚  UnifiedDataSource           โ”‚
โ”‚                             โ”‚ โ”‚                              โ”‚
โ”‚  Generic: Box<dynamic>      โ”‚ โ”‚  Generic: IsarKvEntry        โ”‚
โ”‚  Native:  Box<T>            โ”‚ โ”‚  Native:  IsarCollection<T>  โ”‚
โ”‚           + TypeAdapter     โ”‚ โ”‚           + @collection      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Supported Engines #

Engine Use Case Comes from Platforms
Hive CE (hive_ce) Fast key-value storage, lightweight apps, caching This package All, including web
Isar Community (isar_community) Complex queries, relational data, large datasets unified_local_data_isar All but web
Your Engine Anything else, by implementing UnifiedDataSource Your code Yours to choose

Installation #

flutter pub add unified_local_data

Or add it to your pubspec.yaml yourself:

dependencies:
  unified_local_data: ^2.0.0

For the Isar engine, add unified_local_data_isar instead. It depends on this package and re-exports it:

flutter pub add unified_local_data_isar

Each package depends on its engine, so the generic path needs nothing else. If you use the native path and import hive_ce or isar_community in your own code, add that package to your pubspec.yaml too, along with its generator for build_runner.


Quick Start #

Initialize with Hive #

import 'package:unified_local_data/unified_local_data.dart';
import 'package:path_provider/path_provider.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  final db = await UnifiedDataSourceFactory.create(
    UnifiedDataSourceConfig.hive(
      directory: dir.path,
      preloadBoxes: ['settings', 'cache'],
    ),
  );

  await db.put<String>('settings', 'language', 'en');
  final language = await db.get<String>('settings', 'language');
}
Option Description
directory Where Hive keeps its box files. Ignored on the web.
preloadBoxes Collections opened during init().
collections Collections that drop() always covers, without opening them at init(). See below.
encryption An AES key for every box.

The Hive engine records the name of every collection it opens in a reserved box, _uld_collections, so that drop() can reach collections the app hasn't opened since it started. Don't use that name for a collection.

Versions before 1.2.0 kept no such record. If your app has shipped with an earlier version, pass its collection names as collections, so drop() also covers boxes written before the update:

UnifiedDataSourceConfig.hive(
  directory: dir.path,
  collections: ['auth', 'app_config', 'map_memory'],
);

Initialize with Isar #

With unified_local_data_isar added:

import 'package:unified_local_data_isar/unified_local_data_isar.dart';
import 'package:path_provider/path_provider.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  final db = await UnifiedDataSourceFactory.create(
    IsarDataSourceConfig(
      directory: dir.path,
      maxSizeMiB: 512,
      inspector: true,
    ),
  );

  await db.put<int>('analytics', 'launch_count', 42);
  final count = await db.get<int>('analytics', 'launch_count');
}

That's it. The rest of your code uses the same UnifiedDataSource API regardless of which engine is active.


Upgrading to 2.0 #

2.0 moves the Isar engine into its own package. Hive-only apps need no code changes; bump the constraint to ^2.0.0.

1.x 2.0
UnifiedDataSourceConfig.isar(...) IsarDataSourceConfig(...), from unified_local_data_isar
IsarDataSource, IsarDataSourceConfig imported from unified_local_data Import package:unified_local_data_isar/unified_local_data_isar.dart
schemas: List<dynamic> schemas: List<CollectionSchema<dynamic>>, optional
DatabaseEngine and UnifiedDataSourceConfig.engine Removed. Check the config's type instead.
A config subclass had no way into the factory Override createDataSource(); see Extending

Isar's data is untouched: the Isar engine stores it exactly as 1.x did.


Core API Reference #

All methods are available on the UnifiedDataSource interface returned by UnifiedDataSourceFactory.create().

Primitives #

Store and retrieve simple Dart types: String, int, double, bool, and lists of them.

Lists and maps read back differently on each engine. Isar round-trips values through JSON, so a list comes back as List<dynamic>. Hive keeps typed lists, but a map comes back as Map<dynamic, dynamic> once the app restarts. To read the same way on both:

  • Read lists as List<dynamic> and cast them: (await db.get<List<dynamic>>('c', 'k'))?.cast<String>().
  • Store maps with putObject / getObject (see Complex Objects), not put / get. get<Map<String, dynamic>> throws a DataSourceReadException on Hive after a restart.
await db.put<String>('settings', 'theme', 'dark');

final theme = await db.get<String>('settings', 'theme');

await db.delete('settings', 'theme');

await db.deleteAll('settings', ['theme', 'locale']);

await db.clear('settings');

await db.drop(collection: 'settings');

await db.drop();
Method Signature Description
put<T> Future<void> put<T>(String collection, String key, T value) Stores a value by key
get<T> Future<T?> get<T>(String collection, String key) Retrieves a value by key
delete Future<void> delete(String collection, String key) Removes a single key
deleteAll Future<void> deleteAll(String collection, List<String> keys) Removes several keys in one call
clear Future<void> clear(String collection) Removes all entries in a collection
drop Future<void> drop({String? collection}) Deletes a collection from disk, or the whole database when collection is omitted
dispose Future<void> dispose() Closes the database and releases all resources

clear vs drop: clear empties a collection but keeps it available for reuse. drop removes the collection itself. Calling drop() with no argument wipes the entire database, including collections the app hasn't opened since it started.

  • Hive, native: drop deletes the box files.
  • Hive, web: drop empties each box but keeps its IndexedDB database. Deleting the database would wait until every other connection to it closed, and a connection held by another tab of the app never does. Another open tab still reads the old values from its in-memory copy until it reloads.
  • Isar: drop deletes every entry of the collection, or clears the whole database.

Complex Objects (Generic Path) #

Use DataModelAdapter<T> to serialize/deserialize any custom Dart object. This path stores objects as JSON strings, making it fully portable across all engines.

Tip: If your model class already implements toJson/fromJson, you can optionally have it implement the DataModel<T> interface as a marker contract:

abstract interface class DataModel<T> {
  JsonMap toJson();
  T fromJson(JsonMap json);
}

The adapter (DataModelAdapter<T>) is what the API actually uses; DataModel<T> is an optional structural contract you can adopt for consistency.

Step 1 โ€” Define your model:

class User {
  final String id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as String,
        name: json['name'] as String,
        email: json['email'] as String,
      );

  Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};
}

Step 2 โ€” Create an adapter:

final userAdapter = DataModelAdapter<User>(
  fromJson: User.fromJson,
  toJson: (user) => user.toJson(),
);

Step 3 โ€” Store and retrieve:

final user = User(id: '1', name: 'Yasin', email: 'yasin@example.com');

await db.putObject<User>('users', 'user_1', user, userAdapter);

final retrieved = await db.getObject<User>('users', 'user_1', userAdapter);

final allUsers = await db.getAllObjects<User>('users', userAdapter);
Method Signature Description
putObject<T> Future<void> putObject<T>(String collection, String key, T value, DataModelAdapter<T> adapter) Stores a complex object
getObject<T> Future<T?> getObject<T>(String collection, String key, DataModelAdapter<T> adapter) Retrieves a complex object
getAllObjects<T> Future<List<T>> getAllObjects<T>(String collection, DataModelAdapter<T> adapter) Retrieves all objects in a collection

Batch Operations #

Write multiple entries in a single call for better performance.

await db.putAll<String>('settings', {
  'theme': 'dark',
  'language': 'en',
  'font_size': '16',
});

await db.putAllObjects<User>('users', {
  'user_1': User(id: '1', name: 'Yasin', email: 'yasin@example.com'),
  'user_2': User(id: '2', name: 'Ali', email: 'ali@example.com'),
}, userAdapter);
Method Signature Description
putAll<T> Future<void> putAll<T>(String collection, Map<String, T> entries) Batch stores primitives
putAllObjects<T> Future<void> putAllObjects<T>(String collection, Map<String, T> entries, DataModelAdapter<T> adapter) Batch stores complex objects

Collection Utilities #

final keys = await db.getAllKeys('users');

final exists = await db.containsKey('users', 'user_1');

final total = await db.count('users');

final allValues = await db.getAll<String>('settings');
Method Signature Description
getAll<T> Future<List<T>> getAll<T>(String collection) All raw values in a collection
getAllKeys Future<List<String>> getAllKeys(String collection) All keys in a collection
containsKey Future<bool> containsKey(String collection, String key) Checks key existence
count Future<int> count(String collection) Entry count in a collection

Reactive Streams #

Subscribe to real-time changes on keys, collections, or object collections.

db.watch<String>('settings', 'theme').listen((value) {
  print('Theme changed to: $value');
});

db.watchObject<User>('users', 'user_1', userAdapter).listen((user) {
  print('User changed: ${user?.name}');
});

db.watchAll<String>('settings').listen((values) {
  print('Settings updated: $values');
});

db.watchAllObjects<User>('users', userAdapter).listen((users) {
  print('Users updated: ${users.length}');
});
Method Signature Description
watch<T> Stream<T?> watch<T>(String collection, String key) Watches a single key for changes
watchObject<T> Stream<T?> watchObject<T>(String collection, String key, DataModelAdapter<T> adapter) Watches a single key as a typed object; emits null when the key is absent
watchAll<T> Stream<List<T>> watchAll<T>(String collection) Watches entire collection
watchAllObjects<T> Stream<List<T>> watchAllObjects<T>(String collection, DataModelAdapter<T> adapter) Watches collection as typed objects

All streams emit the current value immediately upon subscription, then emit on every subsequent change.


Query Builder #

Build fluent, chainable queries with filtering, sorting, pagination, and reactive watching.

final activeUsers = await db
    .query<User>('users', userAdapter)
    .where((user) => user.name.startsWith('Y'))
    .sortBy((a, b) => a.name.compareTo(b.name))
    .limit(20)
    .offset(0)
    .findAll();

final firstMatch = await db
    .query<User>('users', userAdapter)
    .where((user) => user.email.contains('@example.com'))
    .findFirst();

final total = await db
    .query<User>('users', userAdapter)
    .where((user) => user.id.isNotEmpty)
    .count();

db.query<User>('users', userAdapter)
    .where((user) => user.name.contains('Yasin'))
    .sortBy((a, b) => a.name.compareTo(b.name))
    .limit(10)
    .watch()
    .listen((users) {
  print('Matching users: ${users.length}');
});
Method Signature Description
query<T> QueryBuilder<T> query<T>(String collection, DataModelAdapter<T> adapter) Creates a query builder
.where() QueryBuilder<T> where(bool Function(T) clause) Adds a filter predicate
.sortBy() QueryBuilder<T> sortBy(int Function(T, T) comparator) Sets sort order
.limit() QueryBuilder<T> limit(int count) Limits result count
.offset() QueryBuilder<T> offset(int start) Skips first N results
.findAll() Future<List<T>> findAll() Executes and returns all matches
.findFirst() Future<T?> findFirst() Executes and returns first match
.count() Future<int> count() Returns number of matches
.deleteAll() Future<bool> deleteAll() Deletes all matched objects (native engines only; throws on generic in-memory path)
.watch() Stream<List<T>> watch() Watches query results reactively

Transactions #

Wrap multiple operations in a transaction for atomicity.

await db.transaction(() async {
  await db.put<String>('settings', 'theme', 'dark');
  await db.put<String>('settings', 'language', 'en');
  await db.putObject<User>('users', 'user_1', newUser, userAdapter);
});

Hive: Operations are executed sequentially (Hive has no native transaction support). If one fails, the writes before it stay.

Isar: The action runs in one native writeTxn. Every data source call inside it, and every write through native<T>(), joins that transaction, and so does a nested transaction(). If the action throws, all of its writes are rolled back. Don't call rawInstance.writeTxn() inside it: Isar doesn't nest transactions.

On both engines, transaction passes on whatever the action throws, unchanged.


Native Engine Access #

Understanding the Two Paths #

The package provides two distinct ways to store complex objects:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        DECISION FLOWCHART                            โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                                      โ”‚
โ”‚  Need to swap engines freely across projects?                        โ”‚
โ”‚  โ”œโ”€ YES โ†’ Use Generic Path                                          โ”‚
โ”‚  โ”‚        db.putObject / db.getObject                                โ”‚
โ”‚  โ”‚        Uses DataModelAdapter<T> + JSON serialization              โ”‚
โ”‚  โ”‚        โœ… Works on ALL engines without changes                    โ”‚
โ”‚  โ”‚        โš ๏ธ No engine-native indexes or binary speed                โ”‚
โ”‚  โ”‚                                                                   โ”‚
โ”‚  โ””โ”€ NO โ†’ Engine is fixed for this project?                           โ”‚
โ”‚     โ”œโ”€ YES โ†’ Use Native Path                                         โ”‚
โ”‚     โ”‚        db.native<T>('collection')                              โ”‚
โ”‚     โ”‚        โœ… Full Hive TypeAdapter support                        โ”‚
โ”‚     โ”‚        โœ… Full Isar @collection + indexes                     โ”‚
โ”‚     โ”‚        โœ… Binary speed, zero JSON overhead                    โ”‚
โ”‚     โ”‚        โš ๏ธ Models are engine-specific                           โ”‚
โ”‚     โ”‚                                                                โ”‚
โ”‚     โ””โ”€ Need Isar's full .filter().sortBy() generated DSL?           โ”‚
โ”‚        โ””โ”€ Cast to IsarDataSource, use rawInstance                    โ”‚
โ”‚           โœ… Complete Isar native query power                       โ”‚
โ”‚           โŒ Completely tied to Isar                                โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Path Portability Performance Query Power Best For
Generic (putObject) โœ… Any engine Good In-memory filter Settings, cache, portable data
Native (db.native<T>()) โš ๏ธ Engine-specific models Best In-memory filter Domain entities with TypeAdapter / @collection
Raw (rawInstance) โŒ Single engine only Best Full native DSL Complex Isar queries with composite indexes

NativeCollectionAccessor API #

The NativeCollectionAccessor<T> interface provides a unified API for working with engine-native models:

Method Signature Description
put Future<void> put(T item) Stores a single native object
putAll Future<void> putAll(List<T> items) Batch stores native objects
getById Future<T?> getById(dynamic id) Retrieves by ID
getAll Future<List<T>> getAll() Retrieves all objects
deleteById Future<void> deleteById(dynamic id) Deletes by ID
deleteAll Future<void> deleteAll() Clears all objects
count Future<int> count() Returns object count
watchAll Stream<List<T>> watchAll() Watches all objects reactively
watchById Stream<T?> watchById(dynamic id) Watches a single object reactively
query QueryBuilder<T> query() Creates a chainable query builder

Hive โ€” Native TypeAdapter Flow #

Use Hive's native TypeAdapter for maximum binary serialization speed.

Step 1 โ€” Define your Hive model:

import 'package:hive_ce/hive_ce.dart';

part 'user_entity.g.dart';

@HiveType(typeId: 1)
class UserEntity extends HiveObject {
  @HiveField(0)
  final String id;

  @HiveField(1)
  final String name;

  @HiveField(2)
  final String email;

  @HiveField(3)
  final DateTime createdAt;

  UserEntity({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });
}

Step 2 โ€” Run code generation:

dart run build_runner build --delete-conflicting-outputs

Step 3 โ€” Register adapter, open typed box, and use:

import 'package:hive_ce/hive_ce.dart';
import 'package:unified_local_data/unified_local_data.dart';
import 'package:path_provider/path_provider.dart';

import 'models/user_entity.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  Hive.registerAdapter(UserEntityAdapter());

  final db = await UnifiedDataSourceFactory.create(
    UnifiedDataSourceConfig.hive(directory: dir.path),
  );

  final hiveDb = db as HiveDataSource;
  await hiveDb.openTypedBox<UserEntity>('users');

  final users = db.native<UserEntity>('users');

  await users.put(UserEntity(
    id: '1',
    name: 'Yasin',
    email: 'yasin@example.com',
    createdAt: DateTime.now(),
  ));

  await users.putAll([
    UserEntity(
      id: '2',
      name: 'Ali',
      email: 'ali@example.com',
      createdAt: DateTime.now(),
    ),
  ]);

  // NOTE: HiveNativeAccessor.putAll() uses box.addAll() โ€” items are appended
  // with auto-incremented integer keys. For keyed upsert, call put() individually.

  final allUsers = await users.getAll();

  final yasin = await users.getById('1');

  users.watchAll().listen((userList) {
    print('Users changed: ${userList.length}');
  });

  final filtered = await users
      .query()
      .where((u) => u.name.startsWith('Y'))
      .sortBy((a, b) => a.createdAt.compareTo(b.createdAt))
      .limit(10)
      .findAll();
}

Isar โ€” Native @collection Flow #

With the Isar engine, db.native<T>() works on your own @collection classes, and IsarDataSource.rawInstance gives you Isar's full query language. Both are covered in the unified_local_data_isar README.


When to Use Which Path #

Scenario Recommended Path Why
App settings, feature flags, simple cache Generic put/get Simple key-value, no need for native features
User profile, auth tokens Generic putObject Portable across engines, easy to serialize
Large product catalog with search Native db.native<T>() Benefits from Isar indexes and binary speed
Chat messages with complex queries Native + Raw Need Isar composite indexes and full-text search
Offline-first app with sync Native db.native<T>() Need full TypeAdapter/collection for performance
Shared package used across many projects Generic path only Maximum portability, no engine lock-in
Prototype / MVP Generic path Ship fast, optimize later

Swapping Engines #

The entire point of this package โ€” swap your database with one config change:

// Using Hive
final db = await UnifiedDataSourceFactory.create(
  UnifiedDataSourceConfig.hive(directory: dir.path),
);

// Switch to Isar, from unified_local_data_isar โ€” no other code changes
// needed (for Generic Path)
final db = await UnifiedDataSourceFactory.create(
  IsarDataSourceConfig(directory: dir.path),
);

Swap at runtime:

// reset() calls dispose() on the current instance, then clears it
await UnifiedDataSourceFactory.reset();

await UnifiedDataSourceFactory.create(
  IsarDataSourceConfig(directory: dir.path),
);

final db = UnifiedDataSourceFactory.instance;

You can also dispose a specific instance directly:

await db.dispose(); // closes the DB and frees all resources

Note: create() automatically disposes any existing instance before creating the new one, so you don't need to call reset() first when simply switching configs.

Code using the Generic Path (put/get/putObject) swaps seamlessly. Code using the Native Path (db.native<T>()) requires engine-specific models and may need adjustment when switching engines.


Extending with a New Engine #

UnifiedDataSource is an ordinary interface, so another engine (Drift, Sqflite, โ€ฆ) can sit behind it, in your app or in a package of its own. The Isar engine, unified_local_data_isar, is built exactly this way.

An engine has two parts:

  1. A UnifiedDataSource implementation.
  2. A UnifiedDataSourceConfig subclass whose createDataSource() builds it. UnifiedDataSourceFactory.create calls createDataSource(), then init().

Import package:unified_local_data/engine.dart. Besides the whole API, it exports helpers that make an engine behave like the built-in ones:

  • guardRead, guardWrite and guardStream report the engine's errors as DataSourceReadException and DataSourceWriteException, keeping the original as cause.
  • InMemoryQueryBuilder answers query() for engines without a query language.
import 'package:unified_local_data/engine.dart';

final class DriftDataSourceConfig extends UnifiedDataSourceConfig {
  const DriftDataSourceConfig({required super.directory, required this.name});

  final String name;

  @override
  UnifiedDataSource createDataSource() => DriftDataSource(config: this);
}

final class DriftDataSource implements UnifiedDataSource {
  DriftDataSource({required this.config});

  final DriftDataSourceConfig config;

  @override
  Future<void> init() async { /* open the Drift database */ }

  @override
  Future<T?> get<T>(String collection, String key) => guardRead(
        'Failed to get key "$key" from "$collection"',
        () async { /* read through Drift */ },
      );

  @override
  QueryBuilder<T> query<T>(String collection, DataModelAdapter<T> adapter) =>
      InMemoryQueryBuilder<T>(
        fetcher: () => getAllObjects(collection, adapter),
        watcher: () => watchAllObjects(collection, adapter),
      );

  // ... implement all other methods
}

final db = await UnifiedDataSourceFactory.create(
  DriftDataSourceConfig(directory: dir.path, name: 'app'),
);

Code written against UnifiedDataSource works with it unchanged.


Error Handling #

The package uses a sealed exception hierarchy for exhaustive, compile-time checked error handling:

try {
  final value = await db.get<String>('settings', 'theme');
} on DataSourceNotInitializedException {
  // Database not initialized โ€” call init() first
} on DataSourceReadException catch (e) {
  print('Read error: ${e.message}, cause: ${e.cause}');
} on DataSourceWriteException catch (e) {
  print('Write error: ${e.message}, cause: ${e.cause}');
} on DataNotFoundException catch (e) {
  // Requested data does not exist
} on UnsupportedOperationException catch (e) {
  // Operation not supported by current engine
} on LocalDataSourceException catch (e) {
  // Catch-all for any data source error
}
Exception When
DataSourceNotInitializedException init() was not called before usage
DataSourceReadException A read operation fails (corruption, cast error, etc.)
DataSourceWriteException A write operation fails (disk full, permission, etc.)
DataNotFoundException Explicitly requested data does not exist
UnsupportedOperationException Operation not available on current engine

Every operation on the data source reports its failures this way, on both engines:

  • Reads throw DataSourceReadException; writes, deletes, clear and drop throw DataSourceWriteException. The engine's own error is in cause, and the original stack trace is kept.
  • The message names the key and the collection, for example Failed to get key "camera" from "map_memory".
  • Watch streams (watch, watchObject, watchAll, watchAllObjects) emit their errors as DataSourceReadException instead of throwing them.
  • transaction passes on whatever its action throws, unchanged.
  • The native path (NativeCollectionAccessor, openTypedBox's box, Isar's rawInstance) is the engine itself and throws the engine's errors.

Stream Extensions #

The package includes utility extensions for working with reactive streams:

import 'package:unified_local_data/unified_local_data.dart';

db.watchAll<String>('settings')
    .debounceTime(Duration(milliseconds: 300))
    .listen((values) {
  print('Debounced update: $values');
});

db.watch<String>('settings', 'theme')
    .distinctUntilChanged()
    .listen((value) {
  print('Theme actually changed to: $value');
});
Extension Description
.debounceTime(Duration) Waits for a pause in emissions before forwarding
.distinctUntilChanged() Filters out consecutive duplicate values

Design Patterns #

Pattern Where Purpose
Strategy UnifiedDataSource interface + engine implementations Swap database engine without changing consumer code
Factory UnifiedDataSourceFactory + UnifiedDataSourceConfig.createDataSource() Each config builds its own engine; the factory opens it and keeps it
Adapter DataModelAdapter<T> + NativeCollectionAccessor<T> Bridge between domain models and storage
Repository Each engine implementation Encapsulates all data access behind a single contract
Singleton UnifiedDataSourceFactory._instance Single database instance across the app
Builder QueryBuilder<T> chain Fluent query construction with deferred execution
Sealed Class Exceptions hierarchy Exhaustive compile-time checked exception types
Abstract Class UnifiedDataSourceConfig Shared config base with engine-specific subclasses

Folder Structure #

unified_local_data/                                # The GitHub repository
โ””โ”€โ”€ packages/
    โ”œโ”€โ”€ unified_local_data/                        # This package
    โ”‚   โ”œโ”€โ”€ pubspec.yaml
    โ”‚   โ”œโ”€โ”€ example/lib/main.dart                  # Minimal Hive example
    โ”‚   โ”œโ”€โ”€ lib/
    โ”‚   โ”‚   โ”œโ”€โ”€ unified_local_data.dart            # The public API
    โ”‚   โ”‚   โ”œโ”€โ”€ engine.dart                        # The API plus helpers for engine authors
    โ”‚   โ”‚   โ””โ”€โ”€ src/
    โ”‚   โ”‚       โ”œโ”€โ”€ core/
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ unified_data_source.dart          # The UnifiedDataSource interface
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ unified_data_source_config.dart   # Base config; builds the data source
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ unified_data_source_factory.dart  # UnifiedDataSourceFactory
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ data_model.dart                   # DataModel<T> + DataModelAdapter<T>
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ native_collection_accessor.dart   # Native engine access interface
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ query_builder.dart                # QueryBuilder<T> + in-memory builder
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ exceptions.dart                   # Sealed exception hierarchy
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ guard.dart                        # Wraps engine errors in those exceptions
    โ”‚   โ”‚       โ”‚   โ””โ”€โ”€ typedefs.dart                     # Shared type aliases
    โ”‚   โ”‚       โ”œโ”€โ”€ engines/hive/
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ hive_data_source.dart             # Hive implementation
    โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ hive_native_accessor.dart         # Hive TypeAdapter accessor
    โ”‚   โ”‚       โ”‚   โ””โ”€โ”€ hive_config.dart                  # Hive-specific configuration
    โ”‚   โ”‚       โ””โ”€โ”€ extensions/
    โ”‚   โ”‚           โ””โ”€โ”€ stream_extensions.dart            # debounceTime, distinctUntilChanged
    โ”‚   โ””โ”€โ”€ test/                                  # Hive on the VM and in the browser
    โ””โ”€โ”€ unified_local_data_isar/                   # The Isar engine, published separately
        โ”œโ”€โ”€ example/lib/main.dart
        โ”œโ”€โ”€ lib/
        โ”‚   โ”œโ”€โ”€ unified_local_data_isar.dart       # Re-exports unified_local_data
        โ”‚   โ””โ”€โ”€ src/                               # IsarDataSource, IsarDataSourceConfig, ...
        โ””โ”€โ”€ test/                                  # Isar on the VM

FAQ #

Can I use this in a pure Dart project (no Flutter)?

No. The package depends on the Flutter SDK, so it needs a Flutter project. Its tests do run on the Dart VM through flutter test, with a temporary directory in place of path_provider.

Do I need to register Hive TypeAdapters manually?

Generic Path: No. Objects are serialized to JSON via DataModelAdapter<T>, avoiding the need for Hive TypeAdapter registration.

Native Path: Yes. Register your TypeAdapters before opening typed boxes:

Hive.registerAdapter(UserEntityAdapter());
final hiveDb = db as HiveDataSource;
await hiveDb.openTypedBox<UserEntity>('users');
Do I need to define Isar @collection schemas?

Generic Path: No. The package uses an internal IsarKvEntry collection for key-value storage.

Native Path: Yes. Define your @collection classes, run build_runner, and pass schemas at initialization:

IsarDataSourceConfig(
  directory: dir.path,
  schemas: [ProductEntitySchema, OrderEntitySchema],
)
Can I mix Generic and Native paths in the same project?

Absolutely. This is a common and recommended pattern:

// Generic: app settings (portable)
await db.put<String>('settings', 'theme', 'dark');

// Native: domain entities (engine-optimized)
final products = db.native<ProductEntity>('products');
await products.put(myProduct);
Can I access the raw Hive/Isar instance?

Yes. Both engine implementations expose their raw instance for advanced use cases:

// Isar
final isarDb = db as IsarDataSource;
final isar = isarDb.rawInstance;

// Hive โ€” access typed boxes directly
final hiveDb = db as HiveDataSource;
final box = await hiveDb.openTypedBox<UserEntity>('users');

This is intentionally not part of the UnifiedDataSource interface to maintain abstraction.

Is this thread-safe?

Yes. Both Hive and Isar handle concurrent access internally. The package uses async/await throughout and avoids shared mutable state beyond the singleton instance.

What happens if I call methods before init()?

A DataSourceNotInitializedException is thrown immediately. Always call UnifiedDataSourceFactory.create() before accessing the database.

How do I encrypt my data?

With Hive, pass an encryption config:

UnifiedDataSourceConfig.hive(
  directory: dir.path,
  encryption: HiveEncryptionConfig(key: mySecureKey),
);

Isar Community does not support encryption natively. You can encrypt at the application layer using the DataModelAdapter<T> serialization step.

When should I call dispose()?

Call dispose() when your app is shutting down or when you want to explicitly release database resources. Both engine implementations close all open boxes/databases and clear internal caches.

// Dispose directly on the instance
await db.dispose();

// Or via the factory (also clears the singleton)
await UnifiedDataSourceFactory.reset();

UnifiedDataSourceFactory.create() automatically calls dispose() on any existing instance before opening the new one, so manual disposal is only needed on app exit or in tests.

How do I handle schema migrations?

Generic Path: Since objects are stored as JSON, schema evolution is handled by your model's fromJson factory. Add default values for new fields and handle missing keys gracefully.

Native Path (Hive): Hive TypeAdapters handle field additions via @HiveField indices. New fields with higher indices are backward-compatible.

Native Path (Isar): Isar handles schema migrations automatically for non-breaking changes (adding fields, indexes). For breaking changes, consult the Isar migration guide.

What if my engine doesn't support a specific operation?

The engine implementation should throw UnsupportedOperationException for operations it cannot fulfill. The sealed exception hierarchy allows you to handle this gracefully:

try {
  await db.transaction(() async { /* ... */ });
} on UnsupportedOperationException {
  // Fallback: execute operations without transaction
}

License #

MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Built with โค๏ธ for the Flutter community
One interface. Any engine. Zero rewrites.
Generic when you need portability. Native when you need power.

1
likes
160
points
473
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Engine-agnostic local storage for Flutter. One API over Hive, Isar or your own engine, with typed objects, queries, reactive streams and transactions.

Repository (GitHub)
View/report issues

Topics

#database #storage #hive #offline #persistence

License

MIT (license)

Dependencies

flutter, hive_ce

More

Packages that depend on unified_local_data