Flutter API Integration: Reliable REST Service Architecture
# 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.
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
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.
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.
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 / SharedPrefsData Transfer Objects (DTOs)
DTOs map one-to-one with API responses. Never let API JSON shapes leak into your domain layer.
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
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
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.
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
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
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
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
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:
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:
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:
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:
dart run build_runner build --delete-conflicting-outputsCommon 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.
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.
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.
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:
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.
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.
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
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Flutter Firebase Integration: Auth, Firestore, and Push Notifications
Set up Firebase in Flutter with production-focused auth flows, Firestore usage, and security basics.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch