Skip to content

Commit

Permalink
feat: add circular dependency detection
Browse files Browse the repository at this point in the history
  • Loading branch information
Evyweb committed Nov 27, 2024
1 parent 4b58f22 commit 2dfbdf0
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 26 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,30 @@ container.runInScope(() => {
```

Note: If you try to resolve a scoped dependency outside a scope, an error will be thrown.

### Circular dependencies

IOctopus can detect circular dependencies.
An error will be thrown if a circular dependency is detected.

```typescript
const container = createContainer();

const A_TOKEN = Symbol('A');
const B_TOKEN = Symbol('B');

class A {
constructor(public b: B) {}
}

class B {
constructor(public a: A) {}
}

container.bind(A_TOKEN).toClass(A, [B_TOKEN]);
container.bind(B_TOKEN).toClass(B, [A_TOKEN]);

container.get(A_TOKEN); // Will throw: "Circular dependency detected: Symbol(A) -> Symbol(B) -> Symbol(A)"
```

This way you can avoid infinite loops and stack overflow errors.
26 changes: 26 additions & 0 deletions specs/container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,30 @@ describe('Container', () => {
.toThrowError(`No binding found for key: ${DI.NOT_REGISTERED_VALUE.toString()}`);
});
});

describe('When circular dependency is detected', () => {
it('should throw an error when a circular dependency is detected', () => {
// Arrange
const container = createContainer();

const A_TOKEN = Symbol('A');
const B_TOKEN = Symbol('B');

class A {
constructor(public b: B) {}
}

class B {
constructor(public a: A) {}
}

container.bind(A_TOKEN).toClass(A, [B_TOKEN]);
container.bind(B_TOKEN).toClass(B, [A_TOKEN]);

// Act & Assert
expect(() => {
container.get(A_TOKEN);
}).toThrowError(/Circular dependency detected: Symbol\(A\) -> Symbol\(B\) -> Symbol\(A\)/);
});
});
});
65 changes: 39 additions & 26 deletions src/container.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Binding, Container, Module } from "./types";
import { createModule } from "./module";
import { Binding, Container, Module } from './types';
import { createModule } from './module';

export function createContainer(): Container {
const modules = new Map<symbol, Module>();
const singletonInstances = new Map<symbol, unknown>();
const scopedInstances = new Map<symbol, Map<symbol, unknown>>();
const resolutionStack: symbol[] = [];
let currentScopeId: symbol | undefined;

const DEFAULT_MODULE_KEY = Symbol("DEFAULT");
const DEFAULT_MODULE_KEY = Symbol('DEFAULT');
const defaultModule = createModule();
modules.set(DEFAULT_MODULE_KEY, defaultModule);

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

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

const { factory, scope } = binding;
resolutionStack.push(key);

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

if (scope === "transient") {
return factory(resolveDependency) as T;
}
const { factory, scope } = binding;

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

if (!scopedInstances.has(currentScopeId)) {
scopedInstances.set(currentScopeId, new Map<symbol, unknown>());
if (scope === 'transient') {
return factory(resolveDependency) as T;
}
const scopeMap = scopedInstances.get(currentScopeId)!;
if (!scopeMap.has(key)) {
scopeMap.set(key, factory(resolveDependency));

if (scope === 'scoped') {
if (!currentScopeId)
throw new Error(`Cannot resolve scoped binding outside of a scope: ${key.toString()}`);

if (!scopedInstances.has(currentScopeId)) {
scopedInstances.set(currentScopeId, new Map<symbol, unknown>());
}
const scopeMap = scopedInstances.get(currentScopeId)!;
if (!scopeMap.has(key)) {
scopeMap.set(key, factory(resolveDependency));
}

return scopeMap.get(key) as T;
}

return scopeMap.get(key) as T;
throw new Error(`Unknown scope: ${scope}`);
} finally {
resolutionStack.pop();
}

throw new Error(`Unknown scope: ${scope}`);
};

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

const runInScope = <T>(callback: () => T): T => {
const previousScopeId = currentScopeId;
currentScopeId = Symbol("scope");
currentScopeId = Symbol('scope');
try {
return callback();
} finally {
Expand Down

0 comments on commit 2dfbdf0

Please sign in to comment.