Flutter Stories — Dependency Injection with injectable and getIt

Dan Patacean
9 min readSep 8, 2022

--

Artwork by Roxana P.

If you landed on this page, you might be:

  1. New Flutter developer that’s hearing about dependency injection left and right and you decided to give it a try but, you don’t know where to start. I will call you _dev1.
  2. A Flutter developer that didn’t used dependency injection so far and now you’re willing to give it a try if it’s not to complicated. You will be _dev2.
  3. A well versed developer, that knows all about dependency injection and you want to see if in this article the dependency injection is used correctly. Obviously, this is _dev3.

Ok, so what’s actually dependency injection ? Well, Wikipedia says that:

Dependency injection is a design pattern in which an object or function receives other objects or functions that it depends on. A form of inversion of control, dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs. The pattern ensures that an object or function which wants to use a given service should not have to know how to construct those services. Instead, the receiving ‘client’ (object or function) is provided with its dependencies by external code (an ‘injector’), which it is not aware of.

The theory sounds good but how do we translate it in our apps, _dev1 would say?

To answer his question, let’s create a simple app that, let’s say it will load random cat facts from the mighty web.

For this app, we will need some implementation for the networking part, something to call and maybe validate the data we get and another implementation that will display the fact to the user.

First off, dependencies

dependencies:
flutter:
sdk: flutter
# this will be used in combination with injectable for DI
get_it: ^7.2.0

#well know library for BLoC state management
flutter_bloc: ^8.1.1

# use it to generate the code for DI
injectable: ^1.5.3

# parse api response
json_serializable: ^6.3.1

#because we are cool
freezed: ^2.0.4

#because we are extra cool
retrofit: ^3.0.1+1

dev_dependencies:
flutter_test:
sdk: flutter
#find problems in our code, because we're not perfect
flutter_lints: ^1.0.0

#generate the code for networking
retrofit_generator: '>=4.0.0 <5.0.0'

#generate the code for DI
injectable_generator: 1.5.4

#you know this one
build_runner: 2.2.0

Networking implementation

For the networking implementation we will create a simple injectable configuration that will provide the URL of the API and headers in case we need it.

import 'package:injectable/injectable.dart';

abstract class IConfig {
String get baseUrl;

Map<String, String> get headers;
}

@Injectable(as: IConfig)
class AppConfig extends IConfig {
@override
String get baseUrl => "https://catfact.ninja/";

@override
Map<String, String> get headers => {};
}

We use the abstract class IConfig in case we want to provide a different configuration.

Since we are using retrofit as our networking library, we need to provide a Dio object to our REST client, but because Dio object is not injectable, we have to make it in injectable, for that, injectable lib has the @module annotation.

We are also passing the config to set the global headers to Dio.

import 'package:dependency_injection/networking/config.dart';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';

@module
abstract class DioProvider {
@singleton
Dio dio(IConfig config) {
Dio dio = Dio();
dio.options.headers = config.headers;
return dio;
}
}

Let’s define our simple API using retrofit.

import 'package:dependency_injection/networking/models/fact.dart';
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'rest_client.g.dart';

@RestApi(baseUrl: "https://catfact.ninja/")
abstract class RestClient {
factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

@GET("/fact")
Future<Fact> randomFact();
}

Now let’s add the networking client code:

import 'package:dependency_injection/networking/config.dart';
import 'package:dependency_injection/networking/models/fact.dart';
import 'package:dependency_injection/networking/rest_client.dart';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';

abstract class FactClient {
Future<Fact> randomFact();
}

@Injectable(as: FactClient)
class CatFactClient extends FactClient {
final Dio dio;
final IConfig config;
final RestClient client;

@override
Future<Fact> randomFact() {
return client.randomFact();
}

CatFactClient({
required this.dio,
required this.config,
}) : client = RestClient(dio, baseUrl: config.baseUrl);
}

Again we are using an abstract class, in case we might want to have a different implementation for the FactClient, that can be easily injected.

Now the networking response model

import 'package:json_annotation/json_annotation.dart';

part 'fact.g.dart';

