← Flutter Reference

Flutter Repository Pattern: A Practical Guide

Architecture · Intermediate

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

The repository pattern is the single most impactful structural pattern in a Flutter app. It is the boundary between your business logic and your data sources — the place where you decide 'where does this data come from?' Without it, business logic leaks into API calls, database queries, and widget trees. With it, you can swap your backend, add caching, or go offline-first without touching a line of business code.

At iStoria, every feature has at least one repository. The repository is the only thing the presentation layer (BLoC/Cubit) knows about when it needs data. Whether that data comes from the API, the local Drift database, or PowerSync's sync layer is the repository's business — nobody else's.

This guide shows the pattern as we use it in production: abstract contracts in the domain, concrete implementations in the data layer, and the cache/offline strategies that make a 5M-user app feel instant.

What the Repository Pattern Actually Does

A repository is an abstraction over data access. It presents a collection-like interface to the business logic: getLessons(), saveLesson(), watchCourse(). Behind that interface, the repository decides:

  • Does this come from the remote API or the local cache?
  • Do I need to refresh the cache from the network?
  • What happens if the network fails — do I return stale local data?
  • How do I map between the wire format (JSON DTO) and the domain entity?

The business logic never asks these questions. It calls repository.getLessons() and gets a List<Lesson> back. The repository handles everything else.

The Abstract Contract

The repository is defined as an abstract class (interface) in the domain layer. This is the contract the rest of the app depends on. The implementation lives in the data layer.

Why abstract? Because it lets you:

1. Mock it in tests — your BLoC unit tests pass a mock repository, no network needed. 2. Swap implementations — switch from API-first to offline-first by changing the implementation, not the call sites. 3. Vary by environment — use a different repository implementation in development (fake data) vs. production (real API + cache).

Data Source Coordination

The repository's main job in a real app is coordinating multiple data sources. At iStoria, a typical repository has:

  • Remote data source — the API client (Dio) that fetches and posts to the server.
  • Local data source — the Drift database that caches data locally for offline access.
  • Network info — a connectivity checker that tells the repository whether the network is available.

The repository decides the strategy. The three common patterns:

Cache-First (Network with Fallback)

Read from the network. If it fails, read from the local cache. This is the default for most data — you want fresh data but degrade gracefully offline.

Cache-Then-Network (Stale-While-Revalidate)

Return the local cache immediately (instant UI), then fetch from the network in the background and update the cache. The UI gets two emissions: stale data fast, then fresh data.

Local-Only (Offline-First)

Read exclusively from the local database. The sync engine handles populating it from the network. This is what we use for data synced via PowerSync.

Mapping: DTOs vs. Entities

The repository maps between the wire format and the domain. The API returns a LessonModel (DTO with fromJson). The repository converts it to a Lesson (domain entity). The BLoC never sees the DTO.

This separation matters because the API format is driven by the backend and changes independently of your domain model. If the backend renames a field, you change the mapper, not every BLoC that uses the entity.

Error Handling

Repositories return Either<Failure, T> (or a Result type), not raw values or exceptions. This forces the calling code to handle both success and failure explicitly. The Failure type is part of the domain — it represents business-meaningful errors (ServerFailure, CacheFailure, NotFoundFailure), not infrastructure exceptions.

See our error handling guide for the full Either<Failure, T> pattern. The key point here: the repository catches data-source exceptions and translates them into domain failures. The BLoC never catches a DioError or a DriftException — those are data-layer concerns.

When You Have Multiple Sources of Truth

In an offline-first app with sync, the repository gets interesting. The local database is the source of truth, and the sync engine keeps it updated. The repository reads exclusively from local and writes to local. The sync engine handles the server.

But not all data is synced. For non-synced data (config, feature flags, one-off API calls), the repository uses the cache-first or cache-then-network strategy. Having a clear repository-per-data-strategy makes the codebase predictable: if you know which repository you are calling, you know the data strategy.

Recommended folder structure

lib/features/lesson/
├── domain/
│   ├── entities/
│   │   └── lesson.dart                   # pure domain entity
│   └── repositories/
│       └── lesson_repository.dart         # ABSTRACT contract
├── data/
│   ├── datasources/
│   │   ├── lesson_remote_datasource.dart  # Dio API calls
│   │   └── lesson_local_datasource.dart   # Drift database queries
│   ├── models/
│   │   └── lesson_model.dart              # DTO: fromJson / toEntity
│   └── repositories/
│       └── lesson_repository_impl.dart     # CONCRETE implementation

Code example

// domain/repositories/lesson_repository.dart — THE CONTRACT
abstract class LessonRepository {
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId);
  Stream<List<Lesson>> watchLessons(String courseId);
  Future<Either<Failure, Lesson>> markComplete(String lessonId);
}

// data/models/lesson_model.dart — DTO (data transfer object)
class LessonModel extends Lesson {
  final String remoteId;

  const LessonModel({
    required this.remoteId,
    required super.id,
    required super.title,
    required super.durationMinutes,
    required super.isCompleted,
    required super.courseId,
  });

  factory LessonModel.fromJson(Map<String, dynamic> json) {
    return LessonModel(
      remoteId: json['id'] as String,
      id: json['uuid'] as String,
      title: json['title'] as String,
      durationMinutes: (json['duration_sec'] as num).toInt() ~/ 60,
      isCompleted: json['completed'] as bool? ?? false,
      courseId: json['course_id'] as String,
    );
  }

  Lesson toEntity() => Lesson(
        id: id,
        title: title,
        durationMinutes: durationMinutes,
        isCompleted: isCompleted,
        courseId: courseId,
      );
}

// data/repositories/lesson_repository_impl.dart — THE IMPLEMENTATION
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remoteDatasource;
  final LessonLocalDatasource localDatasource;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remoteDatasource,
    required this.localDatasource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (await networkInfo.isConnected) {
      try {
        final jsonList = await remoteDatasource.fetchLessons(courseId);
        final models = jsonList.map(LessonModel.fromJson).toList();

        // Cache locally for offline access
        await localDatasource.cacheLessons(courseId, models);

        return Right(models.map((m) => m.toEntity()).toList());
      } on ServerException catch (e) {
        // Network failed — try the cache before giving up
        return _getFromCache(courseId, fallbackFailure: ServerFailure(e.message));
      }
    }
    // Offline — serve from cache
    return _getFromCache(courseId, fallbackFailure: const OfflineFailure());
  }

  Future<Either<Failure, List<Lesson>>> _getFromCache(
    String courseId, {
    required Failure fallbackFailure,
  }) async {
    try {
      final cached = await localDatasource.getCachedLessons(courseId);
      if (cached.isEmpty) return Left(fallbackFailure);
      return Right(cached.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(fallbackFailure);
    }
  }

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    // Reactive stream from local DB — UI updates when cache or sync changes it
    return localDatasource
        .watchCachedLessons(courseId)
        .map((models) => models.map((m) => m.toEntity()).toList());
  }

  @override
  Future<Either<Failure, Lesson>> markComplete(String lessonId) async {
    try {
      // Optimistic: update local first
      await localDatasource.markComplete(lessonId);
      // Then sync to server
      await remoteDatasource.postCompletion(lessonId);
      final updated = await localDatasource.getLesson(lessonId);
      return Right(updated.toEntity());
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    }
  }
}

Implementation checklist

  • Define the repository as an abstract class in the domain layer — the contract the rest of the app depends on.
  • Create a concrete implementation in the data layer that coordinates remote and local data sources.
  • Create a DTO model class (with fromJson/toEntity) separate from the domain entity to handle wire format.
  • Choose a cache strategy per method: cache-first, cache-then-network, or local-only (offline-first).
  • Return Either<Failure, T> so calling code handles errors explicitly — never throw raw exceptions.
  • Inject the repository into BLoCs as the abstract type, never the concrete implementation.
  • Mock the abstract repository in unit tests — no network, no database, just contract verification.

Common mistakes

  • Returning DTOs instead of domain entities, leaking the API format into business logic and BLoCs.
  • Letting exceptions propagate from the repository instead of catching and mapping them to domain Failures.
  • Putting all data-access logic in the BLoC instead of the repository, making the BLoC untestable and tightly coupled.
  • Not having an abstract interface, so the repository cannot be mocked in tests or swapped between environments.
  • Hardcoding a single data strategy (always network or always cache) instead of choosing per method based on requirements.

Related definitions

Related reading

FAQ

Do I need a repository if I'm just calling a simple API?

Yes, even for a simple API. The repository gives you a seam for testing (mock it), caching (add it later without changing call sites), and error handling (map exceptions to domain failures). A repository that just forwards to an API call today can gain caching or offline support tomorrow without its callers changing.

Should the repository return Future or Stream?

Use Future for one-shot reads and writes. Use Stream for data that should reactively update in the UI (watch queries that emit when the underlying data changes). A repository can have both: getLessons() returns Future for the initial fetch, watchLessons() returns Stream for reactive updates from the local database.

How does the repository pattern work with offline-first sync?

In an offline-first architecture, the repository reads exclusively from the local database and writes to it. The sync engine (PowerSync or custom) handles propagating changes to the server. The repository's job simplifies: it always talks to the local database. The sync strategy is handled by the sync engine, not the repository.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work