smart_repository 0.6.0
smart_repository: ^0.6.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.
Installation #
dependencies:
smart_repository: ^0.6.0
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.
Declarative generated repositories #
For applications with many data repositories, smart_repository_generator
can generate complete method coordinators from a data-layer definition. Domain
ports remain annotation-free.
dev_dependencies:
build_runner: ^2.0.0
smart_repository_generator: ^0.6.0
part 'user_repository_impl.g.dart';
@GenerateSmartRepository<UserRemoteDataSource, UserLocalDataSource>()
abstract class UserRepositoryDefinition {
@CacheFirst(
key: #id,
mapRemote: #userFromRemote,
mapLocal: #userFromLocal,
mapToLocal: #userToLocal,
maxAge: Duration(minutes: 10),
)
Future<User> getUser(int id);
}
Generator inspects data-source methods. Matching domain/source types produce a
SmartRepository; DTO/local-model differences produce a MappedRepository.
Zero arguments create one repository. One argument creates a family keyed by
that value. Multiple arguments create a family keyed by an internal composite
record containing every argument:
@CacheFirst(maxAge: Duration(minutes: 10))
Future<List<User>> getUsers(
String name, {
required DateTime date,
int page = 1,
});
Generated code preserves method signature and coordinates each distinct
(name, date, page) query independently. Source read methods use same
parameters. A local writer may accept only value, or every query parameter
followed by value.
For one-shot requests containing passwords, tokens, or other sensitive values, release generated key immediately after request:
@NetworkOnly(
persistRemote: false,
retainKeyedRepository: false,
)
Future<Session> login(String email, String password);
Concurrent identical calls remain deduplicated. Key and in-memory repository state are removed once request finishes.
When even thin coordinator wrappers become repetitive, generate concrete repository implementation directly:
@GenerateRepositoryImplementation<AuthApi, AuthCache>(
defaultPolicy: RepositoryPolicy.networkOnly,
resultType: Session,
mapRemote: #sessionFromDto,
mapError: #mapFailure,
resultAdapter: #toDomainResult,
persistRemote: false,
retainKeyedRepository: false,
)
abstract class AuthRepositoryImpl implements AuthRepository {
factory AuthRepositoryImpl({
required AuthApi remote,
required AuthCache local,
}) = _\$AuthRepositoryImpl;
@RepositoryMethod(
remoteBody: {'email': #email, 'password': #password},
)
Future<DomainResult<Session>> login(String email, String password);
Future<void> dispose();
}
Generated class implements contract, builds remote body, adapts result, and forwards unannotated inherited methods to matching local-source methods.
For command-style sync/create/update/delete methods, use
RepositoryActionExecutor. Public result, raw response, and persisted model may
all differ:
final actions = RepositoryActionExecutor(mapError: mapFailure);
Future<RepositoryResult<bool>> syncVehicles(int page, int perPage) {
return actions.execute<bool, VehiclePageDto>(
operationKey: #syncVehicles,
requestKey: page,
remote: () => api.getVehicles(page: page, perPage: perPage),
mapResult: (response) => response.hasNext,
commit: (response) => cache.savePage(
page,
response.results.map((dto) => dto.toBox()).toList(),
),
recover: (error, stackTrace) => isNotFound(error)
? const RepositoryRecovery.value(VehiclePageDto.empty())
: const RepositoryRecovery.fail(),
);
}
@RepositoryOperation and @RepositoryLocalStream generate this coordination
inside concrete Clean Architecture repository implementations. See generator
README and paginated vehicle example.
Run dart run build_runner build. Concrete data repository adapts generated
RepositoryResult<T> into domain's result abstraction. Existing manual and
adapter-mixin APIs remain supported.
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 the example app. No backend needed:
cd example
flutter pub get
flutter run