Singletons are a very controversial and debated topic in the software development community:
- Some people say you should avoid them at all costs. ❌
- Others are more pragmatic and use them only in specific cases. 🔍
- And some use them willy-nilly like there's no tomorrow. 😅
To bring some clarity, this article will cover the following:
- introduction to singletons in Dart/Flutter
- what problems do they solve
- what other problems they introduce and how to overcome them
- alternatives to singletons
By the end, you'll better understand why singletons can make your code less maintainable and testable and what you can do instead.
Ready? let's go!
What Is a Singleton?
According to this page on Wikipedia:
The singleton pattern is a software design pattern that restricts the instantiation of a class to one "single" instance.
The page also says that the singleton pattern solves problems by allowing it to:
- Ensure that a class only has one instance
- Easily access the sole instance of a class
- Control its instantiation
- Restrict the number of instances
- Access a global variable
In other words, the singleton pattern ensures that only one instance of a class is ever created, making it easy to access it as a global variable.
How to Implement a Singleton in Dart
This is the simplest way:
class Singleton {
/// private constructor
Singleton._();
/// the one and only instance of this singleton
static final instance = Singleton._();
}
By making the constructor private, we ensure that the class cannot be instantiated outside the file where it is defined.
And as a result, the only way to access it is to call Singleton.instance
in our code.
In some cases, it's preferable to use a static getter variable. For alternative ways of implementing a singleton in Dart, read this thread on StackOverflow.
Some Singleton Examples in Flutter
If you used Firebase before, you'll be familiar with this code, that can be used to sign in when a button is pressed:
ElevatedButton(
// access FirebaseAuth as a singleton and call one of its methods
onPressed: () => FirebaseAuth.instance.signInAnonymously(),
child: Text('Sign in anonymously'),
)
The singleton pattern is used by all the Firebase plugins. And the only way to call their methods is with the instance
getter:
FirebaseFirestore.instance.doc('path/to/document');
FirebaseFunctions.instance.httpsCallable('createOrder');
FirebaseMessaging.instance.deleteToken();
But hang on a second! If the official Firebase plugins are implemented as singletons, surely it's ok to design your classes in the same way, right? 🧐
Not so fast.
One Instance Only
You see, these classes were designed as singletons to prevent you from creating more than one instance in your code:
// Note: this code won't compile since the constructor is private
// inside WidgetA
final auth1 = FirebaseAuth();
// inside WidgetB - different instance:
final auth2 = FirebaseAuth();
The code above won't compile. And it shouldn't, because you have only one authentication service, acting as a single source of truth:
// inside WidgetA
final auth1 = FirebaseAuth.instance;
// inside WidgetB - same instance:
final auth2 = FirebaseAuth.instance;
This is a very noble goal, and singletons are often a reasonable solution for library or package design.
But when writing application code, we should be very careful about how we use them, as they can lead to many problems in our codebase.
Flutter apps have deeply nested widget trees. As a result, singletons make it easy to access the objects we need, from any widget. But singletons have many drawbacks and there are better alternatives that are still easy to use.
Singleton Drawbacks
To better understand why singletons are problematic, here's a list of common drawbacks, along with possible solutions.
1. Singletons are hard to test
Using singletons makes your code hard to test.
Consider this example:
class FirebaseAuthRepository {
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
In this case, it's impossible to write a test to check that FirebaseAuth.instance.signOut()
is called:
test('calls signOut', () async {
final authRepository = FirebaseAuthRepository();
await authRepository.signOut();
// how to expect FirebaseAuth.instance.signOut() was called?
});
A simple solution is to inject FirebaseAuth
as a dependency, like this:
class FirebaseAuthRepository {
// declare a FirebaseAuth property and pass it as a constructor argument
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
// use it when needed
Future<void> signOut() => _auth.signOut();
}
As a result, we can easily mock the dependency in our test and write expectations against it:
import 'package:mocktail/mocktail.dart';
// declare a mock class that implements the type of our dependency
class MockFirebaseAuth extends Mock implements FirebaseAuth {}
test('calls signOut', () async {
// create the mock dependency
final mock = MockFirebaseAuth();
// stub its method(s) to return a value when called
when(mock.signOut).thenAnswer((_) => Future.value());
// create the object under test and pass the mock as an argument
final authRepository = FirebaseAuthRepository(mock);
// call the desired method
await authRepository.signOut();
// check that the method was called on the mock
expect(mock.signOut).called(1);
});
Check out the mocktail package for more info about how to write tests using mocks.
2. Singletons are Implicit Dependencies
Let's recall the previous example:
class FirebaseAuthRepository {
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
In this case, it's easy to see that FirebaseAuthRepository
depends on FirebaseAuth
.
But as soon as we have classes with a few dozen lines of code, it becomes much harder to spot the singletons.
On the other hand, dependencies are a lot easier to see when they are passed as explicit constructor arguments:
class FirebaseAuthRepository {
// easy to find the dependencies here,
// even if this class becomes very large
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
Future<void> signOut() => _auth.signOut();
}
3. Lazy Initialization
Initializing certain objects can be expensive:
class HardWorker {
HardWorker._() {
print('work started');
// do some heavy processing
}
static final instance = HardWorker._();
}
void main() {
// prints 'work started' right away
final hardWorker = HardWorker.instance;
}
In the example above, all the heavy processing code runs as soon as we initialize the hardWorker
variable inside the main()
method.
In such cases, we can use late
to defer the object initialization until later (when it's actually used):
void main() {
// prints nothing
// initialization will happen later when we *use* hardWorker
late final hardWorker = HardWorker.instance;
...
// initialization happens here
// prints 'work started' from the constructor
hardWorker.logResult();
}
However, this approach is error-prone as it's too easy to forget to use late
.
Note: In Dart, all global variables are lazy-loaded by default (and this is also true for static class variables). This means that they are only initialized when they are first used. On the other hand, local variables are initialized as soon as they are declared, unless they are declared as
late
.
As an alternative, we can use packages such as get_it, which makes it easy to register a lazy singleton:
class HardWorker {
HardWorker() {
// do some heavy processing
}
}
// register a lazy singleton (won't be created yet)
getIt.registerLazySingleton<HardWorker>(() => HardWorker());
// when we need it, do this
final hardWorker = getIt.get<HardWorker>();
And we can do the same with Riverpod, since all providers are lazy by default:
// create a provider
final hardWorkerProvider = Provider<HardWorker>((ref) {
return HardWorker();
});
// read the provider
final hardWorker = ref.read(hardWorkerProvider);
As a result, the object we need will only be created when we first use it.
One of my favourite things about Riverpod is that it makes it very easy to test code using providers. For more details, read the Riverpod documentation about testing.
4. Instance Lifecycle
When we initialize a singleton instance, it will remain alive until the end of time (a.k.a. when the application is closed 😅).
And if the instance consumes a lot of memory or keeps an open network connection, we can't release it early if we want to.
On the other hand, packages like get_it and Riverpod give us more control over when a certain instance is disposed.
In fact, Riverpod is quite smart and lets us easily control the lifecycle of the state of a provider.
For example, we can use the autoDispose
modifier to ensure our HardWorker
is disposed as soon as the last listener is removed:
final hardWorkerProvider = Provider.autoDispose<HardWorker>((ref) {
return HardWorker();
});
This is most useful when we want to dispose an object as soon as the widget that was using it is unmounted.
5. Thread Safety
In multi-threaded languages, we need to be careful about accessing singletons across different threads, and some synchronization mechanism may be necessary if they share mutable data.
But in Dart, this is usually not a concern because all application code inside a Flutter app belongs to the main isolate.
Though if we end up creating separate isolates to perform some heavy computations, we need to be more careful:
For more info, watch this video about Isolates and Event Loops.
Alternatives to Singletons
Having reviewed the main drawbacks of using singletons, let's see what alternatives are well suited for Flutter app development.
Dependency Injection
Wikipedia defines dependency injection as:
a design pattern in which an object receives other objects that it depends on.
In Dart this is easily implemented with explicit constructor arguments:
class FirebaseAuthRepository {
// inject the dependency as a constructor argument
const FirebaseAuthRepository(this._auth);
// this property is a dependency
final FirebaseAuth _auth;
// use it when needed
Future<void> signOut() => _auth.signOut();
}
Dependency injection promotes good separation of concerns, making classes independent from the creation of the objects they depend on.
But how can we initialize the FirebaseAuthRepository
class we created above, and use it inside deeply nested widgets (or elsewhere in our code)?
Use get_it as a Service Locator
If we use the get_it package, we can register our class as a lazy singleton when the app starts:
void main() {
// GetIt itself is a singleton, see note below for more info
final getIt = GetIt.instance;
getIt.registerLazySingleton<FirebaseAuthRepository>(
() => FirebaseAuthRepository(FirebaseAuth.instance),
);
runApp(const MyApp());
}
And then we can access it like this when needed:
final authRepository = getIt.get<FirebaseAuthRepository>();
Note: the
GetIt
class is itself a singleton. But this is ok because what matters is that it allows us to decouple our dependencies from the objects that need them. For a more in-depth overview, read the package documentation.
Use Riverpod Providers
Riverpod makes it easy to create providers as global variables:
final authRepositoryProvider = Provider<FirebaseAuthRepository>((ref) {
return FirebaseAuthRepository(FirebaseAuth.instance);
});
And if we have a ref
object, we can easily read any provider to get its value:
final authRepository = ref.read(authRepositoryProvider);
As a result, we only need to call FirebaseAuth.instance
once in the entire codebase (rather than many times), since we can now get or read its value (using either get_it or Riverpod).
Unlike solutions based on InheritedWidget or the Provider package, Riverpod providers live outside the widget tree. This makes them safe to use at compile-time, without runtime exceptions. For more info on this, read my Essential Guide to Riverpod.
Conclusion
Now that we've covered the main drawbacks of using singletons and their alternatives, I'd like to leave you with some practical tips based on personal experience.
1. Don't create your own singletons
Unless you're a package author and you have a good reason to do so, don't create your own singletons. Even if you access 3rd party APIs as singletons, don't use Singleton.instance
everywhere, as this makes your code hard to test.
Instead, create your classes by passing any dependencies as constructor arguments.
Then, follow step 2. 👇
2. Use packages such as get_it or Riverpod
These packages give you much better control over your dependencies, meaning that you can easily initialize, access, and dispose them, without any of the drawbacks outlined above.
Once you get the hang of this, you need to figure out what dependencies exist between different kinds of objects (widgets, controllers, services, repositories, etc.). And this leads to step 3. 👇
3. Choose a good app architecture
When building complex apps, choose a good app architecture that helps you:
- structure your code and support your codebase as it grows
- decide what different objects should (and should not) depend on
By following this advice, I've built a medium-sized eCommerce app with testable and maintainable code without creating any singletons, using this reference app architecture based on Riverpod.
Wrap Up
Singletons make it easy to access dependencies in your code. But they create more problems than they solve.
A better alternative is to manage dependencies using battle-tested packages such as get_it and Riverpod.
So pick one and use it in your apps, along with a good architecture. By doing so, you'll avoid many pitfalls and end up with a much better codebase. 👍
Happy coding!
Flutter Foundations Course Now Available
I launched a brand new course that covers Flutter app architecture in great depth, along with other important topics like state management, navigation & routing, testing, and much more: