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