Syringe ๐Ÿ’‰

Syringe is a lightweight, robust, and type-safe dependency injection (DI) library for Dart applications. ๐Ÿš€

Features โœจ

  • Flexible Dependency Types: Support for synchronous (SyncDependency), asynchronous (AsyncDependency), and factory (FactoryDependency) dependencies. ๐Ÿ› ๏ธ
  • Service Locator & Locator API: Clean resolution of dependencies using typed locators. ๐Ÿ”
  • Hierarchical Scopes: Support for parent and child injectors with scoping and inheritance. ๐ŸŒณ
  • Module Visibility: Control dependency visibility using exported (public across scopes) and private (local to the injector) bindings. ๐Ÿ”’
  • Dynamic Factory Creation: Create new instances on-demand using FactoryDependency. โšก
  • Cyclic Dependency Detection: Automatically detect and prevent circular dependencies during resolution. ๐Ÿ”„
  • Progress Tracking: Monitor injection phases and progress via onProgress callbacks. ๐Ÿ“Š
  • Resource Cleanup: Easily dispose resolved instances and manage lifecycles with dispose(). ๐Ÿงน
  • Testability: Decouple components and make unit testing effortless. ๐Ÿงช

Installation ๐Ÿ“ฆ

Add Syringe to your pubspec.yaml:

dependencies:
  syringe: ^0.9.8

Then run dart pub get. ๐Ÿ“ฅ


Usage ๐Ÿ’ก

1. Basic & Asynchronous Dependencies

import 'dart:async';
import 'package:syringe/syringe.dart';

// Define an asynchronous dependency
final class BarDependency extends AsyncDependency<Bar> {
  @override
  Future<Bar> onInject(Locator locator) async {
    await Future.delayed(Duration(milliseconds: 500));
    return Bar(title: 'Hello from Syringe');
  }
}

// Define a synchronous dependency depending on Bar
final class FooDependency extends SyncDependency<Foo> {
  @override
  List<Type> get dependencies => [BarDependency];

  @override
  Foo onInject(Locator locator) {
    // Resolve dependency using type-safe locator
    final bar = locator.value<BarDependency, Bar>();
    return Foo(bar: bar);
  }
}

void main() async {
  final injector = Injector<Foo>(
    bindings: [
      Binding.exported(BarDependency()),
      Binding.exported(FooDependency()),
    ],
    onProgress: (info) {
      print('[${info.percent}%] ${info.type} - ${info.phase.name}');
    },
    onInject: (locator) {
      return locator.value<FooDependency, Foo>();
    },
  );

  final foo = await injector.inject();
  print('Result: ${foo.bar.title}');

  // Clean up resources
  await injector.dispose();
}

class Bar {
  final String title;
  Bar({required this.title});
}

class Foo {
  final Bar bar;
  Foo({required this.bar});
}

2. Hierarchical Scopes (Parent & Child Injectors)

Syringe allows you to structure dependencies hierarchically, where child injectors can access exported bindings from their parent injector.

void main() async {
  // 1. Parent Scope
  final parentInjector = Injector<void>(
    bindings: [
      Binding.exported(BarDependency(), name: 'ParentBar'),
    ],
    onInject: (_) {},
  );
  await parentInjector.inject();

  // 2. Child Scope depending on Parent
  final childInjector = Injector<Foo>(
    parent: parentInjector,
    bindings: [
      Binding.exported(FooDependency()),
    ],
    onInject: (locator) {
      return locator.value<FooDependency, Foo>();
    },
  );

  final foo = await childInjector.inject();
  print('Foo bar title from parent: ${foo.bar.title}');

  // Dispose scopes in order
  await childInjector.dispose();
  await parentInjector.dispose();
}

3. Factory Dependencies (FactoryDependency)

If you need a new transient instance every time a dependency is requested (such as ViewModels or Presenters that depend on shared services), use FactoryDependency:

// A shared singleton repository
final class UserRepositoryDep extends SyncDependency<UserRepository> {
  @override
  UserRepository onInject(Locator locator) => UserRepository();
}

// A factory that resolves the repository and creates a new ViewModel on demand
final class UserViewModelFactory extends FactoryDependency<UserViewModel> {
  @override
  List<Type> get dependencies => [UserRepositoryDep];

  @override
  UserViewModel onInject(Locator locator) {
    // Inject the shared repository from the locator
    final repository = locator.value<UserRepositoryDep, UserRepository>();
    return UserViewModel(repository: repository);
  }
}

void main() async {
  final injector = Injector<List<UserViewModel>>(
    bindings: [
      Binding.exported(UserRepositoryDep()),
      Binding.exported(UserViewModelFactory()),
    ],
    onInject: (locator) {
      // Each call to factory() creates a new UserViewModel instance sharing the same repository
      final vm1 = locator.factory<UserViewModelFactory, UserViewModel>();
      final vm2 = locator.factory<UserViewModelFactory, UserViewModel>();
      
      // vm1 and vm2 are distinct instances, but share the same UserRepository
      print(identical(vm1, vm2)); // false
      print(identical(vm1.repository, vm2.repository)); // true

      return [vm1, vm2];
    },
  );

  await injector.inject();
}

class UserRepository {}

class UserViewModel {
  final UserRepository repository;
  UserViewModel({required this.repository});
}

Visibility Rules ๐Ÿ”’

  • Binding.exported(...): Makes the dependency public. It can be accessed by child injectors when passed as a parent.
  • Binding.private(...): Restricts the dependency to the local injector scope. Private dependencies cannot be accessed externally or by child scopes.

Error Handling โš ๏ธ

Syringe provides descriptive error classes for robust debugging:

  • SyringeCycleDependencyError: Thrown when a circular dependency is detected.
  • SyringeModuleDuplicateError: Thrown when duplicate dependency registrations occur.
  • SyringeUnknownDependencyError: Thrown when an unregistered dependency is requested.

Contributing ๐Ÿค

Contributions, issues, and feature requests are welcome! Feel free to check the repository.

License ๐Ÿ“„

This project is licensed under the terms of the MIT License.

Libraries

syringe