Repository Pattern #

When developing medium-to-large scale Flutter apps, managing where data comes from and how it’s distributed to the user interface (UI) can become very complicated. The habit of writing API calls directly inside state management controllers (like Bloc, Riverpod Notifiers, or Provider) is a dangerous shortcut. It ties your business logic very tightly to third-party libraries (like Dio or Hive) and makes unit testing difficult.

To solve this problem, the software engineering world introduced the Repository Pattern. The Repository Pattern is an architectural design pattern acting as an abstraction layer between the app’s business logic and raw data sources. With this pattern, the state management layer doesn’t need to know whether the requested data comes from the internet (remote API), from a local database, or from temporary memory cache. State management just calls methods on the repository and receives clean, ready-to-serve domain objects (domain entities).

In this document, we’ll break down the Repository Pattern architecture in depth, design a clean directory structure, separate data transfer objects (DTOs) from business models, and learn how to write fast unit tests without depending on an internet connection.

Why Do You Need the Repository Pattern? #

To understand the importance of this pattern, let’s compare development scenarios before and after applying the Repository Pattern:

WITHOUT THE REPOSITORY PATTERN:
  UI Widget --> State Controller (Bloc/Notifier) --> Dio Network Call --> Parse JSON --> Rebuild UI
  
  Problems That Arise:
  ✗ Network call logic (like URL and header handling) is scattered across many state controller files.
  ✗ If you want to replace the HTTP Client library (e.g., from Dio to GraphQL), you must change code lines in many controllers.
  ✗ Unit tests are very hard to write because controllers physically hit the internet (needs internet during testing).
  ✗ There's no centralized place to manage data caching transparently.
  ✗ Code duplication happens when many different features need the same data.

WITH THE REPOSITORY PATTERN:
  UI Widget --> State Controller --> Repository Interface 
                                          │
                  ┌───────────────────────┴───────────────────────┐
                  ▼                                               ▼
     Remote Data Source (Dio API)                   Local Data Source (Hive Cache)
     
  Benefits You Get:
  ✓ All networking logic is centralized in one isolated Data Source layer.
  ✓ Caching logic can be added or modified without changing code in the UI or State Controller layer.
  ✓ Testing is very easy: you just replace the real repository with a mock repository during tests.
  ✓ One repository can be consistently reused by many different UI features.

Here’s a data exchange flow diagram orchestrated by the Repository Pattern to neatly separate local data, remote data, and your UI needs:

graph TD
    classDef default stroke:#333,stroke-width:2px;
    
    UI["UI / State Management Layer"] -->|"1. Call getProducts()"| Repo["Repository Interface / Implementation"]
    
    Repo -->|"2. Check Cache / Fetch Cache"| Local["Local Data Source (Hive/Database)"]
    Repo -->|"3. If Expired, Fetch API"| Remote["Remote Data Source (Dio/API)"]
    
    Remote -. "4. Return DTO" .-> Repo
    Local -. "5. Return Cache DTO" .-> Repo
    
    Repo -->|"6. Map DTO to Domain Entity"| Repo
    Repo -. "7. Return Domain Entity" .-> UI

Clean Architecture Directory Structure #

In professional development, the Repository Pattern is usually applied alongside the Clean Architecture or Feature-First/Layer-First Structure principles.

Here’s an example of a standardized Flutter project directory layout to isolate each part modularly:

lib/
  features/
    product/
      data/
        datasources/
          product_remote_data_source.dart   ← Handles HTTP Calls with Dio
          product_local_data_source.dart    ← Handles Caching with Hive/SQLite
        models/
          product_dto.dart                  ← JSON Model Class (Data Transfer Object)
        repositories/
          product_repository_impl.dart      ← Concrete implementation of the interface contract
      domain/
        entities/
          product.dart                      ← Pure business object free of framework/library
        repositories/
          product_repository.dart           ← Abstract Class Contract (Interface)
      presentation/
        screens/
          product_screen.dart               ← Visual UI Widget
        providers/
          product_notifier.dart             ← State Controller (Riverpod/Bloc)

With this layout, your code is divided into three big regions: domain (containing pure business rules), data (containing technical database and internet details), and presentation (containing the user interface).


