Skip to content
Blog

Flutter Clean Architecture: The Data Layer Explained

Understand the Data layer in Flutter Clean Architecture — repositories, data sources, and how to implement domain contracts with clean abstractions.

Published on • September 17, 2026

AI Assistant

The Data Layer’s Purpose

The Data layer implements the repository contracts defined in the Domain layer. It handles all data operations: API calls, database queries, file I/O, and cache management.

This layer transforms raw data into Domain entities, keeping the rest of your app ignorant of where data comes from.

Repository Implementation

Repositories implement abstract interfaces from Domain and coordinate between multiple data sources:

class TransactionRepositoryImpl implements TransactionRepository {
  final TransactionApiDataSource _api;
  final TransactionLocalDataSource _local;

  TransactionRepositoryImpl(this._api, this._local);

  @override
  Future<List<Transaction>> getTransactions({
    required String userId,
    DateTime? since,
  }) async {
    try {
      final remoteData = await _api.fetchTransactions(userId, since: since);
      await _local.cacheTransactions(remoteData);
      return remoteData.map((dto) => dto.toDomain()).toList();
    } catch (e) {
      final cached = await _local.getTransactions(userId);
      return cached.map((dto) => dto.toDomain()).toList();
    }
  }
}

Data Sources

Each data source handles one concern:

abstract class TransactionApiDataSource {
  Future<List<TransactionDto>> fetchTransactions(String userId, {DateTime? since});
}

abstract class TransactionLocalDataSource {
  Future<List<TransactionDto>> getTransactions(String userId);
  Future<void> cacheTransactions(List<TransactionDto> transactions);
}

Data Transfer Objects (DTOs)

DTOs handle serialization and map to Domain entities:

class TransactionDto {
  final String id;
  final String userId;
  final double amount;
  final String type;

  TransactionDto({required this.id, required this.userId, required this.amount, required this.type});

  factory TransactionDto.fromJson(Map<String, dynamic> json) {
    return TransactionDto(id: json['id'], userId: json['user_id'], amount: json['amount'], type: json['type']);
  }

  Transaction toDomain() {
    return Transaction(
      id: id,
      userId: userId,
      amount: amount,
      type: TransactionType.values.firstWhere((t) => t.name == type),
      timestamp: DateTime.now(),
    );
  }
}

Dependency Injection

Wire everything with a DI container (GetIt, Riverpod, or injectable):

void registerDependencies() {
  getIt.registerLazySingleton<TransactionApiDataSource>(() => TransactionApiDataSourceImpl());
  getIt.registerLazySingleton<TransactionLocalDataSource>(() => TransactionLocalDataSourceImpl());
  getIt.registerLazySingleton<TransactionRepository>(
    () => TransactionRepositoryImpl(getIt(), getIt()),
  );
}

Best Practices

  1. One data source per concern — API, local DB, cache, file
  2. Always map DTOs to Domain entities — never expose raw API models to UI
  3. Handle errors gracefully — fallback to cache, return stale data
  4. Use try/catch at repository level — not in use cases

Conclusion

The Data layer is where the rubber meets the road. Clean abstractions here mean you can swap Firebase for Supabase, or SQLite for Hive, without touching a single widget.