lean_repo 0.0.1
lean_repo: ^0.0.1 copied to clipboard
A boilerplate-killer for caching and network synchronization. Supports Stale-While-Revalidate, Cache-First, and Network-Only strategies.
LeanRepo #
The boilerplate-killer for caching and network synchronization in Dart & Flutter.
Stop writing the same if (cache != null) logic in every repository. LeanRepo provides a standardized, type-safe way to handle the "Cache then Network" flow (Stale-While-Revalidate) and other common synchronization strategies.
It is database-agnostic. Use it with Hive, SQLite, SharedPreferences, or just in-memory.
📑 Index #
- Features
- Installation
- Usage
- Recommended Architecture
- Strategies
- Custom Drivers
- Integration Recipes
- Contributing
✨ Features #
- Strategy Pattern: Switch between
StaleWhileRevalidate,CacheFirst,NetworkOnly, orCacheOnlywith a single enum. - Driver Agnostic: Comes with an
InMemoryDriverfor testing. Easily plug in your own driver for Hive, generic files, or any other storage. - Type Safe: Fully generic
<T>. Nodynamiccasting required. - Fail-Safe: Automatically handles network errors by falling back to cache (if available) or emitting typed error events.
- Zero Dependencies: (Almost). Extremely lightweight.
📦 Installation #
dart pub add lean_repo
🚀 Usage #
1. Initialize the Repository #
Create a single instance of LeanRepository. You must provide a CacheDriver.
// For testing/prototyping, use the built-in InMemoryDriver
final repo = LeanRepository(
cacheDriver: InMemoryDriver(),
);
// For production, see "Custom Drivers" below to use Hive/SharedPrefs
2. Stream Data #
The core method is stream(). It returns a Stream<Resource<T>> that emits updates based on your strategy.
Stream<Resource<User>> getUser(String userId) {
return repo.stream<User>(
// 1. Unique Cache Key
key: 'user_$userId',
// 2. Network Fetcher (Return a Future<T>)
fetch: () async {
final response = await myApiClient.get('/users/$userId');
return User.fromJson(response.data);
},
// 3. Serializers (How to store/retrieve from cache)
fromJson: (json) => User.fromJson(json),
toJson: (user) => user.toJson(),
// 4. Strategy (Optional, defaults to staleWhileRevalidate)
strategy: CacheStrategy.staleWhileRevalidate,
);
}
3. Consume in UI (Flutter Example) #
Use a StreamBuilder to listen to the resource.
StreamBuilder<Resource<User>>(
stream: getUser('123'),
builder: (context, snapshot) {
final resource = snapshot.data;
if (resource == null) return CircularProgressIndicator();
// Show old data while fetching new data?
if (resource.source == SourceType.cache) {
showToast('Refreshing data...');
}
if (resource.isError) {
return Text('Error: ${resource.error}');
}
final user = resource.data!;
return Text('Hello ${user.name}');
},
);
🏗️ Recommended Architecture #
While you can use LeanRepository directly in your UI, the best practice is to wrap it in a specific repository class (e.g., ProductsRepository).
This keeps your UI clean and reusable.
class ProductsRepository {
final LeanRepository _leanRepo;
final MyApiClient _api;
ProductsRepository({
required LeanRepository leanRepo,
required MyApiClient api
}) : _leanRepo = leanRepo, _api = api;
/// The UI calls this simple method.
/// No serializers, no strategies, just arguments.
Stream<Resource<List<Product>>> getProducts(String category, int page) {
return _leanRepo.stream<List<Product>>(
// 1. Centralize Key Logic
key: 'products_${category}_$page',
// 2. Centralize Fetch Logic
fetch: () => _api.fetchProducts(category, page),
// 3. Centralize Serialization
fromJson: (json) => (json['items'] as List)
.map((item) => Product.fromJson(item))
.toList(),
toJson: (products) => {
'items': products.map((p) => p.toJson()).toList()
},
// 4. Define Strategy (or pass it in)
strategy: CacheStrategy.staleWhileRevalidate,
);
}
}
🧠 Strategies #
Control how LeanRepo synchronizes data using the strategy parameter.
| Strategy | Behavior | Best For |
|---|---|---|
staleWhileRevalidate |
(Default) Returns Cache immediately, then fetches Network in background and updates UI. | User Profiles, Feeds, Lists |
cacheFirst |
Checks Cache. If data exists, returns it and stops. Only uses Network if Cache is empty. | Immutable data, Historical records |
networkOnly |
Ignores Cache. Fetches Network -> Saves to Cache -> Returns data. | Critical data (Wallet Balance, Payment Status) |
cacheOnly |
Returns Cache. Never hits Network. | Offline mode |
🔌 Custom Drivers #
LeanRepo doesn't force a database choice on you. To use Hive, for example, just implement CacheDriver.
class HiveDriver implements CacheDriver {
final Box box;
HiveDriver(this.box);
@override
Future<String?> read(String key) async {
return box.get(key);
}
@override
Future<void> write(String key, String data, {Duration? ttl}) async {
await box.put(key, data);
}
@override
Future<void> delete(String key) async => await box.delete(key);
@override
Future<void> clear({String? prefix}) async => await box.clear();
}
Then use it:
final repo = LeanRepository(cacheDriver: HiveDriver(myBox));
🤝 Contributing #
Contributions are welcome! Please check the issues tab for help wanted.
🍳 Integration Recipes #
Here are copy-paste implementations for popular databases.
SQLite (using sqflite) #
1. The Setup Ensure your table is created with a text primary key.
CREATE TABLE cache (
key TEXT PRIMARY KEY,
value TEXT
);
2. The Driver
class SqliteDriver implements CacheDriver {
final Database db;
final String tableName;
SqliteDriver(this.db, {this.tableName = 'cache'});
@override
Future<String?> read(String key) async {
final maps = await db.query(
tableName,
columns: ['value'],
where: 'key = ?',
whereArgs: [key],
);
if (maps.isEmpty) return null;
return maps.first['value'] as String;
}
@override
Future<void> write(String key, String data, {Duration? ttl}) async {
await db.insert(
tableName,
{'key': key, 'value': data},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> delete(String key) async {
await db.delete(tableName, where: 'key = ?', whereArgs: [key]);
}
@override
Future<void> clear({String? prefix}) async {
if (prefix != null) {
await db.delete(tableName, where: 'key LIKE ?', whereArgs: ['$prefix%']);
} else {
await db.delete(tableName);
}
}
}
ObjectBox #
1. The Entity
ObjectBox uses integer IDs by default, so we treat the key as a unique index.
@Entity()
class CacheEntity {
@Id()
int id = 0;
@Unique()
String key;
String value;
CacheEntity({required this.key, required this.value});
}
2. The Driver
class ObjectBoxDriver implements CacheDriver {
final Box<CacheEntity> box;
ObjectBoxDriver(this.box);
@override
Future<String?> read(String key) async {
// Note: In real apps, keep the Query object reused for performance
final query = box.query(CacheEntity_.key.equals(key)).build();
final result = query.findFirst();
query.close();
return result?.value;
}
@override
Future<void> write(String key, String data, {Duration? ttl}) async {
final query = box.query(CacheEntity_.key.equals(key)).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
existing.value = data;
box.put(existing);
} else {
box.put(CacheEntity(key: key, value: data));
}
}
@override
Future<void> delete(String key) async {
final query = box.query(CacheEntity_.key.equals(key)).build();
query.remove();
query.close();
}
@override
Future<void> clear({String? prefix}) async {
if (prefix != null) {
final query = box.query(CacheEntity_.key.startsWith(prefix)).build();
query.remove();
query.close();
} else {
box.removeAll();
}
}
}
🔮 Roadmap #
- ✅ v0.0.1: Core Logic (Stale-While-Revalidate, CacheFirst, NetworkOnly).
- ✅ v0.0.1: InMemoryDriver and Custom Driver support.
- ❌ v0.1.0: Reactive Streams (Listen to DB changes in real-time).
- ❌ v0.2.0: Retry policies (Exponential backoff).