smart_repository 0.4.0
smart_repository: ^0.4.0 copied to clipboard
Policy-driven coordination for remote and local repository data.
smart_repository #
Policy-driven coordination for remote and local repository data. The package has no runtime dependencies and knows nothing about HTTP clients, databases, or state-management frameworks.
Quick start #
import 'package:smart_repository/smart_repository.dart';
final repository = SmartRepository<User>(
remote: api.getUser,
local: storage.getUser,
saveLocal: storage.saveUser,
deleteLocal: storage.deleteUser,
localTimestamp: storage.getUserStoredAt,
config: const SmartRepositoryConfig(
defaultPolicy: RepositoryPolicy.networkFirst,
maxAge: Duration(minutes: 15),
),
);
final result = await repository.get();
switch (result) {
case RepositorySuccess(:final data, :final source, :final isStale):
print('$data from $source (stale: $isStale)');
case RepositoryFailure(:final error):
print(error);
}
Callbacks are normalized internally. Larger projects may instead implement
ReadDataSource<T>, WriteDataSource<T>, and DeleteDataSource and pass the
corresponding *Source arguments.
Clean Architecture #
Keep package in data layer. Domain layer defines entities and repository ports;
presentation depends only on domain. MappedRepository removes DTO/database
mapping boilerplate without leaking either model outside data layer.
final users = MappedRepositoryFamily<int, User, UserDto, UserBox>(
remote: api.getUser,
local: database.getUser,
saveLocal: database.saveUser,
deleteLocal: database.deleteUser,
mapRemote: (dto) => dto.toEntity(),
mapLocal: (box) => box.toEntity(),
mapToLocal: (entity) => entity.toBox(),
fallbackWhen: (id, error) => error is OfflineException,
);
A data-layer implementation can adapt results to any application abstraction
without this package depending on dartz or another result library:
Future<Either<Failure, User>> getUser(int id) async {
final result = await users.get(
id,
policy: RepositoryPolicy.networkFirst,
);
return result.fold(
onSuccess: Right.new,
onFailure: (error, stackTrace) => Left(mapFailure(error, stackTrace)),
);
}
For exception-based repository ports, use result.getOrThrow(). Use
result.match() when source, freshness, or timestamps are needed.
After an external create/update mutation, coordinate local persistence and state emission through the same repository:
final updated = await api.updateUser(request);
await users.setLocal(id, updated.toEntity());
This package remains a data-source coordinator—not a domain repository base class, dependency-injection container, or state-management framework.
Optional generated wiring #
For applications with many data repositories, the companion
smart_repository_generator package can generate the mechanical mapped
repository initialization and delegation. Manual SmartRepository,
MappedRepository, and family construction remains fully supported.
dev_dependencies:
build_runner: ^2.0.0
smart_repository_generator: ^0.3.0
part 'user_repository_impl.g.dart';
@SmartRepositoryFamilyAdapter<int, User, UserDto, UserBox>()
final class UserRepositoryImpl
with _$UserRepositoryImpl
implements UserRepository {
UserRepositoryImpl(this.api, this.database);
final UserApi api;
final UserDatabase database;
@override
Future<UserDto> smartLoadRemote(int key) => api.getUser(key);
@override
Future<UserBox?> smartLoadLocal(int key) => database.getUser(key);
@override
Future<void> smartSaveLocal(int key, UserBox value) =>
database.saveUser(value);
@override
User smartMapRemote(UserDto value) => value.toEntity();
@override
User smartMapLocal(UserBox value) => value.toEntity();
@override
UserBox smartMapToLocal(User value) => value.toBox();
Future<Either<Failure, User>> getUser(int id) async =>
(await smartGet(id)).fold(
onSuccess: Right.new,
onFailure: (error, stackTrace) =>
Left(mapFailure(error, stackTrace)),
);
}
Run dart run build_runner build. Override smartFallbackWhen or
smartRepositoryConfig when application-specific fallback/freshness behavior
is needed. Domain repository interfaces stay independent from this package.
Policies #
| Policy | Contract |
|---|---|
networkOnly |
Read remote; optionally persist; fail on remote error. |
cacheOnly |
Read local only; a miss returns RepositoryCacheMissException. |
cacheFirst |
Return usable cache; stale/missing cache triggers remote; stale cache can be fallback. |
networkFirst |
Read remote; allowed remote failures fall back to stale local data. |
staleWhileRevalidate |
Return cache immediately; stale/unknown cache refreshes in background. |
cacheAndNetwork |
Return any cache immediately and always refresh in background. |
Unknown freshness is usable for cacheFirst, but refreshable for
staleWhileRevalidate. Invalidation marks cached data stale.
Streams #
watch() replays current state to every new subscriber. It emits repository
events only; it is not a state-management framework.
For stale-while-revalidate with stale cache, order is:
RepositoryLoading
RepositoryData(local)
RepositoryRefreshing(local)
RepositoryData(remote)
The operation result and stream state are separate models. To avoid a Dart
name collision, one-shot errors use RepositoryFailure; stream errors use
RepositoryFailureState.
Errors and fallback #
Errors remain generic. mapError can translate transport-specific exceptions,
then fallbackWhen decides whether local fallback is safe. This prevents cases
such as silently serving a cached user after an authorization failure.
final repository = SmartRepository<User>(
remote: api.getUser,
local: storage.getUser,
mapError: (error, stackTrace) => parseApiError(error),
fallbackWhen: (error) => error is OfflineFailure,
);
Concurrent remote reads are deduplicated by default. refresh(force: true)
bypasses an in-flight request. Call dispose() when repository lifecycle ends.
Keyed repositories #
SmartRepositoryFamily<K, T> lazily owns one independent repository per key.
Requests for the same key deduplicate; different keys run independently.
final users = SmartRepositoryFamily<int, User>(
remote: (id) => api.getUser(id),
local: (id) => storage.getUser(id),
saveLocal: (id, user) => storage.saveUser(id, user),
deleteLocal: storage.deleteUser,
);
final result = await users.get(42, policy: RepositoryPolicy.cacheFirst);
final states = users.watch(42);
await users.remove(42); // Dispose and forget only key 42.
await users.dispose(); // Dispose every active key.
Family retains created keys until remove(key) or dispose() is called.
Persistent data is untouched by remove; use clear(key) to delete it.
Observability #
Extend RepositoryObserver<T> to connect logging, metrics, tracing, Sentry, or
analytics without package dependencies. Hooks cover reads, local hits/misses,
remote requests, fallback, persistence, invalidation, and clearing.
final repository = SmartRepository<User>(
remote: api.getUser,
observers: [RepositoryMetrics()],
);
Observers used by a family receive repositoryKey. Observer exceptions are
isolated and never affect repository results.
Scope #
Version 0.2 focuses on single-value, basic-list, and keyed reads. Pagination, remote mutation execution, optimistic updates, and cancellation remain future additions.
Flutter example #
Full cross-platform demo lives in example/. No backend needed:
cd example
flutter pub get
flutter run