Flutter API Integration: Reliable REST Service Architecture

14 min readFebruary 9, 2026Updated: Mar 9, 2026
Flutter API integrationFlutter REST APIFlutter repository patternDio FlutterFlutter error handlingFlutter network layerFlutter clean architecture apiFlutter backend integration

# Flutter API Integration: Reliable REST Service Architecture

Reliable networking is about architecture, not just HTTP calls. In every production Flutter app I have built, the API layer is the single most critical piece of infrastructure. Get it wrong and you end up chasing random crashes, inconsistent states, and frustrated users. Get it right and every feature you add on top just works.

This article walks through the architecture I reach for in every project: Dio configuration, interceptors, the repository pattern, error handling with the Result pattern, offline caching, and the mistakes I see teams make over and over again.

Why Dio Over http

The built-in `http` package is fine for prototypes, but production apps need interceptors, cancel tokens, form-data uploads, and fine-grained timeout control. Dio gives you all of that out of the box.

dart
import class="code-string">'package:dio/dio.dart';

class DioClient {
  late final Dio _dio;

  DioClient({required String baseUrl, String? authToken}) {
    _dio = Dio(
      BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: class="code-number">10),
        receiveTimeout: const Duration(seconds: class="code-number">15),
        sendTimeout: const Duration(seconds: class="code-number">10),
        headers: {
          class="code-string">'Content-Type': class="code-string">'application/json',
          class="code-string">'Accept': class="code-string">'application/json',
          if (authToken != null) class="code-string">'Authorization': class="code-string">'Bearer $authToken',
        },
      ),
    );

    _dio.interceptors.addAll([
      LoggingInterceptor(),
      AuthInterceptor(),
      RetryInterceptor(dio: _dio),
    ]);
  }

  Dio get dio => _dio;
}

A few things to note here. I always set all three timeouts explicitly. The defaults in Dio are quite generous, and in mobile networks you want to fail fast rather than leave the user staring at a spinner. I also inject the auth token at construction time but refresh it through an interceptor, which I will show next.

Interceptors in Practice

Interceptors are where Dio really shines. In production apps, I always set up interceptors for three things: logging, authentication refresh, and retry logic.

Logging Interceptor

dart
class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final method = options.method.toUpperCase();
    final url = options.uri.toString();
    debugPrint(class="code-string">'→ $method $url');
    if (options.data != null) {
      debugPrint(class="code-string">'  Body: ${options.data}');
    }
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final code = response.statusCode;
    final url = response.requestOptions.uri.toString();
    debugPrint(class="code-string">'← $code $url');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    debugPrint(class="code-string">'✗ ${err.type}: ${err.message}');
    handler.next(err);
  }
}

Auth Interceptor with Token Refresh

This is the interceptor I use most. When a 401 comes back, it attempts a token refresh and retries the original request exactly once. If the refresh itself fails, the user is logged out.

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final AuthService _authService;
  bool _isRefreshing = false;

  AuthInterceptor({
    required TokenStorage tokenStorage,
    required AuthService authService,
  })  : _tokenStorage = tokenStorage,
        _authService = authService;

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getAccessToken();
    if (token != null) {
      options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != class="code-number">401 || _isRefreshing) {
      return handler.next(err);
    }

    _isRefreshing = true;
    try {
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) throw Exception(class="code-string">'No refresh token');

      final newTokens = await _authService.refreshToken(refreshToken);
      await _tokenStorage.saveTokens(newTokens);

      class=class="code-string">"code-comment">// Retry original request with new token
      final options = err.requestOptions;
      options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer ${newTokens.accessToken}';
      final response = await Dio().fetch(options);
      handler.resolve(response);
    } catch (_) {
      await _tokenStorage.clear();
      handler.reject(err);
    } finally {
      _isRefreshing = false;
    }
  }
}

Retry Interceptor

Network conditions on mobile are unpredictable. A simple retry with exponential backoff saves a lot of user complaints.

dart
class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int maxRetries;

  RetryInterceptor({required this.dio, this.maxRetries = class="code-number">2});

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final isRetryable = err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError;

    if (!isRetryable) return handler.next(err);

    int attempt = err.requestOptions.extra[class="code-string">'retryCount'] ?? class="code-number">0;
    if (attempt >= maxRetries) return handler.next(err);

    attempt++;
    err.requestOptions.extra[class="code-string">'retryCount'] = attempt;

    final delay = Duration(milliseconds: class="code-number">300 * (class="code-number">1 << attempt));
    await Future.delayed(delay);

    try {
      final response = await dio.fetch(err.requestOptions);
      handler.resolve(response);
    } on DioException catch (e) {
      handler.next(e);
    }
  }
}

Complete API Layer Architecture

The architecture I use follows Clean Architecture principles but without the ceremony that makes it impractical. Here is the full picture:

UI (Widgets)
  ↓
BLoC / Cubit / Riverpod
  ↓
Repository (abstractions)
  ↓
Remote Data Source ←→ Local Data Source
  ↓                        ↓
Dio (HTTP)             Hive / Drift / SharedPrefs

Data Transfer Objects (DTOs)

DTOs map one-to-one with API responses. Never let API JSON shapes leak into your domain layer.

dart
class UserDto {
  final int id;
  final String userName;
  final String emailAddress;
  final String? avatarUrl;
  final String createdAt;

  UserDto({
    required this.id,
    required this.userName,
    required this.emailAddress,
    this.avatarUrl,
    required this.createdAt,
  });

  factory UserDto.fromJson(Map<String, dynamic> json) => UserDto(
        id: json[class="code-string">'id'] as int,
        userName: json[class="code-string">'user_name'] as String,
        emailAddress: json[class="code-string">'email_address'] as String,
        avatarUrl: json[class="code-string">'avatar_url'] as String?,
        createdAt: json[class="code-string">'created_at'] as String,
      );

  Map<String, dynamic> toJson() => {
        class="code-string">'id': id,
        class="code-string">'user_name': userName,
        class="code-string">'email_address': emailAddress,
        class="code-string">'avatar_url': avatarUrl,
        class="code-string">'created_at': createdAt,
      };

  User toDomain() => User(
        id: id,
        name: userName,
        email: emailAddress,
        avatarUrl: avatarUrl,
        memberSince: DateTime.parse(createdAt),
      );
}

Domain Model

dart
class User {
  final int id;
  final String name;
  final String email;
  final String? avatarUrl;
  final DateTime memberSince;

  const User({
    required this.id,
    required this.name,
    required this.email,
    this.avatarUrl,
    required this.memberSince,
  });
}

Remote Data Source

dart
abstract class UserRemoteDataSource {
  Future<UserDto> getUser(int id);
  Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20});
  Future<UserDto> updateUser(int id, Map<String, dynamic> data);
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final Dio _dio;

  UserRemoteDataSourceImpl(this._dio);

  @override
  Future<UserDto> getUser(int id) async {
    final response = await _dio.get(class="code-string">'/users/$id');
    return UserDto.fromJson(response.data);
  }

  @override
  Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20}) async {
    final response = await _dio.get(
      class="code-string">'/users',
      queryParameters: {class="code-string">'page': page, class="code-string">'limit': limit},
    );
    final list = response.data[class="code-string">'data'] as List;
    return list.map((e) => UserDto.fromJson(e)).toList();
  }

  @override
  Future<UserDto> updateUser(int id, Map<String, dynamic> data) async {
    final response = await _dio.put(class="code-string">'/users/$id', data: data);
    return UserDto.fromJson(response.data);
  }
}

Repository

The repository is the gateway between your domain layer and your data layer. It decides whether to fetch from the network or from cache, and it converts DTOs into domain models.

dart
abstract class UserRepository {
  Future<Result<User>> getUser(int id);
  Future<Result<List<User>>> getUsers({int page = class="code-number">1});
  Future<Result<User>> updateUser(int id, Map<String, dynamic> data);
}

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource _remoteDataSource;
  final UserLocalDataSource _localDataSource;

  UserRepositoryImpl({
    required UserRemoteDataSource remoteDataSource,
    required UserLocalDataSource localDataSource,
  })  : _remoteDataSource = remoteDataSource,
        _localDataSource = localDataSource;

  @override
  Future<Result<User>> getUser(int id) async {
    try {
      final dto = await _remoteDataSource.getUser(id);
      await _localDataSource.cacheUser(dto);
      return Result.success(dto.toDomain());
    } on DioException catch (e) {
      class=class="code-string">"code-comment">// Try cache on network failure
      final cached = await _localDataSource.getCachedUser(id);
      if (cached != null) return Result.success(cached.toDomain());
      return Result.failure(NetworkFailure.fromDioException(e));
    } catch (e) {
      return Result.failure(UnexpectedFailure(e.toString()));
    }
  }

  @override
  Future<Result<List<User>>> getUsers({int page = class="code-number">1}) async {
    try {
      final dtos = await _remoteDataSource.getUsers(page: page);
      return Result.success(dtos.map((d) => d.toDomain()).toList());
    } on DioException catch (e) {
      return Result.failure(NetworkFailure.fromDioException(e));
    }
  }

  @override
  Future<Result<User>> updateUser(int id, Map<String, dynamic> data) async {
    try {
      final dto = await _remoteDataSource.updateUser(id, data);
      await _localDataSource.cacheUser(dto);
      return Result.success(dto.toDomain());
    } on DioException catch (e) {
      return Result.failure(NetworkFailure.fromDioException(e));
    }
  }
}

Error Handling Strategy

Throwing exceptions across layer boundaries is a recipe for forgotten catch blocks and crashes in production. Instead, I use a `Result` type that makes the caller deal with both success and failure explicitly.

The Result Type

dart
sealed class Result<T> {
  const Result();

  factory Result.success(T data) = Success<T>;
  factory Result.failure(Failure failure) = Err<T>;

  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  });
}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);

  @override
  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  }) => success(data);
}

class Err<T> extends Result<T> {
  final Failure failure;
  const Err(this.failure);

  @override
  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  }) => failure(this.failure);
}

Typed Failures

dart
sealed class Failure {
  final String message;
  const Failure(this.message);
}

class NetworkFailure extends Failure {
  final int? statusCode;

  const NetworkFailure(super.message, {this.statusCode});

  factory NetworkFailure.fromDioException(DioException e) {
    return switch (e.type) {
      DioExceptionType.connectionTimeout =>
        const NetworkFailure(class="code-string">'Connection timed out'),
      DioExceptionType.receiveTimeout =>
        const NetworkFailure(class="code-string">'Server took too long to respond'),
      DioExceptionType.badResponse => NetworkFailure(
          e.response?.data?[class="code-string">'message'] ?? class="code-string">'Server error',
          statusCode: e.response?.statusCode,
        ),
      DioExceptionType.connectionError =>
        const NetworkFailure(class="code-string">'No internet connection'),
      _ => NetworkFailure(class="code-string">'Network error: ${e.message}'),
    };
  }
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

class UnexpectedFailure extends Failure {
  const UnexpectedFailure(super.message);
}

Using Result in the UI Layer

dart
class UserCubit extends Cubit<UserState> {
  final UserRepository _repository;

  UserCubit(this._repository) : super(const UserState.loading());

  Future<void> loadUser(int id) async {
    emit(const UserState.loading());
    final result = await _repository.getUser(id);
    result.when(
      success: (user) => emit(UserState.loaded(user)),
      failure: (failure) => emit(UserState.error(failure.message)),
    );
  }
}

This pattern eliminates the possibility of unhandled exceptions. Every call site is forced to deal with failure, and the compiler helps you.

Offline Support and Caching

Mobile users lose connectivity constantly. A good API layer degrades gracefully instead of showing error screens.

Local Data Source with Hive

dart
abstract class UserLocalDataSource {
  Future<void> cacheUser(UserDto user);
  Future<UserDto?> getCachedUser(int id);
  Future<void> cacheUsers(List<UserDto> users);
  Future<List<UserDto>> getCachedUsers();
  Future<void> clearCache();
}

class UserLocalDataSourceImpl implements UserLocalDataSource {
  final Box<Map> _userBox;

  UserLocalDataSourceImpl(this._userBox);

  @override
  Future<void> cacheUser(UserDto user) async {
    await _userBox.put(user.id.toString(), user.toJson());
  }

  @override
  Future<UserDto?> getCachedUser(int id) async {
    final json = _userBox.get(id.toString());
    if (json == null) return null;
    return UserDto.fromJson(Map<String, dynamic>.from(json));
  }

