ark_di 0.1.0-dev.1 copy "ark_di: ^0.1.0-dev.1" to clipboard
ark_di: ^0.1.0-dev.1 copied to clipboard

Pure Dart dependency injection with hierarchical scopes and explicit ownership.

Ark DI #

Pure Dart dependency injection with hierarchical scopes, explicit ownership, deterministic asynchronous initialization, and dependency-ordered teardown.

Ark DI builds an object graph without requiring global variables, static service locators, generated code, reflection, or Flutter. A root container can own application-wide dependencies. Child containers provide bounded scopes, inherit missing registrations, and make test or feature overrides explicit.

Русская версия

0.1.0-dev.1 is a prerelease. The core lifecycle model is implemented and tested, but public API details may still change before the first stable version.

Why this package exists #

Dependency injection is often reduced to a global map of factories. That makes access convenient but leaves important runtime questions unanswered:

  • Which scope owns an instance?
  • Does a factory return a transient object or cache it?
  • What happens when several callers initialize one async dependency?
  • Can a child silently replace a parent registration?
  • Which object closes first when one dependency uses another?
  • What happens to active work when a scope starts closing?

Ark DI encodes those decisions in the registration API and container lifecycle. The package remains infrastructure: it constructs, resolves, and closes dependencies, but does not decide application architecture or hide dependencies behind a global access point.

Core model #

Composition root
  └── DiContainer.build
        ├── instance
        ├── transient factory
        ├── async transient factory
        ├── lazy singleton
        ├── async lazy singleton
        └── child scope
              ├── local registrations
              ├── explicit parent overrides
              └── fallback to parent registrations

The central roles are deliberately separate:

  • DiBinder configures a container once;
  • DiResolver reads dependencies while an object graph is being resolved;
  • DiContainer owns registrations, child scopes, active resolutions, and container-managed lifecycle;
  • DiKey<T> distinguishes named registrations of the same type;
  • DiDisposer<T> defines explicit synchronous or asynchronous cleanup.

Installation #

dependencies:
  ark_di: ^0.1.0-dev.1

Import the public library:

import 'package:ark_di/ark_di.dart';

Quick start #

Define dependencies through ordinary Dart constructors and interfaces:

abstract interface class UserRepository {
  Future<User> loadCurrentUser();
}

final class HttpUserRepository implements UserRepository {
  HttpUserRepository(this.client);

  final ApiClient client;

  @override
  Future<User> loadCurrentUser() => client.getCurrentUser();
}

Build the graph in one composition root:

final DiContainer application = DiContainer.build((binder) {
  binder.bindInstance<AppConfig>(
    const AppConfig(apiBaseUrl: 'https://api.example.com'),
  );

  binder.bindAsyncLazySingleton<ApiClient>(
    (resolver) async {
      final AppConfig config = resolver.get<AppConfig>();
      return ApiClient.connect(config.apiBaseUrl);
    },
    dispose: (client) => client.close(),
  );

  binder.bindAsyncLazySingleton<UserRepository>(
    (resolver) async {
      final ApiClient client = await resolver.getAsync<ApiClient>();
      return HttpUserRepository(client);
    },
  );
});

final UserRepository repository =
    await application.getAsync<UserRepository>();

await application.close();

Factories receive their dependencies through the supplied DiResolver. Application objects still receive dependencies through constructors; they do not need to know that Ark DI exists.

Registration strategies #

Binder method Creation Cache Container disposal
bindInstance Before registration Supplied object Yes by default
bindFactory Every get or getAsync None No
bindAsyncFactory Every getAsync None No
bindLazySingleton First resolution One per registration owner Yes
bindAsyncLazySingleton First async resolution One per registration owner Yes

Transient factories intentionally do not accept a disposer. The container does not retain their results, so the consumer that retains a transient object also owns its lifecycle.

Instance ownership #

An already created instance belongs to the container by default:

binder.bindInstance<Database>(
  database,
  dispose: (instance) => instance.close(),
);

Container ownership means that the container retains the registration until close and releases it in the correct teardown order. A disposer is optional. When it is null, closing simply releases the container's reference.

Use external ownership only when another scope is responsible for the object:

binder.bindInstance<PlatformChannel>(
  sharedChannel,
  ownership: DiOwnership.external,
);

An externally owned instance cannot declare a container disposer. This avoids two owners attempting to close the same resource.

Factory resolution context #

Factories must use the resolver supplied to them:

binder.bindLazySingleton<CatalogService>(
  (resolver) => CatalogService(
    repository: resolver.get<CatalogRepository>(),
  ),
);

Do not capture a container and turn it into a hidden service locator. Do not retain the factory resolver for later use. It expires after the root resolution and all nested asynchronous work finish. Reusing it then throws DiResolutionContextExpiredException and it no longer retains the container.

Asynchronous dependencies #

Async factories and async lazy singletons always require getAsync<T>():

final Session session = await container.getAsync<Session>();

