Tutorials

Build a pure-Dart object graph

Configure async clients, repositories, services, child overrides, and dependency-ordered teardown.

v1.1.0Intermediateark_di

Build a pure-Dart object graph

This tutorial builds an application graph with an asynchronously initialized client, a Repository, and a service. It then creates a child preview Scope with an in-memory Repository and proves that teardown follows dependency order.

Before you begin

shell
dart pub add ark_di:^1.1.0

The example assumes ordinary constructor-injected classes:

dart
final class AppConfig {
  const AppConfig(this.apiBaseUrl);
  final String apiBaseUrl;
}

abstract interface class CatalogRepository {
  Future<List<String>> search(String query);
  void close();
}

final class CatalogService {
  CatalogService(this._repository);
  final CatalogRepository _repository;
  Future<List<String>> search(String query) => _repository.search(query);
  void close() {}
}

None of these classes imports Ark DI. Only the Composition Root knows how concrete objects are constructed.

1. Build the application container

dart
final lifecycle = <String>[];

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

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

  binder.bindAsyncLazySingleton<CatalogRepository>(
    (resolver) async =>
        RemoteCatalogRepository(await resolver.getAsync<ApiClient>()),
    dispose: (repository) {
      repository.close();
      lifecycle.add('repository');
    },
  );

  binder.bindAsyncLazySingleton<CatalogService>(
    (resolver) async =>
        CatalogService(await resolver.getAsync<CatalogRepository>()),
    dispose: (service) {
      service.close();
      lifecycle.add('service');
    },
  );
});

Registration states how an object is created, retained, and disposed. It does not execute the factories yet; each Async Lazy Singleton starts on first resolution.

2. Resolve the application entry point

dart
final service = await application.getAsync<CatalogService>();
final results = await service.search('dart');

Resolving CatalogService initializes its dependencies in order:

text
AppConfig → ApiClient → CatalogRepository → CatalogService

Concurrent callers share the same in-flight initialization for each Async Lazy Singleton. A failed initialization is not cached permanently; a later request may retry.

3. Add a bounded preview Scope

The preview needs an in-memory Repository but must not mutate the application container:

dart
final preview = application.createChild((binder) {
  binder.bindInstance<CatalogRepository>(
    MemoryCatalogRepository(<String>['Ark DI guide']),
    overrideParent: true,
    dispose: (repository) {
      repository.close();
      lifecycle.add('preview-repository');
    },
  );

  binder.bindLazySingleton<CatalogService>(
    (resolver) => CatalogService(resolver.get<CatalogRepository>()),
    overrideParent: true,
    dispose: (service) {
      service.close();
      lifecycle.add('preview-service');
    },
  );
});

final previewService = preview.get<CatalogService>();
final previewResults = await previewService.search('guide');

The service is overridden together with the Repository. A service already owned by the parent resolves dependencies in the parent Scope and cannot capture a shorter-lived child object.

4. Close child before parent

dart
await preview.close();
await application.close();

print(lifecycle);

Expected order:

text
[preview-service, preview-repository, service, repository, api-client]

Ark DI records dependency edges while factories resolve. On close it waits for active resolutions, then disposes consumers before their dependencies. One disposer failure does not prevent unrelated cleanup; failures are returned together in DiCloseException.

What this example demonstrates

  • The container is configured once and cannot be mutated by runtime code.
  • Application objects still use constructor injection.
  • Async resolution remains explicit through getAsync.
  • A child Scope changes a bounded part of the graph without changing its parent.
  • Ownership and teardown are part of each registration, not inferred from method names.

Open the source linked above for complete ApiClient, remote, and memory implementations.