How to use Abstraction and the Repository Pattern Effectively in your Flutter apps

If your Flutter app talks to a backend or some server-side APIs, you’ll want to have a good separation of concerns between the UI code and your data-access layer.

A very good way to do this is to use the repository pattern, which lets you encapsulate all the data access logic (serialization, networking) inside a single class (the repository).

The benefit is that the rest of your app only communicates to the public interface of that repository and doesn’t need to know about all its implementation details (such as what 3rd party APIs are used under the hood).

The repository pattern has several advantages:

  • if the implementation changes but the interface stays the same, the rest of the app is unaffected
  • your code becomes more testable (you can mock the repository in your tests)
  • you can scale horizontally by creating multiple repositories if needed

I’ve already covered the repository pattern here:

And in this article, we’ll go further and learn how to write backend-agnostic code, by covering some useful abstractions.

Abstraction lets you decouple the implementation details of a piece of code (usually a class or function) from the rest of the application. When used correctly, abstraction makes our code easier to understand, maintain, and test, and also allows developers to work independently on different parts of the codebase.

As part of this, we’ll talk about “leaky” abstractions, which happen when the public interface of the repository “leaks” some implementation details that should remain hidden.

We’ll also talk about tradeoffs and learn that some kinds of abstractions are worthwhile while others aren’t.

In covering all these concepts, I’ll show you some examples using the Firebase packages since they have a large API surface. But the same considerations apply if you use alternative backends (or, for that matter, any other local or remote storage packages your code depends on).

By the end, you’ll know:

  • how to write repositories that are backend-agnostic (if you use a remote database)
  • how to spot “leaky” abstractions in your code and how to fix them if desired
  • which abstractions are good and make your code more testable, scalable, and maintainable
  • which abstractions are overkill - or even counterproductive

Ready? Let’s go!

If you’re ever planning to migrate a live app to a different backend, there are many considerations to make, including data migration strategies and how to keep downtime to a minimum. These are complex topics that are beyond the scope of this article. But writing a backend-agnostic codebase is a good first step that will make the rest of the process easier.

How to write backend-agnostic code: a practical example

Suppose we want to read a collection of items from a remote database (such as Cloud Firestore) and show the result inside a list view.

If we use the FirestoreListView widget from the firebase_ui_firestore package, this can be easily accomplished by creating this simple widget:

import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; // example code adapted from https://pub.dev/packages/firebase_ui_firestore class ItemsList extends StatelessWidget { const ItemsList({super.key}); @override Widget build(BuildContext context) { // get a query representing the data we want to get final itemsQuery = FirebaseFirestore.instance.collection('items').orderBy('name'); // use it to show all the items in the UI return FirestoreListView<Map<String, dynamic>>( query: itemsQuery, itemBuilder: (context, snapshot) { Map<String, dynamic> item = snapshot.data(); return Text('Item name is ${item['name']}'); }, ); } }

The code above will work, but it clumps together two things that should be separate:

  • getting the data from the remote backend
  • showing the data in the UI

This has some drawbacks:

  • the code is not testable (because we use FirebaseFirestore.instance directly in the widget)
  • the UI code needs to know about Firestore’s QueryDocumentSnapshot API to extract the data
  • the UI code also needs to read key-value pairs inside the snapshot’s data (Map<String, dynamic>)

In other words, we have a poor separation of concerns because the UI code knows too many details about how to get the data and how it is structured.

Can we do better?

Adding a Data Layer

Rather than keeping all the code inside the ItemsList widget, we can introduce a separate data layer:

Separation of concerns between the presentation and data layers
Separation of concerns between the presentation and data layers

This data layer could contain an ItemsRepository that is defined as follows:

class ItemsRepository { ItemsRepository(this._firestore); final FirebaseFirestore _firestore; Query<Item> itemsQuery() { return _firestore .collection('items') .withConverter( fromFirestore: (snapshot, _) => Item.fromMap(snapshot.data()!), toFirestore: (item, _) => item.toMap(), ) .orderBy('name'); } }

We can create the corresponding provider as well (using Riverpod):

final itemsRepositoryProvider = Provider<ItemsRepository>((ref) { return ItemsRepository(FirebaseFirestore.instance); });

If you're not a Riverpod user, you could use a different dependency injection system like get_it, or even flutter_bloc. Read this for more details: Singletons in Flutter: How to Avoid Them and What to do Instead

And we could also define a type-safe Item class:

