Skip to content

Commit 2dfbdf0

Browse files
committed
feat: add circular dependency detection
1 parent 4b58f22 commit 2dfbdf0

File tree

3 files changed

+92
-26
lines changed

3 files changed

+92
-26
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,30 @@ container.runInScope(() => {
293293
```
294294

295295
Note: If you try to resolve a scoped dependency outside a scope, an error will be thrown.
296+
297+
### Circular dependencies
298+
299+
IOctopus can detect circular dependencies.
300+
An error will be thrown if a circular dependency is detected.
301+
302+
```typescript
303+
const container = createContainer();
304+
305+
const A_TOKEN = Symbol('A');
306+
const B_TOKEN = Symbol('B');
307+
308+
class A {
309+
constructor(public b: B) {}
310+
}
311+
312+
class B {
313+
constructor(public a: A) {}
314+
}
315+
316+
container.bind(A_TOKEN).toClass(A, [B_TOKEN]);
317+
container.bind(B_TOKEN).toClass(B, [A_TOKEN]);
318+
319+
container.get(A_TOKEN); // Will throw: "Circular dependency detected: Symbol(A) -> Symbol(B) -> Symbol(A)"
320+
```
321+
322+
This way you can avoid infinite loops and stack overflow errors.

specs/container.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,30 @@ describe('Container', () => {
223223
.toThrowError(`No binding found for key: ${DI.NOT_REGISTERED_VALUE.toString()}`);
224224
});
225225
});
226+
227+
describe('When circular dependency is detected', () => {
228+
it('should throw an error when a circular dependency is detected', () => {
229+
// Arrange
230+
const container = createContainer();
231+
232+
const A_TOKEN = Symbol('A');
233+
const B_TOKEN = Symbol('B');
234+
235+
class A {
236+
constructor(public b: B) {}
237+
}
238+
239+
class B {
240+
constructor(public a: A) {}
241+
}
242+
243+
container.bind(A_TOKEN).toClass(A, [B_TOKEN]);
244+
container.bind(B_TOKEN).toClass(B, [A_TOKEN]);
245+
246+
// Act & Assert
247+
expect(() => {
248+
container.get(A_TOKEN);
249+
}).toThrowError(/Circular dependency detected: Symbol\(A\) -> Symbol\(B\) -> Symbol\(A\)/);
250+
});
251+
});
226252
});

src/container.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { Binding, Container, Module } from "./types";
2-
import { createModule } from "./module";
1+
import { Binding, Container, Module } from './types';
2+
import { createModule } from './module';
33

44
export function createContainer(): Container {
55
const modules = new Map<symbol, Module>();
66
const singletonInstances = new Map<symbol, unknown>();
77
const scopedInstances = new Map<symbol, Map<symbol, unknown>>();
8+
const resolutionStack: symbol[] = [];
89
let currentScopeId: symbol | undefined;
910

10-
const DEFAULT_MODULE_KEY = Symbol("DEFAULT");
11+
const DEFAULT_MODULE_KEY = Symbol('DEFAULT');
1112
const defaultModule = createModule();
1213
modules.set(DEFAULT_MODULE_KEY, defaultModule);
1314

@@ -33,37 +34,49 @@ export function createContainer(): Container {
3334
};
3435

3536
const get = <T>(key: symbol): T => {
36-
const binding = findLastBinding(key);
37-
if (!binding) throw new Error(`No binding found for key: ${key.toString()}`);
37+
if (resolutionStack.includes(key)) {
38+
const cycle = [...resolutionStack, key].map((k) => k.toString()).join(' -> ');
39+
throw new Error(`Circular dependency detected: ${cycle}`);
40+
}
3841

39-
const { factory, scope } = binding;
42+
resolutionStack.push(key);
4043

41-
if (scope === "singleton") {
42-
if (!singletonInstances.has(key)) {
43-
singletonInstances.set(key, factory(resolveDependency));
44-
}
45-
return singletonInstances.get(key) as T;
46-
}
44+
try {
45+
const binding = findLastBinding(key);
46+
if (!binding) throw new Error(`No binding found for key: ${key.toString()}`);
4747

48-
if (scope === "transient") {
49-
return factory(resolveDependency) as T;
50-
}
48+
const { factory, scope } = binding;
5149

52-
if (scope === "scoped") {
53-
if (!currentScopeId) throw new Error(`Cannot resolve scoped binding outside of a scope: ${key.toString()}`);
50+
if (scope === 'singleton') {
51+
if (!singletonInstances.has(key)) {
52+
singletonInstances.set(key, factory(resolveDependency));
53+
}
54+
return singletonInstances.get(key) as T;
55+
}
5456

55-
if (!scopedInstances.has(currentScopeId)) {
56-
scopedInstances.set(currentScopeId, new Map<symbol, unknown>());
57+
if (scope === 'transient') {
58+
return factory(resolveDependency) as T;
5759
}
58-
const scopeMap = scopedInstances.get(currentScopeId)!;
59-
if (!scopeMap.has(key)) {
60-
scopeMap.set(key, factory(resolveDependency));
60+
61+
if (scope === 'scoped') {
62+
if (!currentScopeId)
63+
throw new Error(`Cannot resolve scoped binding outside of a scope: ${key.toString()}`);
64+
65+
if (!scopedInstances.has(currentScopeId)) {
66+
scopedInstances.set(currentScopeId, new Map<symbol, unknown>());
67+
}
68+
const scopeMap = scopedInstances.get(currentScopeId)!;
69+
if (!scopeMap.has(key)) {
70+
scopeMap.set(key, factory(resolveDependency));
71+
}
72+
73+
return scopeMap.get(key) as T;
6174
}
6275

63-
return scopeMap.get(key) as T;
76+
throw new Error(`Unknown scope: ${scope}`);
77+
} finally {
78+
resolutionStack.pop();
6479
}
65-
66-
throw new Error(`Unknown scope: ${scope}`);
6780
};
6881

6982
const resolveDependency = (depKey: symbol): unknown => {
@@ -72,7 +85,7 @@ export function createContainer(): Container {
7285

7386
const runInScope = <T>(callback: () => T): T => {
7487
const previousScopeId = currentScopeId;
75-
currentScopeId = Symbol("scope");
88+
currentScopeId = Symbol('scope');
7689
try {
7790
return callback();
7891
} finally {

0 commit comments

Comments
 (0)