App Architecture & Design Patterns

The Repository Pattern

15 min Lesson 5 of 12

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.

Key Principle: Depend on abstractions, not concretions. When your view-model calls 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 Future because any data source could involve I/O.
  • It contains no imports from packages like dio, sqflite, or hive. 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);
  }
}
Tip: Keep data-mapping logic (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 FakeUserRepository or a Mockito mock — no network required.
  • Offline mode: Inject a LocalOnlyUserRepository that skips the remote source.
  • Migration: Swap RestUserRepositoryImpl for GraphQLUserRepositoryImpl in one place.
  • A/B testing: Toggle between two implementations via a feature flag at startup.
Common Mistake: Returning raw 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.