Skip to content

Repository files navigation

@circulo-ai/di

@circulo-ai/di is an explicit, typed dependency-injection container for TypeScript applications. It works in Node.js, Bun, Hono, Next.js, background jobs, tests, and Edge-oriented code.

The package gives you:

  • Typed symbol and class tokens.
  • Singleton, global-singleton, scoped, and transient lifetimes.
  • Explicit factories and constructor injection without reflect-metadata.
  • Async factories, keyed registrations, and multi-bindings.
  • Request scopes with deterministic async disposal.
  • Startup dependency diagnostics and resolution tracing.
  • Modules, service locators, and framework adapters.
  • Safe defaults for ownership, overwriting, and missing services.

The container is deliberately explicit. It does not scan files, infer erased TypeScript types, dynamically import arbitrary modules, or hide service ownership behind global mutable state.

Install

bun add @circulo-ai/di
# or
npm install @circulo-ai/di

Use the core entrypoint for container code:

import {
  ServiceCollection,
  createToken,
  injectable,
  optional,
} from "@circulo-ai/di";

Framework integrations have explicit subpath entrypoints:

import { bindToHono } from "@circulo-ai/di/hono";
import { getGlobalProvider, withRequestScope } from "@circulo-ai/di/next";

The root entrypoint still exports the integration functions for backwards compatibility. Hono is an optional peer dependency; install it in applications that use the Hono adapter.

Quick start

import { ServiceCollection, createToken, injectable } from "@circulo-ai/di";

type Config = { databaseUrl: string };
type Database = { query(sql: string): Promise<unknown[]> };
type Logger = { info(message: string): void };

const CONFIG = createToken<Config>("app.config");
const DATABASE = createToken<Database>("app.database");
const LOGGER = createToken<Logger>("app.logger");

const collection = new ServiceCollection();

collection
  .useValue(CONFIG, { databaseUrl: process.env.DATABASE_URL ?? "memory://dev" })
  .useFactory(DATABASE, async (services) => {
    const config = await services.resolveAsync(CONFIG);
    return connectDatabase(config.databaseUrl);
  })
  .useFactory(
    LOGGER,
    () => ({ info: (message: string) => console.log(`[app] ${message}`) }),
    { disposal: "scope" },
  );

@injectable([CONFIG, DATABASE, LOGGER])
class UserService {
  constructor(
    private readonly config: Config,
    private readonly database: Database,
    private readonly logger: Logger,
  ) {}

  async list() {
    this.logger.info(`reading from ${this.config.databaseUrl}`);
    return this.database.query("select * from users");
  }
}

collection.addSingleton(UserService);

const provider = collection.buildServiceProvider({ validateOnBuild: true });

await provider.withScope(async (scope) => {
  const users = await scope.resolve(UserService).list();
  console.log(users);
});

await provider.dispose();

function connectDatabase(url: string): Database {
  return { query: async (sql) => [{ url, sql }] };
}

Behavior:

  1. CONFIG is one immutable value for the provider.
  2. The database factory may be asynchronous and is resolved only when requested.
  3. UserService is a provider singleton.
  4. withScope creates and disposes a request-like scope even when the callback throws.
  5. Calling provider.dispose() is terminal. Further resolution throws DisposedProviderError.

Tokens

Tokens are runtime values, so they survive TypeScript compilation and can be used safely in factories, decorators, tests, and adapters.

const CLOCK = createToken<{ now(): Date }>("clock");

collection.useValue(CLOCK, { now: () => new Date() });
const clock = provider.resolve(CLOCK); // { now(): Date }

Class constructors are also tokens:

class AuditService {}
collection.addSingleton(AuditService);
provider.resolve(AuditService);

String tokens are supported for compatibility, but symbols or classes avoid accidental collisions:

const CACHE = Symbol("cache");
collection.useValue(CACHE, new Map());

Use optional(token) when absence is a valid configuration state:

const METRICS = createToken<{ count(name: string): void }>("metrics");
const metrics = provider.resolve(optional(METRICS)); // value or undefined

An optional dependency returns undefined only when the registration is absent. Factory failures and circular dependencies still throw.

Registration APIs

Explicit helpers

API Meaning Default lifetime
useValue(token, value) Register a value exactly as provided Singleton
useFactory(token, factory) Register a resolver-aware factory Singleton
useClass(token, Klass) Construct a class with explicit dependencies Singleton
useExisting(token, existing) Alias one token to another Singleton

useValue is the safe way to register a function as a service: it stores the function instead of invoking it. The legacy addSingleton(token, fn) overload treats a function as a factory for compatibility.

const HANDLER = createToken<(input: string) => string>("handler");
collection.useValue(HANDLER, (input) => input.toUpperCase());

Factories receive a resolver and can resolve synchronously or asynchronously:

collection.useFactory(CACHE, (services) => {
  const config = services.getRequiredService(CONFIG);
  return createCache(config.databaseUrl);
});

collection.useFactory(CLOCK, async () => ({ now: () => new Date() }));
const clock = await provider.resolveAsync(CLOCK);

addSingleton, addGlobalSingleton, addScoped, and addTransient are useful when the lifetime is the important part of the registration:

collection.addSingleton(CONFIG, { databaseUrl: "memory://dev" });
collection.addGlobalSingleton(CACHE, () => new Map());
collection.addScoped(REQUEST_ID, () => crypto.randomUUID());
collection.addTransient(REQUEST_LOGGER, () => new RequestLogger());

Class tokens can self-register when their constructor has no parameters or has @injectable metadata:

collection.addSingleton(UserService);
collection.addScoped(RequestContext);
collection.addTransient(RequestLogger);

Registrations reject accidental overwrites. Create a collection with { allowOverwrite: true } for controlled test overrides:

const tests = new ServiceCollection({ allowOverwrite: true });
tests.useValue(CONFIG, { databaseUrl: "memory://test" });
tests.useValue(CONFIG, { databaseUrl: "memory://fixture" });

Registration options

collection.useFactory(PLUGIN, createPlugin, {
  key: "payments",
  multiple: true,
  disposePriority: 100,
  source: "payments-module",
  dependencies: [CONFIG],
  disposal: "provider",
});
  • key identifies a keyed registration.
  • multiple: true keeps the registration alongside existing registrations.
  • disposePriority controls teardown order; higher priorities dispose first.
  • source and captureStack improve diagnostics.
  • dependencies declares dependencies for startup graph validation.
  • disposal controls who owns cleanup: none, scope, provider, or global.
  • globalKey gives global-singletons an explicit process-wide identity.

Binding DSL

bind(token) provides a compact adapter-oriented API:

collection.bind(CONFIG).toValue({ databaseUrl: "memory://dev" });
collection.bind(HANDLER).toFunction((input: string) => input.toUpperCase());
collection.bind(REPOSITORY).toFactory((services) => {
  return new UserRepository(services.resolve(DATABASE));
});
collection.bind(USER_SERVICE).toClass(UserService, [DATABASE, LOGGER]);

Available methods:

  • toValue(value) and toFunction(value) register values.
  • toFactory(factory) receives the resolver and returns the service.
  • toHigherOrderFunction(fn, dependencies) resolves dependencies and calls fn.
  • toCurry(fn, dependencies) is an alias for toHigherOrderFunction.
  • toClass(Klass, dependencies) constructs a class.
  • toAnnotatedClass(Klass) uses @injectable/annotate metadata.

Dependencies can be positional or named:

collection
  .bind(REPOSITORY)
  .toHigherOrderFunction(
    (database, logger) => new UserRepository(database, logger),
    [DATABASE, LOGGER],
    { scope: "scoped" },
  );

collection
  .bind(USE_CASE)
  .toClass(
    UserUseCase,
    { repository: REPOSITORY, metrics: optional(METRICS) },
    { scope: "scoped" },
  );

Set { async: true } when dependency resolution or construction is asynchronous:

collection
  .bind(DATABASE)
  .toHigherOrderFunction(
    async (config) => connectDatabase(config.databaseUrl),
    [CONFIG],
    { async: true },
  );

Constructor injection without reflection

TypeScript erases constructor parameter types. @circulo-ai/di therefore requires explicit metadata instead of relying on reflection.

const LOGGER = createToken<Logger>("logger");

@injectable([LOGGER])
class BillingService {
  constructor(private readonly logger: Logger) {}
}

collection.addScoped(BillingService);

The decorator is optional; annotate is useful when decorators are not enabled:

class BillingService {
  constructor(private readonly logger: Logger) {}
}

annotate(BillingService, [LOGGER]);
collection.addScoped(BillingService);

Named dependencies make larger constructors easier to review:

class ReportService {
  constructor(
    private readonly deps: {
      database: Database;
      logger: Logger;
    },
  ) {}
}

collection.bind(ReportService).toAnnotatedClass(ReportService);
annotate(ReportService, { database: DATABASE, logger: LOGGER });

If a class declares constructor parameters without metadata, registration or resolution fails with a message explaining how to add metadata. No constructor is guessed from emitted JavaScript.

