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

Libraries

netanchor
netanchor — a swappable, framework-agnostic networking layer.
netanchor_testing
Testing utilities for netanchor consumers.