netanchor 5.0.0
netanchor: ^5.0.0 copied to clipboard
A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.
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