netanchor 5.0.0 copy "netanchor: ^5.0.0" to clipboard
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 open Failure contract, 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 within maxStale.
  • 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-store is 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

4
likes
160
points
51
downloads

Documentation

API reference

Publisher

verified publisherbasuony.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#networking #http #dio #rest #api

License

MIT (license)

Dependencies

crypto, dio, http_parser

More

Packages that depend on netanchor