@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
class Fact {
final String fact;
final int length;

factory Fact.fromJson(Map<String, dynamic> json) {
return _$FactFromJson(json);
}

Fact(this.fact, this.length);

Map<String, dynamic> toJson() => _$FactToJson(this);
}

And one more crucial piece of code, the injector:

import 'package:dependency_injection/di/injector.config.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

final getIt = GetIt.instance;

@InjectableInit(
initializerName: r'$initGetIt', // the default method name that will be generated
preferRelativeImports: true, // default
asExtension: false, // default
)
void configureDependencies() {
$initGetIt(getIt);
}

The injector will be used to provide the dependencies everywhere in our application by injecting them.

Make sure to call the configureDependencies() in your main file like so:

void main() {
WidgetsFlutterBinding.ensureInitialized();
configureDependencies();
runApp(const MyApp());
}

We’re done with the code but we don’t have the generated code, for that, you guest it:

flutter pub run build_runner build — delete-conflicting-outputs

Please remember to run this command, every time you do changes in a file that has injectable annotation.

Pro tip (for _dev1): you can create an alias for the build_runner command, mine is called jeff.

alias jeff=’flutter pub run build_runner build — delete-conflicting-outputs’

Ok, now we have the networking implementation in place, it’s time to test it.

import 'package:dependency_injection/di/injector.dart';
import 'package:dependency_injection/networking/fact_client.dart';
import 'package:dependency_injection/networking/models/fact.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
setUpAll(() {
configureDependencies();
});

test('we should be able to do an api call', () async {
FactClient client = getIt<FactClient>();

Fact fact = await client.randomFact();

expect(fact, isNotNull);
expect(fact.fact, isNotNull);
});
}

And the test will pass, you have to believe me, or you have to write all this code. If your test doesn’t pass, well something is wrong with your code :)).

Now let’s create a simple repo

For this app, the repo is not required but it’s good practice to keep your architecture even for small apps.

import 'package:dependency_injection/networking/fact_client.dart';
import 'package:dependency_injection/networking/models/fact.dart';
import 'package:injectable/injectable.dart';

abstract class FunFactRepo {
Future<Fact> oneMoreFactPlease();
}

@Injectable(as: FunFactRepo)
class CatFacts extends FunFactRepo {
final FactClient factClient;

CatFacts({
required this.factClient,
});

@override
Future<Fact> oneMoreFactPlease() async {
try {
return await factClient.randomFact();
} catch (e) {
return Fact("The fact is you have an error: ${e.toString()}", -1);
}
}
}

Let’s call Jeff aka flutter pub run build_runner build — delete-conflicting-outputs one more time.

Let’s do the BLoC now:

The main BLoC

import 'package:bloc/bloc.dart';
import 'package:dependency_injection/blocs/home_event.dart';
import 'package:dependency_injection/blocs/home_state.dart';
import 'package:dependency_injection/networking/models/fact.dart';
import 'package:dependency_injection/repos/simple_repo.dart';
import 'package:injectable/injectable.dart';

@injectable
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final FunFactRepo factRepo;

HomeBloc(this.factRepo) : super(HomeState.nada()) {
on<HomeEvent>((event, emit) {
event.when(oneMoreFunFact: () async {
emit(HomeState.loading());
Fact fact = await factRepo.oneMoreFactPlease();
if (fact.length == -1) {
// the most safe way to check for error eva
emit(HomeState.error(fact.fact));
} else {
emit(HomeState.funFact(fact));
}
});
});
}
}

The state and event using freeze because we are so cool devs. You too _dev1.

import 'package:dependency_injection/networking/models/fact.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_state.freezed.dart';

@freezed
class HomeState with _$HomeState {
factory HomeState.nada() = Nada;

factory HomeState.loading() = Loading;

factory HomeState.funFact(Fact fact) = FunFact;

factory HomeState.error(String error) = Error;
}
import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_event.freezed.dart';

@freezed
class HomeEvent with _$HomeEvent {

factory HomeEvent.oneMoreFunFact() = OneMorePlease;
}

Let’s call Jeff one more time.

By now we have all the backbone of our app, we just need to add the UI, something simple, a text and a button to get a new fact. Oh, and maybe a loading indicator while we get the fact, because you know it, we are soo cool. We’ll keep the error handling for _dev3, he’s the best of us so far.

