A Small Typed HTTP Client Layer for Flutter Apps
Most Flutter apps start with direct Dio calls inside repositories or view models. That is a perfectly reasonable beginning.
The problem is not the first request. The problem is the fiftieth request, after every feature has slightly different error handling, parsing, query parameters, cache behavior, headers, and retry decisions.
At that point the networking layer starts leaking into the rest of the app. UI code needs to know too much. Repositories repeat the same try-catch shape. Cache reads and network refreshes behave differently from screen to screen. Tests become more about mocking HTTP details than verifying product behavior.
This is where a small typed HTTP layer helps. Not a heavy framework. Not a replacement for Dio. Just a thin boundary that gives every request the same shape.
The core idea is:
- an endpoint describes the request
- the endpoint owns the response parsing
- the client executes the endpoint
- the result is explicit instead of exception-driven
- cache behavior is available without becoming the endpoint's main responsibility
The problem with scattered Dio calls
A direct call often starts like this:
try {
final response = await dio.get('/users/$id');
return User.fromJson(response.data);
} on DioException catch (error) {
// Handle network failure
}
This looks harmless. But production apps rarely stop there.
Soon the same flow needs:
- request headers
- query parameters
POST,PUT,PATCH, andDELETE- status code handling
- typed parsing
- cache reads
- cache writes
- analytics or logging
- custom error messages
- refresh behavior
- test doubles
If every repository solves those details independently, the app gains hidden complexity. The code still works, but the behavior becomes inconsistent.
The goal of this layer is to make the shape of a network request boring and predictable.
Model requests as endpoints
The first piece is a small ApiEndpoint<T> contract:
enum HttpMethod { get, post, put, delete, patch }
abstract class ApiEndpoint<T> {
const ApiEndpoint();
String get path;
HttpMethod get method;
Object? get data => null;
Map<String, dynamic>? get queryParameters => null;
Map<String, dynamic>? get headers => null;
String? get cacheKey => null;
T fromJson(dynamic json);
}
This keeps request details close to the type they produce.
Instead of a repository knowing every HTTP detail, an endpoint can describe one concrete operation:
final class GetUserEndpoint extends ApiEndpoint<User> {
final String id;
const GetUserEndpoint(this.id);
String get path => '/users/$id';
HttpMethod get method => HttpMethod.get;
String get cacheKey => 'users:$id';
User fromJson(dynamic json) {
return User.fromJson(json as Map<String, dynamic>);
}
}
Now the request has a name, a return type, and a single place where parsing is defined.
That seems like a small shift, but it changes how the rest of the app reads. A repository no longer needs to rebuild the same request shape every time. It can focus on application behavior.
Make success and failure explicit
The second piece is ApiResult<T>.
In UI and state management code, I do not want every network call to be an exception boundary. Exceptions are still useful internally, especially around Dio, but most app flows eventually need to answer a simpler question:
Did this request succeed, or did it fail in a way the app can react to?
sealed class ApiResult<T> {
const ApiResult();
const factory ApiResult.success(T data) = ApiSuccess<T>;
const factory ApiResult.failure(Object error, [int? statusCode]) =
ApiFailure<T>;
const factory ApiResult.detailedFailure(
Object error, {
int? statusCode,
dynamic responseData,
StackTrace? stackTrace,
DioException? dioException,
}) = ApiFailure<T>.detailed;
bool get isSuccess => this is ApiSuccess<T>;
bool get isFailure => this is ApiFailure<T>;
}
The concrete result types stay simple:
final class ApiSuccess<T> extends ApiResult<T> {
final T data;
const ApiSuccess(this.data);
}
final class ApiFailure<T> extends ApiResult<T> {
final Object error;
final int? statusCode;
final dynamic responseData;
final StackTrace? stackTrace;
final DioException? dioException;
const ApiFailure(this.error, [this.statusCode])
: responseData = null,
stackTrace = null,
dioException = null;
const ApiFailure.detailed(
this.error, {
this.statusCode,
this.responseData,
this.stackTrace,
this.dioException,
});
}
This gives the caller a typed value on success and useful context on failure. The repository or Cubit can decide what to show to the user without needing to understand every DioException detail.
I also like giving the result a small functional surface:
R fold<R>({
required R Function(T data) onSuccess,
required R Function(Object error, int? statusCode) onFailure,
}) => switch (this) {
ApiSuccess(:final data) => onSuccess(data),
ApiFailure(:final error, :final statusCode) => onFailure(
error,
statusCode,
),
};
That makes call sites honest:
final result = await GetUserEndpoint(userId).execute();
return result.fold(
onSuccess: (user) => UserState.ready(user),
onFailure: (error, statusCode) => UserState.failed(
error: error,
statusCode: statusCode,
),
);
No hidden exception path. No unclear null result. The state transition is visible.
Keep Dio as the transport
This layer does not try to replace Dio. Dio is still the HTTP engine. It should keep doing the things it is good at:
- base URLs
- interceptors
- authentication headers
- logging
- cancellation
- timeouts
- low-level HTTP behavior
The wrapper only standardizes the application-facing contract.
class ApiClient {
final Dio dio;
final CacheReader? readCache;
final CacheWriter? writeCache;
final CacheErrorHandler? onCacheError;
const ApiClient({
required this.dio,
this.readCache,
this.writeCache,
this.onCacheError,
});
}
At app startup, the configured Dio instance can be registered once:
ApiConfig.init(
defaultDio: Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
),
),
globalReadCache: cache.read,
globalWriteCache: cache.write,
globalOnCacheError: logger.recordError,
);
The important detail is ownership. Dio owns transport concerns. ApiEndpoint<T> owns request shape and parsing. ApiClient owns execution.
Those responsibilities are separate enough to change independently.
Add cache without coupling everything to storage
Caching is useful, but it can quickly pull storage details into every feature.
This layer uses function typedefs instead:
typedef CacheReader = Future<dynamic> Function(String key);
typedef CacheWriter = Future<void> Function(String key, dynamic value);
typedef CacheErrorHandler = void Function(Object error, StackTrace stackTrace);
An endpoint can opt into caching by exposing a cacheKey:
String get cacheKey => 'users:$id';
The client decides whether to read from cache before the network call:
Future<ApiResult<T>> execute<T>(
ApiEndpoint<T> endpoint, {
bool forceRefresh = false,
}) async {
final key = endpoint.cacheKey;
final canReadCache = key != null && readCache != null;
if (canReadCache && !forceRefresh) {
try {
final cachedData = await readCache!(key);
if (cachedData != null) {
return ApiResult.success(endpoint.fromJson(cachedData));
}
} catch (error, stackTrace) {
_handleCacheError(error, stackTrace);
}
}
return _fetchFromNetwork(
endpoint,
cacheKey: key != null && writeCache != null ? key : null,
);
}
There are two practical choices here.
First, cache failures do not break the request. If local storage fails, the client still tries the network.
Second, forceRefresh lets the caller skip the cache intentionally:
final cached = await GetUserEndpoint(userId).execute();
final fresh = await GetUserEndpoint(userId).execute(forceRefresh: true);
That is useful for screens that can show cached data on first load but need a pull-to-refresh action to ask the server directly.
Execute every endpoint the same way
The network path is intentionally plain:
Future<ApiResult<T>> _fetchFromNetwork<T>(
ApiEndpoint<T> endpoint, {
String? cacheKey,
}) async {
try {
final response = await dio.request<dynamic>(
endpoint.path,
data: endpoint.data,
queryParameters: endpoint.queryParameters,
options: Options(
method: endpoint.method.name.toUpperCase(),
headers: endpoint.headers,
),
);
final rawData = response.data;
final result = endpoint.fromJson(rawData);
if (cacheKey != null) {
try {
await writeCache!(cacheKey, rawData);
} catch (error, stackTrace) {
_handleCacheError(error, stackTrace);
}
}
return ApiResult.success(result);
} on DioException catch (error, stackTrace) {
return ApiResult.detailedFailure(
error,
statusCode: error.response?.statusCode,
responseData: error.response?.data,
stackTrace: stackTrace,
dioException: error,
);
} catch (error, stackTrace) {
return ApiResult.detailedFailure(error, stackTrace: stackTrace);
}
}
Every endpoint passes through the same behavior:
- build the Dio request from endpoint metadata
- parse the response through
fromJson - write successful raw responses to cache when configured
- convert
DioExceptionintoApiFailure - keep the original status code, response body, stack trace, and exception
This consistency is the value. It is much easier to improve one client path than to find dozens of slightly different try-catch blocks across the app.
Make the call site small
For ergonomics, an extension lets the endpoint execute itself:
extension ApiEndpointExecutor<T> on ApiEndpoint<T> {
Future<ApiResult<T>> execute({
Dio? customDio,
bool forceRefresh = false,
}) async {
final client = customDio == null
? ApiConfig.client
: ApiClient(
dio: customDio,
readCache: ApiConfig.readCache,
writeCache: ApiConfig.writeCache,
onCacheError: ApiConfig.onCacheError,
);
return client.execute(this, forceRefresh: forceRefresh);
}
}
That makes feature code compact:
final result = await GetUserEndpoint(userId).execute();
For tests or isolated calls, customDio can be injected:
final result = await GetUserEndpoint(userId).execute(
customDio: testDio,
);
In larger apps, I still prefer injecting ApiClient into repositories explicitly. The extension is useful for small apps, examples, scripts, and places where the global client is an acceptable tradeoff.
What this gives the app
The benefit is not fewer lines of code in the first endpoint. The benefit shows up as the app grows.
Repositories become smaller because request details move into endpoint objects. UI state becomes easier to model because success and failure are explicit. Cache behavior becomes consistent because the client owns the read/write sequence. Tests can focus on endpoint parsing, result handling, and repository behavior instead of recreating HTTP details everywhere.
It also gives the team a clear place to improve network behavior later. If the app needs request timing logs, retry rules, unauthorized handling, or response normalization, there is one execution path where that work belongs.
That is the main reason for this pattern: it keeps network behavior centralized without hiding the important parts.
What I would not put here
A layer like this should stay small.
I would not put screen-specific decisions into the client. I would not make the endpoint know about widgets, Cubits, or navigation. I would not turn every backend concept into a generic abstraction before the app needs it.
The endpoint should describe a request. The client should execute it. The result should tell the caller what happened.
Everything else should earn its place.
The final shape
With these pieces together, the app gets a simple network vocabulary:
final result = await GetUserEndpoint(userId).execute();
final refreshed = await GetUserEndpoint(userId).execute(
forceRefresh: true,
);
final withCustomClient = await GetUserEndpoint(userId).execute(
customDio: dio,
);
That small vocabulary is enough for most production Flutter apps.
It does not make networking magical. It makes networking explicit. Requests have names. Responses have types. Failures have structure. Cache behavior is predictable. Dio remains available where it belongs, but the rest of the app no longer has to speak raw HTTP for every feature.
That is usually the kind of abstraction worth keeping.
