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.
| Scenario | Expected experience | Automatic retry? |
|---|---|---|
| GET timeout | connection message and retry action | limited |
| 400 | explain rejected input | no |
| 401 | refresh once or end session | not a normal retry |
| 403 | insufficient access | no |
| GET 500 | temporary outage | limited |
| invalid JSON | contract incompatibility | no |
| voluntary cancellation | no error message | no |
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.