Skip to content
ExponaBLOG
FLUTTER · DAY 040 / 100

The repository pattern in Flutter: a step-by-step guide

A single source of truth for each data type.

Expona Admin9 min read

After 40 days of building Flutter apps, you've probably noticed that mixing API calls, caching logic, and state management in your widgets creates a tangled mess. The repository pattern in Flutter solves this by establishing a single source of truth for each data type, separating how you fetch data from where you use it. Today, we'll build a pattern that makes your codebase cleaner, your tests faster, and your previews predictable.

What is the repository pattern?

The repository pattern sits between your UI layer and your data sources (API, database, cache, local files). Instead of your widgets knowing about HTTP clients, SharedPreferences, or Hive boxes, they talk only to a repository. The repository decides where to get the data, whether to cache it, and what format to return it in.

This abstraction gives you three immediate wins: your UI code doesn't care whether data came from the network or cache, you can swap implementations in tests without touching your widgets, and you centralize all data logic in one place where bugs are easier to find and fix.

Abstract the source — UI doesn't know whether data came from API or cache

Your widgets should never import your HTTP client or cache library directly. Instead, they depend on a repository interface that promises to deliver data. The repository handles all the details.

Let's say you're building a product listing screen for an e-commerce app. Your widget doesn't care if the data comes from Supabase, a REST API, or a local SQLite database. It just calls the repository.

dart
abstract class ProductRepository {
  Future<List<Product>> getProducts({required int page});
  Future<Product> getProductById(String id);
}

class ProductRepositoryImpl implements ProductRepository {
  final HttpClient httpClient;
  final LocalCache cache;

  ProductRepositoryImpl({required this.httpClient, required this.cache});

  @override
  Future<List<Product>> getProducts({required int page}) async {
    try {
      final response = await httpClient.get('/products?page=$page');
      final products = (response as List)
          .map((json) => Product.fromJson(json))
          .toList();
      
      // Cache the result
      await cache.setProducts(page, products);
      
      return products;
    } catch (e) {
      // Fall back to cache
      final cached = await cache.getProducts(page);
      if (cached.isNotEmpty) return cached;
      rethrow;
    }
  }

  @override
  Future<Product> getProductById(String id) async {
    try {
      final response = await httpClient.get('/products/$id');
      final product = Product.fromJson(response);
      await cache.setProduct(id, product);
      return product;
    } catch (e) {
      final cached = await cache.getProduct(id);
      if (cached != null) return cached;
      rethrow;
    }
  }
}

Your widget depends only on ProductRepository, never on HttpClient or LocalCache. When you need to change your backend from REST to GraphQL, you update only the repository implementation. Your widget code stays untouched.

Combine sources — Return cached data first, then refresh from the network

One of the most powerful features of the repository pattern is the ability to combine multiple data sources elegantly. In production apps, you often want to show cached data immediately while fetching fresh data in the background. This pattern is sometimes called "cache-first" or "stale-while-revalidate."

Here's how I've implemented this on trading platforms: return cached positions instantly so the user sees something, then refresh from the WebSocket connection. Users feel the app is responsive, and they always get the freshest data within seconds.

dart
class CachingProductRepository implements ProductRepository {
  final RemoteProductDataSource remote;
  final LocalProductDataSource local;
  final Duration cacheDuration;

  CachingProductRepository({
    required this.remote,
    required this.local,
    this.cacheDuration = const Duration(minutes: 5),
  });

  @override
  Future<List<Product>> getProducts({required int page}) async {
    try {
      // Return cached data immediately if available and fresh
      final cached = await local.getProducts(page);
      if (cached.isNotEmpty) {
        final cacheAge = DateTime.now().difference(cached.first.cachedAt);
        if (cacheAge < cacheDuration) {
          return cached;
        }
      }

      // Fetch fresh data
      final fresh = await remote.getProducts(page);
      
      // Update cache for next time
      await local.setProducts(page, fresh);
      
      return fresh;
    } catch (e) {
      // If network fails, return whatever we have
      final cached = await local.getProducts(page);
      if (cached.isNotEmpty) return cached;
      rethrow;
    }
  }

  @override
  Future<Product> getProductById(String id) async {
    final cached = await local.getProduct(id);
    if (cached != null) return cached;
    
    final fresh = await remote.getProductById(id);
    await local.setProduct(id, fresh);
    return fresh;
  }
}

This approach is especially powerful for mobile apps where network conditions are unpredictable. Users on 3G get data instantly from cache, and you refresh in the background. Your Riverpod or Provider listeners will notify the UI when new data arrives.

Map models — Convert API DTOs into domain models

Your API probably returns JSON that doesn't match your domain model perfectly. Dates come as strings, nested objects need flattening, and field names don't match your naming conventions. The repository is where this translation happens. Your UI layer never sees API concerns.

I separate this into three layers of models:

  • DTO (Data Transfer Object): Matches the API response exactly, handles JSON serialization.
  • Domain model: Pure business logic, what your UI actually works with.
  • Presentation model: Formatted for display (optional, sometimes domain models work directly).
dart
// DTO from API
class ProductDto {
  final String id;
  final String name;
  final int priceInCents;
  final String? imageUrl;
  final List<String> tags;
  final Map<String, dynamic> metadata;

  ProductDto({
    required this.id,
    required this.name,
    required this.priceInCents,
    this.imageUrl,
    required this.tags,
    required this.metadata,
  });

  factory ProductDto.fromJson(Map<String, dynamic> json) {
    return ProductDto(
      id: json['product_id'] as String,
      name: json['title'] as String,
      priceInCents: json['price_cents'] as int,
      imageUrl: json['featured_image']?['url'] as String?,
      tags: List<String>.from(json['tags'] as List? ?? []),
      metadata: json['custom_fields'] as Map<String, dynamic>? ?? {},
    );
  }
}

// Domain model for business logic
class Product {
  final String id;
  final String name;
  final double price;
  final String? imageUrl;
  final List<String> tags;
  final bool isOnSale;
  final DateTime cachedAt;

  Product({
    required this.id,
    required this.name,
    required this.price,
    this.imageUrl,
    required this.tags,
    required this.isOnSale,
    required this.cachedAt,
  });

  // Repository converts DTO to domain model here
  factory Product.fromDto(ProductDto dto) {
    return Product(
      id: dto.id,
      name: dto.name,
      price: dto.priceInCents / 100.0,
      imageUrl: dto.imageUrl,
      tags: dto.tags,
      isOnSale: dto.metadata['sale_price'] != null,
      cachedAt: DateTime.now(),
    );
  }

  // Optionally, a display-ready string
  String get displayPrice => '\$${price.toStringAsFixed(2)}';
}

By mapping DTOs to domain models in the repository, your UI code never couples to API field names or data types. If the backend changes product_id to productId, you fix it in one place.

Easy to fake — Swap repositories in tests and previews

This is where the repository pattern really shines. Creating a fake implementation takes minutes, and now your widget previews load instantly without hitting any network.

dart
class FakeProductRepository implements ProductRepository {
  final List<Product> _products = [
    Product(
      id: '1',
      name: 'Flutter in Action',
      price: 49.99,
      imageUrl: null,
      tags: ['programming', 'mobile'],
      isOnSale: false,
      cachedAt: DateTime.now(),
    ),
    Product(
      id: '2',
      name: 'Advanced Dart',
      price: 39.99,
      imageUrl: null,
      tags: ['programming', 'backend'],
      isOnSale: true,
      cachedAt: DateTime.now(),
    ),
  ];

  @override
  Future<List<Product>> getProducts({required int page}) async {
    await Future.delayed(const Duration(milliseconds: 100));
    return _products;
  }

  @override
  Future<Product> getProductById(String id) async {
    await Future.delayed(const Duration(milliseconds: 50));
    return _products.firstWhere((p) => p.id == id);
  }
}

In your tests, inject the fake repository and verify your widget behavior without any network calls or database setup:

dart
testWidgets('ProductList loads and displays products', (WidgetTester tester) async {
  final fakeRepo = FakeProductRepository();
  
  await tester.pumpWidget(
    ProviderContainer(
      overrides: [
        productRepositoryProvider.overrideWithValue(fakeRepo),
      ],
      child: const MyApp(),
    ),
  );

  expect(find.text('Flutter in Action'), findsOneWidget);
  expect(find.text('\$49.99'), findsOneWidget);
});

Your preview widgets load instantly with hardcoded data. No spinners, no timeouts, no flakiness.

Common mistakes

Leaking DTOs into the UI layer: Sometimes I've seen teams return the DTO directly from the repository. Don't. You lose the abstraction. The repository's job is to hide implementation details, not expose them.

Making repositories too thin: A repository that just wraps your HTTP client isn't buying you much. Push logic into it: caching strategy, retry logic, multi-source fallbacks, model mapping. A good repository handles real problems.

Creating one mega-repository: If your repository has 20 methods doing completely different things, split it. I usually have one repository per domain entity (ProductRepository, UserRepository, OrderRepository). Sometimes I have specialized read and write repositories if the logic diverges significantly.

Not testing the repository itself: Your widgets use fake repositories, so they don't test the real implementation. Write unit tests for your real repository implementations. Test cache logic, fallback behavior, DTO mapping. This is where half your bugs will hide.

Ignoring error handling: Real repositories need thoughtful error handling. Network timeout? Try cache. Cache miss? Throw a clean error. API returns 401? Trigger logout. Don't just rethrow exceptions from your data sources.

Key takeaways

  • The repository pattern abstracts data sources so your UI never knows whether data came from cache, API, or database.
  • Combine multiple sources in one repository: return cached data immediately, refresh from network in background, fall back to cache on failure.
  • Use the repository as your model mapping layer. Convert DTOs to domain models here, never expose API concerns to your UI.
  • Fake implementations make testing and previewing trivial. Your widget tests should use fake repositories and run instantly.
  • Keep repositories focused and well-tested. This is not a thin wrapper around your HTTP client; it's where you centralize all data logic for one entity type.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles