Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 7

Offline Data Caching with Local Data Sources

Recommended resources

Hello! In our previous lesson, we successfully implemented the NumberTriviaRemoteDataSource, allowing our application to fetch live data from the Numbers API. This completed the "online" portion of our data layer's logic.

Today, we'll build the other critical half: the local data source. This component will be responsible for caching data, enabling our app to provide a seamless offline experience. By the end of this lesson, you will be able to implement a local data source for caching data to enable offline access. This is a fundamental skill for building robust, user-friendly mobile applications and a key piece of the Clean Architecture puzzle we've been assembling.

1. The Role and Strategy for a Local Data Source

In Clean Architecture, the local data source is the designated component for interacting with the device's storage. Its purpose is to persist data so it can be retrieved later, most commonly when the device is offline.

As this diagram shows, the repository interacts with various data sources. We've built the API part; now we're focusing on the 'mobile local db' component.

The local data source's responsibilities are to:

  1. Receive a data model (e.g., NumberTriviaModel) from the repository to be cached.
  2. Store this data model in a persistent format.
  3. Retrieve the last cached data model when requested by the repository.
  4. Throw a specific exception, like CacheException, if no data is found in the cache.

When choosing a storage method, you have several options. For complex, structured data, a full-fledged SQL database managed by a package like floor (an abstraction over SQLite) is an excellent, professional choice. However, for our current need—caching a single, simple NumberTrivia object—a lighter solution is more appropriate. We'll use the shared_preferences package, which is perfect for storing simple key-value data. Choosing the right tool for the job is a hallmark of a mid-level developer.


2. TDD for the Local Data Source

We will continue to follow a Test-Driven Development (TDD) approach. This professional practice ensures our code is reliable and correct from the start. We'll start by defining what we expect our local data source to do in a test file, and only then will we write the implementation to make those tests pass.

The video below by Reso Coder is an excellent, step-by-step guide on building a local data source using TDD and shared_preferences. We will follow its structure closely.

Flutter TDD Clean Architecture Course [8] – Local Data Source

This video from the "Flutter TDD Clean Architecture Course" series will guide our implementation.

First, watch the introduction to understand the goal (purpose). Then, follow along with the test setup. Pay close attention to how SharedPreferences is mocked to allow for isolated unit testing (test setup). Finally, see how the test setup informs the creation of the NumberTriviaLocalDataSourceImpl class structure (class implementation).

To summarize the setup, you should have:

  1. A new test file: test/features/number_trivia/data/datasources/number_trivia_local_data_source_test.dart.
  2. A mock class: class MockSharedPreferences extends Mock implements SharedPreferences {}.
  3. A setUp block that initializes your dataSource implementation and the mockSharedPreferences.
  4. In lib/features/number_trivia/data/datasources/number_trivia_local_data_source.dart, you'll have the abstract class and a new implementation class, NumberTriviaLocalDataSourceImpl, that takes SharedPreferences in its constructor.

3. Implementing getLastNumberTrivia

Our first goal is to retrieve the last cached NumberTrivia. Since shared_preferences can only store primitive types like strings, numbers, and booleans, we can't save our NumberTriviaModel object directly. We must first convert it into a JSON string. When we retrieve it, we'll do the reverse.

Test Case 1: Data in Cache

Let's write a test for the "happy path"—when there is data in the cache. The test will mock shared_preferences.getString() to return a JSON string, and then assert that our getLastNumberTrivia method correctly parses this string back into a NumberTriviaModel.

Implementation

Now, let's implement the logic to make the test pass. The core steps are: get the string from shared_preferences, decode the JSON, and construct the model.

Flutter TDD Clean Architecture Course [8] – Local Data Source

This section of the Reso Coder video walks through the TDD cycle for getLastNumberTrivia.

Watch how the test is written for retrieving cached data (testing retrieval). Note the use of a new fixture, trivia_cached.json, which represents the data as it would be stored in the cache. Next, see the implementation logic that makes this test pass (implementing retrieval). Pay attention to how a constant is created for the cache key (CACHED_NUMBER_TRIVIA)—a good practice to avoid "magic strings".

Your implementation of getLastNumberTrivia should now look something like this:

// In lib/features/number_trivia/data/datasources/number_trivia_local_data_source.dart

const CACHED_NUMBER_TRIVIA = 'CACHED_NUMBER_TRIVIA';

class NumberTriviaLocalDataSourceImpl implements NumberTriviaLocalDataSource {
  final SharedPreferences sharedPreferences;