class Item { const Item({required this.name}); final String name; factory Item.fromMap(Map<String, dynamic> map) { return Item(name: map['name'] as String); } Map<String, dynamic> toMap() => {'name': name}; }

Finally, we can update our widget code:

import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; class ItemsList extends ConsumerWidget { const ItemsList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final itemsQuery = ref.watch(itemsRepositoryProvider).itemsQuery(); return FirestoreListView<Item>( query: itemsQuery, itemBuilder: (context, snapshot) { Item item = snapshot.data(); return Text('Item name is ${item.name}'); }, ); } }

Clearly, we had to write more code to accomplish the same result (showing items inside a list).

But this was not in vain because all the data-access implementation details are now encapsulated in the ItemsRepository.

And the UI code no longer needs to extract the item name from a Map<String, dynamic> (which is an implementation detail of the data-access layer). Instead, it can use the Item model class that we have defined.

However, our UI code still needs to use the (Firestore-specific) QueryDocumentSnapshot type when reading the data, and our widget class still depends on cloud_firestore.dart and firebase_ui_firestore.dart.

And this means that our UI code is still not backend-agnostic, and we would have to refactor it further if we wanted to move to a different backend in the future (e.g. Supabase).

Leaky Abstractions

One thing we have overlooked is that the public interface of our ItemsRepository contains a Firestore-specific type: the Query class. 👇

class ItemsRepository { ItemsRepository(this._firestore); final FirebaseFirestore _firestore; // Query is defined inside cloud_firestore.dart Query<Item> itemsQuery() { ... } }

I call this a leaky abstraction because our repository exposes a type that is defined inside the cloud_firestore package, as can be seen in this diagram:

Can you spot the
Can you spot the "leaky" abstraction?

As a result, any code that consumes the itemsQuery() method will also depend on cloud_firestore.dart:

// [FirestoreListView] depends on cloud_firestore.dart FirestoreListView<Item>( query: ref.watch(itemsRepositoryProvider).itemsQuery(), itemBuilder: (context, snapshot) { Item item = snapshot.data(); return Text('Item name is ${item.name}'); }, );

But hang on!

If we want to use FirestoreListView in our code, we have no other choice but to give it a Query argument. And inside the itemBuilder callback, we have to use QueryDocumentSnapshot (which is also Firebase-specific).

Indeed, the whole point of using the firebase_ui_firestore package is that:

Firebase UI for Firestore enables you to easily integrate your application UI with your Cloud Firestore database.

So, whether we’ve done this intentionally or not, we’ve made a tradeoff.

We’ve chosen to create a leaky abstraction (using Query in the public interface of our repository) in return for the ease of use of the FirestoreListView widget.

But this abstraction comes with some tangible benefits because by using FirestoreListView, we get pagination for free and a convenient way to handle loading and error states.

Implementing Firestore pagination manually can be a lot of work. So if there’s an official package that does a good job at it, we’d be silly not to use it. 😉

Revisiting the Leaky Abstraction

But let’s suppose that we’ve decided to move away from Cloud Firestore, and we want to make our code truly backend-agnostic.

How can we do that?

Well, assuming we choose another remote database that supports realtime listeners, we can modify our repository to use a Stream and consume it in our widget using a regular ListView, like this:

Updated implementation: the presentation layer is now backend-agnostic as it no longer depends on the Query type
Updated implementation: the presentation layer is now backend-agnostic as it no longer depends on the Query type

Here’s what the public interface would look like:

class ItemsRepository { Stream<List<Item>> itemsStream() { ... } }

And here’s what the complete repository would look like:

class ItemsRepository { ItemsRepository(this._firestore); final FirebaseFirestore _firestore; Stream<List<Item>> itemsStream() { return _firestore .collection('items') .withConverter( fromFirestore: (snapshot, _) => Item.fromMap(snapshot.data()!), toFirestore: (item, _) => item.toMap(), ) .orderBy('name') // needed to transform a Query<User> to a Stream<List<Item>> .snapshots() .map((snapshot) => snapshot.docs.map((snapshot) => snapshot.data()).toList()); } }

This new API is backend-agnostic because it only uses built-in types (such as Stream) and types we have defined (such as the Item class).

If we want to consume the new Stream-based API in the UI, we can use StreamBuilder (which is quite clunky) - or, better still - create a StreamProvider:

final itemsStreamProvider = StreamProvider.autoDispose<List<Item>>((ref) { return ref.watch(itemsRepositoryProvider).itemsStream(); });

Then, in the UI, we can do this:

class ItemsList extends ConsumerWidget { const ItemsList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // get an AsyncValue<Item> by watching the stream provider final itemsAsync = ref.watch(itemsStreamProvider); // use pattern matching to handle the data, loading, error states return itemsAsync.when( data: (items) => ListView.builder( itemBuilder: (context, index) { final item = items[index]; return Text('Item name is ${item.name}'); }, itemCount: items.length, ), loading: () => const CircularProgressIndicator(), error: (e, st) => Text(e.toString()), ); } }

Note that this is more code than the original implementation based on FirestoreListView. And we no longer get the built-in pagination support.

But we can confidently say that this implementation is truly backend-agnostic.

And if later on we decided to implement a SupabaseItemsRepository, the UI code would remain the same.

When are abstractions worth it?

So far, we have considered these two abstractions:

