Networking & REST API Integration

Building a Complete API Service Layer

16 min Lesson 13 of 13

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.

Why a service layer? Without one, networking code bleeds into widgets, view-models, and repositories — making the app brittle to API changes and impossible to unit-test without hitting the real network. A single cohesive 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;
  });
}
Tip: Keep the 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);
  }
}
Warning: Never call 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 typeApiException replaces all Dio-specific exceptions for callers
  • Testable by design — injectable Dio parameter enables fast, isolated unit tests
  • Thin UI layer — widgets call domain methods, never HTTP verbs
Key Takeaway: The 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.