Build your own Service Locator in Dart and Flutter
Frequently, I find myself looking for better ways to pass around references to the different services I use throughout my app's code. These services can be data sources, repositories, loggers, analytics, storage, etc. I've passed them around through constructors, but then my constructor arguments get bloated. I created an InheritedWidget
for each of my services, but that's tiresome and time-consuming. The solution I find best is to use a ServiceLocator
, and I'm going to explain how.
What is a Service Locator?
A ServiceLocator
is an object used to search and obtain another object that would fall under some category (or type) of service. Pretty straight forward definition, but how is it implemented in code? Service Locator is actually a design patter, and a common one implemented in many frameworks.
The Service Locator
is an abstraction away from implementation details to services. It carries the dependencies and acts as a centeral registry to gain access to specific information to perform a task. Normally, services of the Serivce Locator
are listed at the start of the application.
Disadvantages
Before moving forward to the code, I want to cover the disadvantages of the Service Locator
design patter. Yes, there are disadvantages to think about - all patterns have their pros and cons. One issue is compile-time dependency checks. Another is difficulty writing tests.
Tests can be difficult to write if there is a statically referenced Service Locator
; however, this implementation utilizes the Flutter widget tree instead of a static references. This means you can construct the Service Locator
as part of the widget tree, and make it a top-level InheritedWidget
for all Widget
s to inherit.
Compile-time checks, unfortonately, do not have a solution. At least, not a simple one. Service Locator
takes a more dynamic approach to dependency injection, and this prevents the compiler from detecting missing references. This is a trade-off. Passing dependencies via constructors down the Widget tree, or inherting reference to the Service Locator
and resolving your dependencies.
Like I said, all patterns have their pros and cons. Which means there is a time and place for each pattern chosen.
Creating the ServiceLocator
There are going to be three main components to Service Locator
:
- ServiceProvider(s)
- ServiceLocator
- InheritedServiceLocator Widget
- Services Widget
ServiceProvider
import 'package:flutter/widgets.dart';
typedef ServiceBuilder<T extends Object> = T Function(BuildContext context);
sealed class ServiceProvider {
Type get key;
}
final class Singleton<T extends Object> implements ServiceProvider {
const Singleton(this.service);
final T service;
@override
Type get key => T;
}
final class LazySingleton<T extends Object> implements ServiceProvider {
LazySingleton(this._builder);
final ServiceBuilder<T> _builder;
@override
final Type key = T;
T? _service;
T create(BuildContext context) {
return _service ??= _builder(context);
}
}
final class Factory<T extends Object> implements ServiceProvider {
Factory({required ServiceBuilder<T> create}) : _builder = create;
final ServiceBuilder<T> _builder;
@override
final Type key = T;
T? _service;
T create(BuildContext context) {
final service = _service ??= _builder(context);
return service;
}
}
First off, what is the Service Provider
? This class will help define how the services are constructed and collected. Singleton
provides a single instance of a service. LazySingleton
is the same as the Singleton
, but it's single instance will be constructed after the first time the service is located by the Service Locator
, The Factory
creates a new instance of a service everytime the Serivce Locator
references it. Each SerivceProvider
keeps track of the Type
of the service.
ServiceLocator
import 'package:flutter/widgets.dart';
import 'service_provider.dart';
final class ServiceLocator {
ServiceLocator(List<ServiceProvider> services)
: _services = {for (final provider in services) provider.key: provider};
final Map<Type, ServiceProvider> _services;
T locate<T extends Object>(BuildContext context) {
final ServiceProvider? provider = _services[T];
if (provider == null) {
throw StateError('${T.toString()} was not provided to ServiceLocator');
}
switch (provider) {
case Singleton():
return provider.service as T;
case LazySingleton():
return provider.create(context) as T;
case Factory():
return provider.create(context) as T;
}
}
}
The ServiceLocator
keeps track of each ServiceProvider
inside a Map
with the Service's class type as the key. When the locate
function is called, get the ServiceProvider
assocated with the T
type. Because the constructor only takes in a List<ServiceProvider>
, the Map
is guaranteed to correctly construct. Every ServiceProvider
should be mapped to the service Type
.
After fetching the ServiceProvider
from the map, cast it to the correct ServiceProvider
type. Because the ServiceProvider
is a sealed
class, a switch
statement will help ensure type safety. Within each switch case, use the ServiceProvider
's proper method to obtain the service.
Services Widget
final class Services extends InheritedWidget {
Services({
super.key,
required List<ServiceProvider> services,
required super.child
}) : locator = ServiceLocator(services);
static T of<T extends Object>(BuildContext context) {
return context
.getInheritedWidgetOfExactType<Services>()!
.locator
.locate<T>(context);
}
final ServiceLocator locator;
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
}
The Services
widget is a simple InheritedWidget
that does not update. Because it never updates, the updateShouldNotify
will always return false
. There is also the static of
function to obtain reference of a desired service type.
Example Usage
class SomeWidget extends StatelessWidget {
const SomeWidget({super.key});
@override
Widget build(BuildContext context) {
final service = Services.of<MyService>(context);
...
}
}
Within a widget's build
function, services can not be obtained via BuildContext
and the of
function.
Extension Function - Optional
import 'package:flutter/widgets.dart';
import 'services.dart';
extension ServiceExtensions on BuildContext {
T service<T extends Object>() => Services.of(this);
}
To cut-down some of the typing, an extension function can be created to call the of
function from the Services
widget.
Example Usage
class SomeWidget extends StatelessWidget {
const SomeWidget({super.key});
@override
Widget build(BuildContext context) {
final service = context.service<MyService>();
...
}
}
Example Application
void main() {
runApp(
Services(
services: const [
Singleton<Logger>(ConsoleLogger(defaultTag: 'Application'))
],
child: const MyApp(),
)
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
context.service<Logger>().d('Building application...');
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({
super.key,
required this.title
});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
static const tag = 'Home';
int _counter = 0;
void _incrementCounter() {
context.service<Logger>().v('Button clicked!', tag: tag);
setState(() => _counter++);
}
@override
Widget build(BuildContext context) {
context.service<Logger>().v('Scaffolding is getting built', tag: tag);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Using the Logger
and example code created from a previous post, the Logger
no longer needs to be passed via constructors. In a production app, this would be extremely tedious and result in some ugly code. Any new Widgets created and added to the app will automatically gain access to the Logger
via Service Locator
.
Code written throughout this article can be found here.
Thank you for reading!