singleton_manager

Pub Version

A minimal, zero-dependency dependency-injection registry for Dart: RegistryManager, the IRegistry interface it implements, and the @dependencyInjectable annotation used by singleton_manager_generator to write factory constructors for you.

Features

  • Type-safe: generic getInstance<T>() / connectInstance<Interface, Impl>()
  • Zero dependencies: no external package dependencies at runtime
  • Lazy: a connected factory runs once, the first time its type is resolved; the result is then cached
  • Per-key registration: register multiple instances of the same type under different string keys (default key: 'default')
  • Per-subkey disambiguation: register two instances of the same type under the same key — e.g. two implementations of one interface — via an independent subkey (default: 'default')
  • Subkey propagation: @Subkey.inherited() lets a class connected under multiple subkeys transitively resolve its own nested dependencies under that same subkey, instead of a fixed one
  • Code generation ready: annotate a class with @dependencyInjectable, let the generator write its dependencyInjectionFactory()
  • Pure Dart: no platform-specific code, works on VM, Web, and Flutter

Installation

Add to your pubspec.yaml:

dependencies:
  singleton_manager: ^2.2.1

Quick Start

import 'package:singleton_manager/singleton_manager.dart';

abstract class ILogger {
  void log(String message);
}

class ConsoleLogger implements ILogger {
  @override
  void log(String message) => print(message);
}

void main() {
  final registry = RegistryManager.instance;

  // Connect an interface to a factory — invoked lazily, once, on first access.
  registry.connectInstance<ILogger, ConsoleLogger>(() => ConsoleLogger());

  // Resolve it (same cached instance every time after the first call).
  final logger = registry.getInstance<ILogger>();
  logger.log('Hello from ConsoleLogger!');
}

The IRegistry API

abstract class IRegistry {
  /// Returns the instance connected to [InterfaceType] under [key]/[subkey],
  /// creating it via its connected factory on first access. Throws
  /// `RegistryNotFoundError` if nothing is connected.
  InterfaceType getInstance<InterfaceType>({
    String key = 'default',
    String subkey = 'default',
  });

  /// Same as [getInstance] but returns `null` instead of throwing.
  InterfaceType? getInstanceNullable<InterfaceType>({
    String key = 'default',
    String subkey = 'default',
  });

  /// Connects [InterfaceType] to a concrete [InstanceType] via [factory].
  /// The factory runs once, the first time [getInstance] (or
  /// [getInstanceNullable]) is called for this (type, key, subkey) triple;
  /// the result is cached and reused after that.
  void connectInstance<InterfaceType, InstanceType extends InterfaceType>(
    InstanceType Function() factory, {
    String key = 'default',
    String subkey = 'default',
  });

  /// Directly sets [instance] as the value connected to [InterfaceType]
  /// under [key]/[subkey], overwriting any existing instance or connected
  /// factory's cached result for that same (type, key, subkey).
  void setInstance<InterfaceType>(
    InterfaceType instance, {
    String key = 'default',
    String subkey = 'default',
  });
}

RegistryManager is a singleton implementation of IRegistry, accessed via RegistryManager.instance. Every instance/factory is stored under the compound key (Type, key, subkey)key and subkey are independent axes, each defaulting to 'default'.

Setting an instance directly

Use setInstance when you already have an instance in hand — e.g. wiring a mock in a test, or a value produced elsewhere — instead of going through connectInstance's lazy factory:

final registry = RegistryManager.instance;

registry.setInstance<ILogger>(FakeLogger());

final logger = registry.getInstance<ILogger>(); // returns the FakeLogger

It overwrites whatever was previously connected/set for that (Type, key, subkey) triple — including a connected factory's already-cached result.

Per-key registration

Use key to keep more than one instance of the same type — e.g. environment-specific connections:

final registry = RegistryManager.instance;

registry.connectInstance<IDbConnection, PostgresConnection>(
  () => PostgresConnection('postgres://prod'),
  key: 'prod',
);
registry.connectInstance<IDbConnection, PostgresConnection>(
  () => PostgresConnection('postgres://dev'),
  key: 'dev',
);

final prod = registry.getInstance<IDbConnection>(key: 'prod');
final dev = registry.getInstance<IDbConnection>(key: 'dev');

Per-subkey disambiguation

Use subkey when you need two (or more) instances of the same interface, under the same key — e.g. a constructor with two parameters that share a type:

abstract class IStunHandler {
  String resolve();
}

final registry = RegistryManager.instance;

registry.connectInstance<IStunHandler, Ipv4StunHandler>(
  Ipv4StunHandler.new,
  subkey: 'ipv4',
);
registry.connectInstance<IStunHandler, Ipv6StunHandler>(
  Ipv6StunHandler.new,
  subkey: 'ipv6',
);

final ipv4Handler = registry.getInstance<IStunHandler>(subkey: 'ipv4');
final ipv6Handler = registry.getInstance<IStunHandler>(subkey: 'ipv6');

See example/3_subkey_dependency_injection.dart for the full pattern, including the @Subkey('...') annotation that lets singleton_manager_generator wire this up automatically.

Error handling

Situation Result
getInstance<T>() and nothing was connected/set for (T, key, subkey) throws RegistryNotFoundError
getInstanceNullable<T>() and nothing was connected/set for (T, key, subkey) returns null

Code generation with @dependencyInjectable

Annotate a class and run singleton_manager_generator to have its dependencyInjectionFactory({String key = 'default', String subkey = 'default'}) written for you, instead of hand-writing the constructor call and its RegistryManager lookups:

@dependencyInjectable
class OrderService implements IOrderService {
  OrderService(this._repository, {IClock? clock});

  final IOrderRepository _repository;
}

Tag a constructor parameter with @Subkey('...') when it shares an interface type with another parameter, so each is resolved under its own subkey instead of colliding on 'default':

@dependencyInjectable
class DualStunHandler {
  DualStunHandler({
    @Subkey('ipv4') IStunHandler? ipv4Handler,
    @Subkey('ipv6') IStunHandler? ipv6Handler,
  });
}

Tag the class itself with @DependencyInjectable(subkey: '...') instead of the plain @dependencyInjectable shorthand when the generated --registry-output registry must connect that class under a specific subkey — typically because two @dependencyInjectable classes implement the same interface and both need to be auto-wired:

@DependencyInjectable(subkey: 'eu')
class EuEndpoint implements IRegionEndpoint {}

@DependencyInjectable(subkey: 'us')
class UsEndpoint implements IRegionEndpoint {}

Propagating a subkey through nested dependencies

A literal @Subkey('...') only disambiguates the immediate parameter — it says nothing about that parameter's own dependencies further down the graph. Use @Subkey.inherited() instead of a literal value when a class must be connected multiple times (once per subkey) and each variant should transitively resolve its own nested dependencies under that same subkey, rather than always the same one:

abstract class IPart {}

@DependencyInjectable(subkey: 'ipv4')
class Ipv4Part implements IPart {}

@DependencyInjectable(subkey: 'ipv6')
class Ipv6Part implements IPart {}

@dependencyInjectable
class Single {
  Single(@Subkey.inherited() IPart part);
}

Single itself carries no fixed @DependencyInjectable(subkey: ...) — it's one generic class meant to serve every variant. Connect it once per subkey by hand (e.g. in an overridden beforeRegisterAllSingletons<ProjectName>), passing that same subkey into its own factory call:

RegistryManager.instance
  ..connectInstance<ISingle, Single>(
    () => Single.dependencyInjectionFactory(key: key, subkey: 'ipv4'),
    key: key,
    subkey: 'ipv4',
  )
  ..connectInstance<ISingle, Single>(
    () => Single.dependencyInjectionFactory(key: key, subkey: 'ipv6'),
    key: key,
    subkey: 'ipv6',
  );

Each connected variant then resolves IPart via getInstance<IPart>(key: key, subkey: subkey) — the 'ipv4'-registered Single pulls Ipv4Part, the 'ipv6'-registered one pulls Ipv6Part — without Single needing two hand-duplicated subclasses.

The propagation composes across more than one level, and doesn't require manual wiring at every level — only where a class has no fixed subkey of its own. Extend IPart with one more nested dependency:

abstract class IUnit {}

@DependencyInjectable(subkey: 'ipv4')
class Ipv4Unit implements IUnit {}

@DependencyInjectable(subkey: 'ipv6')
class Ipv6Unit implements IUnit {}

@DependencyInjectable(subkey: 'ipv4')
class Ipv4Part implements IPart {
  Ipv4Part(@Subkey.inherited() IUnit unit);
}

@DependencyInjectable(subkey: 'ipv6')
class Ipv6Part implements IPart {
  Ipv6Part(@Subkey.inherited() IUnit unit);
}

Unlike Single, Ipv4Part/Ipv6Part each carry their own @DependencyInjectable(subkey: ...), so --registry-output connects both automatically — no manual connectInstance call needed for this level. It already forwards each class's own subkey into its generated factory call (Ipv4Part.dependencyInjectionFactory(key: key, subkey: 'ipv4')), so their @Subkey.inherited() parameter picks up Ipv4Unit/Ipv6Unit for free. Only Single needs hand-wiring, because it's the one class in the chain with no fixed subkey of its own — everything below it follows automatically.

See singleton_manager_generator for how the generator maps constructor parameters to getInstance/ getInstanceNullable calls, and for the --registry-output flag that also generates a MainInjection<ProjectName> class (with overridable beforeRegisterAllSingletons<ProjectName> / registerAllSingletons<ProjectName> / afterRegisterAllSingletons<ProjectName> methods) wiring everything up.

Examples

See the examples directory for complete, runnable examples:

  • 1_basic_registry.dart: RegistryManager, connectInstance, getInstance, getInstanceNullable
  • 2_dependency_injection_factory.dart: @dependencyInjectable and the generated dependencyInjectionFactory()
  • 3_subkey_dependency_injection.dart: @Subkey('...') disambiguating two constructor parameters that share an interface
cd packages/singleton_manager
dart run example/1_basic_registry.dart
dart run example/2_dependency_injection_factory.dart
dart run example/3_subkey_dependency_injection.dart

Documentation

For more information, see the main project documentation.

Contributing

See CONTRIBUTING.md for contribution guidelines.

License

MIT License - see LICENSE file for details.

Libraries

singleton_manager