Helper functions and environment-aware registration

The helper functions cover common adapter and lazy-resolution patterns:

import {
  factory,
  ifDev,
  ifProd,
  ifTruthy,
  lazy,
  useClass,
  useExisting,
  withScope,
} from "@circulo-ai/di";

// Inject a function that resolves the current token on demand.
collection.bind(GET_DATABASE).toFactory(factory(DATABASE));

// Inject a memoized thunk. The first call resolves the token; later calls reuse it.
collection.bind(GET_CONFIG).toFactory(lazy(CONFIG));

ifProd(collection, (services) => {
  services.addSingleton(CACHE, () => new RedisCache());
});
ifDev(collection, (services) => {
  services.addSingleton(CACHE, () => new MemoryCache());
});
ifTruthy(collection, "ENABLE_SEARCH", (services) => {
  services.addSingleton(SEARCH, () => new SearchClient());
});

// The helper versions register aliases/classes as multi-bindings by default.
useExisting(collection, PUBLIC_REPOSITORY, INTERNAL_REPOSITORY);
useClass(collection, PLUGIN, PaymentsPlugin, {
  lifetime: "transient",
  key: "payments",
});

await withScope(provider, async (scope) => {
  return scope.resolve(UserService).list();
});

ifProd and ifDev inspect NODE_ENV; in an Edge runtime with no process, they use the safe development default. ifTruthy registers only when the named environment variable is present and truthy. The helper useExisting and useClass are convenient multi-binding helpers; use the collection methods when you need every registration option or replacement semantics.

Resolving services

The provider owns application-wide registrations. A scope is a resolver with request-local instances:

const provider = collection.build();

const scope = provider.createScope();
try {
  const service = scope.getRequiredService(UserService);
  const maybeMetrics = scope.getService(METRICS);
} finally {
  await scope.dispose();
}

Prefer withScope for request and job handlers:

const result = await provider.withScope(async (scope) => {
  return scope.resolve(UserService).list();
});

Use resolve for synchronous factories and resolveAsync for async factories:

const config = provider.resolve(CONFIG);
const database = await provider.resolveAsync(DATABASE);

Resolving an async factory synchronously throws AsyncFactoryError. This prevents a pending promise from being mistaken for a service.

Multi-bindings and keyed services

const PLUGIN = createToken<{ name: string; run(): Promise<void> }>("plugin");

collection.addTransient(PLUGIN, () => new PaymentsPlugin(), {
  key: "payments",
  multiple: true,
});
collection.addTransient(PLUGIN, () => new SearchPlugin(), {
  key: "search",
  multiple: true,
});

const plugins = provider.resolveAll(PLUGIN);
const pluginsAsync = await provider.resolveAllAsync(PLUGIN);
const byName = await provider.resolveMapAsync(PLUGIN);

resolveMap and resolveMapAsync require unique keys. Missing registrations return an empty array/map. Duplicate or missing keys throw before an async map is materialized.

Useful aliases:

  • getRequiredService is an explicit alias for resolve.
  • getService returns undefined when a registration is absent.
  • getServices and getServicesAsync are aliases for multi-resolution.
  • tryResolveMissing and tryResolveMissingAsync suppress only MissingServiceError.
  • Legacy tryResolve/tryResolveAsync suppress all errors; use the missing-only variants when factory failures must remain visible.

Lifetimes and ownership

Lifetime Instance identity Default cleanup owner
singleton One per provider Provider
globalSingleton Shared through globalThis across providers Global registry
scoped One per request/job scope Scope
transient New instance per resolution Caller by default

Resolving a scoped service from the root provider throws ScopeResolutionError. This prevents a request-owned object from escaping into application-wide state.

collection.addScoped(REQUEST_CONTEXT, () => ({
  requestId: crypto.randomUUID(),
}));

await provider.withScope(async (scope) => {
  console.log(scope.resolve(REQUEST_CONTEXT).requestId);
});

Disposal

Disposable instances may expose dispose, close, destroy, Symbol.asyncDispose, or Symbol.dispose. The first available protocol is used.

class DatabaseConnection {
  async close() {
    console.log("database closed");
  }
}

collection.addSingleton(DATABASE, () => new DatabaseConnection());
const provider = collection.build();
provider.resolve(DATABASE);
await provider.dispose(); // database closed

Ownership is explicit:

// A transient is caller-owned by default.
collection.addTransient(CLIENT, () => new Client());

// This transient is disposed with its request scope.
collection.addTransient(REQUEST_CLIENT, () => new Client(), {
  disposal: "scope",
});

// This value is closed when the provider is disposed.
collection.useValue(CACHE, new Cache(), { disposal: "provider" });

disposal: "none" disables automatic cleanup. Global singletons are intentionally not disposed by an individual provider because other providers may still use them:

await provider.dispose();
await disposeGlobalServices();

Disposal is reverse-order within the same priority. Higher disposePriority values run first. All cleanup is attempted; multiple failures are reported as an AggregateError so one broken resource does not hide the others.

Register custom cleanup hooks when the resource is external to a service instance:

provider.onDisposeWithPriority(() => metrics.flush(), 50);

Both providers and scopes are terminal after disposal. Concurrent disposal calls share the same completion and do not double-close resources.

Validation and diagnostics

Declare dependencies when a factory has dependencies hidden inside a closure:

collection.addSingleton(
  APP,
  (services) => {
    return new App(services.resolve(CONFIG));
  },
  { dependencies: [CONFIG], source: "app.ts" },
);

Validate before accepting traffic:

const provider = collection.buildServiceProvider({
  validateOnBuild: true,
  requireKeysForMultiple: true,
});

Validation reports missing dependencies, dependency cycles, captive dependencies such as a singleton depending on a scoped service, and invalid multi-binding keys. With throwOnError: false, provider.validateGraph() returns diagnostics:

[
  {
    level: "error",
    message: "Singleton service App depends on scoped service RequestContext.",
    token: App,
    path: [App, RequestContext],
  },
];

Graph validation can inspect declared metadata only. Dependencies obtained through arbitrary runtime control flow should be declared explicitly.

For startup debugging, capture registration locations and resolution events:

const collection = new ServiceCollection({
  captureStack: true,
  trace: (event) => console.debug("DI", event),
});

const provider = collection.build();
console.log(provider.getDescriptors(App));

Descriptors include token, lifetime, key, source, registration time, ownership, and priority. Tracing is useful in development; avoid logging secrets or enabling verbose tracing on hot production paths without a sampling policy.

Modules

Modules keep feature registrations local and composable:

import { createModule } from "@circulo-ai/di";

const paymentsModule = createModule();
paymentsModule.bind(PaymentService).toClass(PaymentService, undefined, {
  scope: "scoped",
});
paymentsModule
  .bind(PaymentGateway)
  .toFactory((resolver) => new PaymentGateway(resolver.resolve(CONFIG)), {
    scope: "transient",
  });

const collection = new ServiceCollection().addModule(paymentsModule);

Modules record bindings and apply them later to a ServiceCollection, so feature registrations can be composed without a service locator or global registry. createModule() currently exposes the binding DSL; use collection.addModule(module) at the composition root.

Typed service locator

For application composition roots or controllers that benefit from grouped access, use createServiceLocator:

const services = createServiceLocator(
  provider,
  {
    config: CONFIG,
    users: {
      service: UserService,
      repository: REPOSITORY,
    },
  },
  { cache: true, strict: true },
);

await services.users.service.list();

The returned object is typed from the token tree. cache/memoize caches property resolutions; leave it disabled when transient semantics matter. strict throws for unknown locator properties instead of returning undefined.

Hono integration

The Hono adapter creates one scope per request and disposes it after the handler, including error paths.

import { Hono } from "hono";
import { ServiceCollection, createToken } from "@circulo-ai/di";
import { bindToHono, resolveFromContext } from "@circulo-ai/di/hono";

const REQUEST_ID = createToken<string>("request.id");
const collection = new ServiceCollection().addScoped(REQUEST_ID, () =>
  crypto.randomUUID(),
);
const provider = collection.build();
const app = new Hono();

bindToHono(app, provider, { requestId: REQUEST_ID }, { strict: true });

app.get("/", (c) => {
  const id = resolveFromContext(c, REQUEST_ID);
  return c.json({ requestId: id });
});

The response is shaped like this:

{ "requestId": "a fresh UUID for this request" }

bindToHono exposes c.var.container and a lazy c.di locator. Use cache: true when repeated property access should resolve once per request. Use decorateContext when you prefer eagerly resolved named values in c.var.services:

app.use("*", decorateContext({ requestId: REQUEST_ID }));

If the handler and scope cleanup both fail, the adapter throws an AggregateError containing both errors. The original request error is never silently replaced.

Next.js integration

Reuse a provider across development hot reloads and create a scope per route invocation:

// lib/di.ts
import { ServiceCollection } from "@circulo-ai/di";
import { getGlobalProvider } from "@circulo-ai/di/next";

