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.7.1

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 implementations from annotation-only data bindings. Domain ports remain annotation-free and method signatures stay in one place.

dev_dependencies:
  build_runner: ^2.0.0
  smart_repository_generator: ^0.7.1
part 'user_repository_binding.g.dart';

@GenerateRepository(
  contract: UserRepository,
  remote: UserApi,
  cache: UserCache,
  mapError: #mapFailure,
  resultAdapter: #toDomainResult,
  methods: {
    #getUser: RepoMethod.networkFirst(
      mapRemote: #userFromDto,
      mapLocal: #userFromBox,
      mapToLocal: #userToBox,
    ),
    #getUsers: RepoMethod.remoteOnly(
      remote: #searchUsers,
      mapRemote: #usersFromDto,
    ),
  },
)
abstract class UserRepositoryBinding {}

This generates public UserRepositoryImpl with remote and cache constructor arguments. Generator reads parameters and return types from UserRepository, including multi-parameter methods.

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

Libraries

smart_repository
Policy-driven coordination between remote and local data sources.