Mobile Development
FlutterDartArchitectureRepository PatternTestingClean Architecture

Flutter Repository Pattern Done Right: DTOs, Mappers, Error Types, and Testability

AO
Adrijan Omićević
·14 min read

# 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:

LayerOwnsModelsKnows about
PresentationScreens, stateView modelsUse cases, domain
DomainBusiness rulesEntities, value objectsRepository interfaces
DataIntegration detailsDTOs, persistence modelsHTTP, 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.

Dart
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.

Dart
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.

Dart
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#

Dart
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#

Dart
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#

Dart
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#

Dart
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 Exception or dynamic from 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”.

Dart
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:

  • UsersApi for remote calls
  • UsersCache for local storage
  • Clock to control time in tests
  • Connectivity as an optional dependency for smarter offline logic

Data Sources#

Dart
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.

PolicyWhen usedProsCons
Network onlyHighly dynamic dataAlways freshFails offline, slower
Cache onlyOffline-first screensWorks offline, fastCan be stale forever
Cache then networkMost list screensFast first paintRequires background refresh logic
TTL basedBalanced defaultPredictableNeeds 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.

Dart
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.

Dart
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 nextCursor is 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> with nextCursor.
  • The UI stores nextCursor and 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:

QuestionIf yesIf no
Does it improve startup or scroll performance?Cache itFetch on demand
Do users need it offline?Cache itOptional
Can it get stale safely?TTL cacheNetwork-first
Is it user-specific?Namespace by user idAvoid 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 Ok with mapped entities
  • does it write to cache on success
  • does it return correct failure on errors
  • does TTL prevent unnecessary network calls

Example Fakes#

Dart
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.

Dart
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#

Dart
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#

Dart
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#

Dart
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 Result type, not raw exceptions.
  • Implement caching with an explicit policy and a controllable clock so TTL logic is deterministic in tests.
  • Model pagination as a Page with 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.