export const provider = getGlobalProvider(() => {
  return new ServiceCollection().addSingleton(AppService).build();
});
// app/api/users/route.ts
import { withRequestScope } from "@circulo-ai/di/next";
import { provider } from "@/lib/di";

export const GET = withRequestScope(
  provider,
  async (_request, { container }) => {
    const users = await container.resolveAsync(UserService);
    return Response.json(users.list());
  },
);

getGlobalProvider stores one provider under a stable globalThis key, which avoids duplicate singleton graphs during hot reload. withRequestScope disposes scoped services after every invocation and preserves both handler and cleanup failures. The adapter is runtime-agnostic; keep Node-only dependencies out of Edge route modules and use a provider factory appropriate for the deployment runtime.

Testing and overrides

Build a fresh collection per test. Replace ports with in-memory adapters rather than mocking container internals:

const collection = new ServiceCollection();
collection
  .useValue(CONFIG, { databaseUrl: "memory://test" })
  .useValue(DATABASE, { query: async () => [{ id: 1 }] })
  .addScoped(UserService);

const provider = collection.buildServiceProvider({ validateOnBuild: true });
await provider.withScope(async (scope) => {
  expect(await scope.resolve(UserService).list()).toEqual([{ id: 1 }]);
});
await provider.dispose();

For an intentional override, use new ServiceCollection({ allowOverwrite: true }) or register a keyed fake with multiple: true. Avoid sharing providers between tests unless the test is specifically verifying global lifetime behavior.

Error behavior

Error Meaning
MissingServiceError A required token or key is not registered. Includes the token and resolution path.
CircularDependencyError The current resolution path contains the same token/key twice.
AsyncFactoryError A promise-producing factory was resolved through the synchronous API.
ScopeResolutionError A scoped service was requested without a scope.
DisposedScopeError A disposed scope was used.
DisposedProviderError A disposed provider was used.

Treat factory and disposal errors as application failures. Use tryResolveMissing only around genuinely optional registrations; broad error suppression can hide broken configuration.

Production checklist

  1. Use symbol or class tokens for shared contracts.
  2. Call buildServiceProvider({ validateOnBuild: true }) during startup.
  3. Declare hidden factory dependencies with dependencies.
  4. Use a scope for each HTTP request, queue job, or isolated unit of work.
  5. Keep singletons free of scoped dependencies.
  6. Decide ownership for every disposable external resource.
  7. Dispose providers during graceful shutdown and call disposeGlobalServices() when the process is finished.
  8. Keep allowOverwrite disabled outside controlled test composition.
  9. Use keyed multi-bindings for plugins and strategy ports.
  10. Keep registries and constructors application-owned; do not dynamically load untrusted modules.
  11. Add idempotency and retry policy at the adapter boundary for at-least-once job systems.
  12. Test cleanup failures and primary-error preservation, not just successful resolution.

Development and release

From the repository root:

bun --filter @circulo-ai/di typecheck
bun --filter @circulo-ai/di test
bun --filter @circulo-ai/di build
bun --filter @circulo-ai/di check
npm pack --dry-run

check runs the complete package gate, including tests, build, package smoke tests, and example typechecks. prepack runs check, so the tarball cannot be created successfully while the package gate is failing.

Releases use Changesets and GitHub Actions trusted publishing:

bunx changeset
bun run version-packages

The version workflow creates or updates the release pull request. After that pull request is merged into the configured release branch, the publish workflow installs with the lockfile, typechecks/builds/tests public packages, and runs Changesets publish with npm OIDC trusted publisher permissions. No local npm token or manual npm publish is required.

Migration from earlier versions

Existing addSingleton, addScoped, addTransient, bind, resolve, and createScope composition remains supported. Adopt the newer APIs incrementally:

- collection.addSingleton("config", () => config);
+ collection.useValue(CONFIG, config);

- const request = provider.createScope();
- try { return handler(request); } finally { await request.dispose(); }
+ return provider.withScope(handler);

- provider.tryResolve(OPTIONAL_SERVICE); // hides all failures
+ provider.tryResolveMissing(OPTIONAL_SERVICE); // hides absence only

The compatibility baseline is the same resolver model. New ownership, async multi-resolution, diagnostics, and subpath adapters are additive; they do not require rewriting existing registrations.

License

Apache-2.0

About

A lightweight dependency injection toolkit with singleton, scoped, and transient lifetimes plus optional Hono helpers. No decorators, no reflect metadata—just factories and tokens.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages