When designing the architecture of a large-scale Flutter app, the networking layer is one of the most consequential decisions you'll make. It's not just about making HTTP requests — it directly impacts:
- How scalable and maintainable your codebase is
- How easily teams can onboard and iterate
- How robust your error handling, testing, and debugging flows are
We evaluated Dio, Retrofit, and Chopper — not based on surface-level ergonomics, but on:
- How they scale across hundreds of endpoints
- How they handle evolving backend contracts
- How testable and mockable they are
- How safely teams can build in parallel without regressions
🚦 Why This Comparison?
Our requirements for the networking layer were clear. It had to:
- Fit clean architecture principles (separation between domain, data, and presentation layers)
- Be mockable and testable (abstract service interfaces, interceptors)
- Handle dynamic headers, tokens, and retries with clean layering
- Scale to hundreds of APIs without growing unmaintainable
- Integrate well with CI/CD and analytics tools
🔧 Option 1: Dio — The Manual but Powerful Approach
Dio is a low-level but full-featured HTTP client. It offers:
✅ Fine-grained interceptors, cancellation, and retry support
✅ Great for custom logic, debugging hooks, and analytics
✅ Actively maintained and widely adopted
But it comes with tradeoffs:
⚠️ No compile-time safety — typos or structure mismatches show up at runtime
🧱 High boilerplate for large codebases
🔍 Harder to mock and test — requires custom wrappers or adapters
Example:
Future<LoginResponse> login(LoginRequest request) async {
final response = await _dio.post('/auth/login', data: request.toJson());
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw Exception('Failed');
}
}
Repeat that for 150+ endpoints — and it quickly becomes unscalable.
📦 Option 2: Retrofit — The Declarative, Type-Safe Layer on Dio
Retrofit builds on Dio and uses annotations + code generation to define your APIs.
✅ Minimal boilerplate with clean interfaces
✅ Compile-time safety for endpoints and payloads
✅ Abstract classes make mocking and testing easy
✅ Codegen integrates directly with Dio interceptors
Example:
@RestApi(baseUrl: 'https://api.example.com')
abstract class AuthApi {
factory AuthApi(Dio dio, {String baseUrl}) = _AuthApi;
@POST('/auth/login')
Future<LoginResponse> login(@Body() LoginRequest request);
}
This simplifies both implementation and testing. Your repositories call api.login()
, and the response is already deserialized.
Tradeoffs:
⚠️ Less flexibility for highly dynamic APIs
⚠️ Regeneration required on API contract changes
⚠️ No per-method converters or custom response envelopes out-of-the-box
🧩 Option 3: Chopper — Extensible and Service-Oriented
Chopper offers Retrofit-like structure but is built on top of http.Client
, not Dio.
✅ Supports service-oriented patterns
✅ Response wrappers include headers and status
✅ Custom converters and plugin-style extensibility
Example:
@ChopperApi(baseUrl: "/v1")
abstract class ApiService extends ChopperService {
static ApiService create() => _$ApiService();
@Get(path: "/user/json")
Future<Response<User>> getJsonUser();
}
Tradeoffs:
📉 Smaller ecosystem than Dio or Retrofit
⚠️ No Dio support (interceptors, retries, etc.)
🛠️ Slightly more boilerplate than Retrofit
🔌 Fewer plugins for advanced use cases (e.g. file upload, custom auth flows)
🔄 Real-World Refactor Example
Before (Dio):
Future<OtpResponse> requestOtp(OtpRequest request) async {
final response = await _dio.post('/auth/request-otp', data: request.toJson());
if (response.statusCode == 200) {
return OtpResponse.fromJson(response.data);
} else {
throw Exception('Failed');
}
}
After (Retrofit):
@POST('/auth/request-otp')
Future<OtpResponse> requestOtp(@Body() OtpRequest request);
Now it's just:
final result = await api.requestOtp(request);
✅ Deserialized
✅ Error-wrapped
✅ Testable and clean
🧠 Decision: Retrofit + Dio
For a Flutter app built at scale, stacking Retrofit on top of Dio gave us the best of both worlds:
- Structure and codegen from Retrofit
- Power and configurability from Dio
- Clear architectural boundaries for domain/data separation
- Easy testing and mocking via abstract interfaces
🚫 Why Not Chopper?
Chopper is conceptually solid and extensible, but the lack of Dio support, smaller ecosystem, and limited plugin integrations made it harder to justify. If it supported Dio natively, we might have made a different call.
🧱 Dio Remains the Foundation
Even with Retrofit, Dio stays under the hood — handling interceptors, base config, cancellation tokens, and retries. Retrofit simply adds a declarative, testable layer on top.
🔍 Impact
✅ When having 150+ endpoints, we estimate 5,000–8,000 lines of code will be either eliminated or simplified by switching to Retrofit.
✅ Onboarding new devs is faster — clear Retrofit interfaces, minimal custom parsing
✅ ~4x faster test setup, fewer test-specific implementations per feature
🧩 Bonus: What Else
As part of architecture evolution, we’re also reviewing:
- State management (Bloc vs Riverpod vs Provider vs GetX)
- Flutter CI/CD pipelines (Azure DevOps, Firebase, fastlane)
- Error and analytics instrumentation
💬 Your Turn
How is your team structuring the networking layer in large Flutter apps?
Are you using raw clients, Retrofit, or something else?
Let’s exchange notes — drop your thoughts below 👇
Top comments (0)