Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 5

Implementing Repository Interfaces

Recommended resources

Welcome to the next lesson in our journey to build a robust Flutter application with Clean Architecture. Last time, we established a crucial boundary by defining the NumberTriviaRepository abstract class in the domain layer. This "contract" specifies what data operations our app needs, but intentionally says nothing about how they are performed.

Today, we will cross that boundary into the data layer. Our goal is to implement the repository interface, connecting it to data sources. This is where the application's data strategy comes to life. We'll build the component that decides whether to fetch fresh data from an API or serve a cached version, and how to handle errors gracefully. Mastering this layer is a key step toward writing professional, mid-level Flutter code.

1. The Role of the Repository Implementation

The repository implementation is the orchestrator of the data layer. It fulfills the contract defined by the domain layer's abstract class. Its primary responsibilities are:

  • Implementing the interface: It provides concrete logic for every method in the NumberTriviaRepository contract.
  • Coordinating data sources: It decides whether to fetch data from a remote source (like a REST API) or a local source (like a cache or database).
  • Handling data-specific errors: It catches exceptions from the data sources (e.g., a network timeout or a database error) and translates them into the Failure types that our domain layer understands.
  • Returning domain entities: It ensures that, no matter the source, the data is returned as a NumberTrivia entity, keeping the rest of the app shielded from data-layer-specific models.

Visually, this is where our new class fits in the architecture:

In this diagram, the `NumberTriviaRepositoryImpl` is the "Repository" block within the Data Layer. It depends on Data Sources (both Remote and Local) and implements the `Repository` interface from the Domain Layer.

2. Structuring the NumberTriviaRepositoryImpl

Let's start by creating the class file and defining its dependencies. In your project, create the file lib/features/number_trivia/data/repositories/number_trivia_repository_impl.dart.

The implementation will need to collaborate with three other components:

  1. NotesRemoteDataSource: An object responsible for fetching data from the internet.
  2. NotesLocalDataSource: An object responsible for caching and retrieving data from the device's storage.
  3. NetworkInfo: A utility to check if the device has an active internet connection.

We haven't built these yet, but following the Dependency Inversion Principle, we can define their contracts (as abstract classes) and inject them into our repository. This allows us to build the repository's logic without having the concrete implementations of its dependencies ready.

Your number_trivia_repository_impl.dart file should look like this:

// lib/features/number_trivia/data/repositories/number_trivia_repository_impl.dart

import 'package:dartz/dartz.dart';

import '../../../../core/error/failures.dart';
import '../../../../core/platform/network_info.dart'; // We will create this
import '../../domain/entities/number_trivia.dart';
import '../../domain/repositories/number_trivia_repository.dart';
import '../datasources/number_trivia_local_data_source.dart'; // We will create this
import '../datasources/number_trivia_remote_data_source.dart'; // We will create this

class NumberTriviaRepositoryImpl implements NumberTriviaRepository {
  final NumberTriviaRemoteDataSource remoteDataSource;
  final NumberTriviaLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  NumberTriviaRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number) {
    // TODO: implement getConcreteNumberTrivia
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia() {
    // TODO: implement getRandomNumberTrivia
    throw UnimplementedError();
  }
}

For this code to work, you'll need to create placeholder files for the data sources and network_info.dart with simple abstract class definitions. We will implement them fully in upcoming lessons.

This structure clearly separates concerns. The repository doesn't know how to make an HTTP request or write to a database; it only knows that it has remoteDataSource and localDataSource objects that can perform these tasks. This is a hallmark of clean, decoupled code.

The article below shows a similar implementation for a "Notes" app, which can help reinforce your understanding of the structure.

Flutter Clean Architecture: Build Scalable Apps Step-by-Step ...

This section from the "Flutter Clean Architecture" guide by Djamware shows the creation of NotesRepositoryImpl.

Focus on the file notes_repository_impl.dart. Notice how it takes remoteDataSource and localDataSource in its constructor, just like our implementation. Also, pay attention to the logic inside the getAllNotes method, which tries to fetch from the remote source and falls back to the local source. This is a common and valid data-fetching strategy.


3. Implementing the Data-Fetching Logic

Now for the core logic. For both getConcreteNumberTrivia and getRandomNumberTrivia, the decision process will be the same:

  1. Check if the device is online using networkInfo.isConnected.
  2. If online:
    a. Try to fetch data from the remoteDataSource.
    b. If successful, cache the data using localDataSource and return the data.
    c. If a ServerException occurs, return a ServerFailure.
  3. If offline:
    a. Try to get the last cached data from localDataSource.
    b. If successful, return the cached data.
    c. If a CacheException occurs (e.g., no data is cached), return a CacheFailure.

