netanchor
A small, typed networking facade for Dart and Flutter. NetAnchor owns the HTTP pipeline—transport, authentication, safe retries, parsing, caching, and known network failures—so application code receives one of two values:
Result<User> // Success<User> or FailureResult<User>
No Dio exceptions leak into repositories, and no network-to-domain mapper is required just to pass a failure through the application.
What belongs where
NetAnchor deliberately stops at the networking boundary:
- Core:
Result<T>, the openFailurecontract,NetworkFailure, HTTP, auth/refresh, retry, parsing, cache policy, observers, and redacted logging. - Your app: business/cache/database failures, localized UI messages, Cubit states, navigation, crash reporting, and the final unexpected-error runner.
That boundary keeps the package usable with Clean Architecture, feature-first, MVC, MVVM, Bloc, Riverpod, or plain Dart.
Install
dependencies:
netanchor: ^5.0.0
Minimal setup
final api = NetAnchor.builder()
.baseUrl('https://api.example.com')
.auth(
AuthConfig(
tokenStorage: secureTokenStorage,
refreshPath: '/auth/refresh',
),
)
.build();
DioAdapter is the default. You can supply another HttpAdapter without
changing data sources or repositories.
The normal request path
Data source
Pass the model constructor directly. NetAnchor validates the payload and turns
parsing problems into ParseFailure.
final class CommentsRemoteDataSource {
const CommentsRemoteDataSource(this._api);
final NetAnchor _api;
Future<Result<AddCommentResponse>> addComment(
AddCommentParams params,
) {
return _api.post(
'/comments',
body: params.toJson(),
fromJson: AddCommentResponse.fromJson,
);
}
}
For lists and raw JSON:
final users = api.getList('/users', fromJson: UserDto.fromJson);
final settings = api.getJson('/settings');
fromJson accepts synchronous and asynchronous factories.
Repository
Transform only success. A failure passes through unchanged—including the exact failure instance and all diagnostic data.
final class CommentsRepositoryImpl implements CommentsRepository {
const CommentsRepositoryImpl(this._remote);
final CommentsRemoteDataSource _remote;
@override
Future<Result<Comment>> addComment(AddCommentParams params) {
return _remote
.addComment(params)
.mapSuccess((response) => response.toEntity());
}
}
mapSuccess means: “if successful, transform the data; otherwise return the
existing failure.” It is available on both Result<T> and
Future<Result<T>>, so repositories do not need temporary variables.
Use thenResult when the next operation can also fail:
return remote.login(request).thenResult((dto) async {
final session = dto.toEntity();
final saved = await sessionStore.save(session); // Result<void>
return saved.mapSuccess((_) => session);
});
Cubit or controller
when is available when you prefer callbacks over pattern matching:
final result = await repository.addComment(params);
result.when(
success: (comment) => emit(AddCommentSuccess(comment)),
failure: (failure) => emit(AddCommentFailure(failure)),
);
The failure is still typed at runtime:
switch (failure) {
case NoInternetFailure():
// offline UI
case ValidationFailure(:final fieldErrors):
// highlight fields
case UnauthorizedFailure():
// session is invalid
case NetworkFailure():
// another known networking failure
default:
// an application-owned Failure
}
One failure contract, no global mapper
Applications can define their own expected failures:
final class CacheFailure implements Failure {
const CacheFailure(this.code, this.message);
@override
final String code;
@override
final String message;
}
Result<User> cachedUser() => const Result.failure(
CacheFailure('cache.user_missing', 'No saved user was found.'),
);
NetworkFailure implements the same open contract. Result<T> therefore
composes network, persistence, and business failures without widenFailure,
casts, dynamic, or a NetworkFailureMapper.
Every failure exposes:
code: stable machine-readable key for localization and analytics.message: safe fallback text.
Network failures may additionally expose statusCode, serverMessage,
cause, stackTrace, and requestTag. Do not display serverMessage without
an explicit trust policy; backend text is diagnostic data by default.
Backend-specific errors
Map special non-2xx responses once during integration:
final api = NetAnchor.builder()
.baseUrl(baseUrl)
.failureMapper(
CallbackResponseFailureMapper((request, response) {
final body = response.data;
if (response.statusCode == 409 &&
body is Map &&
body['code'] == 'COMMENTS_CLOSED') {
return const CommentsClosedFailure();
}
return null; // use NetAnchor's normal HTTP mapping
}),
)
.build();
401 handling remains owned by the auth pipeline. The mapper is for expected, application-specific HTTP failures—not UI messages or navigation.
Localized messages without repetition
Keep localization in the presentation layer and resolve failure.code once:
final class AppErrorPresenter {
const AppErrorPresenter(this.messages);
final AppMessages messages;
String present(Failure failure) => switch (failure.code) {
'network.no_internet' => messages.noInternet,
'network.request_timeout' => messages.timeout,
'network.unauthorized' => messages.sessionExpired,
_ => failure.message,
};
}
The UI does not repeat this mapping on every page. A Cubit can store the
Failure, and a shared widget/presenter chooses the localized text.
Expected failures versus unexpected bugs
NetAnchor catches errors it understands at the networking boundary. A bug in a repository, mapper, or UI should not be disguised as “no internet.” Put one runner at the presentation boundary so loading always terminates while the original diagnostics remain available:
final class UnexpectedErrorDetails {
const UnexpectedErrorDetails({
required this.userMessage,
required this.debugMessage,
required this.cause,
required this.stackTrace,
this.errorCode = 'app.unexpected',
this.userMessageKey = 'errors.unexpected',
});
final String userMessage;
final String userMessageKey;
final String errorCode;
final String debugMessage;
final Object cause;
final StackTrace stackTrace;
}
The complete runner and Cubit integration live in the
result_runner.dart example.
It is intentionally an example, not core API: localization, logging policy,
crash reporting, and UI state ownership differ between applications.
Safe retry behavior
The default policy retries transient GET and HEAD failures. Mutations are
not retried automatically:
await api.post('/attendance', body: body); // no automatic retry
Only opt in when the backend guarantees idempotency:
await api.post(
'/payments',
body: body,
headers: {'Idempotency-Key': operationId},
retryMode: RetryMode.always,
);
Use RetryMode.never to disable retry for one read.
Authentication and wrapped refresh responses
final api = NetAnchor.builder()
.baseUrl(baseUrl)
.auth(
AuthConfig(
tokenStorage: secureTokenStorage,
refreshPath: '/auth/refresh',
refreshTokenExtractor: (response) {
final root = response.data! as Map<String, dynamic>;
final session = root['data']! as Map<String, dynamic>;
return RefreshedTokens(
accessToken: session['access_token']! as String,
refreshToken: session['refresh_token'] as String?,
);
},
),
)
.build();
Concurrent 401 responses share one refresh operation. Public endpoints opt out:
api.post('/auth/login', requiresAuth: false, body: credentials);
clearSession() clears tokens and the current user's private cache partition.
Secure traffic logging
Traffic logging is opt-in. Sensitive headers, query values, and nested JSON fields are redacted by default—even in release builds:
final api = NetAnchor.builder()
.baseUrl(baseUrl)
.enableTrafficLog()
.build();
Authorization, passwords, tokens, and secrets become [REDACTED]. Supply a
custom LogRedactor for application-specific formats. Avoid logging opaque
custom objects unless your redactor understands them.
Response cache
Caching is opt-in per request. Configure a store once and provide a stable user identity that does not change when access tokens rotate:
final api = NetAnchor.builder()
.baseUrl(baseUrl)
.cache(
store: MemoryCacheStore(maxEntries: 200),
identityProvider: CallbackCacheIdentityProvider(
() => sessionStore.currentUserId(),
),
defaultVaryByHeaders: {'accept-language'},
)
.build();
Then opt in on a read:
Future<Result<List<CommentDto>>> loadComments({
required String tenantId,
required String locale,
}) {
return api.getList(
'/comments',
fromJson: CommentDto.fromJson,
headers: {'Accept-Language': locale},
cache: CacheOptions(
strategy: CacheStrategy.staleWhileRevalidate,
namespace: 'comments',
schemaVersion: 2,
scope: 'tenant:$tenantId:locale:$locale',
ttl: const Duration(minutes: 2),
maxStale: const Duration(days: 1),
tags: {'comments'},
),
);
}
Invalidate after a successful mutation:
return api.post<CommentDto>(
'/comments',
body: params.toJson(),
fromJson: CommentDto.fromJson,
invalidateCacheTagsOnSuccess: const {'comments'},
);
For a public resource shared across signed-in users:
const CacheOptions(
visibility: CacheVisibility.public,
namespace: 'countries',
tags: {'countries'},
);
To invalidate public tags after mutation, pass
invalidationVisibility: CacheVisibility.public.
Strategies:
cacheFirst: return fresh cache, otherwise network.networkFirst: network first; use cache only for offline/timeout and withinmaxStale.staleWhileRevalidate: return usable cache immediately and run one background refresh per key.refresh: skip reading cache and replace it from the network.
Safety guarantees:
- private cache is bypassed if no stable identity is available;
- tokens and unrelated headers are not part of cache identity;
- custom keys never bypass identity/scope/schema isolation;
- corrupt entries are removed and recovered from the network;
- older responses cannot overwrite or
no-store-remove newer data; - invalidation/logout prevents in-flight reads from repopulating stale data;
Cache-Control: no-storeis respected;- only explicitly varied headers affect the key;
- background stale refresh is single-flight.
MemoryCacheStore is process-local. A persistent CacheStore implementation
is responsible for serialization, encryption at rest, and atomic writes.
Observability, not UI side effects
final api = NetAnchor.builder()
.observer(
CallbackNetworkObserver(
onFailureCallback: (failure, event) {
metrics.recordFailure(failure.code, event.elapsed);
},
),
)
.build();
Observers are for tracing, metrics, and diagnostics. They do not show toasts or drive page state because a repository may recover after a network failure.
Testing
Test helpers use a secondary import so production API stays focused:
import 'package:netanchor/netanchor.dart';
import 'package:netanchor/netanchor_testing.dart';
final adapter = MockAdapter()
..when(pathContains: '/users/1').thenReturn(
HttpResponse(
statusCode: 200,
data: const {'id': 1},
headers: const {},
),
);
Migrating from 4.x
Version 5 is intentionally breaking because it removes the generic failure type and the APIs that made normal repositories harder to write.
| 4.x | 5.x |
|---|---|
Result<T, Failure> |
Result<T> |
NetworkResult<T> |
Result<T> |
FailureResult<T, F> |
FailureResult<T> |
result.map(...) |
result.mapSuccess(...) |
mapAsync(...) |
mapSuccessAsync(...) |
flatMap/flatMapAsync |
thenResult/thenResultAsync |
widenFailure() |
delete it; no widening is needed |
decoder: Decoders.jsonObject(User.fromJson) |
fromJson: User.fromJson |
.listener(...) |
.observer(...) |
NetAnchorListener.onError |
NetworkObserver.onFailure |
silent: true |
remove; UI feedback belongs after repository result |
.cacheStore(store) |
.cache(store: ..., identityProvider: ...) |
CacheConfig/CachePolicy |
CacheOptions/CacheStrategy |
UnknownFailure |
UnknownNetworkFailure |
package:netanchor/netanchor.dart for mocks |
add netanchor_testing.dart |
Before:
Future<Result<User, Failure>> loadUser() async {
final NetworkResult<UserDto> result = await api.get(
'/user',
decoder: Decoders.jsonObject(UserDto.fromJson),
);
return result.widenFailure().map((dto) => dto.toEntity());
}
After:
Future<Result<User>> loadUser() {
return api
.get('/user', fromJson: UserDto.fromJson)
.mapSuccess((dto) => dto.toEntity());
}
Application failures must now implement both code and message. See
the v5 migration guide
for a step-by-step checklist.
Verification
Before release, the package is checked with:
dart format --output=none --set-exit-if-changed .
dart analyze
dart test
dart pub publish --dry-run
The Flutter example is also analyzed and tested separately.
License
MIT
Libraries
- netanchor
- netanchor — a swappable, framework-agnostic networking layer.
- netanchor_testing
- Testing utilities for netanchor consumers.