App Architecture & Design Patterns

Dependency Injection: Concepts & GetIt Setup

16 min Lesson 8 of 12

Dependency Injection: Concepts & GetIt Setup

As Flutter applications scale, classes accumulate dependencies — other objects they rely on to do their work. Without a principled approach, you end up constructing those objects deep inside business logic, making the code impossible to test and painful to swap. Dependency Injection (DI) solves this by inverting control: instead of a class creating its own dependencies, they are supplied from outside. This lesson formalises the principle and then wires a complete Clean Architecture stack using the GetIt service locator.

The Dependency Inversion Principle (DIP)

SOLID's fifth principle states:

  • High-level modules (use-cases, blocs) must not depend on low-level modules (HTTP clients, databases).
  • Both must depend on abstractions (abstract classes / interfaces).
  • Abstractions must not depend on details — details depend on abstractions.

In practice this means your GetWeatherUseCase references a WeatherRepository interface, not the concrete WeatherRepositoryImpl that calls an API. The concrete implementation is injected at composition time.

Note: DI is the technique; DIP is the reason you apply it. You can do manual DI (pass objects via constructors) or use a library. GetIt is a service-locator library — a popular, lightweight choice for Flutter that does not require code generation.

Types of GetIt Registrations

GetIt provides three registration strategies, each with distinct object-lifetime semantics:

  • registerSingleton<T>(T instance) — creates the object immediately; the same instance is returned on every get<T>() call.
  • registerLazySingleton<T>(() => T) — defers construction until the first get<T>(); same instance thereafter. Best for heavyweight dependencies you might not always use.
  • registerFactory<T>(() => T) — creates a new instance on every get<T>(). Ideal for Blocs/Cubits so each screen gets a fresh state machine.
Tip: Register low-level infrastructure (HTTP client, database) as singletons or lazy singletons. Register ViewModels, Blocs, and Cubits as factories so each route starts with clean state.

Adding GetIt to Your Project

Add the package to pubspec.yaml, then run flutter pub get:

pubspec.yaml dependency

dependencies:
  flutter:
    sdk: flutter
  get_it: ^7.7.0          # service locator
  dio: ^5.4.0             # HTTP client (example infrastructure dep)
  shared_preferences: ^2.2.3

Building the Injection Container

The convention in Clean Architecture Flutter projects is a single file — typically lib/core/di/injection_container.dart — that registers every dependency in the correct order (infrastructure first, then data, then domain, then presentation).

lib/core/di/injection_container.dart

import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';

// Domain abstractions
import '../../features/weather/domain/repositories/weather_repository.dart';
import '../../features/weather/domain/usecases/get_current_weather.dart';

// Data implementations
import '../../features/weather/data/datasources/weather_remote_datasource.dart';
import '../../features/weather/data/repositories/weather_repository_impl.dart';

// Presentation
import '../../features/weather/presentation/bloc/weather_bloc.dart';

/// Global service-locator instance — exposed for the whole app.
final GetIt sl = GetIt.instance;

/// Call once in [main] before [runApp].
Future<void> initDependencies() async {
  // ── 1. External / third-party ─────────────────────────────────────
  // SharedPreferences must be awaited before registration.
  final sharedPrefs = await SharedPreferences.getInstance();
  sl.registerSingleton<SharedPreferences>(sharedPrefs);

  // Dio singleton — shared across all network calls.
  sl.registerLazySingleton<Dio>(
    () => Dio(BaseOptions(
      baseUrl: 'https://api.openweathermap.org/data/2.5/',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
    )),
  );

  // ── 2. Data layer ──────────────────────────────────────────────────
  sl.registerLazySingleton<WeatherRemoteDataSource>(
    () => WeatherRemoteDataSourceImpl(dio: sl()),
  );

  // Register IMPL under the ABSTRACT type so callers reference the interface.
  sl.registerLazySingleton<WeatherRepository>(
    () => WeatherRepositoryImpl(remoteDataSource: sl()),
  );

  // ── 3. Domain layer ────────────────────────────────────────────────
  sl.registerLazySingleton(
    () => GetCurrentWeather(repository: sl()),
  );

  // ── 4. Presentation layer ──────────────────────────────────────────
  // Factory: every screen gets a fresh WeatherBloc instance.
  sl.registerFactory(
    () => WeatherBloc(getCurrentWeather: sl()),
  );
}

Bootstrapping in main.dart

Await initDependencies() before calling runApp so that every singleton is ready before the widget tree mounts:

lib/main.dart

import 'package:flutter/material.dart';
import 'core/di/injection_container.dart' as di;
import 'app.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await di.initDependencies();   // register ALL services before runApp
  runApp(const App());
}

Resolving Dependencies in Widgets

Call sl<T>() (or the equivalent GetIt.instance<T>()) anywhere you need an instance. The most common usage is inside a BlocProvider:

Resolving a factory-registered Bloc

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../bloc/weather_bloc.dart';
import 'weather_view.dart';

class WeatherPage extends StatelessWidget {
  const WeatherPage({super.key});

  @override
  Widget build(BuildContext context) {
    // sl<WeatherBloc>() calls the factory → brand-new bloc every time
    return BlocProvider(
      create: (_) => sl<WeatherBloc>(),
      child: const WeatherView(),
    );
  }
}
Warning: Never call sl<T>() inside a build() method without wrapping it in a BlocProvider or similar. Calling a factory registration directly in build() creates a new instance on every rebuild, leaking memory and losing state.

Why GetIt Over Pure Constructor Injection?

Constructor injection is the purest DI form and should be preferred for domain and data layers. GetIt shines at the composition root — the single place that wires everything together. Benefits include:

  • No code generation (unlike injectable or auto_route).
  • Works outside the widget tree — use sl() in plain Dart classes.
  • Supports async registration (registerSingletonAsync) for awaitable initialisations.
  • Easy to reset in tests: sl.reset() wipes all registrations for a clean slate.

Summary

Dependency Injection, guided by the Dependency Inversion Principle, decouples your layers and makes each component independently testable. GetIt's three registration modes — singleton, lazy singleton, and factory — map cleanly onto Clean Architecture lifetimes: infrastructure lives as long as the app, while Blocs are recreated per screen. A single injection_container.dart file, initialised in main(), is all you need to wire the entire dependency graph.