  1. Moving the data-access code into a repository
  2. Replace Query<Item> with Stream<List<Item>

The first abstraction is worth it, because leads to a better separation of concerns between UI and data-access logic.

But the second one isn't, as we had to write more code, while losing some useful functionality (pagination) along the way.

But there's one more thing we need to consider. 👇

Note about testing

One of the benefits of abstraction is that we can more easily test our code.

So what can we say about testing with regards to the Query vs Stream-based implementations in the example above?

Let’s consider our ItemsRepository once again:

class ItemsRepository { ... // first solution using a query Query<Item> itemsQuery() { ... } // second solution using a stream Stream<List<Item>> itemsStream() { ... } }

How can we mock the ItemsRepository so that it returns the data we want?

If we use mocktail, we can create our mock like this:

import 'package:mocktail/mocktail.dart'; class MockItemsRepository extends Mock implements ItemsRepository {}

And then, we can stub the itemsQuery() and itemsStream() methods so they can respond when called.

In the stream-based approach, we could setup our mock like this:

final mockRepo = MockItemsRepository(); when(() => mockRepo.itemsStream()).thenAnswer( (invocation) => Stream.value([ const Item(name: 'Beer'), const Item(name: 'Wine'), ]), );

But in the query-based approach, we're out of luck:

final mockRepo = MockItemsRepository(); // ! Abstract classes can't be instantiated. // ! Try creating an instance of a concrete subtype. when(() => mockRepo.itemsQuery()).thenReturn(Query());

That’s because Query is defined as an abstract class inside the cloud_firestore package.

But let’s not give up just yet, for we can create a MockQuery class:

class MockQuery<T> extends Mock implements Query<T> {} final mockRepo = MockItemsRepository(); final mockQuery = MockQuery(); when(() => mockRepo.itemsQuery()).thenReturn(mockQuery);

But as it turns out, we get this warning:

The class 'Query' shouldn't be extended, mixed in, or implemented because it's sealed. Try composing instead of inheriting, or refer to the documentation of 'Query' for more information.

This is no good. And if we look at the documentation for the Query class, we see that it contains nearly 20 methods. And it would be hard to figure out which ones we need to stub to get the test working.

Bottom line: it’s really hard to write tests that use the Query class since it is not meant to be mocked.

So when you design the API (public interface) of your repositories, keep testing in mind.

Indeed, sometimes you have to make a trade between ease of use and ease of testing. And you can choose different abstractions depending on what's most important to you.


Learning to choose the right abstractions is a good skill to have.

So let’s consider some additional examples so that we can build some more intuition. 👇

Example: FirebaseAuth wrapper

When adding user authentication to your app, we need to work directly with the FirebaseAuth class (unless we use the Firebase UI packages).

Here are some of the methods we can find inside this class (along with many others):

// defined in firebase_auth.dart class FirebaseAuth { Future<UserCredential> signInWithEmailAndPassword({ required String email, required String password, }); Future<UserCredential> createUserWithEmailAndPassword({ required String email, required String password, }); Future<void> signOut(); Stream<User?> authStateChanges(); User? get currentUser; }

Some of these methods return UserCredential and User, which are types that are also defined in the same package.

But if we use this class directly in our code, we have two problems:

  • Using FirebaseAuth.instance all over the place makes our code less testable
  • We have to import firebase_auth.dart every time we want to use the firebase_auth APIs

Not great, because the codebase becomes tightly coupled to the firebase_auth package.

Let's see if we can do better. 👇

Step 1: Add a Provider

The first step is to create a provider:

final firebaseAuthProvider = Provider<FirebaseAuth>((ref) { return FirebaseAuth.instance; });

And then, we can watch or read the firebaseAuthProvider as needed.

If you’re unfamiliar with the difference between ref.watch and ref.read with Riverpod, check the official documentation page about Reading a Provider.

This will make our code more testable (since we can override firebaseAuthProvider with a mock in our tests).

But any code that uses the firebaseAuthProvider will still depend on firebase_auth.dart, which can lead to maintenance issues (for example, when upgrading to a new version of firebase_auth that contains breaking changes).

Step 2: Write an AuthRepository

The next step is to create a separate AuthRepository class that acts as a wrapper for FirebaseAuth.

As a first attempt, we could write this code:

class AuthRepository { AuthRepository(this._auth); final FirebaseAuth _auth; // leaks [UserCredential] Future<UserCredential> signInWithEmailAndPassword(String email, String password) { return _auth.signInWithEmailAndPassword( email: email, password: password, ); } // leaks [UserCredential] Future<UserCredential> createUserWithEmailAndPassword(String email, String password) { return _auth.createUserWithEmailAndPassword( email: email, password: password, ); } Future<void> signOut() => _auth.signOut(); // leaks [User] Stream<User?> authStateChanges() => _auth.authStateChanges(); // leaks [User] User? get currentUser => _auth.currentUser; }

This is just a simple wrapper, but we can immediately notice that the public interface is leaking the UserCredential and User types (from firebase_auth).

This is probably fine as long as we don’t plan to migrate to a different authentication system, or don't care too much about testing (more on this below).

With the methods we added, we can already:

