Building a Complete API Service Layer
Building a Complete API Service Layer
Throughout this tutorial you have learned Dio configuration, typed model deserialization, centralised error handling, response caching, and automatic retry logic. In this final lesson you will consolidate all of those concepts into a single, production-ready ApiService class that acts as the sole bridge between your Flutter UI and the outside network. A clean service layer means your widgets never touch HTTP verbs, base URLs, or error codes directly — they simply call expressive Dart methods and receive domain objects in return.
ApiService encapsulates every networking concern and can be injected (or mocked) anywhere.1. Final Dio Client Setup
Centralise your Dio instance with interceptors wired in the correct order: auth first (injects tokens), then cache, then retry, and finally logging (outermost, so it sees the final request and response).
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
Dio createDio() {
final options = BaseOptions(
baseUrl: 'https://api.example.com/v1/',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
);
final dio = Dio(options);
// 1. Auth interceptor — attach Bearer token
dio.interceptors.add(AuthInterceptor());
// 2. Cache interceptor — serve stale-while-revalidate
final cacheOptions = CacheOptions(
store: MemCacheStore(),
policy: CachePolicy.refreshForceCache,
maxStale: const Duration(minutes: 5),
);
dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
// 3. Retry interceptor — 3 attempts on 5xx / network errors
dio.interceptors.add(RetryInterceptor(
dio: dio,
retries: 3,
retryDelays: [
const Duration(seconds: 1),
const Duration(seconds: 2),
const Duration(seconds: 4),
],
));
// 4. Logger (dev-only, stripped in release builds)
assert(() {
dio.interceptors.add(LogInterceptor(responseBody: true));
return true;
}());
return dio;
}
2. Centralised Error Handling
Wrap every Dio call in a single private helper that converts DioException values into your own ApiException type. UI code never imports Dio at all — it catches only ApiException.
class ApiException implements Exception {
final String message;
final int? statusCode;
const ApiException(this.message, {this.statusCode});
@override
String toString() => 'ApiException($statusCode): $message';
}
Future<T> safeCall<T>(Future<T> Function() call) async {
try {
return await call();
} on DioException catch (e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
throw const ApiException('Request timed out. Check your connection.');
case DioExceptionType.badResponse:
final code = e.response?.statusCode;
final msg = e.response?.data?['message'] as String? ?? 'Server error';
throw ApiException(msg, statusCode: code);
case DioExceptionType.connectionError:
throw const ApiException('No internet connection.');
default:
throw ApiException(e.message ?? 'Unexpected error');
}
}
}
3. Typed Model Integration
Every API response is immediately mapped to a typed Dart model using a fromJson factory constructor. The service layer owns this mapping so the rest of the app works with plain Dart objects, never raw Map<String, dynamic> values.
4. The Complete ApiService Class
Assemble everything into a class that can be registered once (e.g. via get_it or Riverpod) and injected wherever needed.
class ApiService {
ApiService({Dio? dio}) : _dio = dio ?? createDio();
final Dio _dio;
// --- Users ---
Future<User> fetchUser(int id) => safeCall(() async {
final res = await _dio.get('users/$id');
return User.fromJson(res.data as Map<String, dynamic>);
});
Future<List<User>> fetchUsers({int page = 1}) => safeCall(() async {
final res = await _dio.get('users', queryParameters: {'page': page});
final list = res.data['data'] as List;
return list
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
});
// --- Posts ---
Future<Post> createPost(Map<String, dynamic> body) => safeCall(() async {
final res = await _dio.post('posts', data: body);
return Post.fromJson(res.data as Map<String, dynamic>);
});
Future<void> deletePost(int id) => safeCall(() async {
await _dio.delete('posts/$id');
});
// --- Auth ---
Future<String> login(String email, String password) => safeCall(() async {
final res = await _dio.post('auth/login', data: {
'email': email,
'password': password,
});
return res.data['token'] as String;
});
}
ApiService constructor injectable. Accept an optional Dio? parameter — in production it defaults to createDio(), but in tests you pass a MockDio or a Dio pointed at a local mock server without touching a line of production code.5. Consuming the Service from a Widget
Widgets stay thin: they call the service, handle ApiException, and display the result. No HTTP details leak into the UI layer.
class UserScreen extends StatefulWidget {
final int userId;
const UserScreen({super.key, required this.userId});
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
late final ApiService _api;
User? _user;
String? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_api = ApiService(); // inject via Provider or get_it in real apps
_load();
}
Future<void> _load() async {
try {
final user = await _api.fetchUser(widget.userId);
if (mounted) setState(() { _user = user; _loading = false; });
} on ApiException catch (e) {
if (mounted) setState(() { _error = e.message; _loading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const CircularProgressIndicator();
if (_error != null) return Text('Error: $_error');
return Text(_user!.name);
}
}
setState() after the widget has been unmounted. Always guard async callbacks with if (mounted), as shown above, to prevent "setState called after dispose" runtime errors.6. Architecture Principles Summary
A well-structured networking layer follows these principles:
- Single Dio instance — created once, shared everywhere via dependency injection
- Interceptors in order — auth, then cache, then retry, then logger
- Typed models only — raw maps never escape the service layer
- One error type —
ApiExceptionreplaces all Dio-specific exceptions for callers - Testable by design — injectable
Dioparameter enables fast, isolated unit tests - Thin UI layer — widgets call domain methods, never HTTP verbs
ApiService pattern is the culmination of everything in this tutorial. It isolates every networking concern — configuration, serialisation, caching, retry, error normalisation — behind a clean Dart API. Your UI code becomes easier to read, test, and maintain, and your networking logic can evolve independently of the rest of the app.