get<T>() rejects an async registration even after an async singleton has initialized. This keeps call-site behavior deterministic and independent of earlier execution order.

Concurrent requests for an async lazy singleton share one in-flight factory:

final Future<ApiClient> first = container.getAsync<ApiClient>();
final Future<ApiClient> second = container.getAsync<ApiClient>();

Both futures resolve to the same cached instance. If initialization fails, the failure is delivered to every waiting caller, the failed value is not cached, and a later request may retry.

Named registrations #

Use DiKey<T> when several configurations have the same contract and type:

abstract final class ClientKeys {
  static const DiKey<ApiClient> publicApi = DiKey<ApiClient>('public-api');
  static const DiKey<ApiClient> telemetry = DiKey<ApiClient>('telemetry');
}

binder.bindInstance<ApiClient>(publicClient, key: ClientKeys.publicApi);
binder.bindInstance<ApiClient>(telemetryClient, key: ClientKeys.telemetry);

final ApiClient client = container.get<ApiClient>(key: ClientKeys.publicApi);

Prefer different interface types when dependencies have materially different responsibilities. Keys are best for multiple configurations of one genuine contract, not for hiding unrelated behavior behind strings.

Child scopes and overrides #

A child resolves locally first and falls back through its parent hierarchy:

final DiContainer requestScope = application.createChild((binder) {
  binder.bindInstance<RequestContext>(requestContext);
});

Shadowing is never implicit. A child must declare the intent:

final DiContainer testScope = application.createChild((binder) {
  binder.bindInstance<UserRepository>(
    FakeUserRepository(),
    overrideParent: true,
  );
});

Setting overrideParent: true without a matching ancestor is also rejected. These two checks catch misspelled keys, missing production registrations, and accidental shadowing while the graph is configured.

A parent-owned factory resolves its own dependencies from the parent scope. It does not capture a child override merely because the request started in that child. This prevents a long-lived parent singleton from retaining scoped child state.

Test substitutions #

Child scopes provide bounded test substitutions without mutating the production container:

final DiContainer testScope = production.createChild((binder) {
  binder.bindInstance<Clock>(FakeClock(), overrideParent: true);
  binder.bindInstance<UserRepository>(
    InMemoryUserRepository(),
    overrideParent: true,
  );
  binder.bindLazySingleton<UseCase>(
    (resolver) => UseCase(
      clock: resolver.get<Clock>(),
      repository: resolver.get<UserRepository>(),
    ),
    overrideParent: true,
  );
});

final UseCase useCase = testScope.get<UseCase>();

await testScope.close();
await production.close();

Register the object under test locally when it must consume local overrides. A parent singleton intentionally remains connected to its parent dependencies.

Lifecycle and teardown #

close() has three guarantees:

  1. new public resolutions and child creation are rejected immediately;
  2. child scopes and active resolutions are allowed to finish;
  3. container-owned instances are disposed in dependency order.

If CatalogService resolved CatalogRepository, the service is disposed before the repository. Independent instances are disposed in reverse creation order. Synchronous and asynchronous disposers are both awaited.

Every disposer is attempted. When several fail, close() finishes teardown and throws one DiCloseException containing ordered DiCloseFailure records. The container is closed even when cleanup reports failures.

Repeated close() calls return the same future. Closing a root also closes all attached children. Closing a child independently detaches it without closing its parent.

Circular dependencies #

Ark DI detects:

  • direct and indirect synchronous cycles;
  • direct and indirect asynchronous cycles;
  • cycles formed by concurrent async singleton initialization.

DiCircularDependencyException.path contains the repeated endpoint, for example CatalogService -> CatalogRepository -> CatalogService.

A cycle normally signals that responsibilities should be separated or that a runtime interaction should replace construction-time coupling.

Architectural boundaries #

Ark DI is not:

  • a global or static service locator;
  • a reflection or code-generation framework;
  • a Flutter state-management package;
  • an application lifecycle singleton;
  • an automatic close() or dispose() detector;
  • a cancellation or timeout policy for arbitrary user futures.

The core package stays pure Dart. Flutter context integration belongs in the separate ark_di_flutter companion package.

Public API #

API Responsibility
DiContainer Root and child scopes, resolution, ownership, close
DiBinder One-shot registration configuration
DiResolver Contextual sync and async resolution
DiKey<T> Type-safe qualifier for named registrations
DiOwnership Container or external ownership for supplied instances
DiRegistrationKind Registration strategy in diagnostics
DiContainerState Active, closing, or closed lifecycle state
DiException Base for typed configuration and runtime failures

Documentation and project #

License #

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.

0
likes
160
points
--
downloads

Documentation

Documentation
API reference

Publisher

verified publisherarktelos.dev

Weekly Downloads

Pure Dart dependency injection with hierarchical scopes and explicit ownership.

Homepage
Repository (GitLab)
View/report issues

Topics

#architecture #dependency-injection #inversion-of-control

License

Apache-2.0 (license)

More

Packages that depend on ark_di