Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Circular dependency detection with `PodCycleError` during resolution
- Optional `debugName` for providers to improve diagnostics

## [0.1.1] - 2026-02-04

### Added
Expand Down
1 change: 1 addition & 0 deletions lib/dartypod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
library;

export 'src/disposable.dart';
export 'src/errors.dart';
export 'src/pod.dart';
export 'src/pod_resolver.dart';
export 'src/provider.dart';
Expand Down
18 changes: 18 additions & 0 deletions lib/src/errors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'provider.dart';

/// Error thrown when a circular dependency is detected during resolution.
class PodCycleError implements Exception {
/// The cycle chain, including the provider that closed the loop.
final List<Provider<Object?>> cycle;

PodCycleError(List<Provider<Object?>> cycle)
: cycle = List<Provider<Object?>>.unmodifiable(cycle);

/// Human-readable provider names in the cycle chain.
List<String> get chain =>
cycle.map((provider) => provider.debugLabel).toList(growable: false);

@override
String toString() =>
'PodCycleError: Circular dependency detected: ${chain.join(' -> ')}';
}
38 changes: 34 additions & 4 deletions lib/src/pod.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'errors.dart';
import 'pod_resolver.dart';
import 'provider.dart';
import 'scope.dart';
Expand All @@ -6,32 +7,36 @@ import 'scope.dart';
class Pod implements PodResolver {
final Map<Provider<Object?>, Object?> _cache = {};
final Map<Provider<Object?>, Object? Function(Pod)> _overrides = {};
final List<Provider<Object?>> _resolvingStack = [];
final Set<Provider<Object?>> _resolvingSet = {};

/// Resolves an instance from the given provider.
///
/// For [SingletonScope] providers, the instance is cached and reused.
/// For [TransientScope] providers, a new instance is created each time.
/// For [CustomScope] providers, instances are cached per scope.
///
/// Throws [PodCycleError] if a circular dependency is detected.
@override
T resolve<T>(Provider<T> provider) {
// Check for override first
if (_overrides.containsKey(provider)) {
final builder = _overrides[provider]!;
// Overrides follow the same scoping rules
if (provider.scope is TransientScope) {
return builder(this) as T;
return _buildWithCycleCheck(provider, () => builder(this) as T);
}
if (_cache.containsKey(provider)) {
return _cache[provider] as T;
}
final instance = builder(this) as T;
final instance = _buildWithCycleCheck(provider, () => builder(this) as T);
_cache[provider] = instance;
return instance;
}

// Transient scope always creates new instance
if (provider.scope is TransientScope) {
return provider.build(this);
return _buildWithCycleCheck(provider, () => provider.build(this));
}

// Check cache for singleton and custom scopes
Expand All @@ -40,11 +45,36 @@ class Pod implements PodResolver {
}

// Create and cache new instance
final instance = provider.build(this);
final instance = _buildWithCycleCheck(provider, () => provider.build(this));
_cache[provider] = instance;
return instance;
}

T _buildWithCycleCheck<T>(Provider<T> provider, T Function() build) {
final resolvingProvider = provider as Provider<Object?>;
if (_resolvingSet.contains(resolvingProvider)) {
throw _createCycleError(resolvingProvider);
}

_resolvingSet.add(resolvingProvider);
_resolvingStack.add(resolvingProvider);

try {
return build();
} finally {
_resolvingStack.removeLast();
_resolvingSet.remove(resolvingProvider);
}
}

PodCycleError _createCycleError(Provider<Object?> provider) {
final startIndex = _resolvingStack.indexOf(provider);
if (startIndex == -1) {
return PodCycleError([..._resolvingStack, provider]);
}
return PodCycleError([..._resolvingStack.sublist(startIndex), provider]);
}

/// Overrides a provider with a custom builder for testing.
void overrideProvider<T>(Provider<T> provider, T Function(Pod) builder) {
// Clear any cached instance when overriding
Expand Down
15 changes: 15 additions & 0 deletions lib/src/provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'scope.dart';

/// A provider that knows how to create instances of type [T].
class Provider<T> {
/// Optional debug-friendly name for diagnostics.
final String? debugName;
final T Function(PodResolver pod) _builder;
final void Function(T instance)? _dispose;
final ProviderScope scope;
Expand All @@ -12,16 +14,29 @@ class Provider<T> {
///
/// [builder] - Function that creates instances of [T], receiving the Pod for
/// resolving dependencies.
/// [debugName] - Optional name used in diagnostics (e.g. cycle errors).
/// [scope] - Controls instance lifecycle. Defaults to [SingletonScope].
/// [dispose] - Optional custom dispose function. If not provided and the
/// instance implements [Disposable], its dispose method will be called.
Provider(
T Function(PodResolver pod) builder, {
this.debugName,
this.scope = const SingletonScope(),
void Function(T)? dispose,
}) : _builder = builder,
_dispose = dispose;

/// Debug-friendly label for this provider.
///
/// Returns [debugName] if set, otherwise falls back to `Provider<T>`.
String get debugLabel {
final name = debugName;
if (name != null && name.isNotEmpty) {
return name;
}
return 'Provider<$T>';
}

/// Builds a new instance using the provided pod for dependency resolution.
T build(PodResolver pod) => _builder(pod);

Expand Down
65 changes: 65 additions & 0 deletions test/pod_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,71 @@ void main() {
expect(identical(dependent1.dependency, dependent2.dependency), isTrue);
expect(SimpleService.instanceCount, equals(1));
});

test('throws PodCycleError on circular dependencies', () {
final pod = Pod();
late Provider<String> providerA;
late Provider<String> providerB;
late Provider<String> providerC;

providerA = Provider<String>(
(pod) => pod.resolve(providerB),
debugName: 'A',
);
providerB = Provider<String>(
(pod) => pod.resolve(providerC),
// No debugName - tests the Provider<T> fallback format
);
providerC = Provider<String>(
(pod) => pod.resolve(providerA),
debugName: 'C',
);

expect(
() => pod.resolve(providerA),
throwsA(
isA<PodCycleError>().having(
(error) => error.toString(),
'message',
contains('A -> Provider<String> -> C -> A'),
),
),
);
});

test('does not treat concurrent async resolves as a cycle', () async {
final pod = Pod();
final providerD = Provider<String>(
(pod) => 'D',
debugName: 'D',
);
final providerC = Provider<String>(
(pod) => pod.resolve(providerD),
debugName: 'C',
);
final providerB = Provider<String>(
(pod) => pod.resolve(providerC),
debugName: 'B',
);
final providerA = Provider<Future<String>>(
(pod) async {
final value = pod.resolve(providerB);
await Future<void>.delayed(Duration.zero);
return 'A$value';
},
debugName: 'A',
);
final providerE = Provider<Future<String>>(
(pod) => pod.resolve(providerA),
debugName: 'E',
);

final futureA = pod.resolve(providerA);
final futureE = pod.resolve(providerE);

expect(identical(futureA, futureE), isTrue);
expect(await futureE, equals('AD'));
});
});

group('Pod.overrideProvider', () {
Expand Down