  @override
  Future<void> cacheUsers(List<UserDto> users) async {
    final entries = {for (var u in users) u.id.toString(): u.toJson()};
    await _userBox.putAll(entries);
  }

  @override
  Future<List<UserDto>> getCachedUsers() async {
    return _userBox.values
        .map((e) => UserDto.fromJson(Map<String, dynamic>.from(e)))
        .toList();
  }

  @override
  Future<void> clearCache() async => await _userBox.clear();
}

Cache-First Strategy

In practice, I use a cache-first pattern for data that does not change often (user profiles, settings) and a network-first pattern for everything else. The repository decides which strategy to apply:

dart
Future<Result<User>> getUser(int id) async {
  class=class="code-string">"code-comment">// Try cache first
  final cached = await _localDataSource.getCachedUser(id);
  if (cached != null) {
    class=class="code-string">"code-comment">// Return cached data immediately, refresh in background
    _refreshUserInBackground(id);
    return Result.success(cached.toDomain());
  }

  class=class="code-string">"code-comment">// No cache, must fetch from network
  try {
    final dto = await _remoteDataSource.getUser(id);
    await _localDataSource.cacheUser(dto);
    return Result.success(dto.toDomain());
  } on DioException catch (e) {
    return Result.failure(NetworkFailure.fromDioException(e));
  }
}

void _refreshUserInBackground(int id) async {
  try {
    final dto = await _remoteDataSource.getUser(id);
    await _localDataSource.cacheUser(dto);
  } catch (_) {
    class=class="code-string">"code-comment">// Silent failure — cached data is still valid
  }
}

Connectivity Awareness

I pair caching with a connectivity listener so the UI can show an offline banner and avoid pointless network calls:

dart
class ConnectivityService {
  final Connectivity _connectivity = Connectivity();

  Stream<bool> get onlineStream =>
      _connectivity.onConnectivityChanged.map(
        (result) => result != ConnectivityResult.none,
      );

  Future<bool> get isOnline async {
    final result = await _connectivity.checkConnectivity();
    return result != ConnectivityResult.none;
  }
}

JSON Serialization

For anything beyond a trivial project, I use `json_serializable` with `freezed`. Here is a practical setup:

dart
class=class="code-string">"code-comment">// pubspec.yaml dependencies
class=class="code-string">"code-comment">// json_annotation, freezed_annotation
class=class="code-string">"code-comment">// dev_dependencies: json_serializable, freezed, build_runner

@freezed
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse({
    required bool success,
    required T data,
    String? message,
    ApiMeta? meta,
  }) = _ApiResponse;

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object?) fromJsonT,
  ) => _$ApiResponseFromJson(json, fromJsonT);
}

@freezed
class ApiMeta with _$ApiMeta {
  const factory ApiMeta({
    required int currentPage,
    required int lastPage,
    required int perPage,
    required int total,
  }) = _ApiMeta;

  factory ApiMeta.fromJson(Map<String, dynamic> json) =>
      _$ApiMetaFromJson(json);
}

Run code generation with:

bash
dart run build_runner build --delete-conflicting-outputs

Common API Integration Mistakes

These are issues I have seen repeatedly in codebases I have reviewed or inherited.

1. Calling APIs Directly from Widgets

This is the most common mistake. When you call Dio directly inside a widget, you lose testability, reusability, and any hope of swapping the data source later.

dart
class=class="code-string">"code-comment">// Bad: API call inside a widget
class UserScreen extends StatefulWidget { ... }
class _UserScreenState extends State<UserScreen> {
  Future<void> _loadUser() async {
    final response = await Dio().get(class="code-string">'https:class=class="code-string">"code-comment">//api.example.com/users/class="code-number">1');
    setState(() { _user = User.fromJson(response.data); });
  }
}

class=class="code-string">"code-comment">// Good: Go through a repository
class _UserScreenState extends State<UserScreen> {
  Future<void> _loadUser() async {
    final result = await userRepository.getUser(class="code-number">1);
    result.when(
      success: (user) => setState(() { _user = user; }),
      failure: (f) => _showError(f.message),
    );
  }
}

2. Ignoring Cancellation

