Sparround

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.

dart
// ══ 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.

ResponsibilityServiceRepository
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 (the ApiClient above).
  • dio — interceptors, CancelToken, FormData and 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.