DEV Community

Cover image for Offline First: Using the Decorator Pattern to Cache API Responses in Flutter
Mutombo jean-vincent
Mutombo jean-vincent

Posted on

Offline First: Using the Decorator Pattern to Cache API Responses in Flutter

The Problem

Imagine the network disappears mid-session. What does your app show? For a long time, mine showed nothing. The platform serves articles, videos, user profiles, and settings, all pulled from a .NET backend. No connection meant no data, and I had not planned for that at all.

The first version was simple. A screen called a use case, the use case called a repository, the repository called the API. If the API responded, great. If not, the user saw an error. That is fine for a first pass, but it is not acceptable for a real app. Users expect data to be there, especially for things they have already seen.

The obvious answer is caching. Fetch from the API, store the result locally, and the next time the user opens the app, show the cached data immediately while the fresh data loads in the background. But how you wire that up matters a lot. I did not want the caching logic mixed into my use cases or bleeding into the domain layer. I wanted the rest of the app to be completely unaware that caching was happening at all.

That is where the Proxy and Decorator patterns came in.

The Idea

Both patterns involve wrapping an object and adding behavior around it without changing the interface. The difference is mostly about intent:

  • Proxy controls access to the real object. It can intercept calls to add caching, authorization checks, lazy loading, or logging. The caller does not know whether it is talking to the real thing or the proxy.
  • Decorator adds new behavior to an existing object while delegating the original behavior to it. You stack decorators on top of each other, each one adding one responsibility.

In practice, the two patterns look almost identical in code. What I ended up with in the mobile app is a Decorator at the repository layer: a class that implements the same repository interface as the remote implementation, wraps it, and adds local storage behavior on top of it without the rest of the app ever knowing.

The architecture looks like this:

Use Case
    ↓
ISettingsRepository (interface)
    ↓
SettingsCachedRepository (decorator, implements ISettingsRepository)
    ↓
SettingsRemoteRepository (real implementation, also implements ISettingsRepository)
    ↓
HTTP API via Chopper/Dio
    ↓
Backend: /api/v1/settings/profile
Enter fullscreen mode Exit fullscreen mode

The use case only knows about ISettingsRepository. The dependency injection container decides which concrete class gets injected. Everything below the interface is invisible to the caller.

The Implementation

The Interface

First, there is a port (interface) that defines what the repository can do. This is the contract that both the remote and the cached implementations must honour:

/// Repository port for settings operations.
///
/// Both the remote and the cached implementations satisfy this contract.
/// Use cases depend only on this interface, never on concrete classes.
abstract class ISettingsRepository {
  Future<Either<Failure, ProfileResponseEntity>> updateProfile(ProfileModel profile);
  Future<Either<Failure, ProfileResponseEntity>> updateAvatar(File avatarFile);
}
Enter fullscreen mode Exit fullscreen mode

Notice the return type: Either<Failure, T>. This is from fpdart, a functional programming library for Dart. Instead of throwing exceptions, every repository method returns a Left(failure) on error or a Right(value) on success. The caller pattern-matches on the result. No try-catch in use cases, no unhandled exceptions flying up the stack.

The Remote Repository

The remote repository is the real implementation. It calls the API through a data source, maps the DTO response into a domain entity, and wraps errors into Left(failure):

/// Remote settings repository.
///
/// Calls the API through the remote data source and maps DTOs to domain entities.
/// All errors are caught and returned as Left(Failure), never thrown.
class SettingsRemoteRepository implements ISettingsRepository {
  final ISettingsRemoteDataSource _remoteDataSource;

  const SettingsRemoteRepository(this._remoteDataSource);

  @override
  Future<Either<Failure, ProfileResponseEntity>> updateProfile(ProfileModel profile) async {
    try {
      final response = await _remoteDataSource.updateProfile(profile);
      final profileEntity = SettingsMapper.profileResponseFromDto(response);
      return Right(profileEntity);
    } on ServerException catch (exception) {
      return Left(ProblemMapper.toFailure(exception));
    }
  }

  @override
  Future<Either<Failure, ProfileResponseEntity>> updateAvatar(File avatarFile) async {
    try {
      final response = await _remoteDataSource.updateAvatar(avatarFile);
      final profileEntity = SettingsMapper.avatarResponseFromDto(response);
      return Right(profileEntity);
    } on ServerException catch (exception) {
      return Left(ProblemMapper.toFailure(exception));
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

This class knows nothing about local storage. Its one job is to make HTTP calls and translate results.

The Cached Repository (The Decorator)

Here is where the pattern pays off. The cached repository also implements ISettingsRepository, but instead of making API calls itself, it delegates to the inner remote repository and then persists the result locally if the call succeeded:

/// Cached settings repository (decorator pattern).
///
/// Wraps the inner repository and adds local caching functionality.
/// Delegates remote operations to the inner repository, then persists
/// successful results to local storage via auth local datasource.
/// The rest of the app never knows this layer exists.
class SettingsCachedRepository implements ISettingsRepository {
  final ISettingsRepository _remoteRepository;
  final IAuthLocalDataSource _localDataSource;

  const SettingsCachedRepository(this._remoteRepository, this._localDataSource);

  @override
  Future<Either<Failure, ProfileResponseEntity>> updateProfile(ProfileModel profile) async {
    final result = await _remoteRepository.updateProfile(profile);

    // Only persist if the remote call succeeded
    return result.fold(
      (failure) => Left(failure),
      (profileResponse) async {
        try {
          await _localDataSource.setUser(UserModel.fromEntity(profileResponse.user));
          return Right(profileResponse);
        } on CacheException catch (exception) {
          return Left(ProblemMapper.toFailure(exception));
        }
      },
    );
  }

  @override
  Future<Either<Failure, ProfileResponseEntity>> updateAvatar(File avatarFile) async {
    final result = await _remoteRepository.updateAvatar(avatarFile);

    // Only persist if the remote call succeeded
    return result.fold(
      (failure) => Left(failure),
      (profileResponse) async {
        try {
          await _localDataSource.setUser(UserModel.fromEntity(profileResponse.user));
          return Right(profileResponse);
        } on CacheException catch (exception) {
          return Left(ProblemMapper.toFailure(exception));
        }
      },
    );
  }

}
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out:

Only cache on success. The result.fold(...) call handles both branches. If the remote call returned a Left(failure), the decorator passes the failure through unchanged. It never writes stale or partial data to local storage. The cache only ever holds data that the server confirmed was saved successfully.

The decorator does not know about HTTP. It does not import Chopper/Dio or touch any networking code. It only knows about the ISettingsRepository interface and the IAuthLocalDataSource interface. The two responsibilities, talking to the API and talking to local storage, are in separate classes and stay there. This also means swapping the HTTP client is trivial. The project uses Chopper, but Dio, http, or Retrofit would work exactly the same way. The decorator never sees any of it.

Local Storage with Hive

The local data source uses Hive CE, a fast, pure-Dart key-value store. Each module has its own Hive box, its own set of constants, and its own Hive model classes. The auth module's local data source writes and reads UserModel instances from a dedicated auth_box:

// hive.constants.dart
const String kAuthBox = 'auth_box';
const int kUserTypeId = 0;
const String kUserKey = 'user';
Enter fullscreen mode Exit fullscreen mode
/// Hive-backed implementation of IAuthLocalDataSource.
///
/// Reads and writes UserModel to the auth_box using Hive's put/get/delete API.
/// Throws typed CacheException subclasses so errors can be mapped to Failure
/// by the repository layer.
class AuthLocalDataSource implements IAuthLocalDataSource {
  final Box<dynamic> _authBox;

  AuthLocalDataSource(this._authBox);

  @override
  Future<void> setUser(UserModel user) async {
    try {
      await _authBox.put(kUserKey, user);
    } catch (e) {
      throw WriteFailedCacheException(
        instance: 'AuthLocalDataSource.setUser',
        detail: t.auth.cacheError.setUser(error: e.toString()),
      );
    }
  }

  @override
  Future<UserModel?> getUser() async {
    try {
      return _authBox.get(kUserKey) as UserModel?;
    } catch (e) {
      throw ReadFailedCacheException(
        instance: 'AuthLocalDataSource.getUser',
        detail: t.auth.cacheError.getUser(error: e.toString()),
      );
    }
  }

  @override
  Stream<UserModel?> watchUser() async* {
    try {
      // Emit the current value immediately, then stream all future changes
      yield _authBox.get(kUserKey) as UserModel?;

      await for (final event in _authBox.watch(key: kUserKey)) {
        yield event.value as UserModel?;
      }
    } catch (e) {
      throw ReadFailedCacheException(
        instance: 'AuthLocalDataSource.watchUser',
        detail: t.auth.cacheError.watchUser(error: e.toString()),
      );
    }
  }

  @override
  Future<void> clearUser() async {
    try {
      await _authBox.delete(kUserKey);
    } catch (e) {
      throw WriteFailedCacheException(
        instance: 'AuthLocalDataSource.clearUser',
        detail: t.auth.cacheError.clearUser(error: e.toString()),
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The watchUser() method is particularly useful. It yields the current stored value immediately when a subscriber starts listening, then continues yielding every time the Hive box emits a change event for that key. A BLoC or Provider widget can subscribe to this stream and reactively update the UI whenever the cached user changes, for example right after updateProfile writes new data.

Hive Initialization

Hive needs to be initialized once at app startup, before any box is accessed. Each module registers its own type adapters and opens its own box. For the auth module that looks like this:

/// Initializes Hive and opens all required boxes.
///
/// Called once in main() before runApp().
Future<void> initializeHive() async {
  final directory = await getApplicationDocumentsDirectory();
  Hive.init(directory.path);

  // Generated by hive_ce, registers all @HiveType annotated models
  Hive.registerAdapters();

  await Hive.openBox(kAuthBox);
}
Enter fullscreen mode Exit fullscreen mode

The Hive.registerAdapters() call is code-generated. Every model annotated with @HiveType gets an adapter generated at build time. When a new module needs persistence, you add a new box constant, annotate its model class, run dart run build_runner build, and add the box to initializeHive().

Wiring It Up with Dependency Injection

The final piece is the DI container. This is where the proxy/decorator composition actually happens. The use case gets SettingsCachedRepository injected, which in turn gets SettingsRemoteRepository and AuthLocalDataSource injected:

// In the module's DI registration:
getIt.registerLazySingleton<ISettingsRepository>(
  () => SettingsCachedRepository(
    getIt<SettingsRemoteRepository>(),   // inner: talks to the API
    getIt<IAuthLocalDataSource>(),       // local storage layer
  ),
);
Enter fullscreen mode Exit fullscreen mode

The use case asks for ISettingsRepository. It gets SettingsCachedRepository. That class forwards calls to SettingsRemoteRepository and then persists results through IAuthLocalDataSource. The entire chain is transparent to everything above it.

What I Got Out of It

A few things changed after putting this in place:

  • Use cases stayed clean. They call the repository interface and handle Either<Failure, T>. They have no idea that caching exists. If I want to add or remove caching from a module, I change one line in the DI registration, nothing else.

  • Cache writes never corrupt data. Because the decorator only persists on Right(...), the local storage is always consistent with a confirmed server response. A network failure means no write. A partial response means no write. The cache either has the full, validated data, or nothing.

  • The local data source is independently testable. AuthLocalDataSource takes a Box<dynamic> in its constructor. In tests, you pass a fake or in-memory box. You can verify that setUser writes the right key and that watchUser emits updates without spinning up any networking code.

  • Reactivity is free. Hive's box.watch(key: ...) stream means that any widget subscribed to watchUser() automatically sees changes the moment the decorator writes new data after a successful profile update. No manual notification, no separate state management event.

The pattern is not free. You end up with one more class per module that needs caching, and you have to be deliberate about which operations get cached and which do not. But that deliberateness is a feature. You are forced to think about what is worth persisting, what the staleness policy is, and what happens when the write fails. Each of those decisions ends up in one place, the cached repository, and nowhere else.

Top comments (0)