Supercharge Your Flutter Apps with Google’s App Architecture: Data Layer Deep Dive

Henry Ifebunandu
7 min readOct 29, 2023

--

Image AI-Generated using DALL-E

If you’re drawn to the idea of building better apps with less technical debt, I’m guessing at some point you’ve written code that’s not so ideal… We’ve all been there.

The first part of this series introduced the App Architecture — Feature First Approach, the idea of using it to create scalable apps. In this part, we will focus on the Data layer, discussing how all the parts of this layer should come together.

The data layer as previously discussed handles all the data storage and retrieval tasks and houses the repositories and data sources. To explain the concepts in code, I’ll walk you through using a blog app as a case study… well, it's not a real blog app per se but something along the lines. I’ll be using the JSONPlaceHolder API as the remote data source to get dummy posts and their respective authors. Here’s the GitHub repo.

Disclaimer: This is a very simple case study to explain a concept. Some of what you’ll see here is probably overkill for this sort of project. Use this as a guide, be sure to know the scale of the project you’ll be building and apply only if necessary to avoid over-engineering.

From the ground up, we have our data sources which could either be Local or Remote or both, either way, we only have one source of truth. I want the app to provide offline-first support, so surely our source of truth is the Local data source. This typically means whenever we call for our local data it should provide the most up-to-date data for our app. This reconciliation of what to present to the UI be it Local or Remote is usually done in the repository.

Reconciliation in this context means what best to present to the user. Is the app offline? Get local data. Is the local data outdated? Get the latest data. The repository best handles these scenarios. This can be a tricky thing to do when providing offline-first support in your apps. If you would like to learn more read Build an offline first app.

For our case study, PostsRepository depends on both the local and remote data sources as shown below:

class PostsRepository {
const PostsRepository({
required PostsRemoteDataSource postsRemoteDataSource,
required PostsLocalDataSource postsLocalDataSource,
}) : _postsRemoteDataSource = postsRemoteDataSource,
_postsLocalDataSource = postsLocalDataSource;

final PostsRemoteDataSource _postsRemoteDataSource; // remote data source
final PostsLocalDataSource _postsLocalDataSource; // local data source

Future<Result<PostsResponse>> getPosts() async {
try {
final response = await _postsRemoteDataSource.getPosts(); // get most up-to-date data
_postsLocalDataSource.updatePosts(posts: response); // store/update the data locally

return Result.success(_postsLocalDataSource.getPosts()); // return the cached posts (this way, we only return the most up-to-date data)
} catch (e) {
// Errors can happen and when they do, return the local data if available
if (_postsLocalDataSource.isPostsCacheAvailable) {
return Result.success(_postsLocalDataSource.getPosts());
}
return Result.failure(errorMessage: e.toString());
}
}
}

Notice how in the method getPosts we return the local data but ensure it is up-to-date with the remote data source. When the UI layer or domain layer accesses your repository it does not need to know where the data originates from, it just gets what it needs.

The Result type helps us model the result of interactions with the data layer. This pattern models errors and other signals that can happen as part of processing the result. This model returns Result<T> type instead of T . We can let the UI know when we have an error by using Result.failure or our interaction was a success by returning Result.success. You can find it’s implementation in here.

Multiple levels of repositories

When dealing with tricky business needs, one group of data might have to rely on another group of data. This happens when we need to bring together information from different sources or when it makes sense to organize our work into separate groups.

For example, in this case study for our blog app, I needed to implement a search functionality, search by title. The JSONPlaceHolder API does not provide such functionality, so I had to do it the best way I could, get the posts data and perform a ‘where’ and ‘contains’ operation on the data to get results.

It’s quite easy to create a search function inside our PostRepository but it makes more sense to create a SearchRepository and depend on our PostRepository, thereby organizing our work into a separate group. In our case, get posts, perform a search and return the result.

class SearchPostsRepository {
SearchPostsRepository({required PostsRepository postsRepository})
: _postsRepository = postsRepository;

final PostsRepository _postsRepository;

Future<Result<PostsResponse>> searchPostByTitle({
required String searchTerm,
}) {
// get posts
return _postsRepository.getPosts().then((resource) {
// perform search
if (resource.isSuccess) {
final foundPosts = resource.data?.posts
.where((post) => post.title.toLowerCase().contains(searchTerm))
.toList();
// return posts
return Result.success(PostsResponse(posts: foundPosts!));
}
return Result.failure(errorMessage: resource.errorMessage);
});
}
}