Our wonderful UI

import 'package:dependency_injection/blocs/home_bloc.dart';
import 'package:dependency_injection/blocs/home_event.dart';
import 'package:dependency_injection/blocs/home_state.dart';
import 'package:dependency_injection/di/injector.dart';
import 'package:dependency_injection/networking/models/fact.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocProvider(
create: (_) => getIt<HomeBloc>(),
child: SafeArea(
child: BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
return state.when(nada: () {
return Center(
child: mainButton(context),
);
}, loading: () {
return _loading;
}, funFact: (fact) {
return _factUI(fact, context);
}, error: (error) {
return _error(error);
});
},
),
),
),
);
}

Center _error(String error) {
return Center(
child: Text(error),
);
}

Widget _factUI(Fact fact, BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(fact.fact),
const SizedBox(
height: 10,
),
Text(
'Fun fact, the abve fact has: ${fact.length} characters',
),
const SizedBox(
height: 15,
),
mainButton(context),
],
),
);
}

Widget get _loading => const Center(
child: CircularProgressIndicator(),
);

Widget mainButton(BuildContext context) {
return ElevatedButton(
onPressed: () {
context.read<HomeBloc>().add(HomeEvent.oneMoreFunFact());
},
child: const Text("Hit me baby one more time"));
}
}

Hit run and enjoy your cat fact app. Yeah, it’s a cat fact app…

If you check the code, all our dependencies are passed trough constructor, when you create a new instance of a class, you know exactly what you have to provide to it in order to be created, and the class that needs the dependency doesn’t know anything about it’s creation, it just getIt (see what i did there).

What’s even better, we can actually use the implementation without any library, in that case, we will have to instantiate on our own the dependencies and provide them when we create new objects.

Short summary:

  • injectable library is a powerful tool that will help you in the dependency injection world.
  • use @injectable to mark a class as injectable
  • use @module to inject 3rd party library instances
  • use @Inject(as:) to provide a solid implementation for an abstract class.

Developer notes:

  • For _dev1, you may wonder why are we doing al of this crazy stuff, when we could just ad 3 classes, do an async request and boom, app works and it’s done. Well, besides all the best practices and things that are around there, you’ll find out one day, that you’ll have to use unit tests to cover your code, and then you’ll see why you want to separate the implementation. Moreover, one day, your beloved client will tell you that, nope, now i want dog facts, and i want it done by tomorrow, and you’ll be smiling knowing that you just have to change a few pieces of your code and it will magically work.
  • For _dev2, i would say, injectable lib and getit are cool and you don’t have to use them, but i would strongly advise you to use dependency injection in your future apps.
  • For _dev3, thank you for staying with me this long, you know all, i don’t have anything to say to you.

Q&A form _dev1 & _dev2

  1. _dev1: Why should i ever use dependency injection pattern ?

In my experience, DI pattern, will decouple your code. You will have no problem covering your implementation with tests and in my opinion the implementation will be more clean and clear.

2. _dev1: Do i have to use injectable and getIt for dependency injection in Flutter?

Of course not, DI is a design pattern, and a design pattern can be implemented in different ways. Injectable and getIt will reduce the amount of boilerplate code you have to write and it will take care of the construction and injection of the dependencies.

3. _dev2: The example is a simple app, what if I have a huge app that i need to build and i also have to configure different environments and settings ?

I think that DI with injectable and getIt can be used on any scale. On the configuration side, I will cover it in a different article that will come up soon.

4. _dev 2: Do i have to start the project with DI or i can integrate it already in my codebase ?

In my opinion both ways can work with a minor caveat on the second part, if your base code is highly coupled, you’ll have a bit of a hard time refactoring it, but at the end, you’ll get a high quality code base, testable and scalable. You can start with smaller pieces of your code then work your way trough the whole project.

One more thing before you go:

You can find all the code on my Github at:

Stay tuned for the next article where we will do some advanced injections with injectable and getIt.

--

--

Dan Patacean

Mobile Application Architect 📲 Flutter • Android • IOS • Contractor • Freelancer • Consultant