Dart Tutorial

Flutter Lesson 75 of 83 5 min read

Dependency Injection and Environment Configuration in Flutter

Wire up a Flutter app with dependency injection using constructors, provider or get_it, and manage dev and production settings safely.

On this page

In the last lesson, the view model received its repository through its constructor, and the repository received its API the same way. Handing an object what it needs, instead of letting it create or look up its own, is called dependency injection. It is what makes code testable and replaceable.

The problem it solves #

// Hard-wired: the view model creates its own dependencies.
class OrdersViewModel extends ChangeNotifier {
  final _repository = OrderRepository(OrderApi(http.Client()));
}

// Injected: it is given them.
class OrdersViewModel extends ChangeNotifier {
  OrdersViewModel(this._repository);
  final OrderRepository _repository;
}

The first version always talks to the real server. The second can be handed a fake in a test, a cached version offline, or a demo version for screenshots, without changing a line of the class.

The remaining question is who creates everything and passes it along. There are three common answers.

1. By hand, in main #

Create the objects at start-up, in dependency order, and pass them down.

void main() {
  final client = http.Client();
  final productApi = ProductApi(client);
  final productRepository = RemoteProductRepository(productApi);

  runApp(MyApp(productRepository: productRepository));
}

This is explicit and needs no package. For a small app it is the right choice. It becomes tedious when objects must be threaded through many widgets.

2. With provider #

Place the objects above the app, and let any widget below read them.

import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider(create: (_) => http.Client()),
        Provider(create: (context) => ProductApi(context.read<http.Client>())),
        Provider<ProductRepository>(
          create: (context) => RemoteProductRepository(context.read<ProductApi>()),
        ),
      ],
      child: const MyApp(),
    ),
  );
}

// Where a screen is created:
ChangeNotifierProvider(
  create: (context) => ProductsViewModel(context.read<ProductRepository>())..load(),
  child: const ProductsPage(),
)

Each provider can read the ones above it. Providers are lazy: an object is created the first time something asks for it.

With Riverpod, providers themselves are the injection system: ref.watch(productRepositoryProvider). With Bloc, use RepositoryProvider.

3. With a service locator: get_it #

get_it is a registry. You register how to make each object at start-up, then ask for one anywhere, with no BuildContext.

flutter pub add get_it
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupDependencies() {
  getIt.registerLazySingleton<http.Client>(http.Client.new);
  getIt.registerLazySingleton(() => ProductApi(getIt()));
  getIt.registerLazySingleton<ProductRepository>(() => RemoteProductRepository(getIt()));
  getIt.registerFactory(() => ProductsViewModel(getIt()));
}

void main() {
  setupDependencies();
  runApp(const MyApp());
}

// Anywhere
final viewModel = getIt<ProductsViewModel>();
RegistrationGives
registerSingleton(obj)One instance, created now
registerLazySingleton(() => ...)One instance, created on first use
registerFactory(() => ...)A new instance every time

Keep the calls to getIt<T>() at the edges: where screens are created. Classes themselves should still take dependencies through their constructors. If a repository calls getIt inside its methods, its dependencies are hidden again, and you are back to the original problem.

Which to use? #

ApproachGood for
By handSmall apps, and learning
provider, Riverpod or Bloc’s providersApps already using that package for state
get_itAccess outside the widget tree, such as in background tasks, or a team that prefers a locator

All three are fine. Use one, consistently.

Replacing dependencies in tests #

This is the reward.

// With constructors
final vm = ProductsViewModel(FakeProductRepository());

// With provider, in a widget test
await tester.pumpWidget(
  Provider<ProductRepository>.value(
    value: FakeProductRepository(),
    child: const MaterialApp(home: ProductsScreen()),
  ),
);

// With get_it
setUp(() {
  getIt.reset();
  getIt.registerSingleton<ProductRepository>(FakeProductRepository());
});

Environment configuration #

Your app needs different settings for development and production: the API address, whether logging is on, which analytics project to use.

Compile-time values with –dart-define #

flutter run --dart-define=API_URL=https://dev.example.com --dart-define=ENV=dev
flutter build appbundle --dart-define=API_URL=https://api.example.com --dart-define=ENV=prod
abstract final class AppConfig {
  static const apiUrl = String.fromEnvironment(
    'API_URL',
    defaultValue: 'https://dev.example.com',
  );
  static const env = String.fromEnvironment('ENV', defaultValue: 'dev');

  static bool get isProduction => env == 'prod';
}

The values are fixed when the app is built. For several values, keep them in a file and pass it:

flutter run --dart-define-from-file=config/dev.json
{ "API_URL": "https://dev.example.com", "ENV": "dev" }

Add the production file to .gitignore if it contains anything sensitive, and supply it in your CI system.

Several entry points #

Another approach is one main file per environment.

// lib/main_dev.dart  (Not standalone: bootstrap is shown further down)
void main() => bootstrap(const AppConfig(apiUrl: 'https://dev.example.com', logging: true));

// lib/main_prod.dart
void main() => bootstrap(const AppConfig(apiUrl: 'https://api.example.com', logging: false));
flutter run -t lib/main_dev.dart
flutter build appbundle -t lib/main_prod.dart

Flavors #

A flavor is a native build variant with its own app id, name and icon. With flavors you can install the development and production apps side by side on one phone, each pointing at a different backend and Firebase project.

flutter run --flavor dev -t lib/main_dev.dart
flutter build appbundle --flavor prod -t lib/main_prod.dart

Flavors are configured in the Android Gradle file and in Xcode schemes. They take an hour to set up and are worth it for any app with real users.

Secrets do not belong in the app #

Anything compiled into an app can be extracted from the published file, whatever technique you use: --dart-define, an .env asset or obfuscation.

Kind of valueWhere it belongs
API base URL, public keys, Firebase configIn the app. These are not secrets
Keys for services that restrict use by app id or domain, such as MapsIn the app, with the restriction switched on in the provider’s console
Private keys, payment secrets, admin tokens, database passwordsOn your server only. The app calls your server, and your server calls the third party

Never commit secrets to Git either. Rotating a leaked key is far more work than keeping it out.

A bootstrap function #

Gather the start-up work in one place.

Future<void> bootstrap(AppConfig config) async {
  WidgetsFlutterBinding.ensureInitialized();

  final prefs = await SharedPreferences.getInstance();
  final client = http.Client();
  final productRepository = RemoteProductRepository(
    ProductApi(client, baseUrl: config.apiUrl),
  );

  runApp(
    MultiProvider(
      providers: [
        Provider.value(value: config),
        Provider.value(value: prefs),
        Provider<ProductRepository>.value(value: productRepository),
      ],
      child: const MyApp(),
    ),
  );
}

Try it yourself #

Take the products feature from the previous lesson. Register its dependencies with either provider or get_it. Add an AppConfig read from --dart-define with a base URL and a showDebugBanner flag, and run the app with two different sets of values.

Practise in the playground Updated by Santosh Adhikari