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.
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:
The business logic never asks these questions. It calls repository.getLessons() and gets a List<Lesson> back. The repository handles everything else.
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).
The repository's main job in a real app is coordinating multiple data sources. At iStoria, a typical repository has:
The repository decides the strategy. The three common patterns:
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.
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.
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.
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.
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.
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.
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
// 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));
}
}
}
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.
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.
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