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 Widgets 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:

  1. ServiceProvider(s)
  2. ServiceLocator
  3. InheritedServiceLocator Widget
  4. 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!