Layer 1: Domain Entity (Pure Business Model) #

Our first step is defining the Domain Entity. The Domain Entity is the original data representation used by your app’s core business logic. The main characteristic of a Domain Entity is cleanliness: this class must not import third-party libraries, have no JSON serialization annotations (like @JsonSerializable), and have no fromJson or toJson methods.

// domain/entities/product.dart
class Product {
  final String id;
  final String name;
  final double price;
  final bool available;
  final String category;
  final DateTime createdAt;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.available,
    required this.category,
    required this.createdAt,
  });

  // Overriding the == operator to compare objects by value,
  // not by their memory address references.
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Product &&
          runtimeType == other.runtimeType &&
          id == other.id &&
          name == other.name &&
          price == other.price;

  @override
  int get hashCode => id.hashCode ^ name.hashCode ^ price.hashCode;
}

Layer 2: Repository Interface (Data Contract) #

Before writing code to make API calls, you must establish a working contract first. This contract is an Abstract Class defining what methods are available, what input parameters they take, and what return data is expected.

This contract is placed in the domain directory because business logic only cares about “what” operations can be done, not “how” the operation is technically executed.

// domain/repositories/product_repository.dart
import '../entities/product.dart';

abstract class ProductRepository {
  /// Fetches a paginated product list.
  /// Can throw [NetworkException] if the internet connection is lost.
  Future<List<Product>> fetchProducts({
    int page = 1,
    int limit = 20,
    String? category,
  });

  /// Fetches the detail of one product based on its unique ID.
  Future<Product> fetchProductDetail(String id);

  /// Adds a new product to the system.
  Future<Product> addNewProduct(Product newProduct);

  /// Updates information for an existing product.
  Future<Product> updateProduct(Product editedProduct);

  /// Deletes a product from the system based on ID.
  Future<void> deleteProduct(String id);
}

Layer 3: DTO (Data Transfer Object) & Mapper #

Inside the data layer, the data format sent by the API often doesn’t match the field names in your domain Product class. The API might send properties in snake_case like category_name or created_at.

For that, you create a DTO (Data Transfer Object). A DTO is a data model purely responsible for communicating with the JSON parser (equipped with @freezed annotations and fromJson functions).

You separate DTOs from Domain Entities so that backend database/API structure changes don’t break your app’s main business logic. You also provide Mapper functions using extensions to convert DTOs into Domain Entities and vice versa.

// data/models/product_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/product.dart';

part 'product_dto.freezed.dart';
part 'product_dto.g.dart';

@freezed
class ProductDto with _$ProductDto {
  const factory ProductDto({
    required String id,
    required String name,
    required double price,
    required bool available,
    @JsonKey(name: 'category_name') required String category,
    @JsonKey(name: 'created_at') required DateTime createdAt,
  }) = _ProductDto;

  factory ProductDto.fromJson(Map<String, dynamic> json) =>
      _$ProductDtoFromJson(json);
}

// === MAPPER EXTENSION ===
// Used to convert DTO data (data layer) into Entities (domain layer)
extension ProductDtoMapper on ProductDto {
  Product toDomain() => Product(
        id: id,
        name: name,
        price: price,
        available: available,
        category: category,
        createdAt: createdAt,
      );
}

// Used to convert Entities into DTOs before sending to the API
extension ProductEntityMapper on Product {
  ProductDto toDto() => ProductDto(
        id: id,
        name: name,
        price: price,
        available: available,
        category: category,
        createdAt: createdAt,
      );
}

Layer 4: Remote Data Source (API Logic) #

RemoteDataSource is fully responsible for the technical matter of network API calls using an HTTP Client (Dio). This layer handles requests, reads response status, and parses JSON payloads into DTO objects.

// data/datasources/product_remote_data_source.dart
import 'package:dio/dio.dart';
import '../models/product_dto.dart';

abstract class ProductRemoteDataSource {
  Future<List<ProductDto>> getProducts({int page, int limit, String? category});
  Future<ProductDto> getProductDetail(String id);
  Future<ProductDto> sendNewProduct(ProductDto dto);
  Future<void> deleteProduct(String id);
}

class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
  final Dio _dio;

  ProductRemoteDataSourceImpl(this._dio);

  @override
  Future<List<ProductDto>> getProducts({
    int page = 1,
    int limit = 20,
    String? category,
  }) async {
    final response = await _dio.get(
      '/products',
      queryParameters: {
        'page': page,
        'limit': limit,
        if (category != null) 'category': category,
      },
    );

    // Assume the success response format is: { "success": true, "data": [...] }
    final List<dynamic> listData = response.data['data'];
    return listData.map((json) => ProductDto.fromJson(json)).toList();
  }

  @override
  Future<ProductDto> getProductDetail(String id) async {
    final response = await _dio.get('/products/$id');
    return ProductDto.fromJson(response.data['data']);
  }

  @override
  Future<ProductDto> sendNewProduct(ProductDto dto) async {
    final response = await _dio.post(
      '/products',
      data: dto.toJson(),
    );
    return ProductDto.fromJson(response.data['data']);
  }

  @override
  Future<void> deleteProduct(String id) async {
    await _dio.delete('/products/$id');
  }
}

Layer 5: Local Data Source (Cache Logic) #

LocalDataSource handles offline storage matters in the device’s local storage memory using database libraries like Hive. This library is usually equipped with cache data expiration logic (Time To Live / TTL).

// data/datasources/product_local_data_source.dart
import 'package:hive/hive.dart';
import '../models/product_dto.dart';

abstract class ProductLocalDataSource {
  Future<List<ProductDto>?> fetchProductCache();
  Future<void> saveProductCache(List<ProductDto> dtoList);
  Future<void> clearCache();
}

class ProductLocalDataSourceImpl implements ProductLocalDataSource {
  static const String _boxName = 'box_product_cache';
  static const String _cacheKey = 'key_product_list';
  static const String _timeKey = 'key_cache_time';
  
  // Our cache expiration limit is set to 15 minutes
  static const int _expiryDurationMinutes = 15;

  @override
  Future<List<ProductDto>?> fetchProductCache() async {
    final box = await Hive.openBox(_boxName);
    final savedTime = box.get(_timeKey) as DateTime?;

    if (savedTime == null) return null;

    // Evaluate whether the cache has expired
    final timeDifference = DateTime.now().difference(savedTime).inMinutes;
    if (timeDifference > _expiryDurationMinutes) {
      await clearCache();
      return null;
    }

    final rawData = box.get(_cacheKey) as List?;
    if (rawData == null) return null;

    return rawData
        .map((item) => ProductDto.fromJson(Map<String, dynamic>.from(item)))
        .toList();
  }

  @override
  Future<void> saveProductCache(List<ProductDto> dtoList) async {
    final box = await Hive.openBox(_boxName);
    
    // Convert the DTO list into JSON Map format before storing to Hive
    final listMap = dtoList.map((dto) => dto.toJson()).toList();
    
    await box.put(_cacheKey, listMap);
    await box.put(_timeKey, DateTime.now());
  }

  @override
  Future<void> clearCache() async {
    final box = await Hive.openBox(_boxName);
    await box.clear();
  }
}

Layer 6: Repository Implementation (Concrete) #

ProductRepositoryImpl is the main orchestrator. This class intelligently combines data from RemoteDataSource and LocalDataSource to serve to the UI. This is where the “should we fetch the local cache or re-download from the internet” decision happens.

The UI or state management never knows where the data comes from. They just call methods on this class.

// data/repositories/product_repository_impl.dart
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../datasources/product_remote_data_source.dart';
import '../datasources/product_local_data_source.dart';
import '../models/product_dto.dart';

class ProductRepositoryImpl implements ProductRepository {
  final ProductRemoteDataSource _remoteDataSource;
  final ProductLocalDataSource _localDataSource;

  ProductRepositoryImpl({
    required ProductRemoteDataSource remote,
    required ProductLocalDataSource local,
  })  : _remoteDataSource = remote,
        _localDataSource = local;

  @override
  Future<List<Product>> fetchProducts({
    int page = 1,
    int limit = 20,
    String? category,
  }) async {
    // 1. If the user opens the first page without filters, try loading from local cache first
    if (page == 1 && category == null) {
      try {
        final localCache = await _localDataSource.fetchProductCache();
        if (localCache != null) {
          // Convert the local DTO list into a Domain Entity list
          return localCache.map((dto) => dto.toDomain()).toList();
        }
      } catch (_) {
        // Ignore cache errors, continue downloading from the internet
      }
    }

    // 2. If the cache is unavailable or expired, fetch from the internet (remote)
    final remoteDtos = await _remoteDataSource.getProducts(
      page: page,
      limit: limit,
      category: category,
    );

    // 3. Save that new data to the local cache for the next visit
    if (page == 1 && category == null) {
      try {
        await _localDataSource.saveProductCache(remoteDtos);
      } catch (_) {
        // Ignore caching failures so the app doesn't crash
      }
    }

    // 4. Return the data in Domain Entity form
    return remoteDtos.map((dto) => dto.toDomain()).toList();
  }

  @override
  Future<Product> fetchProductDetail(String id) async {
    // Here we can directly call the remote source
    final dto = await _remoteDataSource.getProductDetail(id);
    return dto.toDomain();
  }

  @override
  Future<Product> addNewProduct(Product newProduct) async {
    // Convert the domain entity into a DTO before sending to the API
    final sendDto = newProduct.toDto();
    final resultDto = await _remoteDataSource.sendNewProduct(sendDto);
    
    // Clear the local cache because the server data has changed (stale)
    await _localDataSource.clearCache();
    
    return resultDto.toDomain();
  }

  @override
  Future<Product> updateProduct(Product editedProduct) async {
    // The product update process is identical to adding data
    final sendDto = editedProduct.toDto();
    final response = await _remoteDataSource.sendNewProduct(sendDto);
    await _localDataSource.clearCache();
    return response.toDomain();
  }

  @override
  Future<void> deleteProduct(String id) async {
    await _remoteDataSource.deleteProduct(id);
    await _localDataSource.clearCache();
  }
}

Dependency Injection Integration with Riverpod #

To make repository instances easily accessible to state management controllers without breaking the architecture pattern, you unify all layers using a Dependency Injection (DI) service provider like Riverpod.

You create a separate provider for each component from the bottom to the top layer:

// presentation/providers/product_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../data/datasources/product_remote_data_source.dart';
import '../../data/datasources/product_local_data_source.dart';
import '../../data/repositories/product_repository_impl.dart';
import '../../domain/repositories/product_repository.dart';
import '../../domain/entities/product.dart';

// 1. Provide the global Dio instance
final dioProvider = Provider<Dio>((ref) {
  return Dio(BaseOptions(baseUrl: 'https://api.ourstore.com/v1'));
});

// 2. Provide the Remote Data Source instance
final productRemoteDataSourceProvider = Provider<ProductRemoteDataSource>((ref) {
  final dio = ref.watch(dioProvider);
  return ProductRemoteDataSourceImpl(dio);
});

// 3. Provide the Local Data Source instance
final productLocalDataSourceProvider = Provider<ProductLocalDataSource>((ref) {
  return ProductLocalDataSourceImpl();
});

// 4. Provide the Repository instance (Exposing the contract/interface class)
final productRepositoryProvider = Provider<ProductRepository>((ref) {
  final remote = ref.watch(productRemoteDataSourceProvider);
  final local = ref.watch(productLocalDataSourceProvider);
  
  return ProductRepositoryImpl(remote: remote, local: local);
});

// 5. Provide the State Management Notifier for consumption by UI Widgets
class ProductNotifier extends AutoDisposeAsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    // Reading data modularly through the repository
    return ref.watch(productRepositoryProvider).fetchProducts();
  }

  Future<void> addProduct(Product newProduct) async {
    state = const AsyncLoading();
    try {
      await ref.read(productRepositoryProvider).addNewProduct(newProduct);
      // Invalidating self will trigger a re-call of build() to refresh the data
      ref.invalidateSelf();
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }
}

final productListProvider = AsyncNotifierProvider.autoDispose<ProductNotifier, List<Product>>(
  ProductNotifier.new,
);

Testing Business Logic with Mock Repositories #

The biggest advantage of using the Repository Pattern is testing ease. Because state management only communicates with the abstract ProductRepository interface, you can create a Mock class implementing the same interface for testing purposes without needing to call Dio or load the real Hive database.

Let’s create a unit test using a mock repository:

// test/product_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../lib/domain/entities/product.dart';
import '../../lib/domain/repositories/product_repository.dart';
import '../../lib/presentation/providers/product_providers.dart';

// 1. Create a manual Mock Repository implementing ProductRepository
class MockProductRepository implements ProductRepository {
  final List<Product> mockData;
  bool triggerNetworkError;

  MockProductRepository({
    required this.mockData,
    this.triggerNetworkError = false,
  });

  @override
  Future<List<Product>> fetchProducts({int page = 1, int limit = 20, String? category}) async {
    if (triggerNetworkError) {
      throw Exception('Poor internet signal.');
    }
    // Simulate network latency delay
    await Future.delayed(const Duration(milliseconds: 10));
    return mockData;
  }

  @override
  Future<Product> fetchProductDetail(String id) async {
    return mockData.firstWhere((p) => p.id == id);
  }

  @override
  Future<Product> addNewProduct(Product newProduct) async {
    mockData.add(newProduct);
    return newProduct;
  }

  @override
  Future<Product> updateProduct(Product editedProduct) async {
    return editedProduct;
  }

  @override
  Future<void> deleteProduct(String id) async {
    mockData.removeWhere((p) => p.id == id);
  }
}

// 2. Running the unit test scenarios
void main() {
  group('ProductNotifier Business Logic Testing', () {
    late List<Product> mockList;

    setUp(() {
      mockList = [
        Product(
          id: '1',
          name: 'Gaming Laptop',
          price: 15000000.0,
          available: true,
          category: 'electronics',
          createdAt: DateTime.now(),
        ),
        Product(
          id: '2',
          name: 'Plain T-Shirt',
          price: 50000.0,
          available: true,
          category: 'clothing',
          createdAt: DateTime.now(),
        ),
      ];
    });

    test('Must successfully load product data using mock data', () async {
      // Setup a Riverpod Container by overriding the real repository with a mock repository
      final container = ProviderContainer(
        overrides: [
          productRepositoryProvider.overrideWithValue(
            MockProductRepository(mockData: mockList),
          ),
        ],
      );
      addTearDown(container.dispose);

      // Reading the async state
      final resultList = await container.read(productListProvider.future);

      expect(resultList.length, equals(2));
      expect(resultList.first.name, equals('Gaming Laptop'));
      expect(resultList.last.id, equals('2'));
    });

    test('Must emit AsyncError status if an internet disruption occurs', () async {
      final container = ProviderContainer(
        overrides: [
          productRepositoryProvider.overrideWithValue(
            MockProductRepository(mockData: mockList, triggerNetworkError: true),
          ),
        ],
      );
      addTearDown(container.dispose);

      // Ensure the function call throws an Exception
      expect(
        () => container.read(productListProvider.future),
        throwsA(isA<Exception>()),
      );
    });
  });
}

Through isolated unit tests like this, you can validate your app’s business logic correctness in less than 1 second on a local development machine without being affected by your backend API server status.

Summary #

  • The Repository Pattern presents a clean separation layer hiding the details of where data comes from (internet, memory cache, local database) from the UI and State Management layers.
  • Domain Entities are pure business data model classes sterilized from visual library dependencies and JSON deserialization functions.
  • DTOs (Data Transfer Objects) purely serve as network transition data containers adapted to the API server’s writing schema.
  • Mapper Extensions act as two-way data type converters to safely bridge DTOs with Domain Entities.
  • Remote Data Sources hold technical control of HTTP call execution, while Local Data Sources handle offline cache storage.
  • Caching Orchestration is placed inside the pure repository implementation file, freeing UI widgets from cache data expiration verification matters.
  • Centralized Dependency Injection (DI) helps distribute repository instances with modular and flexible configuration.
  • Lightning-Fast Unit Testing: Repository interface separation makes it easy to inject mock data simulating internet signal disruptions during unit tests.

← Previous: JSON & Serialization   Next: Error Handling →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact