get_it Documentation

repository·main·Indexed 23 days ago

https://github.com/flutter-it/get_it

A fast, type-safe service locator for Dart and Flutter that provides O(1) access to objects without requiring BuildContext or code generation. It supports various registration types including Singletons, LazySingletons, and Factories, as well as scope management, asynchronous initialization with signalReady, and a DevTools extension for visualizing registered objects.

Tokens
13.5K
Snippets
26
Records
45
Agent score
81%

What's inside get_it

  1. Implement Manager init() and Commands correctly

    main

    In the Pragmatic Flutter Architecture (PFA), distinguish between data loading and UI interaction:

    1. init(): Used for loading initial data via direct API calls. Do not use commands inside init().
    2. Commands: The UI-facing reactive interface. Widgets watch isRunning, errors, and results.

    Crucial Rule: Do not nest commands. If a command needs to refresh data after a mutation (e.g., after a delete), call the API directly inside the command body instead of calling another command's .run() method.

    class MyManager {
      final items = ValueNotifier<List<Item>>([]);
    
      // Command for UI-triggered refresh (widget watches isRunning)
      late final loadCommand = Command.createAsyncNoParam<List<Item>>(
        () async {
          final result = await di<ApiClient>().getItems();
          items.value = result;
          return result;
        },
        initialValue: [],
      );
    
      // init() calls API directly — no command needed
      Future<MyManager> init() async {
        items.value = await di<ApiClient>().getItems();
        return this;
      }
    }
    
    // ✅ Direct API call inside command
    late final deleteCommand = Command.createAsync<int, bool>((id) async {
      final result = await di<ApiClient>().delete(id);
      items.value = await di<ApiClient>().getItems(); // reload directly
      return result;
    }, initialValue: false);
    
    // ❌ Don't call another command from inside a command
    late final deleteCommand = Command.createAsync<int, bool>((id) async {
      final result = await di<ApiClient>().delete(id);
      loadCommand.run(); // WRONG — nesting commands
      return result;
    }, initialValue: false);
  2. Manage service scopes and shadowing

    main

    Scopes allow you to group related services and manage their lifecycle (e.g., for a user session).

    Scope Shadowing: Scopes act as a stack. When you register a type in a new scope that already exists in a lower scope, the new registration shadows (hides) the original. getIt<T>() searches top-down, returning the first match. Popping the scope restores access to the original service.

    Key Operations:

    • pushNewScope(): Synchronous scope creation.
    • pushNewScopeAsync(): Asynchronous scope creation (use for async initialization).
    • popScope(): Asynchronous operation that removes the top scope and calls its dispose callbacks.
    • popScopesTill(name, {inclusive: false}): Removes multiple scopes until a specific one is reached.
    • dropScope(name): Removes a specific scope by name.
    // Push scope (synchronous init)
    getIt.pushNewScope(
      scopeName: 'user-session',
      init: (getIt) {
        getIt.registerSingleton<UserData>(currentUser);
        getIt.registerLazySingleton<UserPrefs>(() => UserPrefs(currentUser.id));
      },
    );
    
    // Push scope (async init)
    await getIt.pushNewScopeAsync(
      scopeName: 'user-session',
      init: (getIt) async {
        final prefs = await UserPrefs.load(currentUser.id);
        getIt.registerSingleton<UserPrefs>(prefs);
      },
    );
    
    // Pop scope (always async - calls dispose callbacks)
    await getIt.popScope();
    
    // Pop multiple scopes
    await getIt.popScopesTill('base-scope', inclusive: false);
    
    // Drop specific scope by name
    getIt.dropScope('user-session');
    
    // Query scopes
    getIt.hasScope('user-session');    // bool
    getIt.currentScopeName;            // String?
  3. Implement Pragmatic Flutter Architecture (PFA)

    main

    Pragmatic Flutter Architecture (PFA) organizes an application into three distinct components to separate concerns:

    1. Services: These wrap a single external boundary (e.g., a REST API, database, or OS service). They are responsible for converting data between external formats (like JSON) and domain models. Services must not manage application state.
    2. Managers: These encapsulate semantically related business logic (e.g., UserManager, BookingManager). They are not 1:1 ViewModels; instead, they provide Commands and ValueListenables for the UI to consume. Managers can depend on Services or other Managers.
    3. Views: These are full pages or high-level widgets. They are 'self-responsible,' meaning they know which data they need. Views read data from Managers via ValueListenables and trigger modifications through Managers, never by calling Services directly.
  4. Deduplicate entities with DataRepository and Reference Counting

    main

    When the same entity (e.g., a User or a Post) appears in multiple parts of the app (feeds, search results, detail pages), use a DataRepository to manage their lifecycle via Reference Counting. This prevents duplicate proxies for the same ID and ensures proxies are disposed of only when no longer needed.

    The Reference Counting Flow:

    1. Feed creates ChatProxy(id=1) $\rightarrow$ refCount=1.
    2. Detail Page opens the same proxy $\rightarrow$ refCount=2.
    3. Detail Page closes and releases the proxy $\rightarrow$ refCount=1 (proxy stays alive for the feed).
    4. Feed refreshes and releases the proxy $\rightarrow$ refCount=0 (proxy is disposed).

    Implementation Pattern:

    • DataRepository maintains a map of active proxies by ID.
    • createProxy(item): Returns an existing proxy (and updates it with fresh data) or creates a new one, then increments the _referenceCount.
    • releaseProxy(proxy): Decrements the _referenceCount. If it reaches zero, the proxy is disposed and removed from the repository.
    abstract class DataProxy<T> extends ChangeNotifier {
      DataProxy(this._target);
      T _target;
      int _referenceCount = 0;
    
      T get target => _target;
      set target(T value) { _target = value; notifyListeners(); }
    
      @override
      void dispose() {
        assert(_referenceCount == 0);
        super.dispose();
      }
    }
    
    abstract class DataRepository<T, TProxy extends DataProxy<T>, TId> {
      final _proxies = <TId, TProxy>{};
    
      TId identify(T item);
      TProxy makeProxy(T entry);
    
      // Returns existing proxy (updated) or creates new one
      TProxy createProxy(T item) {
        final id = identify(item);
        if (!_proxies.containsKey(id)) {
          _proxies[id] = makeProxy(item);
        } else {
          _proxies[id]!.target = item;  // Update with fresh data
        }
        _proxies[id]!._referenceCount++;
        return _proxies[id]!;
      }
    
      void releaseProxy(TProxy proxy) {
        proxy._referenceCount--;
        if (proxy._referenceCount == 0) {
          proxy.dispose();
          _proxies.remove(identify(proxy.target));
        }
      }
    }
  5. Implement a three-layer Error Handling strategy

    main

    Error handling follows a hierarchy from local to global:

    1. Local Error Listeners: In a Manager's init(), attach .errors.listen() to specific commands. This allows you to show user-friendly messages (via an InteractionManager) and suppresses the global handler for that specific error.
    2. Global Exception Handler: A static method assigned to Command.globalExceptionHandler. This catches any command error that does not have a local listener.
    3. InteractionManager: A sync singleton that abstracts user-facing feedback (toasts, dialogs). It uses an InteractionConnector widget to safely acquire a BuildContext for showing UI elements without passing context through business logic.

    Error Flow: Command fails $\rightarrow$ ErrorFilter checks for local listeners $\rightarrow$ If local listeners exist, they fire $\rightarrow$ If no local listeners exist, the global handler fires.

    // 1. Local Error Listeners in Manager
    Future<MyManager> init() async {
      final interaction = di<InteractionManager>();
      startSessionCommand.errors.listen((error, _) {
        interaction.showToast('Could not start session', isError: true);
      });
      return this;
    }
    
    // 2. Global Error Handler
    static void globalErrorHandler(CommandError error, StackTrace stackTrace) {
      di<InteractionManager>().showToast(error.error.toString(), isError: true);
    }
    
    // 3. InteractionManager Setup
    // Register sync in base scope before async services
    di.registerSingleton<InteractionManager>(InteractionManager());
  6. How to use signalsReady for async initialization

    main
    The signalsReady workflow is used to coordinate the availability of instances that require asynchronous initialization. If you encounter errors stating an instance is not available in GetIt during async setup, ensure you are correctly using getIt.signalReady(this) within your initialization logic to notify the locator that the instance is now ready for retrieval.
  7. Registration Types in get_it

    main

    When registering objects, you can choose a lifetime (registration type) that best fits your use case:

    • Singleton: Creates the instance once and shares that same instance everywhere it is requested. Ideal for services that maintain state.
    • LazySingleton: Similar to a singleton, but the instance is only created when it is first accessed. This delays initialization until it is actually needed.
    • Factory: Returns a new instance of the object every single time it is requested. Best for stateless services or objects with short lifecycles.
  8. Basic Usage of get_it

    main

    Using get_it involves three main steps: defining your services, registering them at app startup, and accessing them from anywhere in your application without needing BuildContext.

    1. Define services: Create your classes as usual.
    2. Register services: Use a GetIt instance (e.g., GetIt.instance) to register your objects during the app's configuration phase.
    3. Access services: Use the getIt<Type>() syntax to retrieve registered instances.
    import 'package:get_it/get_it.dart';
    
    // Create a global instance (or use GetIt.instance)
    final getIt = GetIt.instance;
    
    // 1. Define your services
    class ApiClient {
      Future<void> fetchData() async { /* ... */ }
    }
    
    class UserRepository {
      final ApiClient apiClient;
      UserRepository(this.apiClient);
    }
    
    // 2. Register them at app startup
    void configureDependencies() {
      getIt.registerSingleton<ApiClient>(ApiClient());
      getIt.registerLazySingleton<UserRepository>(
        () => UserRepository(getIt<ApiClient>())
      );
    }
    
    // 3. Access from anywhere in your app
    class MyHomePage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return ElevatedButton(
          onPressed: () {
            // No BuildContext passing needed!
            getIt<UserRepository>().apiClient.fetchData();
          },
          child: Text('Fetch Data'),
          )
        );
      }
    }
  9. Register services with get_it

    main

    Use getIt.register... methods to add services to the locator. Common registration types include:

    • Singleton: Created immediately upon registration.
    • Lazy Singleton: Created only when first accessed.
    • Factory: Returns a new instance every time it is called.
    • Factory with Parameters: Allows passing arguments during retrieval.
    • Named Instances: Use instanceName to register multiple instances of the same type (e.g., different configurations).

    Critical Rule: Register all services BEFORE calling runApp().

    final getIt = GetIt.instance;
    
    void configureDependencies() {
      // Singleton - created immediately
      getIt.registerSingleton<ApiClient>(ApiClient());
    
      // Singleton with dispose callback
      getIt.registerSingleton<StreamController>(
        StreamController(),
        dispose: (c) => c.close(),
      );
    
      // Lazy singleton - created on first access
      getIt.registerLazySingleton<Database>(() => Database());
    
      // Factory - new instance every call
      getIt.registerFactory<Logger>(() => Logger());
    
      // Factory with parameters
      getIt.registerFactoryParam<Logger, String, void>(
        (tag, _) => Logger(tag),
      );
    
      // Named instances
      getIt.registerSingleton<Config>(devConfig, instanceName: 'dev');
      getIt.registerSingleton<Config>(prodConfig, instanceName: 'prod');
    }
  10. Handle app loading state with allReady() in the UI

    main

    The allReady() method should be used in the UI layer (within a WatchingWidget), not in imperative code. The root widget can use allReady() to show a loading indicator until all async singletons (including those in newly pushed scopes) are ready. This allows the UI to reactively handle the transition from loading to the main application state.

    // ✅ UI handles loading state
    class MyApp extends WatchingWidget {
      @override
      Widget build(BuildContext context) {
        if (!allReady()) return LoadingScreen();
        return MainApp();
      }
    }
    
    // ✅ Push scope, let UI react
    Future<void> onAuthenticated(Client client) async {
      di.pushNewScope(scopeName: 'auth', init: (scope) {
        scope.registerSingleton<Client>(client);
        scope.registerSingletonAsync<MyManager>(() => MyManager().init(), dependsOn: [Client]);
      });
      // No await di.allReady() here — UI handles it
    }
  11. Testing with get_it

    main

    To ensure test isolation and allow for easy mocking, use getIt.reset() to clear all registrations between tests. You can also replace real implementations with mocks during setUp.

    // In tests
    setUp(() {
      getIt.registerSingleton<ApiClient>(MockApiClient());
    });
    
    tearDown(() async {
      await getIt.reset();
    });
  12. Test services using scope-based mocking

    main

    The preferred way to test with get_it is to use scopes. By pushing a new scope in your setUp and registering mocks, you shadow the real implementations. You must then popScope() in your tearDown to clean up.

    // Option 1: Scope-based (preferred) - mocks shadow real registrations
    setUp(() {
      GetIt.I.pushNewScope(
        init: (getIt) {
          getIt.registerSingleton<ApiClient>(MockApiClient());
        },
      );
    });
    
    tearDown(() async {
      await GetIt.I.popScope();
    });
    
    // Option 2: Hybrid constructor injection (optional convenience)
    class MyService {
      final ApiClient api;
      MyService({ApiClient? api}) : api = api ?? getIt<ApiClient>();
    }
    // Test: MyService(api: MockApiClient());