SuriDevs Logo
Production-Ready Networking in Flutter with DIO - Part 3: Clean API Calls

Production-Ready Networking in Flutter with DIO - Part 3: Clean API Calls

By Sagar Maiyad  Feb 03, 2026

I counted once. Our app had 23 endpoints, and every single one had its own try-catch block, its own status code checking, its own error message formatting. When the backend team changed the response format for one endpoint, I had to update the parsing logic in... 23 places.

That's when I built the abstraction layer I'm about to show you. It's the part of this series that changed how our team writes code day-to-day more than anything else.

Here's what we're eliminating:

// Before: scattered everywhere
try {
  final response = await dio.get('/users/profile');
  if (response.statusCode == 200) {
    final user = User.fromJson(response.data['data']);
    return user;
  } else {
    throw Exception('Failed to load profile');
  }
} on DioException catch (e) {
  // handle error
} catch (e) {
  // handle other errors
}

Here's what we're achieving:

// After: clean, predictable, type-safe
final response = await userRepository.getProfile();
if (response.success) {
  final user = response.data;
}

The Base API Service

This abstract class handles the repetitive parts of every API call:

// lib/core/network/base_api_service.dart

import 'package:dio/dio.dart';
import 'api_response.dart';
import 'api_error_handler.dart';

abstract class BaseApiService {
  final Dio _dio;

  BaseApiService(this._dio);

  /// GET request
  Future<ApiResponse<T>> get<T>(
    String endpoint, {
    Map<String, dynamic>? queryParams,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.get(
        endpoint,
        queryParameters: queryParams,
      );

      return ApiResponse.fromJson(
        response.data,
        fromJson,
        statusCode: response.statusCode,
      );
    } on DioException catch (e) {
      return ApiErrorHandler.handleError<T>(e);
    } catch (e) {
      return ApiResponse.error('Unexpected error: $e');
    }
  }

  /// POST request
  Future<ApiResponse<T>> post<T>(
    String endpoint, {
    dynamic data,
    Map<String, dynamic>? queryParams,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.post(
        endpoint,
        data: data,
        queryParameters: queryParams,
      );

      return ApiResponse.fromJson(
        response.data,
        fromJson,
        statusCode: response.statusCode,
      );
    } on DioException catch (e) {
      return ApiErrorHandler.handleError<T>(e);
    } catch (e) {
      return ApiResponse.error('Unexpected error: $e');
    }
  }

  /// POST request that returns headers (for token extraction)
  Future<ApiResponseWithHeaders<T>> postWithHeaders<T>(
    String endpoint, {
    dynamic data,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.post(endpoint, data: data);

      return ApiResponseWithHeaders(
        response: ApiResponse.fromJson(
          response.data,
          fromJson,
          statusCode: response.statusCode,
        ),
        headers: response.headers.map,
      );
    } on DioException catch (e) {
      return ApiResponseWithHeaders(
        response: ApiErrorHandler.handleError<T>(e),
        headers: e.response?.headers.map,
      );
    } catch (e) {
      return ApiResponseWithHeaders(
        response: ApiResponse.error('Unexpected error: $e'),
      );
    }
  }

  /// PUT request
  Future<ApiResponse<T>> put<T>(
    String endpoint, {
    dynamic data,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.put(endpoint, data: data);

      return ApiResponse.fromJson(
        response.data,
        fromJson,
        statusCode: response.statusCode,
      );
    } on DioException catch (e) {
      return ApiErrorHandler.handleError<T>(e);
    } catch (e) {
      return ApiResponse.error('Unexpected error: $e');
    }
  }

  /// PATCH request
  Future<ApiResponse<T>> patch<T>(
    String endpoint, {
    dynamic data,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.patch(endpoint, data: data);

      return ApiResponse.fromJson(
        response.data,
        fromJson,
        statusCode: response.statusCode,
      );
    } on DioException catch (e) {
      return ApiErrorHandler.handleError<T>(e);
    } catch (e) {
      return ApiResponse.error('Unexpected error: $e');
    }
  }

  /// DELETE request
  Future<ApiResponse<T>> delete<T>(
    String endpoint, {
    dynamic data,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.delete(endpoint, data: data);

      return ApiResponse.fromJson(
        response.data,
        fromJson,
        statusCode: response.statusCode,
      );
    } on DioException catch (e) {
      return ApiErrorHandler.handleError<T>(e);
    } catch (e) {
      return ApiResponse.error('Unexpected error: $e');
    }
  }
}

"Why abstract?"

Because you don't instantiate BaseApiService directly. You extend it for each feature area:

class UserRepository extends BaseApiService { ... }
class ProductRepository extends BaseApiService { ... }
class OrderRepository extends BaseApiService { ... }