If a user navigates away while a request is in flight, you should cancel it. Otherwise you waste bandwidth and risk calling `setState` on a disposed widget.

dart
class _UserScreenState extends State<UserScreen> {
  final CancelToken _cancelToken = CancelToken();

  Future<void> _loadUser() async {
    final response = await dio.get(
      class="code-string">'/users/class="code-number">1',
      cancelToken: _cancelToken,
    );
    class=class="code-string">"code-comment">// ...
  }

  @override
  void dispose() {
    _cancelToken.cancel(class="code-string">'Widget disposed');
    super.dispose();
  }
}

3. Not Handling Pagination Properly

Fetching all records at once works during development with 10 items. It falls apart in production with 10,000. Always implement cursor or offset-based pagination from the start.

dart
class PaginatedResult<T> {
  final List<T> items;
  final int currentPage;
  final int totalPages;
  final bool hasMore;

  PaginatedResult({
    required this.items,
    required this.currentPage,
    required this.totalPages,
  }) : hasMore = currentPage < totalPages;
}

4. Hardcoding Base URLs

Use environment-specific configuration. I typically define base URLs per flavor:

dart
enum Environment { dev, staging, prod }

class AppConfig {
  final String baseUrl;
  final Duration timeout;

  const AppConfig._({required this.baseUrl, required this.timeout});

  factory AppConfig.fromEnvironment(Environment env) {
    return switch (env) {
      Environment.dev => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//dev-api.example.com',
          timeout: Duration(seconds: class="code-number">30),
        ),
      Environment.staging => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//staging-api.example.com',
          timeout: Duration(seconds: class="code-number">15),
        ),
      Environment.prod => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//api.example.com',
          timeout: Duration(seconds: class="code-number">10),
        ),
    };
  }
}

5. Swallowing Errors Silently

Empty catch blocks are the enemy of debuggability. At a minimum, log the error. Better yet, use the Result pattern so errors are always surfaced.

dart
class=class="code-string">"code-comment">// Terrible: silent failure
try {
  await dio.post(class="code-string">'/orders', data: order.toJson());
} catch (_) {}

class=class="code-string">"code-comment">// Better: propagate as a typed failure
try {
  await dio.post(class="code-string">'/orders', data: order.toJson());
} on DioException catch (e) {
  return Result.failure(NetworkFailure.fromDioException(e));
}

Testing the API Layer

A well-layered API architecture is trivially testable. Mock the data source, test the repository logic in isolation.

dart
class MockUserRemoteDataSource extends Mock
    implements UserRemoteDataSource {}

void main() {
  late UserRepositoryImpl repository;
  late MockUserRemoteDataSource mockRemote;
  late MockUserLocalDataSource mockLocal;

  setUp(() {
    mockRemote = MockUserRemoteDataSource();
    mockLocal = MockUserLocalDataSource();
    repository = UserRepositoryImpl(
      remoteDataSource: mockRemote,
      localDataSource: mockLocal,
    );
  });

  test(class="code-string">'returns user from remote and caches it', () async {
    final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
    when(() => mockRemote.getUser(class="code-number">1)).thenAnswer((_) async => dto);
    when(() => mockLocal.cacheUser(dto)).thenAnswer((_) async {});

    final result = await repository.getUser(class="code-number">1);

    expect(result, isA<Success<User>>());
    verify(() => mockLocal.cacheUser(dto)).called(class="code-number">1);
  });

  test(class="code-string">'falls back to cache on network failure', () async {
    final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
    when(() => mockRemote.getUser(class="code-number">1)).thenThrow(
      DioException(type: DioExceptionType.connectionError, ...),
    );
    when(() => mockLocal.getCachedUser(class="code-number">1)).thenAnswer((_) async => dto);

    final result = await repository.getUser(class="code-number">1);

    expect(result, isA<Success<User>>());
  });
}

Conclusion

A clean API layer is the foundation of every reliable Flutter app. The architecture I have described here — Dio with interceptors, the repository pattern, typed error handling with Result, and offline caching — has served me well across dozens of production projects. It takes a bit more setup upfront compared to raw HTTP calls, but the payoff in maintainability, testability, and user experience is enormous.

Contact me for an API layer review and refactor plan.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch