# What You’ll Build (and Why It Matters)#
A solid Flutter repository pattern is not just about hiding HTTP calls. It is about drawing boundaries so your UI and domain logic never depend on API JSON shapes, database schemas, or third-party SDK quirks.
This guide shows a concrete, test-friendly approach with:
- API models as DTOs, mapped into domain entities
- Separate persistence models for local storage
- Typed failures instead of ad hoc exceptions
- Pagination and caching that are easy to reason about
- Unit tests with fakes and mocks you can run in milliseconds
If you are already using Clean Architecture, this fits naturally. If you are not, you can still apply the pattern incrementally. For deeper context on feature-first structure and boundaries, see Flutter app architecture: Clean Architecture feature-first.
# The Core Principle: One Boundary, One Responsibility#
Repositories exist to protect your domain from churn. APIs change fields, databases change columns, and providers introduce breaking changes. Your domain should not care.
A practical split looks like this:
| Layer | Owns | Models | Knows about |
|---|---|---|---|
| Presentation | Screens, state | View models | Use cases, domain |
| Domain | Business rules | Entities, value objects | Repository interfaces |
| Data | Integration details | DTOs, persistence models | HTTP, DB, cache, mapping |
Rule: The domain defines repository interfaces. The data layer implements them. Presentation depends on domain, not data.
🎯 Key Takeaway: If your UI imports DTOs, your repository boundary is leaking.
# Data Contracts: DTOs, Entities, and Persistence Models#
The biggest source of architectural drift is mixing models. Fixing this early saves months later.
DTOs: Mirror the API, Even If It’s Ugly#
DTOs should follow the API schema exactly, including naming inconsistencies. That makes them resilient to backend changes and keeps mapping explicit.
class UserDto {
final String id;
final String email;
final String? full_name;
UserDto({
required this.id,
required this.email,
required this.full_name,
});
factory UserDto.fromJson(Map<String, dynamic> json) {
return UserDto(
id: json['id'] as String,
email: json['email'] as String,
full_name: json['full_name'] as String?,
);
}
}If your backend adds full_name tomorrow, only the DTO and mapper should change.
Domain Entities: Reflect Business Meaning#
Entities should be stable and express what the app needs, not what the API happens to provide.
class User {
final String id;
final String email;
final String displayName;
const User({
required this.id,
required this.email,
required this.displayName,
});
}Persistence Models: Optimized for Local Storage#
Persistence models follow your storage solution constraints. For example, SQLite tables or Hive objects may use different types, indexes, and optionality.
class UserRecord {
final String id;
final String email;
final String displayName;
final int updatedAtMs;
UserRecord({
required this.id,
required this.email,
required this.displayName,
required this.updatedAtMs,
});
}ℹ️ Note: It is normal to have three representations of the same “thing”. The stability of your domain is worth the extra mapping.
# Mapping: Keep It Explicit and Centralized#
Mappers are where you document transformations, defaults, and backwards compatibility rules.
DTO to Domain Mapper#
extension UserDtoMapper on UserDto {
User toDomain() {
return User(
id: id,
email: email,
displayName: (full_name == null || full_name!.trim().isEmpty)
? email
: full_name!.trim(),
);
}
}Domain to Persistence and Back#
extension UserRecordMapper on UserRecord {
User toDomain() => User(id: id, email: email, displayName: displayName);
}
extension UserToRecordMapper on User {
UserRecord toRecord({required int nowMs}) {
return UserRecord(
id: id,
email: email,
displayName: displayName,
updatedAtMs: nowMs,
);
}
}This keeps “business defaults” like fallback display names in one place, not scattered across UI widgets.
💡 Tip: Put mappers next to their model types inside the data feature folder. Avoid a global
mappers/directory that grows into a dumping ground.
# Typed Failures: Make Error Handling Predictable#
Most Flutter apps end up with a mix of thrown exceptions, string messages, and nullable returns. That makes retries, analytics, and UI states inconsistent.
Instead, return a Result with typed failures.
A Simple Result Type#
sealed class Result<T> {
const Result();
}
class Ok<T> extends Result<T> {
final T value;
const Ok(this.value);
}
class Err<T> extends Result<T> {
final Failure failure;
const Err(this.failure);
}Failure Types Your UI Can Act On#
sealed class Failure {
final String message;
const Failure(this.message);
}
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure() : super('Unauthorized');
}
class NotFoundFailure extends Failure {
const NotFoundFailure() : super('Not found');
}
class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class UnknownFailure extends Failure {
const UnknownFailure(super.message);
}Now your UI can map failures to actions: show offline banner, redirect to login, show retry, or display validation details.
⚠️ Warning: Avoid returning
Exceptionordynamicfrom repositories. You will lose exhaustiveness, and tests will stop being precise.
# Repository Interface: Domain Owns the Contract#
The repository interface belongs in the domain layer. It uses domain entities and typed failures via Result.
Pagination deserves its own model because pagination is not “just a list”.
class Page<T> {
final List<T> items;
final String? nextCursor;
const Page({required this.items, required this.nextCursor});
}
abstract class UsersRepository {
Future<Result<Page<User>>> getUsersPage({
required int limit,
String? cursor,
bool forceRefresh = false,
});
Future<Result<User>> getUserById(String id, {bool forceRefresh = false});
}This interface is stable even if you later switch from REST to GraphQL, or from SQLite to Isar.
# Data Layer Implementation: API + Cache + Mapping#
A good repository composes smaller units:
UsersApifor remote callsUsersCachefor local storageClockto control time in testsConnectivityas an optional dependency for smarter offline logic
Data Sources#
abstract class UsersApi {
Future<ApiPageDto<UserDto>> fetchUsers({required int limit, String? cursor});
Future<UserDto> fetchUserById(String id);
}
class ApiPageDto<T> {
final List<T> items;
final String? nextCursor;
ApiPageDto({required this.items, required this.nextCursor});
}
abstract class UsersCache {
Future<void> upsertMany(List<UserRecord> records);
Future<void> upsertOne(UserRecord record);
Future<List<UserRecord>> getPage({required int limit, String? cursor});
Future<UserRecord?> getById(String id);
Future<int?> getLastSyncMs();
Future<void> setLastSyncMs(int ms);
}
abstract class Clock {
int nowMs();
}Cache Policy: Make It Explicit#
A simple but effective policy is “stale while revalidate” with a TTL. Many teams pick 5 to 30 minutes depending on the data. For consumer apps, 10 minutes is often a good balance.
| Policy | When used | Pros | Cons |
|---|---|---|---|
| Network only | Highly dynamic data | Always fresh | Fails offline, slower |
| Cache only | Offline-first screens | Works offline, fast | Can be stale forever |
| Cache then network | Most list screens | Fast first paint | Requires background refresh logic |
| TTL based | Balanced default | Predictable | Needs time source |
ℹ️ Note: Offline-first goes beyond caching and includes sync and conflict resolution. If you need that, read Flutter offline-first: sync and conflict resolution.
# Repository Implementation with Pagination and Caching#
This repository reads from cache when allowed, refreshes from network when stale or forced, and always maps into domain entities.
class UsersRepositoryImpl implements UsersRepository {
final UsersApi api;
final UsersCache cache;
final Clock clock;
final int ttlMs;
UsersRepositoryImpl({
required this.api,
required this.cache,
required this.clock,
this.ttlMs = 10 * 60 * 1000,
});
@override
Future<Result<Page<User>>> getUsersPage({
required int limit,
String? cursor,
bool forceRefresh = false,
}) async {
final now = clock.nowMs();
final lastSync = await cache.getLastSyncMs();
final isStale = lastSync == null || (now - lastSync) > ttlMs;
if (!forceRefresh && !isStale) {
final cached = await cache.getPage(limit: limit, cursor: cursor);
final users = cached.map((r) => r.toDomain()).toList();
return Ok(Page(items: users, nextCursor: _nextCursorFromCache(cached, limit)));
}
try {
final dtoPage = await api.fetchUsers(limit: limit, cursor: cursor);
final users = dtoPage.items.map((d) => d.toDomain()).toList();
final records = users.map((u) => u.toRecord(nowMs: now)).toList();
await cache.upsertMany(records);
await cache.setLastSyncMs(now);
return Ok(Page(items: users, nextCursor: dtoPage.nextCursor));
} on ApiUnauthorizedException {
return Err(const UnauthorizedFailure());
} on ApiNotFoundException {
return Err(const NotFoundFailure());
} on ApiNetworkException catch (e) {
return Err(NetworkFailure(e.message));
} catch (e) {
return Err(UnknownFailure(e.toString()));
}
}
@override
Future<Result<User>> getUserById(String id, {bool forceRefresh = false}) async {
if (!forceRefresh) {
final cached = await cache.getById(id);
if (cached != null) {
return Ok(cached.toDomain());
}
}
try {
final dto = await api.fetchUserById(id);
final user = dto.toDomain();
await cache.upsertOne(user.toRecord(nowMs: clock.nowMs()));
return Ok(user);
} on ApiUnauthorizedException {
return Err(const UnauthorizedFailure());
} on ApiNotFoundException {
return Err(const NotFoundFailure());
} on ApiNetworkException catch (e) {
return Err(NetworkFailure(e.message));
} catch (e) {
return Err(UnknownFailure(e.toString()));
}
}
String? _nextCursorFromCache(List<UserRecord> cached, int limit) {
if (cached.length < limit) return null;
return cached.last.id;
}
}You get:
- Domain-only outputs
- Typed failures
- A cache TTL that you can tune per feature
- Pagination via cursor, still testable
API Exceptions: Keep Them in Data Layer#
You do not want API exceptions escaping into domain or UI.
class ApiNetworkException implements Exception {
final String message;
ApiNetworkException(this.message);
}
class ApiUnauthorizedException implements Exception {}
class ApiNotFoundException implements Exception {}In production, your API client might translate Dio errors, HTTP status codes, and timeouts into these exceptions.
# Pagination Details: Cursor, Page Size, and Edge Cases#
Cursor pagination is common because it scales better than offset pagination for large datasets. Still, you should define behavior precisely:
- If
nextCursoris null, you are at the end. - If a refresh happens mid-scroll, you can either reset the list or merge updates.
A practical approach is:
- Repository returns
Page<User>withnextCursor. - The UI stores
nextCursorand appends pages. - Force refresh resets cursor to null and clears list in state.
💡 Tip: For fast first paint, load the cached first page immediately, then run a background refresh. Keep this logic in a use case or state layer, not inside the repository, so your repository stays deterministic.
# Caching Strategy: What to Cache and What Not to Cache#
Not everything should be cached. Cache data that is:
- expensive to fetch
- needed for offline
- needed for perceived performance
Avoid caching data that is:
- security sensitive without encryption
- highly volatile unless you have a clear invalidation plan
A pragmatic caching checklist:
| Question | If yes | If no |
|---|---|---|
| Does it improve startup or scroll performance? | Cache it | Fetch on demand |
| Do users need it offline? | Cache it | Optional |
| Can it get stale safely? | TTL cache | Network-first |
| Is it user-specific? | Namespace by user id | Avoid shared cache |
# Testability: Dependency Injection and Pure Boundaries#
Repositories are easy to unit test when:
- dependencies are injected
- mapping is pure
- time is controlled via a
Clock - cache and API are interfaces
This aligns with the “test pyramid” recommendation: many cheap unit tests, fewer widget tests, and minimal integration tests. For a concrete testing strategy, see Flutter testing pyramid: unit, widget, integration, golden.
# Unit Testing Repositories with Fakes and Mocks#
You want to test both output and side effects:
- does it return
Okwith mapped entities - does it write to cache on success
- does it return correct failure on errors
- does TTL prevent unnecessary network calls
Example Fakes#
class FakeClock implements Clock {
int _nowMs;
FakeClock(this._nowMs);
@override
int nowMs() => _nowMs;
void advanceMs(int delta) => _nowMs += delta;
}
class FakeUsersCache implements UsersCache {
final Map<String, UserRecord> store = {};
int? lastSync;
@override
Future<UserRecord?> getById(String id) async => store[id];
@override
Future<List<UserRecord>> getPage({required int limit, String? cursor}) async {
final items = store.values.toList()..sort((a, b) => a.id.compareTo(b.id));
if (cursor == null) return items.take(limit).toList();
final start = items.indexWhere((e) => e.id == cursor) + 1;
return items.skip(start).take(limit).toList();
}
@override
Future<int?> getLastSyncMs() async => lastSync;
@override
Future<void> setLastSyncMs(int ms) async => lastSync = ms;
@override
Future<void> upsertMany(List<UserRecord> records) async {
for (final r in records) {
store[r.id] = r;
}
}
@override
Future<void> upsertOne(UserRecord record) async {
store[record.id] = record;
}
}Example Mock API#
Use your preferred mocking tool. The example below shows a minimal hand-rolled fake, which is often faster to maintain than heavy mocks for repositories.
class FakeUsersApi implements UsersApi {
ApiPageDto<UserDto>? pageToReturn;
Exception? errorToThrow;
int calls = 0;
@override
Future<ApiPageDto<UserDto>> fetchUsers({required int limit, String? cursor}) async {
calls += 1;
if (errorToThrow != null) throw errorToThrow!;
return pageToReturn!;
}
@override
Future<UserDto> fetchUserById(String id) {
throw UnimplementedError();
}
}Unit Test: Returns Cached When Fresh#
import 'package:test/test.dart';
void main() {
test('returns cached page when within TTL', () async {
final api = FakeUsersApi();
final cache = FakeUsersCache();
final clock = FakeClock(1_000_000);
await cache.upsertMany([
User(id: '1', email: 'a@x.com', displayName: 'A').toRecord(nowMs: clock.nowMs()),
User(id: '2', email: 'b@x.com', displayName: 'B').toRecord(nowMs: clock.nowMs()),
]);
await cache.setLastSyncMs(clock.nowMs());
final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock, ttlMs: 60_000);
final result = await repo.getUsersPage(limit: 2);
expect(result is Ok<Page<User>>, true);
final page = (result as Ok<Page<User>>).value;
expect(page.items.length, 2);
expect(api.calls, 0);
});
}Unit Test: Fetches Network When Stale and Updates Cache#
import 'package:test/test.dart';
void main() {
test('fetches network when stale and caches results', () async {
final api = FakeUsersApi()
..pageToReturn = ApiPageDto(
items: [
UserDto(id: '10', email: 'c@x.com', full_name: 'C'),
],
nextCursor: null,
);
final cache = FakeUsersCache();
final clock = FakeClock(2_000_000);
await cache.setLastSyncMs(clock.nowMs() - 999_999);
final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock, ttlMs: 60_000);
final result = await repo.getUsersPage(limit: 20);
expect(result is Ok<Page<User>>, true);
expect(api.calls, 1);
expect(cache.store.containsKey('10'), true);
});
}Unit Test: Typed Failure on Unauthorized#
import 'package:test/test.dart';
void main() {
test('returns UnauthorizedFailure on 401', () async {
final api = FakeUsersApi()..errorToThrow = ApiUnauthorizedException();
final cache = FakeUsersCache();
final clock = FakeClock(3_000_000);
final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock);
final result = await repo.getUsersPage(limit: 10, forceRefresh: true);
expect(result is Err<Page<User>>, true);
final failure = (result as Err<Page<User>>).failure;
expect(failure is UnauthorizedFailure, true);
});
}These tests run without Flutter bindings, without HTTP, and without a database. That is the point: fast feedback and stable builds.
# Common Mistakes and How to Avoid Them#
Mixing DTOs into the Domain#
If domain entities contain JSON annotations or snake_case fields, your boundary has collapsed. Keep DTOs in data.
Using Exceptions as Control Flow#
Exceptions are fine inside the data layer, but repository outputs should be explicit. Typed failures improve UI logic and telemetry.
Hiding Cache Policy Inside the UI#
If every screen makes its own caching decision, behavior becomes inconsistent. Put policy in repositories or use cases, then reuse it.
Building One Mega-Repository per App#
One repository per aggregate or feature is easier to maintain. Feature-first structure helps prevent cross-feature coupling. See Flutter app architecture: Clean Architecture feature-first.
# Key Takeaways#
- Keep DTOs, domain entities, and persistence models separate, and map explicitly at the boundaries.
- Make repositories return domain entities and typed failures via a
Resulttype, not raw exceptions. - Implement caching with an explicit policy and a controllable clock so TTL logic is deterministic in tests.
- Model pagination as a
Pagewith a cursor, and test end-of-list and refresh behaviors. - Unit test repositories with injected fakes or mocks, asserting both return values and side effects like cache writes.
# Conclusion#
A testable Flutter repository pattern is less about “where you put files” and more about boundaries you enforce: DTOs for remote schemas, persistence models for storage constraints, and domain entities for business meaning. Add typed failures and explicit cache policies, and your data layer becomes predictable, reusable, and easy to refactor.
If you want help applying this pattern to an existing Flutter codebase, or designing an offline-first strategy with sync and conflict handling, contact Samioda and we will review your architecture and propose a concrete migration plan. For further reading, start with Flutter offline-first: sync and conflict resolution and Flutter testing pyramid: unit, widget, integration, golden.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Mobile Development
All →Flutter Authentication in 2026: Firebase vs Supabase vs Custom OAuth (Pros, Cons, and Decision Criteria)
A practical comparison of Flutter authentication approaches in 2026 — Firebase Auth vs Supabase Auth vs Custom OAuth — with a decision matrix, implementation notes, and iOS/Android pitfalls.
Flutter Accessibility Guide: Semantics, Focus Order, and Dynamic Text Done Right
A practical Flutter accessibility guide for 2026: Semantics labels, correct focus order, keyboard navigation, contrast, and text scaling with real testing steps for TalkBack and VoiceOver.
Flutter Cold Start Optimization: Faster Launch, Better Splash, Fewer Jank Frames
A practical 2026 guide to Flutter cold start optimization: how to profile launch time, reduce heavy init, avoid sync I/O, tame plugin startup cost, and ship a smoother first frame with better splash and deep link sequencing.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter App Architecture That Scales: Clean Architecture vs Feature-First (With Real Folder Structures)
A practical guide to Flutter app architecture in 2026: compare Clean Architecture and Feature-First, see real folder structures, dependency boundaries, and testing strategies, and choose the right approach for your team and release cadence.
Scaling Flutter with Modularization: Monorepo Setup with Melos, Shared Packages, and Clean Boundaries
A practical guide to Flutter monorepo Melos modularization: when to split into packages, how to structure shared code, enforce boundaries, and run CI and tests efficiently across a growing codebase.
Flutter Testing Strategy: Unit, Widget, Integration and Golden Tests for Fast, Reliable CI (2026)
A practical Flutter testing strategy built around the testing pyramid: when to use unit, widget, integration, and golden tests, how to reduce flakes, and how to run everything fast in CI.