Welcome to the next lesson on building your Flutter application with Clean Architecture. In our last session, we implemented the NumberTriviaRepositoryImpl, which acts as the central orchestrator for our data layer. That implementation depends on a NumberTriviaRemoteDataSource contract to fetch data from the internet, but we left it as an abstract class.
Today, we'll bring that contract to life by implementing the remote data source. This is a significant step, as it's where our app will finally reach out across the network to fetch live data. You'll learn how to implement a remote data source to fetch data from a network API using the Dio package. Given your experience with APIs and Dio, we'll focus on how to integrate it cleanly and professionally within our architecture, a key skill for a mid-level developer.
1. The Role of the Remote Data Source
In Clean Architecture, the remote data source has a very specific and narrow set of responsibilities. It sits at the outermost edge of the data layer, acting as the sole gateway to a specific remote API.
Its job is to:
- Accept primitive types or simple data objects from the repository (e.g., the number
42). - Formulate and execute an HTTP request to the correct API endpoint.
- Parse the server's response (typically JSON).
- Convert the parsed data into a data layer model (e.g.,
NumberTriviaModel). - Handle network-specific errors (like HTTP status codes or timeouts) and throw a specific exception (e.g.,
ServerException) that the repository can understand and handle.
It does not handle business logic or decide where data comes from; it only fetches from the remote server.

