← Articles

The repository pattern in Flutter

By Ann Tech · 28 November 2024

The repository pattern puts a boundary between your business logic and your data sources. Your domain layer asks "give me the orders" without knowing whether they come from an API, a local database, or a cache. The repository decides.

Why this boundary matters

Without a repository, your BLoC or ViewModel calls Dio directly. When you want to add caching, you modify the BLoC. When you want to swap the API endpoint, you modify the BLoC. When you want to test without a network, you have to mock Dio at the HTTP level, which is brittle.

With a repository, the BLoC calls orderRepository.fetchOrders(). Adding caching, swapping endpoints, or testing with fake data happens inside the repository — the BLoC doesn't change.

The basic structure

// 1. Define what the domain needs
abstract interface class OrderRepository {
  Future<List<Order>> fetchOrders({bool forceRefresh = false});
  Future<Order> getOrder(String id);
  Future<void> updateOrderStatus(String id, OrderStatus status);
  Stream<List<Order>> watchOrders();
}

// 2. Implement with real data sources
class OrderRepositoryImpl implements OrderRepository {
  OrderRepositoryImpl({
    required this.api,
    required this.localDb,
  });

  final OrderApiClient api;
  final OrderDao localDb;

  @override
  Future<List<Order>> fetchOrders({bool forceRefresh = false}) async {
    if (!forceRefresh) {
      final cached = await localDb.getAllOrders();
      if (cached.isNotEmpty) return cached;
    }
    final fresh = await api.getOrders();
    await localDb.upsertAll(fresh);
    return fresh;
  }

  @override
  Stream<List<Order>> watchOrders() => localDb.watchAllOrders();
}

The abstract class is the contract. The implementation is interchangeable.

The data sources

Keep the API client and the local DAO as separate classes — they're distinct concerns:

class OrderApiClient {
  OrderApiClient(this._dio);
  final Dio _dio;

  Future<List<Order>> getOrders() async {
    final response = await _dio.get('/orders');
    return (response.data as List)
        .map((j) => Order.fromJson(j as Map<String, dynamic>))
        .toList();
  }

  Future<Order> updateStatus(String id, OrderStatus status) async {
    final response = await _dio.patch(
      '/orders/$id',
      data: {'status': status.name},
    );
    return Order.fromJson(response.data as Map<String, dynamic>);
  }
}

class OrderDao {
  OrderDao(this._db);
  final Database _db;

  Future<List<Order>> getAllOrders() async {
    final rows = await _db.query('orders', orderBy: 'created_at DESC');
    return rows.map(Order.fromRow).toList();
  }

  Future<void> upsertAll(List<Order> orders) async {
    final batch = _db.batch();
    for (final order in orders) {
      batch.insert('orders', order.toRow(),
          conflictAlgorithm: ConflictAlgorithm.replace);
    }
    await batch.commit(noResult: true);
  }

  Stream<List<Order>> watchAllOrders() {
    // Implementation depends on your database package
    // Drift or Isar both support reactive streams natively
    throw UnimplementedError();
  }
}

Offline-first with stale-while-revalidate

A common pattern: return cache immediately, then fetch fresh data in the background:

@override
Stream<List<Order>> watchOrders() async* {
  // Emit cached data immediately
  final cached = await localDb.getAllOrders();
  if (cached.isNotEmpty) yield cached;

  // Fetch fresh in background
  try {
    final fresh = await api.getOrders();
    await localDb.upsertAll(fresh);
    yield fresh;
  } on DioException catch (e) {
    if (cached.isEmpty) rethrow; // Only error if there's nothing to show
    // Otherwise silently swallow: stale data is better than an error screen
  }
}

Error handling in repositories

Convert implementation-specific errors (DioException, SqliteException) to domain errors at the repository boundary:

sealed class RepositoryException implements Exception {
  const RepositoryException(this.message);
  final String message;
}

final class NetworkException extends RepositoryException {
  const NetworkException() : super('No internet connection');
}

final class NotFoundException extends RepositoryException {
  const NotFoundException(String id) : super('Order $id not found');
}

// In the repository:
Future<Order> getOrder(String id) async {
  try {
    return await api.getOrder(id);
  } on DioException catch (e) {
    if (e.response?.statusCode == 404) throw NotFoundException(id);
    if (e.type == DioExceptionType.connectionError) throw const NetworkException();
    rethrow;
  }
}

Your BLoC catches RepositoryException subclasses. It never needs to know about DioException.

Registering with get_it / injectable

@module
abstract class RepositoryModule {
  @lazySingleton
  OrderApiClient orderApiClient(Dio dio) => OrderApiClient(dio);

  @lazySingleton
  OrderDao orderDao(Database db) => OrderDao(db);

  @LazySingleton(as: OrderRepository)
  OrderRepositoryImpl orderRepository(
    OrderApiClient api,
    OrderDao localDb,
  ) => OrderRepositoryImpl(api: api, localDb: localDb);
}

Testing

The abstract interface makes testing trivial:

class FakeOrderRepository implements OrderRepository {
  final List<Order> orders;
  FakeOrderRepository({this.orders = const []});

  @override
  Future<List<Order>> fetchOrders({bool forceRefresh = false}) async => orders;

  @override
  Future<Order> getOrder(String id) async =>
      orders.firstWhere((o) => o.id == id,
          orElse: () => throw NotFoundException(id));

  @override
  Future<void> updateOrderStatus(String id, OrderStatus status) async {}

  @override
  Stream<List<Order>> watchOrders() => Stream.value(orders);
}

void main() {
  test('BLoC emits orders', () async {
    final bloc = OrderBloc(
      repository: FakeOrderRepository(orders: [Order(id: '1', status: OrderStatus.pending)]),
    );
    bloc.add(const FetchOrders());
    await expectLater(
      bloc.stream,
      emitsInOrder([isA<OrdersLoading>(), isA<OrdersLoaded>()]),
    );
  });
}

Common pitfalls

Repositories that know about widgets. A repository should never import Flutter — it's pure Dart. No BuildContext, no Navigator, no showDialog.

Caching without invalidation. A cache that never expires shows stale data. Add a lastFetchedAt timestamp and a TTL (e.g., 5 minutes).

One repository per screen. Group by domain concept (orders, users, products), not by screen. A repository used by five screens is normal.

Catching all exceptions. Only catch what you can handle. A SocketException wraps to NetworkException; an unexpected NullPointerException should crash loudly in debug and report to Crashlytics in release.

Sign in to like, dislike, or report.

The repository pattern in Flutter — ANN Tech