repository_kit 0.1.1
repository_kit: ^0.1.1 copied to clipboard
A pure Dart toolkit for implementing clean, offline-first repositories by owning cache-vs-remote strategy decisions.
repository_kit #
A Dart utility for implementing offline-first repositories without repeating the same data-layer boilerplate across every feature.
The Problem #
In most Flutter applications, every repository method that needs to serve cached data while fetching fresh data ends up looking like this:
Future<User?> getUser(String id) async {
// Check cache
final cached = await _db.findUser(id);
if (cached != null) return cached;
// Fetch from network
try {
final response = await _api.getUser(id);
final user = User.fromJson(response);
await _db.saveUser(user);
return user;
} catch (e) {
return cached; // stale fallback
}
}
This works, but it forces the presentation layer to make decisions it should not need to make: when to show cached data, when to show a loading indicator, and when to show an error alongside stale data. The same orchestration is duplicated across every repository method.
The deeper issue is that this pattern uses a Future, which can only return one value. An offline-first data layer naturally produces multiple values over time: first the local cache, then fresh data from the network. A Future cannot express that.
What This Package Does #
repository_kit provides a base class and state wrapper that replaces this orchestration with a two-shot Stream. Instead of returning a single Future<T>, a repository returns Stream<RKitState<T>>. The stream emits up to three events:
RKitLoading— immediately, before any async work begins.RKitCache— the local data, if it exists, while the network call is in progress.RKitSuccessorRKitFailure— the result of the network call.
The presentation layer subscribes to the stream and reacts to each state. It never decides where data comes from. All generic types are prefixed with RKit to prevent naming conflicts with existing packages in the Flutter/Dart ecosystem.
When to Use This #
This package is useful when:
- Your app needs to show cached data immediately while refreshing from a remote source.
- You want the repository to own the cache-vs-network decision entirely.
- You are building an offline-first feature and want a consistent pattern across all repositories.
This package is not useful when:
- Your repository only fetches from the network with no local storage.
- You only need a one-shot
Future<T>with no caching. - You want a state management solution — this is not one.
Installation #
dependencies:
repository_kit: ^0.1.0
Usage #
1. Extend RKitRepository #
Implement three methods. The framework handles the rest.
class UserRepository extends RKitRepository<User, Map<String, dynamic>> {
UserRepository(this._api, this._db, this._userId);
final UserApi _api;
final UserDao _db;
final String _userId;
/// Load from local storage. Return null if nothing is cached.
@override
Future<User?> load() => _db.findUser(_userId);
/// Make the network request.
@override
Future<Map<String, dynamic>> fetch() => _api.getUser(_userId);
/// Persist the network response and return the domain model.
/// Both saving and returning are required.
@override
Future<User> persist(Map<String, dynamic> response) async {
final user = User.fromJson(response);
await _db.saveUser(user);
return user;
}
}
2. Consume the Stream #
The state sealed class is exhaustive, so a switch expression covers all cases at compile time.
userRepository.watch().listen((state) {
switch (state) {
case RKitLoading():
showSpinner();
case RKitCache(:final data):
showUser(data, isStale: true);
case RKitSuccess(:final data):
showUser(data);
case RKitFailure(:final error, :final data):
showError(error, stale: data);
}
});
With a StreamBuilder in Flutter:
StreamBuilder<RKitState<User>>(
stream: userRepository.watch(),
builder: (context, snapshot) {
return switch (snapshot.data) {
null || RKitLoading() => const CircularProgressIndicator(),
RKitCache(:final data) => UserCard(user: data, isStale: true),
RKitSuccess(:final data) => UserCard(user: data),
RKitFailure(:final data?, :final error) => Column(
children: [ErrorBanner(error: error), UserCard(user: data)],
),
RKitFailure(:final error) => ErrorView(error: error),
};
},
)
Cache Policy #
Control whether the network request is made at all.
// Always fetch from the network (default)
@override
RKitCachePolicy get cachePolicy => RKitCachePolicy.always;
// Only fetch if no local data exists
@override
RKitCachePolicy get cachePolicy => RKitCachePolicy.ifEmpty;
// Never fetch — serve local data only
@override
RKitCachePolicy get cachePolicy => RKitCachePolicy.never;
// Fetch dynamically if the cached object is stale
@override
RKitCachePolicy get cachePolicy => RKitCachePolicy.staleIf<User>((cachedUser) {
final age = DateTime.now().difference(cachedUser.updatedAt);
return age > const Duration(minutes: 5);
});
Retry Policy #
Control retry behaviour when the network request fails.
// No retries (default)
@override
RKitRetryPolicy get retryPolicy => RKitRetryPolicy.none;
// Retry up to 3 times with exponential backoff starting at 1 second
@override
RKitRetryPolicy get retryPolicy => RKitRetryPolicy.exponential(
maxAttempts: 3,
initialDelay: const Duration(seconds: 1),
);
Error Handling #
| Scenario | Behaviour |
|---|---|
load() throws |
Treated as a cache miss. The network fetch proceeds normally. |
fetch() throws |
Emits RKitFailure with stale data if cache was available. The original exception is wrapped in RKitException to preserve error/stacktrace context. |
persist() throws |
Emits RKitFailure with stale data if cache was available. |
RKitCachePolicy.never, no local data |
Emits RKitFailure with an RKitException. |
API Reference #
RKitRepository<ResultType, RemoteType> #
| Member | Description |
|---|---|
Future<ResultType?> load() |
Load from local storage. Return null if nothing is cached. |
Future<RemoteType> fetch() |
Make the remote request. |
Future<ResultType> persist(RemoteType) |
Save the remote response and return the domain model. |
RKitCachePolicy get cachePolicy |
When to fetch from remote. Defaults to RKitCachePolicy.always. |
RKitRetryPolicy get retryPolicy |
How to handle request failures. Defaults to RKitRetryPolicy.none. |
Stream<RKitState<ResultType>> watch() |
The two-shot stream. |
RKitState<T> #
| Subtype | When emitted |
|---|---|
RKitLoading |
Always, immediately. |
RKitCache(data) |
When load() returns non-null. |
RKitSuccess(data) |
When the network fetch and persist succeed. |
RKitFailure(error, {data?}) |
When the network fetch or persist fails. Holds an RKitException. |
License #
MIT