Factory logo

Factory

Verify

Dependency injection for Flutter apps that already use Provider. Declare how objects are built, install a module, and keep using context.read, context.watch, context.select, and Consumer.

Factory is an initial implementation under development. The optional generator collects declarations; it does not annotate your business classes or infer their constructors.

Architecture at a glance

flowchart LR
  subgraph Composition
    D["Factory&lt;T&gt;<br/>constructor + policies"]
    G["Optional generator<br/>@Register + @FactoryRegistry"]
    M["FactoryModule<br/>factories + expose"]
    D --> M
    G --> M
  end

  subgraph Scope[Scope boundary]
    S["FactoryScope<br/>Flutter lifecycle"]
    C["FactoryContainer<br/>resolution graph"]
    V["Owned and borrowed instances<br/>scoped or unique"]
    S -->|owns| C
    C -->|"FactoryRef read / watch / select"| V
  end

  O["Overrides + local factories"] --> S
  M -->|install| S
  V -. observed changes .-> C

  C -->|exposed factories only| P["Provider bridge"]
  P --> W["Widgets<br/>context.read / watch / select"]
  C -->|direct read| T["Dart tests and services"]
  C -->|reverse dependency order| X["Async cleanup"]

Declarations describe construction and lifecycle without creating values. Modules install those declarations into a scope; overrides and local factories can replace or reconnect parts of the graph. The container resolves lazily, tracks dependencies and ownership, reacts to explicit observations, and cleans up dependents before dependencies. In Flutter, only declarations listed in expose cross the Provider bridge, so existing widgets keep using the standard Provider APIs while Factory retains lifecycle ownership.

Manual setup

import 'package:factory_provider/factory_provider.dart';
import 'package:flutter/material.dart' hide Factory;

final client = Factory<ApiClient>(
  (_) => ApiClient(),
  dispose: (value) => value.close(),
);
final repository = Factory<UserRepository>(
  (ref) => UserRepository(ref.read(client)),
);
final appModule = FactoryModule(
  factories: [client, repository],
  expose: [repository],
);

// Around the existing application:
FactoryScope(modules: [appModule], child: const MyApp());

Only expose entries become Provider values. Every declaration belongs to the scope where it is installed; registration order does not matter. Separate Factory<T> objects can represent different dependencies with the same type. Exposing two different declarations of the same type in one scope is an error.

Lazy creation and lifetimes

The default is lazy creation and Lifetime.scoped: one value per owning scope. lazy: false creates the value when that scope opens. Lifetime.unique creates a value for every container resolution. Provider caches its exposed value, so reading it through a widget does not repeatedly resolve a unique declaration. Construction is synchronous; initialize asynchronous services outside Factory and supply their ready instances.

Existing dependencies and nested flows

final session = Factory<Session>.external();

// Under an existing Provider<Session>; install session in appModule too.
FactoryScope(
  modules: [appModule],
  overrides: [session.overrideWithValue(context.watch<Session>())],
  child: const MyApp(),
);

// A nested flow with a local replacement and explicitly local dependent:
FactoryScope(
  modules: [profileModule],
  overrides: [client.overrideWithValue(previewClient)],
  local: [repository],
  child: const ProfilePage(),
);

Borrowed values are never disposed by Factory. overrideWith replaces a constructor and accepts its own dispose callback for an owned value. A child inherits the parent's instances unless declarations are installed locally. local reconnects selected dependents without changing the parent's graph. Expose local values through a local module when widgets need to consume them.

Explicit observation

  • ref.read(factory) connects without subscribing.
  • ref.watch(factory) also observes instance replacement.
  • ref.select(factory, selector) also observes selected state changes. Flutter supplies Listenable support; pure Dart containers accept an observe adapter.

A declaration using observation must choose a response:

final account = Factory<AccountRepository>(
  (ref) => AccountRepository(ref.select(session, (value) => value.userId)),
  onChange: ChangePolicy.recreate,
  dispose: (value) => value.close(),
);

final controller = Factory<ProfileController>(
  (ref) => ProfileController(ref.watch(repository)),
  onChange: ChangePolicy.update,
  update: (ref, value) => value.setRepository(ref.watch(repository)),
  dispose: (value) => value.dispose(),
);

update mutates the existing instance and must re-declare its observations. A failed recreation is reported by subsequent resolution; Factory does not serve the old value as a fallback. A later observed change can retry the graph. An in-place update cannot promise rollback of mutations. Use pure constructors, selectors and updates; they must not change the graph while it is being resolved.

Declared ChangeNotifier types automatically forward their notifications to Provider. Register their dispose callback explicitly; Provider does not own these objects. Declaring a notifier merely as Object does not opt into this adapter.

Optional dependency access inside notifiers

Use FactoryChangeNotifier when a notifier should resolve its own dependencies. Pass ref.resolver through its constructor and request a typed declaration:

class ProfileController extends FactoryChangeNotifier {
  ProfileController(super.resolver);

  late final UserRepository repository = resolve(userRepository);

  Future<void> load() async {
    await repository.loadProfileName();
  }
}

final profileController = Factory<ProfileController>(
  (ref) => ProfileController(ref.resolver),
  dispose: (controller) => controller.dispose(),
);

Here userRepository is an installed Factory<UserRepository> declaration. resolve works in constructor bodies, late initializers and later methods. Each call respects scoped/unique; storing a result in a field explicitly retains that instance. Reads do not subscribe to replacements or state changes.

Resolution uses the notifier's owning scope, including its overrides and parent scopes. Dependencies keep their existing cleanup owner, including those first resolved from a later method. Calls after notifier disposal or scope closure, and reads that introduce dependency cycles, throw StateError. Subclasses that override dispose must call super.dispose(); asynchronous work and its cancellation remain the application's responsibility.

This API is optional and couples the notifier to Factory. Constructor injection of concrete dependencies remains available for classes that must support removing Factory by changing composition alone. No field annotations or code generation are needed.

Cleanup and tests without widgets

final container = FactoryContainer(
  modules: [appModule],
  overrides: [client.overrideWithValue(fakeClient)],
);
try {
  final value = container.read(repository);
  // Exercise the real wiring.
} finally {
  await container.close();
}

Closing rejects new resolutions immediately, closes children first and releases dependents before dependencies. Every cleanup is attempted. Failures are reported as FactoryCleanupException; repeated close() calls return the same future. A failed eager initialization throws FactoryInitializationException with the original error and an awaitable cleanup future.

Owned unique values with cleanup and replaced scoped generations remain alive until their scope closes, because previously returned references may still be in use. Direct unique resolutions without cleanup, observation or a retained resolver are not retained. Use short scopes for short-lived resources. FactoryScope.onClose exposes the close future; onError handles cleanup failures (the default reports through FlutterError). Unmounting starts cleanup without waiting. For an explicitly awaited close, use FactoryScope.of(context).close() before leaving a flow. Dispose callbacks should not resolve more dependencies or await the scope's own closing future.

Optional annotations

Flutter applications add only factory_provider at runtime and add factory_generator plus build_runner as development dependencies. Standalone Dart applications use factory_core instead.

// lib/composition/factories.dart
@Register(module: 'app')
final client = Factory<ApiClient>((_) => ApiClient());

@Register(module: 'app', expose: true)
final repository = Factory<UserRepository>((ref) => UserRepository(ref.read(client)));

// lib/composition/registry.dart
@FactoryRegistry(
  include: ['lib/composition/**.dart'],
  runtime: FactoryRuntime.flutter,
)
void configureFactories() {}

Run dart run build_runner watch. Import composition/registry.factory.dart and install its appModule. Generated modules include internal/eager declarations as well as the exposed subset. The manual equivalent remains available.

Integration, propagation, and closure

flowchart LR
  D["Factory declaration"] --> M["FactoryModule"]
  M --> S["Factory scope"] --> C["FactoryContainer"]
  C --> I["Internal dependency"]
  C -->|"expose only"| P["Provider bridge"] --> W["Widget"]

A declaration enters a module and is installed in a Factory scope. The container can resolve every installed declaration for composition, but only the module's expose subset crosses the Provider bridge to widgets. Module and scope are different concepts. Start with the executable quick start and the public API guide. Review this diagram whenever module exposure or the Flutter bridge changes.

flowchart LR
  E["Override or observed event"] --> G["Propagation wave"]
  G --> R["read: unchanged"]
  G --> W["watch: replacement"]
  G --> Q["select: changed selection"]
  W --> C{"Policy"}
  Q --> C
  C -->|recreate| N["New instance"]
  C -->|update| U["Update current instance"]
  N --> P["Provider notification"]
  U --> P

read keeps its existing connection. watch reacts to instance replacement; select additionally reacts when its selected state changes. The declaration's explicit policy either recreates or updates, after which an exposed declaration notifies Provider. There is no rollback or asynchronous construction. Review this diagram when observation or propagation semantics change.

flowchart TD
  X["Close requested"] --> H["Close child scopes"]
  H --> O["Cancel observers"]
  O --> D["Release dependents"]
  D --> R["Release dependencies"]
  R --> F["Complete close Future"]
  D -. failure .-> A["Collect all errors"]
  R -. failure .-> A
  A --> F

Closing rejects new resolution immediately, closes children before their parent, cancels observers, and releases dependents before dependencies. Every cleanup is attempted; aggregated failures complete the close future with FactoryCleanupException and reach FactoryScope.onError. Factory does not release delivered references early. Review this diagram when ownership or closure changes. See the memory playbook.

Repository development

This repository contains the Flutter adapter at the root, a standalone Dart core in packages/factory_core, and the optional packages/factory_generator. Local dependency_overrides connect the packages before publication.

flutter pub get
(cd packages/factory_core && dart pub get && dart test && dart analyze)
(cd packages/factory_generator && dart pub get && dart test && dart analyze)
flutter test
flutter analyze lib test
(cd example && dart run build_runner build && flutter test && flutter analyze)
./tool/verify_consumers.sh

See the runnable example for both Factory and Provider-only entrypoints using the same business classes and widgets, and compatibility and validation for tested SDKs and platforms.

Contributions are welcome. Read the contribution guide before opening a pull request. Please report vulnerabilities according to the security policy, not through a public issue.

Factory is available under the MIT License.

Libraries

factory_provider
Flutter and Provider integration for scoped Factory declarations.