๐ฆ 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
- Why unified_local_data?
- Architecture
- Supported Engines
- Installation
- Quick Start
- Core API Reference
- Native Engine Access
- Swapping Engines
- Extending with a New Engine
- Error Handling
- Stream Extensions
- Design Patterns
- Folder Structure
- FAQ
- License
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.
The package offers two data access paths:
- Generic Path โ Engine-agnostic
put/get/putObjectAPI using JSON serialization. Fully portable across all engines. - Native Path โ Direct access to engine-native features like Hive
TypeAdapterand Isar@collectionwith 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 | Open/Closed Principle โ add engines without touching core |
| Can't use Hive TypeAdapters or Isar @collection natively | NativeCollectionAccessor<T> gives full engine-native power |
Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ APPLICATION LAYER โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Generic Path โ Native Path โ
โ (Portable) โ (Full Engine Power) โ
โ โ โ
โ LocalDataSource โ NativeCollectionAccessor<T> โ
โ put/get/putObject โ Hive: full TypeAdapter โ
โ via JSON โ Isar: full @collection โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโดโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ LocalDataSourceFactory.create() โ
โ (Singleton ยท Strategy Selection ยท Init) โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HiveDataSource โ โ IsarDataSource โ
โ implements โ โ implements โ
โ LocalDataSource โ โ LocalDataSource โ
โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Generic: Box<dynamic>โ โ โ โ Generic: IsarKvEntry โ โ
โ โ Native: Box<T> โ โ โ โ Native: IsarCollection โ โ
โ โ + TypeAdapter โ โ โ โ + @collection โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ๏ฟฝ๏ฟฝโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ FUTURE ENGINE SLOT โ
โ DriftDataSource / SqfliteDataSource / etc. โ
โ implements LocalDataSource โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Supported Engines
| Engine | Use Case | Package |
|---|---|---|
| Hive CE | Fast key-value storage, lightweight apps, caching | hive_ce |
| Isar Community | Complex queries, relational data, large datasets | isar_community |
| Your Engine | Extend anytime โ zero changes to core | See Extending |
Installation
Add the following to your pubspec.yaml:
dependencies:
unified_local_data:
path: packages/unified_local_data # or your git/pub reference
The package bundles both Hive CE and Isar Community internally. You do not need to add them separately.
Quick Start
Initialize with Hive
import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
final db = await LocalDataSourceFactory.create(
LocalDataSourceConfig.hive(
directory: dir.path,
preloadBoxes: ['settings', 'cache'],
),
);
await db.put<String>('settings', 'language', 'en');
final language = await db.get<String>('settings', 'language');
}
Initialize with Isar
import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
final db = await LocalDataSourceFactory.create(
LocalDataSourceConfig.isar(
directory: dir.path,
schemas: [],
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
LocalDataSourceAPI regardless of which engine is active.
Core API Reference
All methods are available on the LocalDataSource interface returned by LocalDataSourceFactory.create().
Primitives
Store and retrieve simple Dart types (String, int, double, bool, List, Map).
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 |
clearvsdrop:clearempties a collection but keeps it available for reuse.dropremoves the collection itself โ on Hive it deletes the box file from disk, on Isar it deletes every entry belonging to that collection. Callingdrop()with no argument wipes the entire 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 theDataModel<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).
Isar: Operations are wrapped in a native
writeTxnfor true ACID compliance.
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/local_data_source.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 LocalDataSourceFactory.create(
LocalDataSourceConfig.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
Use Isar's native @collection annotation for full index and schema support.
Step 1 โ Define your Isar model:
import 'package:isar_community/isar_community.dart';
part 'product_entity.g.dart';
@collection
class ProductEntity {
Id id = Isar.autoIncrement;
@Index()
late String title;
late double price;
@Index(composite: [CompositeIndex('price')])
late String category;
late DateTime createdAt;
}
@embedded
class Address {
late String street;
late String city;
late String zipCode;
}
Step 2 โ Run code generation:
dart run build_runner build --delete-conflicting-outputs
Step 3 โ Pass schemas at init and use:
import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';
import 'models/product_entity.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
final db = await LocalDataSourceFactory.create(
LocalDataSourceConfig.isar(
directory: dir.path,
schemas: [ProductEntitySchema],
),
);
final products = db.native<ProductEntity>('products');
await products.put(
ProductEntity()
..title = 'Flutter Widget'
..price = 29.99
..category = 'Development'
..createdAt = DateTime.now(),
);
await products.putAll([
ProductEntity()
..title = 'Dart Package'
..price = 9.99
..category = 'Development'
..createdAt = DateTime.now(),
ProductEntity()
..title = 'UI Kit'
..price = 49.99
..category = 'Design'
..createdAt = DateTime.now(),
]);
final allProducts = await products.getAll();
final product = await products.getById(1);
products.watchAll().listen((productList) {
print('Products changed: ${productList.length}');
});
final expensive = await products
.query()
.where((p) => p.price > 20.0)
.sortBy((a, b) => b.price.compareTo(a.price))
.limit(5)
.findAll();
}
Isar โ Raw Instance Access
When you need Isar's full native query DSL โ generated .filter(), .sortBy(), composite index queries โ access the raw Isar instance directly:
import 'package:isar_community/isar_community.dart';
import 'package:unified_local_data/local_data_source.dart';
import 'models/product_entity.dart';
Future<void> advancedIsarQuery(LocalDataSource db) async {
final isarDb = db as IsarDataSource;
final isar = isarDb.rawInstance;
final results = await isar.productEntitys
.filter()
.categoryEqualTo('Development')
.and()
.priceGreaterThan(10.0)
.sortByPriceDesc()
.limit(20)
.findAll();
isar.productEntitys
.filter()
.priceGreaterThan(50)
.watch(fireImmediately: true)
.listen((premiumProducts) {
print('Premium products: ${premiumProducts.length}');
});
}
โ ๏ธ Warning: Using
rawInstanceties your code directly to Isar. This should be limited to repository implementations where you have consciously chosen Isar as the engine.
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 LocalDataSourceFactory.create(
LocalDataSourceConfig.hive(directory: dir.path),
);
// Switch to Isar โ no other code changes needed (for Generic Path)
final db = await LocalDataSourceFactory.create(
LocalDataSourceConfig.isar(directory: dir.path, schemas: []),
);
Swap at runtime:
// reset() calls dispose() on the current instance, then clears it
await LocalDataSourceFactory.reset();
await LocalDataSourceFactory.create(
LocalDataSourceConfig.isar(directory: dir.path, schemas: []),
);
final db = LocalDataSourceFactory.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 callreset()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
Adding a new database engine (e.g., Drift, Sqflite) requires zero changes to the core abstraction. This is the Open/Closed Principle in action.
Step 1 โ Add the engine enum value:
enum DatabaseEngine { hive, isar, drift }
Step 2 โ Create engine-specific config:
final class DriftDataSourceConfig extends LocalDataSourceConfig {
final String databaseName;
const DriftDataSourceConfig({
required super.directory,
required this.databaseName,
}) : super(engine: DatabaseEngine.drift);
}
Step 3 โ Implement the interface:
final class DriftDataSource implements LocalDataSource {
final DriftDataSourceConfig config;
DriftDataSource({required this.config});
@override
Future<void> init() async { /* Drift initialization */ }
@override
Future<void> put<T>(String collection, String key, T value) async { /* ... */ }
@override
NativeCollectionAccessor<T> native<T>(String collection) { /* ... */ }
// ... implement all other methods
}
Step 4 โ Register in the factory (one line):
final LocalDataSource dataSource = switch (config) {
HiveDataSourceConfig() => HiveDataSource(config: config),
IsarDataSourceConfig() => IsarDataSource(config: config),
DriftDataSourceConfig() => DriftDataSource(config: config),
};
That's it. No existing code changes. Pure Open/Closed Principle.
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 |
Stream Extensions
The package includes utility extensions for working with reactive streams:
import 'package:unified_local_data/local_data_source.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 | LocalDataSource interface + engine implementations |
Swap database engine without changing consumer code |
| Factory | LocalDataSourceFactory |
Unified creation with sealed config type matching |
| Adapter | DataModelAdapter<T> + NativeCollectionAccessor<T> |
Bridge between domain models and storage |
| Repository | Each engine implementation | Encapsulates all data access behind a single contract |
| Singleton | LocalDataSourceFactory._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 | LocalDataSourceConfig |
Shared config base with engine-specific subclasses |
Folder Structure
local_data_source/
โโโ pubspec.yaml
โโโ lib/
โ โโโ local_data_source.dart # Public barrel export
โ โโโ src/
โ โโโ core/
โ โ โโโ local_data_source.dart # Base abstract interface
โ โ โโโ local_data_source_config.dart # Unified configuration (abstract)
โ โ โโโ local_data_source_factory.dart # Factory entry point
โ โ โโโ data_model.dart # DataModel<T> interface + DataModelAdapter<T>
โ โ โโโ native_collection_accessor.dart # Native engine access interface
โ โ โโโ query_builder.dart # Abstract + in-memory query builder
โ โ โโโ exceptions.dart # Sealed exception hierarchy
โ โ โโโ 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
โ โ โโโ isar/
โ โ โโโ isar_data_source.dart # Isar implementation
โ โ โโโ isar_native_accessor.dart # Isar @collection accessor
โ โ โโโ isar_config.dart # Isar-specific configuration
โ โ โโโ isar_kv_entry.dart # Internal @collection for KV ops
โ โ โโโ isar_kv_entry.g.dart # Generated Isar schema (build_runner)
โ โโโ extensions/
โ โโโ stream_extensions.dart # Reactive stream utilities
โโโ test/
โโโ local_data_source_test.dart
FAQ
Can I use this in a pure Dart project (no Flutter)?
Yes, but you will need to provide the directory path manually instead of using path_provider. The core interface and Hive engine work in pure Dart. Isar also supports pure Dart with manual library setup.
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:
LocalDataSourceConfig.isar(
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 LocalDataSource 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 LocalDataSourceFactory.create() before accessing the database.
How do I encrypt my data?
With Hive, pass an encryption config:
LocalDataSourceConfig.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 LocalDataSourceFactory.reset();
LocalDataSourceFactory.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.