The following video from Reso Coder's TDD Clean Architecture series walks through building this logic step-by-step. The video uses a Test-Driven Development approach, writing tests before implementation. While we won't focus on writing tests today, the tests perfectly describe the behavior we need to implement.

Flutter TDD Clean Architecture Course [6] – Repository Implementation

This video is a masterclass in implementing a repository. We'll focus on the implementation code that is written to satisfy the tests shown.

Watch the following segments to see how each piece of the logic is built: Online Success: See how the repository calls the remote data source and returns a Right with the entity. Caching Data: Watch how the successfully fetched remote data is then passed to the local data source to be cached. Handling Server Errors: This part shows how to wrap the remote call in a try-catch block to handle ServerException and return a Left(ServerFailure). Offline Success: Learn how the else block (for when the device is offline) is implemented to call the local data source. Handling Cache Errors: Finally, see how a try-catch is added to the offline logic to handle CacheException and return a Left(CacheFailure).

After following the logic in the video, the implementation for getConcreteNumberTrivia would look something like this:

// In NumberTriviaRepositoryImpl

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number) async {
  if (await networkInfo.isConnected) {
    try {
      final remoteTrivia = await remoteDataSource.getConcreteNumberTrivia(number);
      localDataSource.cacheNumberTrivia(remoteTrivia);
      return Right(remoteTrivia);
    } on ServerException {
      return Left(ServerFailure());
    }
  } else {
    try {
      final localTrivia = await localDataSource.getLastNumberTrivia();
      return Right(localTrivia);
    } on CacheException {
      return Left(CacheFailure());
    }
  }
}

4. Refactoring to Stay DRY (Don't Repeat Yourself)

You'll notice that the implementation for getRandomNumberTrivia will be almost identical to getConcreteNumberTrivia. The only difference is the specific method called on the remoteDataSource. Copying and pasting this large block of code is a classic violation of the DRY principle and a common trait of junior-level code.

To elevate this to a mid-level standard, we can refactor this logic into a single private method that accepts the remote call as a parameter. This is a perfect use case for higher-order functions in Dart.

Flutter TDD Clean Architecture Course [6] – Repository Implementation

The final part of the Reso Coder video demonstrates an elegant refactoring technique.

Watch from refactoring with higher-order functions. Pay close attention to: The creation of a private _getTrivia method. How this method accepts a function, getConcreteOrRandom, as an argument. The use of typedef to create a clean, readable alias for the function's signature.

By applying this refactoring, our final NumberTriviaRepositoryImpl is much cleaner and more maintainable. The two public methods become simple one-liners that delegate to the private helper, passing in the specific remote call they need to execute.

Here is the refactored code:

// Final version of NumberTriviaRepositoryImpl

class NumberTriviaRepositoryImpl implements NumberTriviaRepository {
  // ... constructor ...

  @override
  Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number) async {
    return await _getTrivia(() {
      return remoteDataSource.getConcreteNumberTrivia(number);
    });
  }

  @override
  Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia() async {
    return await _getTrivia(() {
      return remoteDataSource.getRandomNumberTrivia();
    });
  }

  // Private helper method
  Future<Either<Failure, NumberTrivia>> _getTrivia(
    Future<NumberTrivia> Function() getConcreteOrRandom,
  ) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteTrivia = await getConcreteOrRandom();
        localDataSource.cacheNumberTrivia(remoteTrivia);
        return Right(remoteTrivia);
      } on ServerException {
        return Left(ServerFailure());
      }
    } else {
      try {
        final localTrivia = await localDataSource.getLastNumberTrivia();
        return Right(localTrivia);
      } on CacheException {
        return Left(CacheFailure());
      }
    }
  }
}

Note: In the video, typedef is used to give the function signature a name. While good practice, it's omitted here for brevity. The core concept is passing the function getConcreteOrRandom as an argument.


Conclusion

In this lesson, you've implemented a crucial piece of the Clean Architecture puzzle. You've created a repository that not only fulfills its contract with the domain layer but also intelligently orchestrates data fetching, caching, and error handling.

Key Takeaways:

  • The repository implementation lives in the data layer and provides the concrete logic for the domain layer's abstract repository contract.
  • It acts as a single source of truth for data, deciding whether to go to the network or a local cache.
  • It is responsible for catching data-layer-specific exceptions (like ServerException) and converting them into domain-layer Failures.
  • Using higher-order functions to refactor repeated logic is a powerful technique for writing clean, maintainable, mid-level code.

We've now defined and implemented our repository, but it depends on data sources that don't exist yet. In our next lesson, we'll implement the NumberTriviaRemoteDataSource, finally connecting our application to a live internet API to fetch real number trivia.