  NumberTriviaLocalDataSourceImpl({required this.sharedPreferences});
  
  @override
  Future<NumberTriviaModel> getLastNumberTrivia() {
    final jsonString = sharedPreferences.getString(CACHED_NUMBER_TRIVIA);
    // Implementation to handle jsonString...
    throw UnimplementedError(); // We'll complete this next.
  }

  // ... cacheNumberTrivia method
}

Test Case 2: No Data in Cache (Cache Miss)

What happens if the user opens the app for the first time, or if the cache is empty? In this case, shared_preferences.getString() will return null. Our app should not crash. Instead, it should throw a CacheException that the repository can catch and handle gracefully.

Let's write a test for this scenario.

Flutter TDD Clean Architecture Course [8] – Local Data Source

This part covers handling the cache miss scenario.

Watch how to write a test that expects an exception to be thrown (testing cache exception). Then, see the simple if/else logic added to the implementation to throw the CacheException when the retrieved string is null (implementing cache exception).

After implementing this, your getLastNumberTrivia method will be complete:

// In NumberTriviaLocalDataSourceImpl

@override
Future<NumberTriviaModel> getLastNumberTrivia() {
  final jsonString = sharedPreferences.getString(CACHED_NUMBER_TRIVIA);
  if (jsonString != null) {
    // Since this method must return a Future, and shared_preferences is synchronous,
    // we wrap the result in a Future.value().
    return Future.value(NumberTriviaModel.fromJson(json.decode(jsonString)));
  } else {
    throw CacheException();
  }
}

Note on Modern Testing: The video uses a TypeMatcher, which is now deprecated. The modern way to test for a specific exception type is to use isA<ExceptionType>(). Your test assertion should look like this:
expect(() => call(), throwsA(isA<CacheException>()));


4. Implementing cacheNumberTrivia

Finally, we need to implement the method for saving data to the cache. The TDD cycle is the same: write a test, then write the implementation.

The test for cacheNumberTrivia won't check the actual stored value. Instead, it will verify that the shared_preferences.setString() method was called with the correct key and the correctly serialized JSON string. This respects the boundary of our unit test—we trust that the shared_preferences package works correctly, and we only test that our code uses it as intended.

Flutter TDD Clean Architecture Course [8] – Local Data Source

This final section covers the implementation and testing of the caching method.

Follow along to write the test for cacheNumberTrivia (testing caching). Notice that the test uses verify to check that a method was called on the mock object. Finally, watch the implementation of cacheNumberTrivia, which involves encoding the model to JSON and calling setString (implementing caching).

Your final implementation will look like this:

// In NumberTriviaLocalDataSourceImpl

@override
Future<void> cacheNumberTrivia(NumberTriviaModel triviaToCache) {
  return sharedPreferences.setString(
    CACHED_NUMBER_TRIVIA,
    json.encode(triviaToCache.toJson()),
  );
}

If you prefer reading to watching, the entire process is also covered in the corresponding article. It's a great reference, and the comments section contains useful updates for null-safety.

Flutter TDD Clean Architecture Course [8] – Local Data Source

This article by Reso Coder is the text version of the video. It covers all the same concepts.

Read through the sections "getLastNumberTrivia" and "cacheNumberTrivia". Pay special attention to the code snippets for both the tests and the implementation. Also, look at the comments section for helpful tips from the community regarding updates for null safety and testing, such as using isA<CacheException>().


Conclusion

Excellent work! You have now implemented a fully tested local data source. This completes the core components of our data layer. The NumberTriviaRepositoryImpl now has both a remote and a local data source it can use to fetch data, fulfilling the logic we designed for it.

Key Takeaways:

  • The Local Data Source is responsible for persisting and retrieving data from the device's storage.
  • For simple key-value data, shared_preferences is a lightweight and effective choice. For more complex, relational data, a tool like floor would be more suitable.
  • Objects must be serialized (e.g., to a JSON string) before being stored in a key-value store.
  • The data source must handle the "cache miss" scenario by throwing a custom, predictable exception (e.g., CacheException) for the layer above to handle.
  • TDD helps ensure each part of the data source—retrieval, caching, and error handling—works as expected in isolation.

In our next lesson, we will move up the architectural stack. With our data layer now complete, we'll see how to connect the UseCases from the domain layer to the BLoC/Cubit classes in the presentation layer, finally bridging our business logic to the UI.