Each repository gets all the HTTP methods with error handling built in.

Building a Repository

Let's build a real example. First, assume you have a User model:

// lib/data/models/user_model.dart

class UserModel {
  final String id;
  final String name;
  final String email;
  final String? avatarUrl;
  final DateTime createdAt;

  UserModel({
    required this.id,
    required this.name,
    required this.email,
    this.avatarUrl,
    required this.createdAt,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      name: json['name'],
      email: json['email'],
      avatarUrl: json['avatar_url'],
      createdAt: DateTime.parse(json['created_at']),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'avatar_url': avatarUrl,
      'created_at': createdAt.toIso8601String(),
    };
  }
}

Now the repository:

// lib/data/repositories/user_repository.dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/base_api_service.dart';
import '../../core/network/api_response.dart';
import '../../core/network/dio_provider.dart';
import '../models/user_model.dart';

class UserRepository extends BaseApiService {
  UserRepository(Dio dio) : super(dio);

  Future<ApiResponse<UserModel>> getProfile() {
    return get<UserModel>(
      '/users/profile',
      fromJson: (data) => UserModel.fromJson(data),
    );
  }

  Future<ApiResponse<UserModel>> updateProfile({
    required String name,
    String? email,
  }) {
    return put<UserModel>(
      '/users/profile',
      data: {
        'name': name,
        if (email != null) 'email': email,
      },
      fromJson: (data) => UserModel.fromJson(data),
    );
  }

  Future<ApiResponse<UserModel>> uploadAvatar(String filePath) async {
    final formData = FormData.fromMap({
      'avatar': await MultipartFile.fromFile(filePath),
    });

    return post<UserModel>(
      '/users/avatar',
      data: formData,
      fromJson: (data) => UserModel.fromJson(data),
    );
  }

  Future<ApiResponse<List<UserModel>>> searchUsers(String query) {
    return get<List<UserModel>>(
      '/users/search',
      queryParams: {'q': query},
      fromJson: (data) => (data as List)
          .map((item) => UserModel.fromJson(item))
          .toList(),
    );
  }

  Future<ApiResponse<void>> deleteAccount() {
    return delete<void>('/users/account');
  }
}

// Riverpod provider
final userRepositoryProvider = Provider<UserRepository>((ref) {
  final dio = ref.read(dioProvider);
  return UserRepository(dio);
});

Notice what's NOT here:

  • No try-catch blocks
  • No status code checking
  • No error message formatting
  • No token handling

All handled by the layers below.

Using It in the UI Layer

Now in your ViewModel, Controller, or directly in a widget:

// lib/features/profile/profile_view_model.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/user_repository.dart';
import '../../data/models/user_model.dart';

class ProfileState {
  final UserModel? user;
  final bool isLoading;
  final String? errorMessage;

  ProfileState({
    this.user,
    this.isLoading = false,
    this.errorMessage,
  });

  ProfileState copyWith({
    UserModel? user,
    bool? isLoading,
    String? errorMessage,
  }) {
    return ProfileState(
      user: user ?? this.user,
      isLoading: isLoading ?? this.isLoading,
      errorMessage: errorMessage,
    );
  }
}

class ProfileViewModel extends StateNotifier<ProfileState> {
  final UserRepository _userRepository;

  ProfileViewModel(this._userRepository) : super(ProfileState());

  Future<void> loadProfile() async {
    state = state.copyWith(isLoading: true, errorMessage: null);

    final response = await _userRepository.getProfile();

    if (response.success) {
      state = state.copyWith(
        user: response.data,
        isLoading: false,
      );
    } else {
      state = state.copyWith(
        isLoading: false,
        errorMessage: response.message,  // Already user-friendly
      );
    }
  }

  Future<void> updateName(String newName) async {
    state = state.copyWith(isLoading: true, errorMessage: null);

    final response = await _userRepository.updateProfile(name: newName);

    if (response.success) {
      state = state.copyWith(
        user: response.data,
        isLoading: false,
      );
    } else {
      state = state.copyWith(
        isLoading: false,
        errorMessage: response.message,
      );
    }
  }
}

final profileViewModelProvider =
    StateNotifierProvider<ProfileViewModel, ProfileState>((ref) {
  final userRepository = ref.read(userRepositoryProvider);
  return ProfileViewModel(userRepository);
});

And in the widget:

// lib/features/profile/profile_screen.dart

class ProfileScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(profileViewModelProvider);
    final viewModel = ref.read(profileViewModelProvider.notifier);

    if (state.isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (state.errorMessage != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(state.errorMessage!),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: viewModel.loadProfile,
              child: Text('Retry'),
            ),
          ],
        ),
      );
    }

    final user = state.user;
    if (user == null) {
      return Center(child: Text('No profile data'));
    }

    return Scaffold(
      appBar: AppBar(title: Text('Profile')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            if (user.avatarUrl != null)
              CircleAvatar(
                backgroundImage: NetworkImage(user.avatarUrl!),
                radius: 50,
              ),
            SizedBox(height: 16),
            Text(user.name, style: Theme.of(context).textTheme.headlineSmall),
            Text(user.email),
          ],
        ),
      ),
    );
  }
}

Complete Architecture

Here's everything together:

lib/
├── core/
│   ├── network/
│   │   ├── api_config.dart           # Environment, URLs, timeouts
│   │   ├── api_response.dart         # Generic response wrapper
│   │   ├── api_error_handler.dart    # DIO errors → user messages
│   │   ├── token_refresh_lock.dart   # Race condition prevention
│   │   ├── dio_provider.dart         # DIO instance with interceptors
│   │   └── base_api_service.dart     # Abstract HTTP methods
│   └── services/
│       └── token_manager.dart        # Secure token storage
│
├── data/
│   ├── models/
│   │   └── user_model.dart           # Data models
│   └── repositories/
│       └── user_repository.dart      # API endpoints for users
│
└── features/
    └── profile/
        ├── profile_view_model.dart   # Business logic
        └── profile_screen.dart       # UI

The Complete Flow

┌─────────────────────────────────────────────────────────────────┐
│                     COMPLETE REQUEST FLOW                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ProfileScreen                                                  │
│        │                                                         │
│        │ viewModel.loadProfile()                                 │
│        ▼                                                         │
│   ProfileViewModel                                               │
│        │                                                         │
│        │ userRepository.getProfile()                             │
│        ▼                                                         │
│   UserRepository (extends BaseApiService)                        │
│        │                                                         │
│        │ get<UserModel>('/users/profile', fromJson: ...)        │
│        ▼                                                         │
│   BaseApiService.get()                                           │
│        │                                                         │
│        │ dio.get(endpoint)                                       │
│        ▼                                                         │
│   ┌─────────────────────────────────────────┐                   │
│   │         REQUEST INTERCEPTOR              │                   │
│   │   • Read token from TokenManager         │                   │
│   │   • Add Authorization header             │                   │
│   │   • Add platform header                  │                   │
│   └─────────────────────────────────────────┘                   │
│        │                                                         │
│        ▼                                                         │
│   HTTP Request ───────────────────────► Server                   │
│        │                                                         │
│        │◄──────────────────────────────────────                  │
│        │                                                         │
│   ┌────┴────────────────────────────────────────────────────┐   │
│   │                                                          │   │
│   │  200 OK                                                  │   │
│   │    │                                                     │   │
│   │    └──► ApiResponse.fromJson() ──► response.success=true │   │
│   │                                                          │   │
│   │  401 Unauthorized                                        │   │
│   │    │                                                     │   │
│   │    ▼                                                     │   │
│   │  ┌────────────────────────────────────────────────┐     │   │
│   │  │           ERROR INTERCEPTOR                     │     │   │
│   │  │                                                 │     │   │
│   │  │  Auth endpoint? ──Yes──► Pass through           │     │   │
│   │  │       │                                         │     │   │
│   │  │      No                                         │     │   │
│   │  │       │                                         │     │   │
│   │  │  TokenRefreshLock.tryAcquire()                  │     │   │
│   │  │       │                                         │     │   │
│   │  │  ┌────┴────┐                                    │     │   │
│   │  │  │         │                                    │     │   │
│   │  │ Got      Didn't                                 │     │   │
│   │  │ lock     get lock                               │     │   │
│   │  │  │         │                                    │     │   │
│   │  │  │         ▼                                    │     │   │
│   │  │  │    Wait for completer.future                 │     │   │
│   │  │  │         │                                    │     │   │
│   │  │  │         ▼                                    │     │   │
│   │  │  │    Retry with new token                      │     │   │
│   │  │  │                                              │     │   │
│   │  │  ▼                                              │     │   │
│   │  │ Call /auth/refresh                              │     │   │
│   │  │  │                                              │     │   │
│   │  │  ├─ Success ─► Save tokens                      │     │   │
│   │  │  │             Lock.complete(newToken)          │     │   │
│   │  │  │             Retry original request           │     │   │
│   │  │  │                                              │     │   │
│   │  │  └─ Failure ─► Lock.completeWithError()         │     │   │
│   │  │                Clear tokens                     │     │   │
│   │  │                Reject (user needs to log in)    │     │   │
│   │  └────────────────────────────────────────────────┘     │   │
│   │                                                          │   │
│   │  4xx/5xx                                                 │   │
│   │    │                                                     │   │
│   │    └──► ApiErrorHandler.handleError()                    │   │
│   │              │                                           │   │
│   │              └──► response.success=false                 │   │
│   │                   response.message="User-friendly msg"   │   │
│   │                                                          │   │
│   └──────────────────────────────────────────────────────────┘   │
│        │                                                         │
│        ▼                                                         │
│   ApiResponse<UserModel> returned                                │
│        │                                                         │
│        ▼                                                         │
│   ProfileViewModel                                               │
│        │                                                         │
│        │ if (response.success) { state = ... }                   │
│        ▼                                                         │
│   ProfileScreen rebuilds with new state                          │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

