The Repository Pattern
The Repository Pattern
The Repository Pattern is a design pattern that mediates between the domain and data-mapping layers. It introduces a clean abstraction over your data sources so that the rest of the application — use-cases, view-models, or UI — depends only on a well-defined interface, not on concrete implementation details such as HTTP clients, local databases, or file storage.
In a Flutter app that follows clean architecture, the repository sits at the boundary between the Domain layer (pure business logic) and the Data layer (network, cache, and local storage). The domain layer declares the contract; the data layer fulfills it.
userRepository.getUserById(id), it does not know — and should not care — whether the result comes from a REST API, SQLite, or a mock in memory. This is the power of the Repository Pattern.Declaring the Repository Interface (Domain Layer)
The interface lives in the domain layer and describes what the repository can do, without any implementation details. In Dart, you use an abstract class (or abstract interface class in Dart 3) for this purpose.
Domain Layer — Abstract Repository
// lib/domain/repositories/user_repository.dart
import '../entities/user.dart';
abstract class UserRepository {
/// Fetch a single user by their unique identifier.
Future<User> getUserById(String id);
/// Return all users belonging to a given team.
Future<List<User>> getUsersByTeam(String teamId);
/// Persist a new or updated user record.
Future<void> saveUser(User user);
/// Remove a user permanently.
Future<void> deleteUser(String id);
}
Notice three things about this interface:
- It returns domain entities (
User), not raw JSON maps or database rows. - It is async by design — all operations return
Futurebecause any data source could involve I/O. - It contains no imports from packages like
dio,sqflite, orhive. The domain layer is completely framework-agnostic.
Implementing the Repository (Data Layer)
The data layer provides a concrete class that implements the interface. A single repository implementation typically coordinates one or more data sources — a remote API source and a local cache source — to apply strategies such as "cache-first" or "network-then-cache".
Data Layer — Concrete Implementation
// lib/data/repositories/user_repository_impl.dart
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
import '../datasources/remote/user_remote_datasource.dart';
import '../datasources/local/user_local_datasource.dart';
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remote;
final UserLocalDataSource _local;
const UserRepositoryImpl({
required UserRemoteDataSource remote,
required UserLocalDataSource local,
}) : _remote = remote,
_local = local;
@override
Future<User> getUserById(String id) async {
// Cache-first strategy: try local, fall back to remote
final cached = await _local.findUserById(id);
if (cached != null) return cached;
final dto = await _remote.fetchUser(id);
final user = dto.toDomain(); // map DTO → domain entity
await _local.cacheUser(user);
return user;
}
@override
Future<List<User>> getUsersByTeam(String teamId) async {
final dtos = await _remote.fetchTeamUsers(teamId);
return dtos.map((dto) => dto.toDomain()).toList();
}
@override
Future<void> saveUser(User user) async {
await _remote.updateUser(user.id, user.toDto());
await _local.cacheUser(user);
}
@override
Future<void> deleteUser(String id) async {
await _remote.deleteUser(id);
await _local.removeUser(id);
}
}
dto.toDomain(), user.toDto()) in extension methods or mapper classes, not scattered through the repository. This keeps the repository focused on coordination, not transformation.Wiring It Together with Dependency Injection
The domain layer declares UserRepository. The data layer provides UserRepositoryImpl. A dependency injection container (or a simple service locator like get_it) binds the interface to the implementation at startup, so the rest of the app never instantiates UserRepositoryImpl directly.
Dependency Injection Setup (get_it)
// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'data/datasources/remote/user_remote_datasource.dart';
import 'data/datasources/local/user_local_datasource.dart';
import 'data/repositories/user_repository_impl.dart';
import 'domain/repositories/user_repository.dart';
import 'domain/usecases/get_user_by_id.dart';
final sl = GetIt.instance;
void configureDependencies() {
// Data sources
sl.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSourceImpl(client: sl()),
);
sl.registerLazySingleton<UserLocalDataSource>(
() => UserLocalDataSourceImpl(db: sl()),
);
// Repository — bind INTERFACE to IMPLEMENTATION
sl.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(remote: sl(), local: sl()),
);
// Use-cases depend only on UserRepository (the abstract class)
sl.registerFactory(() => GetUserById(repository: sl()));
}
Swapping Implementations Without Touching Business Logic
The greatest benefit of this pattern is replaceability. If you decide to migrate from a REST API to GraphQL, you create a new implementation class and change a single line in your DI setup. All use-cases, view-models, and UI widgets remain untouched.
- Testing: Inject a
FakeUserRepositoryor a Mockito mock — no network required. - Offline mode: Inject a
LocalOnlyUserRepositorythat skips the remote source. - Migration: Swap
RestUserRepositoryImplforGraphQLUserRepositoryImplin one place. - A/B testing: Toggle between two implementations via a feature flag at startup.
Map<String, dynamic> or response model objects from the repository. The repository must always return domain entities. If a view-model receives a DTO, it is tightly coupled to the data layer and the abstraction is broken.Summary
The Repository Pattern enforces a strict separation between what the application needs (domain interface) and how it is fetched (data implementation). Declaring the interface in the domain layer keeps business logic pure and portable. Implementing it in the data layer confines all data-access complexity — HTTP, caching, error handling, mapping — to one place. Dependency injection glues the two layers together at runtime, leaving every other part of the codebase free to be tested, refactored, or extended independently.