This approach promotes code reusability and separation of concerns, allowing each repository to handle specific data retrieval tasks.

Now let’s take a look behind the scenes, let’s see how our data sources (PostsRemoteDataSource and PostsLocalDataSource) work.

Remote data source

class PostsRemoteDataSource {
const PostsRemoteDataSource({required PostsApi postsApi})
: _postApi = postsApi;

final PostsApi _postApi;

Future<PostsResponse> getPosts() => _postApi.getPosts();
}

/// Network request to get posts (abstract to hide implementation)
abstract class PostsApi {
Future<AuthorsResponse> getPosts();
}

The PostsRemoteDataSource depends on the PostsApi which is an interface (abstract class), this way we hide the actual implementation of how we choose to get our Posts data, making it swappable. We can choose to use Http or Dio implementation without our PostsRemoteDataSource knowing.

// Http implementation of PostsApi
class HttpPostsApi implements PostsApi {
@override
Future<PostsResponse> getPosts() async {
// ...http impl of get posts
}
}

// Dio implementation of PostsApi
class DioPostsApi implements PostsApi {
@override
Future<PostsResponse> getPosts() async {
// ...dio impl of get posts
}
}

The implementation to be used can be decided using dependency injection.

Local data source

class PostsLocalDataSource {
PostsLocalDataSource({required PostsDao postsDao}) : _postsDao = postsDao;

final PostsDao _postsDao;

void updatePosts({required PostsResponse posts}) =>
_postsDao.cachePosts(posts: posts);

PostsResponse? getPosts() => _postsDao.getCachedPosts();
}

// Local interface for posts (abstract to hide implementation)
abstract class PostsDao {
void cachePosts({required PostsResponse posts});
bool get isPostsCacheAvailable;
PostsResponse? getCachedPosts();
}

For the PostsLocalDataSource we depend on a DAO (Data Access Object) of Posts. Similar to the PostsApi interface, creating a PostsDao would make sense in situations where we might need to swap implementations, we can decide to make implementations of either Hive or sqflite.

DAO (Data Access Object) is an abstract interface for interacting with a database or other data sources. It’s most notable function is to encapsulate the underlying data access logic.

// hive implementation
class HivePostsDao implements PostsDao {
@override
PostsResponse? getCachedPosts() {

}
}

// sqflite implementation
class SqfPostsDao implements PostsDao {
@override
PostsResponse? getCachedPosts() {

}
}

Naming Conventions

From the examples presented above, you will notice how we name our objects representing our respective layers.

It is effortless to name our classes anything we’d like, but naming them according to the data they provide and where they get data from is the best way to go, it makes our code readable and easy to understand.

From merely looking, we can right off the bat guess what they do (yes, I know the names can get long). Of course, you are free to choose whatever you would like to name them but remember the next person who inherits the code base🥲, go easy on them.

Testing

Structuring our tests should be similar to how we have it on our features, where all feature layer would have their tests. For example: Feature Posts should have their test cases for their layers i.e Data, Domain or UI (If applicable).

> test
> features
> posts
> data
> data_sources
> local
> remote
> repository
- posts_repository_test.dart

Since the Data layer is what is being discussed, we should have tests for our DAOs, APIs, Data Sources and repositories. You can see how I cover test cases in the sample project, which you can find here.

Conclusion

In summary, the Data Layer in your Flutter app acts as a crucial bridge between various data sources and the rest of your application. It ensures data consistency, supports offline-first functionality (if needed), and simplifies data access.

We have discussed how to structure the data layer of a Flutter application using a well-defined architecture.

By organizing your code with clear naming conventions and conducting comprehensive testing, you can build more robust and maintainable apps. This structured approach to data management simplifies data access, fosters code reusability and enhances your app’s performance and resilience.

Code sample

More on Data Layer

Clap and share to support me and follow for more.

Cheers!

--

--