2. Setting Up Dio for Network Requests
While Flutter's built-in http package is functional, dio is a more powerful and flexible HTTP client that is well-suited for professional applications. Its features like interceptors, advanced error handling, and request cancellation make it an excellent choice.
First, ensure dio is added to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
# ... other dependencies
dio: ^5.4.3+1 # Use the latest version
Instead of creating a new Dio() instance every time you need it, a mid-level practice is to configure a single, reusable instance. This ensures consistency for settings like base URLs and timeouts.
The following article provides an excellent overview of Dio's key features and how to set it up.
Mastering Flutter Dio: Simplify API calls - Level Up Coding
This article from Level Up Coding explains why Dio is a great choice and how to get started.
Read the introduction ("What is Dio, and why should you use it?") and the section "Dio’s key components broken down," focusing on "1. Setting up Dio." Pay attention to the use of BaseOptions to configure a global instance.
3. Implementing NumberTriviaRemoteDataSource
Now, let's create the concrete implementation. We will create a class NumberTriviaRemoteDataSourceImpl that implements our abstract NumberTriviaRemoteDataSource.
A key principle of clean, testable code is Dependency Injection. Our data source should not create its own Dio client; instead, it should receive it as a dependency through its constructor.
The following video demonstrates this exact pattern. While the feature is different (fetching users), the architectural concept is identical.
Flutter Dependency Injection Tutorial | Clean Architecture + BLoC + Dio + get_it Explained
This video from DevVibe shows the creation of a UserRemoteDataSource.
Watch the segment from Data Source implementation. Notice how the UserRemoteDataSource class takes a Dio instance in its constructor and then uses that instance to make a network request. This is precisely the pattern we will follow.
Now, let's apply this to our NumberTrivia feature. We'll use the Numbers API for our data.
Create the file lib/features/number_trivia/data/datasources/number_trivia_remote_data_source.dart. First, we define the abstract class that our repository depends on (this should already exist from the previous lesson, but is included here for completeness).
// lib/features/number_trivia/data/datasources/number_trivia_remote_data_source.dart
import '../models/number_trivia_model.dart';
abstract class NumberTriviaRemoteDataSource {
/// Calls the http://numbersapi.com/{number} endpoint.
///
/// Throws a [ServerException] for all error codes.
Future<NumberTriviaModel> getConcreteNumberTrivia(int number);
/// Calls the http://numbersapi.com/random endpoint.
///
/// Throws a [ServerException] for all error codes.
Future<NumberTriviaModel> getRandomNumberTrivia();
}
Next, we create the implementation. This class will depend on a Dio client, which we'll inject via the constructor.
// In the same file, below the abstract class
import 'package:dio/dio.dart';
import '../../../../core/error/exceptions.dart';
class NumberTriviaRemoteDataSourceImpl implements NumberTriviaRemoteDataSource {
final Dio dio;
NumberTriviaRemoteDataSourceImpl({required this.dio});
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) async {
// Implementation will go here
throw UnimplementedError();
}
@override
Future<NumberTriviaModel> getRandomNumberTrivia() async {
// Implementation will go here
throw UnimplementedError();
}
}
Implementing the get Methods
Now for the core logic. The getConcreteNumberTrivia method needs to make a GET request to http://numbersapi.com/{number}. Since we can configure dio with a base URL, the call will be to the /number path.
We also need to request the response as JSON by setting the Accept header.
// In NumberTriviaRemoteDataSourceImpl
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) =>
_getTriviaFromUrl('http://numbersapi.com/$number');
@override
Future<NumberTriviaModel> getRandomNumberTrivia() =>
_getTriviaFromUrl('http://numbersapi.com/random/trivia');
Future<NumberTriviaModel> _getTriviaFromUrl(String url) async {
final response = await dio.get(
url,
options: Options(headers: {'Accept': 'application/json'}),
);
if (response.statusCode == 200) {
return NumberTriviaModel.fromJson(response.data);
} else {
throw ServerException();
}
}
Note: We've created a private helper _getTriviaFromUrl to avoid duplicating the network call logic, following the DRY (Don't Repeat Yourself) principle.
The code above is a good start, but dio provides an even more robust way to handle errors.
4. Professional Error Handling with DioException
By default, dio will throw a DioException if the server returns a response with a non-2xx status code (e.g., 404 Not Found, 500 Internal Server Error). This is useful because it bundles all network-related issues into a single exception type. Our job in the data source is to catch this specific DioException and re-throw our application-specific ServerException.
This creates a clean boundary. The repository layer doesn't need to know about DioException; it only cares about ServerException.
Let's refine our implementation to use a try-catch block. The article you read earlier has a great section on this pattern.
Mastering Flutter Dio: Simplify API calls - Level Up Coding
This article provides a clear pattern for error handling and building a reusable service.
Review the sections "5. Error handling" and "Step 1: Define the ApiService class". Focus on the try-catch block that specifically catches DioException and differentiates between server errors (like 401 or 404) and other network errors. This is the pattern we will adopt.
Applying this more robust error-handling pattern, our _getTriviaFromUrl method becomes much cleaner and safer.
// Final version of the private helper method in NumberTriviaRemoteDataSourceImpl
Future<NumberTriviaModel> _getTriviaFromUrl(String url) async {
try {
final response = await dio.get(
url,
options: Options(headers: {'Accept': 'application/json'}),
);
return NumberTriviaModel.fromJson(response.data);
} on DioException {
// Catches all Dio-related exceptions, including response errors (4xx, 5xx),
// connection errors, timeouts, etc.
throw ServerException();
}
}
This is much better. We attempt the request. If it succeeds and returns a 200-level status, dio gives us the response, and we parse it. If anything goes wrong with the network request (bad status code, no internet, DNS failure), a DioException is thrown, which we catch and translate into the ServerException our repository expects.
Conclusion
Congratulations! You have successfully implemented the remote data source, connecting your app's architecture to a real-world API. This component is a critical part of a scalable and maintainable application.
Key Takeaways:
- The Remote Data Source is a focused class in the data layer responsible only for communicating with a remote API.
- Dependencies like an HTTP client (
Dio) should be injected into the constructor, not created internally. - The data source is responsible for converting raw API data (JSON) into data layer models (
NumberTriviaModel). - It plays a crucial role in error translation, catching network-specific exceptions (
DioException) and throwing application-defined exceptions (ServerException) for the repository to handle.
In the previous lesson, we built a repository that knew how to decide between online and offline data. Now, we've built the "online" part. In our next lesson, we will complete the data layer's core logic by implementing the NumberTriviaLocalDataSource to handle caching and enable offline access.