Integration is a boundary

An external API should not dictate the domain or leak transport details into the UI.

Widget -> Controller -> Use case -> Repository -> Data source -> HTTP

The return path translates JSON into a DTO, the DTO into an entity, and exceptions into known failures.

Centralize Dio configuration

Dio buildDio(AppEnvironment environment) {
  return Dio(
    BaseOptions(
      baseUrl: environment.apiUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 20),
      sendTimeout: const Duration(seconds: 20),
      headers: const {'Accept': 'application/json'},
    ),
  );
}

Do not create a client for every request. A shared instance supports consistent interceptors, cancellation, metrics, and policy.

Defensive DTO parsing

final class ProductDto {
  const ProductDto({required this.id, required this.name, required this.price});

  factory ProductDto.fromJson(Map<String, Object?> json) {
    final id = json['id'];
    final name = json['name'];
    final price = json['price'];
    if (id is! String || name is! String || price is! num) {
      throw const FormatException('Invalid product');
    }
    return ProductDto(id: id, name: name, price: price.toDouble());
  }

  final String id;
  final String name;
  final double price;
}

Code generation reduces repetition; it does not remove contract validation and compatibility decisions.

Data sources and repositories

final class DioProductRemoteDataSource {
  const DioProductRemoteDataSource(this._dio);
  final Dio _dio;

  Future<List<ProductDto>> getProducts() async {
    final response = await _dio.get<List<dynamic>>('/products');
    final data = response.data ?? const [];
    return data
        .map((item) => ProductDto.fromJson(item as Map<String, Object?>))
        .toList(growable: false);
  }
}

The repository decides cache, fallback, and entity conversion. Presentation should not know Response, status codes, or headers.

Authentication without disclosure

final class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._tokenStore);
  final TokenStore _tokenStore;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStore.read();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }
}

Never write tokens into logs, errors, or analytics. Token refresh needs mutual exclusion so that concurrent 401 responses do not launch multiple refresh calls.

Failure, retry, and cancellation

Retry automatically only when an operation is idempotent and the failure is transient. Do not repeat a POST indefinitely without an idempotency key. Use cancellation when a screen is disposed or a search is replaced, and distinguish cancellation from failure.

Responsible logging

Record the method, normalized route, duration, status, and correlation ID. Redact authorization headers, cookies, personal data, and sensitive payloads. Development logging must never become accidental production policy.

Guided practice: replace the fake with HTTP without contaminating domain

Continue from Module 3. UI remains unchanged; composition replaces only the fake implementation.

Checkpoint 1 — configure one client

flutter pub add dio

Create core/network/dio_factory.dart:

import 'package:dio/dio.dart';

Dio buildDio({required String baseUrl}) => Dio(
      BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 20),
        sendTimeout: const Duration(seconds: 20),
        headers: const {'Accept': 'application/json'},
      ),
    );

Read the public URL through String.fromEnvironment('API_URL') and run with --dart-define=API_URL=.... Anything compiled into a client binary is not a secret.

Checkpoint 2 — adapt Dio to a small interface

abstract interface class JsonClient {
  Future<Object?> get(String path, {Object? cancelToken});
}

final class DioJsonClient implements JsonClient {
  DioJsonClient(this._dio);
  final Dio _dio;

  @override
  Future<Object?> get(String path, {Object? cancelToken}) async {
    final response = await _dio.get<Object?>(
      path,
      cancelToken: cancelToken as CancelToken?,
    );
    return response.data;
  }
}

Keep the cast at the adapter boundary. Data depends on the operation it needs, not Dio’s entire API.

Checkpoint 3 — parse defensively

Replace the fake data source:

final class HttpProductRemoteDataSource implements ProductRemoteDataSource {
  const HttpProductRemoteDataSource(this._client);
  final JsonClient _client;

  @override
  Future<List<ProductDto>> getProducts() async {
    final payload = await _client.get('/products');
    if (payload is! List) throw const FormatException('Expected a list');
    return payload.map((item) {
      if (item is! Map) throw const FormatException('Invalid product item');
      return ProductDto.fromJson(Map<String, Object?>.from(item));
    }).toList(growable: false);
  }
}

Never cast an unverified payload directly to Map<String, dynamic>.

Checkpoint 4 — prove HTTP with a disposable server

Write a VM test with dart:io. Bind HttpServer to InternetAddress.loopbackIPv4 and port 0, return a JSON product list, point Dio to the allocated port, call the data source, and close the server with addTearDown. This traverses real loopback HTTP without internet or a shared environment. Add invalid JSON, 500, and timeout cases.

Checkpoint 5 — translate DioException once

Create a sealed failure taxonomy and map transport details in data:

AppFailure mapDioException(DioException error) => switch (error.response?.statusCode) {
  401 => const UnauthorizedFailure('Session expired'),
  >= 400 && < 500 => const ContractFailure('Request rejected'),
  _ when error.type == DioExceptionType.connectionTimeout ||
      error.type == DioExceptionType.receiveTimeout =>
    const NetworkFailure('Request timed out'),
  _ => const UnexpectedFailure('Unexpected communication failure'),
};

UI and domain never import Dio. Presentation chooses final copy and recovery action from AppFailure.

Checkpoint 6 — authentication, redaction, and cancellation

Add an interceptor that depends on TokenStore, not a global. Redact authorization, cookies, personal fields, and bodies from production logs. Use a CancelToken for replaceable searches and distinguish cancellation from failure. Retry only idempotent operations and transient faults.

Compose Dio → DioJsonClient → HttpProductRemoteDataSource → ProductRepositoryImpl → GetProducts, then run formatting, analysis, and tests.

ScenarioExpected experienceAutomatic retry?
GET timeoutconnection message and retry actionlimited
400explain rejected inputno
401refresh once or end sessionnot a normal retry
403insufficient accessno
GET 500temporary outagelimited
invalid JSONcontract incompatibilityno
voluntary cancellationno error messageno

Independent practice: implement product POST with an idempotency key and test that timeout is not treated as proof the server did not process it.

Deeper understanding: semantics before the library

Dio organizes transport; it does not define the contract. Before coding, describe method, route, authentication, timeout, body, success status, errors, and idempotency. A repeated GET should observe the same resource without creating an effect; a POST can duplicate work when retried without an idempotency key.

Separate transport failure, a valid HTTP error response, and an invalid payload. A timeout does not prove the server failed to process an operation. A contractual 400 must not enter infinite retry. A 401 might require refresh; a 403 usually means insufficient authorization, not an expired session.

Test the failure matrix and confirm the message and action offered to the user. “Unexpected error” is a fallback, not the primary strategy.

Terms to review: idempotency, timeout, cancellation, DTO, status code, interceptor, correlation ID, and redaction. See the course reference.