  • route the user to a “logged in” or “logged out” page depending on the authentication state in authStateChanges()
  • sign in once the user submits an email & password form, or sign out when the user clicks on a logout button

If we need additional methods from the FirebaseAuth class, we can just add them to the AuthRepository and use them.

But sometimes, the methods we need are not in the AuthRepositoryclass. For example, if we want to send a verification email, we need to call a method that belongs to the User class:

// part of firebase_auth class User { Future<void> sendEmailVerification([ ActionCodeSettings? actionCodeSettings, ]); }

Step 3 (optional): Write a wrapper for the User class

Now, let’s assume we really want to make our data layer backend-agnostic, such that the AuthRepository class doesn’t expose any types from the firebase_auth package.

Can we do that while retaining the ability to send a verification email or call any other methods from the User class?

Well, let’s try to write a wrapper for the User class.

We could start by creating an abstract class that contains the methods and fields we care about:

abstract class AppUser { String get uid; String? get email; bool get emailVerified; Future<void> sendEmailVerification(); }

Then, we could implement a FirebaseAppUser that implements AppUser, by holding on to the underlying User as a private variable:

class FirebaseAppUser implements AppUser { const FirebaseAppUser(this._user); final User _user; // private @override String get uid => _user.uid; @override String? get email => _user.email; @override bool get emailVerified => _user.emailVerified; @override Future<void> sendEmailVerification() => _user.sendEmailVerification(); }

Finally, we could update the AuthRepository class so that the relevant methods return the new AppUser type (rather than the User class from firebase_auth):

class AuthRepository { ... Stream<AppUser?> authStateChanges() { return _auth.authStateChanges().map(_convertUser); } AppUser? get currentUser => _convertUser(_auth.currentUser); /// Helper method to convert a [User] to an [AppUser] AppUser? _convertUser(User? user) => user != null ? FirebaseAppUser(user) : null; }

Here's a visual representation of what we have created:

Abstracting away all details of the FirebaseAuth class behind an AuthRepository
Abstracting away all details of the FirebaseAuth class behind an AuthRepository

With these changes, all implementation details from firebase_auth are abstracted away, and the calling code will only ever use types and classes that we have defined.

For example, the code for sending a verification email inside a button callback would look like this:

onPressed: () => ref.read(authRepositoryProvider) .currentUser! .sendEmailVerification();

Once again: since we're not talking to the FirebaseAuth APIs directly, the UI code no longer depends on the firebase_auth package.


But what about testing?

Testing with the AuthRepository class

Like we’ve done in the previous example, we could ask ourselves how to mock the AuthRepository class so that we can stub its methods and write tests using them.

I won’t go into all the details. But suffice it to say that we have the same problem as before: we can’t create instances of the User class because - just like the Query class - it’s not meant to be mocked.

Instead, the right approach is to use composition, which is exactly what we have done by creating a wrapper FirebaseAppUser class (along with a base AppUser abstract class). This makes it much easier to write mocks without worrying about all the (complex) implementation details of the User class.


In summary, we’ve learned how to design domain-specific APIs (such as AuthRepository and AppUser) that only expose the functionality we need while ensuring that the rest of the code doesn’t depend on 3rd party APIs (FirebaseAuth and User from the firebase_auth package).

Without a doubt, this extra level of indirection forces us to write more code (that needs to be maintained and understood by other developers). And for small projects, it may not be worthwhile.

But it also makes testing easier, and this is valuable further down the line (or if you have bigger projects).

With this knowledge, let’s take a look at one last example. 👇

Example: Transactions in Firestore

A common feature in many modern databases is the ability to execute transactions or batched writes.

For example, suppose we wanted to implement a method that withdraws money from a bank account, but only if there are available funds.

To avoid race conditions (e.g. two withdrawals happening at the same time), we may implement this using a transaction:

class BankBalanceRepository { const BankBalanceRepository(this._firestore); final FirebaseFirestore _firestore; Future<void> withdraw(String uid, double amount) { return _firestore.runTransaction((transaction) async { final accountRef = _firestore.doc('accounts/$uid'); final snapshot = await transaction.get(accountRef); final balance = snapshot.get('balance'); if (balance > amount) { transaction.update(accountRef, {'balance': balance > amount}); } else { throw StateError('Not enough funds'); } }); } }

In this case, the public interface of the BankBalanceRepository doesn’t leak any Firestore-specific types.

But what if we need to call multiple methods that are defined in separate repositories and run all the code inside a single transaction?

Should we try to somehow mimic the Firestore transaction APIs by creating our own wrappers?

// a wrapper class for running Cloud Firestore transactions class FirestoreTransaction { const FirestoreTransaction(this._firestore); final FirebaseFirestore _firestore; // a wrapper for the [runTransaction] method // strictly speaking, we should write a wrapper for [TransactionHandler] too Future<T> runTransaction<T>(TransactionHandler<T> transactionHandler) { return _firestore.runTransaction(transactionHandler); } }

This kind of abstraction quickly takes us down a rabbit hole, and I’d argue that it’s overkill.

Instead, it may be best to keep things simple by bringing all the logic inside a single method (such as the withdraw method above) that doesn’t leak any Firestore-specific APIs to the outside.

Warning against “lowest common denominator” API design

Initially, we set out on a quest to discover how to write backend-agnostic APIs.

Along the way, we’ve learned how to write repositories that encapsulate the implementation details of 3rd party APIs.

We’ve also seen that some abstractions can “leak” external types (such as the itemsQuery() method that returns a Query<Item>):

A basic abstraction that separates the presentation and data layers (backend-specific)
A basic abstraction that separates the presentation and data layers (backend-specific)

And we said that if we really wanted to, we could have replaced Query<T> with Stream<List<T>>. But in doing so, we ended up with a less powerful API that doesn’t support pagination:

Updated implementation: the presentation layer is now backend-agnostic as it no longer depends on the Query type
Updated implementation: the presentation layer is now backend-agnostic as it no longer depends on the Query type

Indeed, designing APIs with abstraction in mind can lead to the “lowest common denominator” syndrome.

In other words, by writing backend-agnostic code, we risk creating APIs that are too generic and can’t make the most of what our backend has to offer.

For example, Cloud Firestore offers features such as realtime listeners, caching, transactions, and offline mode.

On the other hand, a simple REST API gives you only one feature: the ability to make a request and get a response (as a one-time read).

But it would be a mistake to write a Cloud Firestore wrapper that can only read data using futures (rather than streams or queries) because one day, we might move to a REST API that doesn’t support realtime listeners.

So try to design APIs that let you make the most of all the features your backend offers today, and don’t compromise functionality “for the sake of abstraction” or try to make your code “future-proof”.

Remember: abstraction is a tool, not a goal.

Conclusion

When building an app, it’s important to use API design principles, design patterns, and app architecture to ensure your code is testable, scalable, and maintainable.

But these are all skills that you will acquire by practising over time.

Whether you’re starting out or you have a few years of experience, remember this: don’t overcomplicate things and keep it simple.

I hope this article has helped you build some intuition about when to use abstractions (and when not to), so you can make the right tradeoffs depending on what's most important to you.

New Firebase course

In my latest course, I show how to build a full-stack eCommerce app using many Firebase features, such as Firebase Auth, Cloud Firestore, Firebase Storage, Cloud Functions and more.

The course places great emphasis on app architecture, building the right abstractions, and evaluating their tradeoffs, as needed when building medium-to-large scale apps.

So if you want to dive deeper, check it out for all the details. 👇

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.