Flutter GetX Dependency Injection: A Practical Tutorial
Master GetX dependency injection in Flutter with Bindings, lifetimes, async setup, testing, and best practices—complete with concise code examples.
Image used for representation purposes only.
Overview
Dependency injection (DI) in Flutter helps you write modular, testable, and maintainable code by separating object creation from object usage. GetX offers a lightweight, pragmatic DI container built into the same toolkit you might already use for routing and state management. In this tutorial, you’ll learn the essential GetX DI APIs, recommended wiring patterns with Bindings, lifetime management, testing strategies, and common pitfalls to avoid.
Prerequisites
- Familiarity with Flutter and Dart basics
- A project using GetX (add it in pubspec.yaml under dependencies)
- IDE extensions for Dart/Flutter
Why dependency injection with GetX?
- Centralized wiring: Create and configure services, repositories, and controllers in one place.
- Clear lifetimes: Keep singletons alive as long as needed, and recreate short‑lived objects on demand.
- Simple testing: Swap real implementations with fakes or mocks without touching UI code.
Core GetX DI concepts
GetX’s DI is a small service locator with a thin API. The most useful registration methods are:
- Get.put
(instance, permanent: bool) – Register an already created instance (often a singleton). - Get.lazyPut
(() => T(), fenix: bool) – Register a factory that creates the instance on first use. With fenix: true, GetX can recreate an instance after it’s disposed. - Get.putAsync
(() async => T) – Register an asynchronously initialized instance (useful for SharedPreferences, databases, or secure storage). - Get.create
(() => T()) – Always return a new instance (factory), useful for short‑lived objects where you never want a shared singleton. - Get.find
(tag?) – Retrieve an instance by type (and optional tag for multiple variants). - Get.replace
(newInstance) – Swap an existing registration (handy during runtime configuration changes).
Project setup
Add GetX to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
get:
Create a basic app shell with GetMaterialApp so you can use bindings and navigation together.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Example: async service bootstrapping before runApp
await Get.putAsync<PreferencesService>(() async {
final service = PreferencesService();
await service.init();
return service;
});
// Example: eager singleton
Get.put<AnalyticsService>(AnalyticsServiceImpl(), permanent: true);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'GetX DI Tutorial',
initialBinding: AppBinding(),
getPages: [
GetPage(name: '/', page: () => const HomePage(), binding: HomeBinding()),
GetPage(name: '/details', page: () => const DetailsPage(), binding: DetailsBinding()),
],
// Optional: choose how GetX disposes and recreates instances
// smartManagement: SmartManagement.full,
);
}
}
Example domain model
We’ll wire a small stack:
- ApiClient: makes HTTP calls
- CounterRepository: business logic depending on ApiClient
- CounterController: exposes observable state for the UI
- AnalyticsService and PreferencesService: cross‑cutting services
class ApiClient {
ApiClient({required this.baseUrl});
final String baseUrl;
Future<int> getCounter() async { /* call backend */ return 0; }
Future<int> incrementCounter() async { /* call backend */ return 1; }
}
class CounterRepository {
CounterRepository(this._api);
final ApiClient _api;
Future<int> load() => _api.getCounter();
Future<int> increment() => _api.incrementCounter();
}
abstract class AnalyticsService { void log(String message); }
class AnalyticsServiceImpl implements AnalyticsService {
@override
void log(String message) => debugPrint('[analytics] $message');
}
class PreferencesService {
bool _initialized = false;
Future<void> init() async { _initialized = true; }
bool get isReady => _initialized;
}
Wiring with Bindings (recommended)
Bindings keep your wiring close to routes and app startup, instead of scattering Get.put calls throughout widgets.
class AppBinding extends Bindings {
@override
void dependencies() {
// Recreated on demand if disposed (fenix)
Get.lazyPut<ApiClient>(
() => ApiClient(baseUrl: 'https://api.example.com'),
fenix: true,
);
// Repository depends on ApiClient
Get.lazyPut<CounterRepository>(
() => CounterRepository(Get.find<ApiClient>()),
);
}
}
class HomeBinding extends Bindings {
@override
void dependencies() {
// Controller depends on repository
Get.lazyPut<CounterController>(
() => CounterController(Get.find<CounterRepository>()),
);
}
}
class DetailsBinding extends Bindings {
@override
void dependencies() {
// Example of factory per access
Get.create<SessionToken>(() => SessionToken.newToken());
}
}
class SessionToken {
SessionToken.newToken();
}
The controller: accessing dependencies cleanly
Prefer constructor injection for testability. Use Get.find only for optional conveniences.
class CounterController extends GetxController {
CounterController(this._repo);
final CounterRepository _repo;
final count = 0.obs;
late final AnalyticsService _analytics = Get.find<AnalyticsService>();
@override
void onInit() {
super.onInit();
_analytics.log('CounterController initialized');
_load();
}
Future<void> _load() async {
count.value = await _repo.load();
}
Future<void> increment() async {
count.value = await _repo.increment();
_analytics.log('Counter incremented to ${count.value}');
}
@override
void onClose() {
_analytics.log('CounterController disposed');
super.onClose();
}
}
Using the controller in UI
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final c = Get.find<CounterController>();
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Obx(() => Text('Count: ${c.count.value}',
style: Theme.of(context).textTheme.headlineMedium)),
),
floatingActionButton: FloatingActionButton(
onPressed: c.increment,
child: const Icon(Icons.add),
),
);
}
}
class DetailsPage extends StatelessWidget {
const DetailsPage({super.key});
@override
Widget build(BuildContext context) {
// Each access gets a fresh SessionToken because we used Get.create
final token = Get.find<SessionToken>();
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: Center(child: Text('Using new session token: $token')),
);
}
}
Lifetimes, disposal, and recreation
- Eager singletons: Use Get.put with permanent: true for app‑wide services that should never be disposed (e.g., analytics, app configuration).
- Lazy singletons: Use Get.lazyPut for services you want created on first use. With fenix: true, they’ll be recreated automatically if removed.
- Factories: Use Get.create when you always need a new instance (e.g., per navigation or per operation objects).
- Manual disposal: Use Get.delete
() to dispose a specific instance, or call Get.reset() during log‑out or test tearDown to clear the container. - Smart management: Configure GetMaterialApp.smartManagement to control how aggressively instances are removed as routes are popped. When you also use fenix on lazyPut, disposed instances can be created again on demand.
Named instances for multiple variants
When you need multiple instances of the same type (e.g., dev vs prod ApiClient), use tags.
Get.lazyPut<ApiClient>(() => ApiClient(baseUrl: 'https://dev.api'), tag: 'dev');
Get.lazyPut<ApiClient>(() => ApiClient(baseUrl: 'https://api'), tag: 'prod');
final devClient = Get.find<ApiClient>(tag: 'dev');
final prodClient = Get.find<ApiClient>(tag: 'prod');
Environment‑specific wiring
Use compile‑time environment flags to pick implementations without sprinkling if/else across the codebase.
const bool isProd = bool.fromEnvironment('PROD', defaultValue: false);
class EnvBinding extends Bindings {
@override
void dependencies() {
if (isProd) {
Get.lazyPut<ApiClient>(() => ApiClient(baseUrl: 'https://api'));
} else {
Get.lazyPut<ApiClient>(() => ApiClient(baseUrl: 'https://dev.api'));
}
}
}
Run with: flutter run –dart-define=PROD=true
Asynchronous initialization patterns
- Before runApp: await Get.putAsync to guarantee availability for the whole app (e.g., databases, preferences).
- Splash/loader: Navigate only after putAsync completes if you prefer to show a loading screen.
await Get.putAsync<DatabaseService>(() async => DatabaseService()..open());
Replacing registrations at runtime
Swap an implementation in response to user actions (e.g., switch analytics provider).
Get.replace<AnalyticsService>(VerboseAnalytics());
Testing your DI setup
Keep tests deterministic by isolating wiring and cleaning up afterwards.
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
class FakeRepo extends CounterRepository {
FakeRepo() : super(ApiClient(baseUrl: ''));
@override
Future<int> load() async => 41;
@override
Future<int> increment() async => 42;
}
class MockAnalytics implements AnalyticsService {
final messages = <String>[];
@override
void log(String message) => messages.add(message);
}
void main() {
setUp(() {
Get.testMode = true; // Safe lookups; prevents certain side effects in tests
});
tearDown(Get.reset);
test('CounterController loads and increments', () async {
Get.put<AnalyticsService>(MockAnalytics(), permanent: true);
final c = CounterController(FakeRepo());
c.onInit();
expect(c.count.value, 41);
await c.increment();
expect(c.count.value, 42);
});
}
Guidelines:
- Prefer constructor injection for controllers/services under test.
- Put test doubles into the container with Get.put before creating the subject.
- Always call Get.reset in tearDown to avoid cross‑test leakage.
Common pitfalls and how to avoid them
- Registering in build(): Never call Get.put or Get.lazyPut inside widget build methods. Use Bindings or app startup.
- Hidden globals: Minimize ad‑hoc Get.find scattered across business code. Prefer constructor injection; keep Get.find for leaf UI code or optional conveniences.
- Memory leaks: If you manually create instances outside bindings, ensure you dispose them via Get.delete or appropriate lifecycle methods (onClose in controllers).
- Async races: When services require async initialization, ensure they’re ready before first use (await Get.putAsync or gate routes behind a splash).
- Overusing permanent: Use permanent: true sparingly; prefer lazy singletons with fenix for services that can be recreated.
Migration tips (from Provider or manual singletons)
- Move your object creation into Bindings.
- Replace context.read
()/Provider.of () with Get.find () where appropriate. - Keep constructor injection in controllers and repositories so tests remain straightforward.
Quick checklist
- Do you centralize wiring in Bindings and/or initialBinding?
- Are long‑lived services singletons (Get.put) and short‑lived objects factories (Get.create)?
- Do you use lazyPut with fenix for recreate‑on‑demand behavior?
- Are tests resetting the container (Get.reset) and injecting fakes via Get.put?
Conclusion
GetX’s DI is small but powerful. With a handful of APIs—put, lazyPut, putAsync, create, find, and replace—you can express clear lifetimes, enable simple testing, and keep wiring out of your UI code. Pair these with Bindings and smart management, and your Flutter app architecture becomes cleaner, faster to reason about, and easier to maintain.
Related Posts
Flutter GetX Tutorial: State, Routing, and Dependency Injection in One Lightweight Package
Step-by-step Flutter GetX tutorial covering state, routing, DI, bindings, workers, theming, and i18n with practical code snippets.
Mastering Flutter AnimationController: Advanced Techniques, Patterns, and Performance
Advanced Flutter AnimationController techniques: staging, physics, scroll/gesture-driven motion, lifecycle, performance, and testing with code.
Flutter go_router Navigation Guide: From Basics to Advanced Patterns
A practical guide to Flutter’s go_router: setup, parameters, guards, nested tabs, deep links, transitions, and testing with concise, production-ready code.