Skip to main content
Skip to main content

Core Concepts

HexDI is built around a single insight: if your dependency graph is a first-class TypeScript object, the compiler can validate it. Architecture becomes a constraint, not a convention.

The core model:

Port → Adapter → Graph → Container

Ports

A Port is a contract. It defines what a service is, not how it works. It's also a typed token — a unique runtime identifier that carries the service's interface type at the type level.

import { port } from '@hex-di/core';

interface Logger {
log(message: string): void;
warn(message: string): void;
error(message: string): void;
}

// The port is the contract.
// The name is inferred as a literal type — "Logger" — enabling structural validation.
const LoggerPort = port<Logger>()({ name: 'Logger' });

Ports are nominal. Two ports with the same interface are still distinct:

interface Logger {
log(message: string): void;
}

const ConsoleLoggerPort = port<Logger>()({ name: 'ConsoleLogger' });
const FileLoggerPort = port<Logger>()({ name: 'FileLogger' });

// These are type-incompatible even though Logger interface is identical.
// The compiler distinguishes them by name, not structure.

Ports belong to your domain — they describe what your application needs, without specifying which technology provides it.


Adapters

An Adapter is an implementation of a port. Crucially, it also declares what it depends on — making the entire dependency graph explicit and machine-readable.

import { createAdapter } from '@hex-di/core';

const ConsoleLoggerAdapter = createAdapter({
provides: LoggerPort, // Which contract this implements
requires: [], // Declared dependencies (none here)
lifetime: 'singleton', // Instance lifecycle
factory: () => ({ // How to create the service
log: (msg) => console.log(`[INFO] ${msg}`),
warn: (msg) => console.warn(`[WARN] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`)
})
});

When an adapter has dependencies, they are declared explicitly in requires. TypeScript infers the deps type automatically — no manual annotations needed:

const UserServiceAdapter = createAdapter({
provides: UserServicePort,
requires: [LoggerPort, DatabasePort], // Declare what you need
lifetime: 'scoped',
factory: (deps) => {
// deps is automatically typed as:
// { Logger: Logger; Database: Database }
return {
getUser: async (id) => {
deps.Logger.log(`Fetching user ${id}`);
return deps.Database.query('SELECT * FROM users WHERE id = ?', [id]);
}
};
}
});

This declaration is not documentation — it's the graph. Every requires entry becomes an edge in the dependency graph that the compiler validates.

Adapter configuration

PropertyRequiredDescription
providesYesThe port this adapter implements
requiresYesDeclared dependency ports (use [] for none)
lifetimeYes'singleton', 'scoped', or 'transient'
factoryYesFactory function; receives resolved dependencies
finalizerNoCleanup function called on container/scope disposal

Graphs

A Graph is a structurally validated collection of adapters. It is not built at runtime from a config file — it is constructed at compile time via a type-checked builder.

import { GraphBuilder } from '@hex-di/graph';

const graph = GraphBuilder.create()
.provide(LoggerAdapter)
.provide(DatabaseAdapter)
.provide(UserServiceAdapter)
.build();

Structural validation

The graph enforces two invariants at compile time:

Every declared dependency must be provided:

// This compiles — all dependencies are provided
const validGraph = GraphBuilder.create()
.provide(LoggerAdapter) // provides Logger
.provide(DatabaseAdapter) // provides Database
.provide(UserServiceAdapter) // requires Logger, Database ✓
.build();

// This fails to compile — UserServiceAdapter requires Logger and Database
const invalidGraph = GraphBuilder.create()
.provide(UserServiceAdapter) // requires Logger, Database
.build(); // Error: "ERROR[HEX008]: Missing adapters for Logger | Database. Call .provide() first."

Each port can only be provided once:

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

Immutable builder

Each provide() returns a new builder. The original is unchanged. This enables safe branching:

const base = GraphBuilder.create()
.provide(LoggerAdapter)
.provide(ConfigAdapter);

// Different implementations for different environments
const devGraph = base.provide(InMemoryDatabaseAdapter).build();
const prodGraph = base.provide(PostgresDatabaseAdapter).build();
// base is unchanged by either branch

The graph is the architecture as a live, queryable object — not a diagram that drifts away from the code.


Containers

A Container is the runtime resolver that creates service instances from a validated graph.

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

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

Resolving a service returns a fully-typed instance:

const logger = container.resolve(LoggerPort);
// type: Logger — TypeScript knows this is valid

logger.log('Hello!');

// Resolving a port not in the graph is a compile error:
container.resolve(UnknownPort); // TypeScript Error

Containers are disposed when the application shuts down. Finalizers run in reverse dependency order:

await container.tryDispose();

Scopes

A Scope is a child container for managing the lifecycle of scoped services. Scoped services are created once per scope and disposed when the scope is disposed.

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

// Per-request scope
const scope = container.createScope();

const session = scope.resolve(UserSessionPort); // one instance for this scope
// ... handle request ...
await scope.tryDispose(); // session is disposed, finalizers run

Lifetime behavior

LifetimeRoot ContainerScope
singletonCreated once, cachedSame instance from container
scopedError (requires scope)Created once per scope
transientFresh each resolutionFresh each resolution
// Singletons are shared across all scopes
const logger1 = container.resolve(LoggerPort);
const scope = container.createScope();
const logger2 = scope.resolve(LoggerPort);
logger1 === logger2; // true

// Scoped instances are isolated per scope
const scope1 = container.createScope();
const scope2 = container.createScope();
const session1 = scope1.resolve(UserSessionPort);
const session2 = scope2.resolve(UserSessionPort);
session1 === session2; // false

Putting It Together

import { port, createAdapter } from '@hex-di/core';
import { GraphBuilder } from '@hex-di/graph';
import { createContainer } from '@hex-di/runtime';
import { fromPromise } from '@hex-di/result';

// Contracts
interface Logger { log(msg: string): void; }
interface UserService { getUser(id: string): Promise<{ id: string; name: string }>; }

const LoggerPort = port<Logger>()({ name: 'Logger' });
const UserServicePort = port<UserService>()({ name: 'UserService' });

// Implementations with explicit dependency declarations
const LoggerAdapter = createAdapter({
provides: LoggerPort,
requires: [],
lifetime: 'singleton',
factory: () => ({ log: (msg) => console.log(`[App] ${msg}`) })
});

const UserServiceAdapter = createAdapter({
provides: UserServicePort,
requires: [LoggerPort], // explicit declaration — this edge is in the graph
lifetime: 'scoped',
factory: (deps) => ({
getUser: async (id) => {
deps.Logger.log(`Getting user ${id}`);
return { id, name: 'Alice' };
}
})
});

// Structurally validated graph — fails to compile if any dependency is missing
const graph = GraphBuilder.create()
.provide(LoggerAdapter)
.provide(UserServiceAdapter)
.build();

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

async function handleRequest() {
const scope = container.createScope();
const result = await scope.tryResolve(UserServicePort)
.asyncAndThen((userService) => fromPromise(userService.getUser('user-1'), (e) => e));
await scope.tryDispose();
result.match(
(user) => console.log('User:', user),
(error) => console.error('Failed:', error),
);
}

handleRequest();

Summary

ConceptWhat it isCreated with
PortA contract — what a service doesport<T>()({ name })
AdapterAn implementation with declared depscreateAdapter({ provides, requires, … })
GraphA compile-time-validated wiringGraphBuilder.create().provide(…).build()
ContainerA runtime resolvercreateContainer({ graph, name })
ScopeA lifetime boundary for scoped servicescontainer.createScope()