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
NumberTriviaRepositorycontract. - 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
Failuretypes that our domain layer understands. - Returning domain entities: It ensures that, no matter the source, the data is returned as a
NumberTriviaentity, keeping the rest of the app shielded from data-layer-specific models.
Visually, this is where our new class fits in the architecture:

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:
NotesRemoteDataSource: An object responsible for fetching data from the internet.NotesLocalDataSource: An object responsible for caching and retrieving data from the device's storage.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:
- Check if the device is online using
networkInfo.isConnected. - If online:
a. Try to fetch data from theremoteDataSource.
b. If successful, cache the data usinglocalDataSourceand return the data.
c. If aServerExceptionoccurs, return aServerFailure. - If offline:
a. Try to get the last cached data fromlocalDataSource.
b. If successful, return the cached data.
c. If aCacheExceptionoccurs (e.g., no data is cached), return aCacheFailure.
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-layerFailures. - 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.