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.

ASOasis
7 min read
Flutter GetX Dependency Injection: A Practical Tutorial

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;
}

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