Services: the only contact with the outside world
A service wraps one external source: a REST API, a local database, the file system, a platform channel. In the official guide's description its most important property is that it holds no state.
A service's responsibilities:
- Endpoints, headers, query parameters, timeouts.
- Executing the request and checking the status code.
- Converting the response into a DTO (
fromJson). - Throwing a typed exception on failure (
UnauthorizedException,ServerException).
What is not a service's responsibility: caching, retry policy, mapping to domain models, combining two sources. Those belong to the repository.
The practical reason for this split: while a service stays thin and stable it is easy to test and to replace. Decisions concentrate in the repository instead — how long the cache lives, what happens on failure, which source to read from.
// ══ lib/data/services/api_client.dart ══ (ortaq nazik qat)
class ApiClient {
ApiClient({
required http.Client client,
required String baseUrl,
required Future<String?> Function() tokenProvider,
}) : _client = client,
_baseUrl = baseUrl,
_tokenProvider = tokenProvider;
final http.Client _client;
final String _baseUrl;
final Future<String?> Function() _tokenProvider;
Future<Map<String, dynamic>> getJson(
String path, {
Map<String, String>? query,
}) async {
final token = await _tokenProvider();
final response = await _client
.get(
Uri.parse('$_baseUrl$path').replace(queryParameters: query),
headers: {
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
)
.timeout(const Duration(seconds: 20));
// Status kodunun TİPLİ exception-a çevrilməsi burada baş verir.
return switch (response.statusCode) {
200 || 201 => jsonDecode(response.body) as Map<String, dynamic>,
401 => throw const UnauthorizedException(),
403 => throw const ForbiddenException(),
404 => throw const NotFoundException(),
>= 500 => throw ServerException(response.statusCode),
_ => throw ApiException(response.statusCode, response.body),
};
}
}
// ══ lib/data/services/order_api_client.dart ══ (bir endpoint qrupu)
class OrderApiClient {
OrderApiClient({required ApiClient api}) : _api = api;
final ApiClient _api;
// Giriş: sadə tiplər. Çıxış: DTO. State: YOXDUR.
Future<List<OrderDto>> getOrders({int page = 1, int limit = 20}) async {
final json = await _api.getJson('/v1/orders', query: {
'page': '$page',
'limit': '$limit',
});
return (json['data'] as List<dynamic>)
.map((e) => OrderDto.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<OrderDto> getOrder(String id) async =>
OrderDto.fromJson(await _api.getJson('/v1/orders/$id'));
}A shared `ApiClient` plus a service for one group of endpoints. The `http.Client` arrives through the constructor — that is the test's only seam.
| Responsibility | Service | Repository |
|---|---|---|
| URLs, headers, timeouts | ✅ | — |
| Checking status codes | ✅ | — |
| JSON → DTO | ✅ | — |
| DTO → domain model | — | ✅ |
| Caching and its lifetime | — | ✅ |
| Retry policy | — | ✅ |
| Combining two sources | — | ✅ |
| Holding state | ❌ Never | ✅ Cache, session |
`http` or `dio`? Architecturally it makes no difference — both stay inside the service and never appear in a contract. The practical difference:
http— the Dart team's package, minimal API. It has no notion of interceptors, so you write your own thin layer to attach a token (theApiClientabove).dio— interceptors,CancelToken,FormDataand upload progress come built in. In large projects that saves time.
Whichever you pick, two rules hold:
1. The library's types (Response, DioException) never leave the service.
2. The HTTP client is passed through the constructor — that is what lets a test supply MockClient (from http/testing) or a fake.
The auth token. Storing the token inside the service means giving it state. In the official case study the session lives in an AuthRepository; the service reads the token through a function (the tokenProvider above). That keeps the service stateless and centralises token refresh in one place.
The most common mistake: using a service "as a repository" — keeping the HTTP call, the cache and the domain mapping all inside a ProductService. That merges two layers, and the consequence is not immediately visible; the problem surfaces when offline support or a second source is needed, and network, database and cache logic pile into the same class.
Naming reduces this mistake: ...ApiClient, ...DatabaseService, ...PreferencesService — the name states the source and reminds you the class should stay thin.
Practice. Split one networking class in your project in two: a thin ...ApiClient (endpoints plus DTOs) and a ...RepositoryRemote (cache plus domain). Then move http.Client into the constructor and write one test with MockClient.
Done means: the test makes no network call, and grep -rn "http\.\|Dio(" lib/data/repositories finds nothing.
📚 Sources and documentation
- Case study: the data layerofficialdocs.flutter.dev
How the service/repository split looks in real code, including the auth service.
- Flutter: fetch data from the internetofficialdocs.flutter.dev
The basics with the `http` package — request, status code, `fromJson`.
- The `http` packageofficialpub.dev
The `Client` abstraction and `MockClient` in `http/testing` — for replacing the network in tests.
- The `dio` packageofficialpub.dev
Interceptors, `CancelToken` and more — the alternative in larger projects.