What This Setup Doesn't Cover

Let's be clear about the boundaries:

Not Covered Why What to Add
Offline support Different architecture Add local database + sync logic
Request caching App-specific decisions Add dio_cache_interceptor
Certificate pinning Security-critical apps only Add dio_certificate_pinning
GraphQL Different paradigm Use graphql_flutter instead
File download progress Specialized need Add onReceiveProgress callback

This setup covers 80% of mobile app networking needs. If you're in the other 20%, you know who you are.

Pre-Launch Checklist

Before you ship, verify these:

Token Handling

  • [ ] Fresh login → tokens saved to secure storage
  • [ ] App restart → user still logged in
  • [ ] Token expires → refresh happens automatically
  • [ ] Refresh token expires → user redirected to login
  • [ ] Multiple 401s simultaneously → only 1 refresh call

Error Handling

  • [ ] No internet → "Check your connection" (not crash)
  • [ ] Server 500 → "Try again later" (not technical dump)
  • [ ] Timeout → Clear message (not infinite spinner)
  • [ ] Invalid input (422) → Server validation message shown

Security

  • [ ] Tokens NOT in SharedPreferences
  • [ ] Tokens NOT logged in production
  • [ ] HTTPS only (no HTTP endpoints)

Development

  • [ ] Request/response logging works in debug
  • [ ] Logging disabled in production builds

Quick Reference

Copy-paste this when you need to add a new repository:

// lib/data/repositories/[feature]_repository.dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/base_api_service.dart';
import '../../core/network/api_response.dart';
import '../../core/network/dio_provider.dart';

class FeatureRepository extends BaseApiService {
  FeatureRepository(Dio dio) : super(dio);

  Future<ApiResponse<Model>> getItem(String id) {
    return get<Model>(
      '/items/$id',
      fromJson: (data) => Model.fromJson(data),
    );
  }

  Future<ApiResponse<List<Model>>> getItems() {
    return get<List<Model>>(
      '/items',
      fromJson: (data) => (data as List)
          .map((item) => Model.fromJson(item))
          .toList(),
    );
  }

  Future<ApiResponse<Model>> createItem(Map<String, dynamic> data) {
    return post<Model>(
      '/items',
      data: data,
      fromJson: (data) => Model.fromJson(data),
    );
  }

  Future<ApiResponse<Model>> updateItem(String id, Map<String, dynamic> data) {
    return put<Model>(
      '/items/$id',
      data: data,
      fromJson: (data) => Model.fromJson(data),
    );
  }

  Future<ApiResponse<void>> deleteItem(String id) {
    return delete<void>('/items/$id');
  }
}

final featureRepositoryProvider = Provider<FeatureRepository>((ref) {
  return FeatureRepository(ref.read(dioProvider));
});

What I Got Wrong the First Time

My first version of BaseApiService wasn't abstract. It was a concrete class with the DIO instance hardcoded inside. Worked fine until I needed to write tests — I couldn't inject a mock DIO instance. Spent an afternoon refactoring it to what you see above. Make it abstract from the start.

Also, I initially had the fromJson callback as required, not optional. That meant even DELETE /users/account — which returns no body — needed a parser function. Small thing, but it annoyed everyone on the team. Making it optional with T Function(dynamic)? fixed that.

The Payoff

We used to have a rule: "When you add a new endpoint, copy the try-catch pattern from an existing one." That rule is gone now. A new dev on the team added 4 endpoints last week. Each one was 5 lines in the repository class. No error handling code, no response parsing boilerplate, no token logic. They didn't even know those layers existed — they just worked.

That's the whole point. Not clever code. Invisible infrastructure.


This is Part 3 of a 3-part series:

  • Part 1: Foundation — ApiConfig, ApiResponse, ErrorHandler
  • Part 2: Token Refresh — TokenManager, TokenRefreshLock, DIO Interceptors
  • Part 3: Clean API Calls (you're here) — BaseApiService, Repository pattern
Flutter DIO Repository Pattern Clean Architecture Dart

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Latest Post

Latest Tags