Skip to main content
Skip to main content

Error Handling

This guide covers HexDI's error hierarchy and best practices for handling errors.

Error Philosophy

HexDI distinguishes between two types of errors:

  1. Programming Errors — Bugs in your code that should be fixed (e.g., circular dependencies)
  2. Runtime Errors — External failures that may need recovery (e.g., factory threw an exception)

All errors include an isProgrammingError flag to help you decide how to handle them.

Result-first: Prefer container.tryResolve() over container.resolve(). The try* variants return Result<T, ContainerError> and never throw, eliminating the need for try/catch.

Error Hierarchy

ContainerError (base class)
├── CircularDependencyError (programming error)
├── FactoryError (runtime error)
├── DisposedScopeError (programming error)
└── ScopeRequiredError (programming error)

All errors extend ContainerError which provides:

  • code - Stable string constant for programmatic handling
  • message - Human-readable description
  • isProgrammingError - Whether this indicates a code bug

Error Types

ContainerError

Base class for all container errors.

import { ContainerError } from "@hex-di/runtime";

const result = container.tryResolve(SomePort);
if (result.isErr()) {
const { error } = result;
console.log("Code:", error.code);
console.log("Message:", error.message);
console.log("Is programming error:", error.isProgrammingError);
}

CircularDependencyError

Thrown when services depend on each other in a cycle.

import { CircularDependencyError } from "@hex-di/runtime";

// Example: A depends on B, B depends on A
const result = container.tryResolve(ServiceAPort);
if (result.isErr()) {
const { error } = result;
if (error instanceof CircularDependencyError) {
console.log("Code:", error.code); // 'CIRCULAR_DEPENDENCY'
console.log("Chain:", error.dependencyChain);
// ['ServiceA', 'ServiceB', 'ServiceA']
console.log("Is programming error:", error.isProgrammingError); // true
}
}

Properties:

  • code: 'CIRCULAR_DEPENDENCY'
  • dependencyChain: string[] - The ports forming the cycle
  • isProgrammingError: true

How to Fix:

  1. Review the dependency chain to understand the cycle
  2. Break the cycle by:
    • Extracting shared logic into a new service
    • Using lazy resolution (factory returning a function)
    • Restructuring your dependencies

FactoryError

Thrown when an adapter's factory function throws an exception.

import { FactoryError } from "@hex-di/runtime";

const result = container.tryResolve(DatabasePort);
if (result.isErr()) {
const { error } = result;
if (error instanceof FactoryError) {
console.log("Code:", error.code); // 'FACTORY_FAILED'
console.log("Port:", error.portName); // 'Database'
console.log("Original error:", error.cause);
console.log("Is programming error:", error.isProgrammingError); // false
}
}

Properties:

  • code: 'FACTORY_FAILED'
  • portName: string - Which port's factory failed
  • cause: Error - The original error thrown by the factory
  • isProgrammingError: false

How to Handle:

  • This is typically an external failure (database unavailable, etc.)
  • May warrant retry logic or fallback behavior
  • Log the original error for debugging

DisposedScopeError

Thrown when trying to resolve from a disposed scope.

import { DisposedScopeError } from "@hex-di/runtime";

const scope = container.createScope();
await scope.tryDispose();

const result = scope.tryResolve(UserSessionPort); // Scope is already disposed!
if (result.isErr()) {
const { error } = result;
if (error instanceof DisposedScopeError) {
console.log("Code:", error.code); // 'DISPOSED_SCOPE'
console.log("Is programming error:", error.isProgrammingError); // true
}
}

Properties:

  • code: 'DISPOSED_SCOPE'
  • isProgrammingError: true

How to Fix:

  • Don't use scopes after disposing them
  • Check scope lifecycle in async code
  • Use proper cleanup patterns in React (useEffect cleanup)

ScopeRequiredError

Thrown when trying to resolve a scoped service from the root container.

import { ScopeRequiredError } from "@hex-di/runtime";

// UserSession is scoped, but we're resolving from root container
const result = container.tryResolve(UserSessionPort);
if (result.isErr()) {
const { error } = result;
if (error instanceof ScopeRequiredError) {
console.log("Code:", error.code); // 'SCOPE_REQUIRED'
console.log("Port:", error.portName); // 'UserSession'
console.log("Is programming error:", error.isProgrammingError); // true
}
}

Properties:

  • code: 'SCOPE_REQUIRED'
  • portName: string - Which scoped port was requested
  • isProgrammingError: true

How to Fix:

  • Create a scope: const scope = container.createScope()
  • Resolve from the scope: scope.resolve(UserSessionPort)
  • In React, use AutoScopeProvider or ScopeProvider

Error Codes

Error TypeCodeProgramming Error
CircularDependencyErrorCIRCULAR_DEPENDENCYYes
FactoryErrorFACTORY_FAILEDNo
DisposedScopeErrorDISPOSED_SCOPEYes
ScopeRequiredErrorSCOPE_REQUIREDYes

Handling Patterns

Pattern 1: Switch on Error Code

Use resolveResult for exhaustive switching — it returns Result<T, ResolutionError> where ResolutionError is a discriminated union keyed by code:

import { resolveResult, FactoryError } from "@hex-di/runtime";

const result = resolveResult(() => container.resolve(SomePort));
result.match(
(service) => {
// use service
},
(error) => {
switch (error.code) {
case "CIRCULAR_DEPENDENCY":
console.error("Fix your dependency graph!");
throw error; // Re-throw programming errors
case "FACTORY_FAILED":
console.error("Service creation failed:", error.cause);
// Maybe retry or use fallback
break;
case "SCOPE_REQUIRED":
console.error("Need a scope for this service");
throw error;
case "DISPOSED_SCOPE":
console.error("Scope was already disposed");
throw error;
}
},
);

Pattern 2: Handle Programming vs Runtime Errors

function resolveService<P extends AppPorts>(port: P): InferService<P> | null {
return container.tryResolve(port).match(
(service) => service,
(error) => {
if (error.isProgrammingError) {
// Log and re-throw — this is a bug
console.error("Programming error:", error.message);
throw error;
}
// Handle gracefully — this is an external failure
console.warn("Service unavailable:", error.message);
return null; // Or fallback value
},
);
}

Pattern 3: Factory with Retry

import { FactoryError } from "@hex-di/runtime";
import type { Result, ContainerError } from "@hex-di/runtime";

async function resolveWithRetry<P extends AppPorts>(
port: P,
maxRetries = 3,
): Promise<Result<InferService<P>, ContainerError>> {
let last = container.tryResolve(port);
for (let attempt = 1; attempt < maxRetries && last.isErr(); attempt++) {
if (!(last.error instanceof FactoryError)) break; // Only retry factory failures
console.warn(`Attempt ${attempt} failed, retrying...`);
await delay(1000 * attempt); // Exponential backoff
last = container.tryResolve(port);
}
return last;
}

Pattern 4: React Error Boundary

import { ContainerError } from '@hex-di/runtime';

class DIErrorBoundary extends React.Component {
state = { hasError: false, error: null };

static getDerivedStateFromError(error) {
if (error instanceof ContainerError) {
return { hasError: true, error };
}
throw error; // Re-throw non-DI errors
}

render() {
if (this.state.hasError) {
const error = this.state.error;
return (
<div className="error-panel">
<h2>Dependency Injection Error</h2>
<p><strong>Code:</strong> {error.code}</p>
<p><strong>Message:</strong> {error.message}</p>
{error.isProgrammingError && (
<p className="warning">This is a bug that needs to be fixed!</p>
)}
</div>
);
}
return this.props.children;
}
}

// Usage
function App() {
return (
<DIErrorBoundary>
<ContainerProvider container={container}>
<MyApp />
</ContainerProvider>
</DIErrorBoundary>
);
}

Compile-Time Errors

HexDI catches many errors at compile time, not runtime.

Missing Dependencies

const graph = GraphBuilder.create()
.provide(UserServiceAdapter) // requires Logger, Database
.build();
// Type: "ERROR[HEX008]: Missing adapters for Logger | Database. Call .provide() first."

How to Read:

  • The error type tells you exactly which ports are missing
  • Add the missing adapters to your graph

Duplicate Providers

const graph = GraphBuilder.create()
.provide(LoggerAdapter)
.provide(AnotherLoggerAdapter) // Same port!
.build();
// Type: "ERROR[HEX001]: Duplicate adapter for 'Logger'. Fix: Remove one .provide() call."

How to Fix:

  • Remove the duplicate provider
  • Or use different ports for different implementations

Invalid Port Resolution

container.resolve(UnregisteredPort);
// TypeScript Error: Argument of type 'typeof UnregisteredPort' is not assignable

How to Fix:

  • Add the adapter to your graph
  • Or check you're using the correct port

Best Practices

1. Fail Fast for Programming Errors

if (error.isProgrammingError) {
// Don't try to recover - fix the code
throw error;
}

2. Log Original Errors

if (error instanceof FactoryError) {
console.error("Factory failed:", {
port: error.portName,
originalError: error.cause,
});
}

3. Use Error Codes for Monitoring

// Send to monitoring service
metrics.increment(`di.error.${error.code}`);

4. Document Possible Errors

/**
* Resolves the database service.
* @returns Result containing the service or FactoryError if the connection fails.
*/
function getDatabase(): Result<DatabaseService, ContainerError> {
return container.tryResolve(DatabasePort);
}

5. Test Error Scenarios

describe("error handling", () => {
it("handles factory errors gracefully", () => {
const badAdapter = createAdapter({
provides: TestPort,
requires: [],
lifetime: "singleton",
factory: () => {
throw new Error("Connection failed");
},
});

const graph = GraphBuilder.create().provide(badAdapter).build();

const container = createContainer({ graph, name: "App" });

expect(() => container.resolve(TestPort)).toThrow(FactoryError);
});
});

Troubleshooting

"Maximum call stack size exceeded"

Usually indicates a circular dependency that wasn't detected. Check your adapter factories for:

  • Self-references
  • Indirect cycles through multiple services

"Cannot read property of undefined"

Check that:

  • All required ports are in the graph
  • You're resolving from the correct container/scope
  • The factory function returns the correct type

Factory errors during tests

  • Mock external dependencies
  • Check test isolation (don't share containers between tests)
  • Verify mock implementations return expected types

Next Steps