Skip to content

circulo-ai/di

Repository files navigation

@circulo-ai/di

A lightweight dependency injection toolkit with singleton, scoped, global-singleton, and transient lifetimes plus Hono helpers. No decorators, no reflect metadata—just factories and tokens (sync or async).

What's Inside

  • ServiceCollection: Register services with lifetimes (Singleton, GlobalSingleton, Scoped, Transient), defaults (allowOverwrite/defaultMultiple), metadata (registeredAt/source), and dispose priorities.
  • Binding DSL: services.bind(Token).toValue/toFunction/toFactory/toClass/toHigherOrderFunction with array/object dependencies and scope aliases for lifetimes.
  • ServiceProvider: Root container with singleton/global caches, async-aware resolution, scopes, disposal hooks, tracing, and withScope.
  • ServiceScope: Per-request/per-operation scoped instances with disposal ordering and async caching.
  • Hono Helpers: bindToHono for one-liner setup; decorateContext for “put it on c.var”; strict/memoized proxies.
  • Service Locator: createServiceLocator for typed, lazily-resolved proxies from nested token trees.
  • Tokens: createToken, optional(token) for optional resolution; keyed/multi registrations; resolveMap for keyed lookups; factory/lazy helpers.
  • Diagnostics: validateGraph, runtime circular detection, structured errors with path/token.
  • Conditional registration: ifProd, ifDev, ifTruthy.

Install

bun add @circulo-ai/di

Quickstart

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

const services = new ServiceCollection();

// Singleton
services.addSingleton("Config", { port: 3000 });

// Scoped (e.g., per request)
services.addScoped("RequestId", () => crypto.randomUUID());

// Transient
services.addTransient("Now", () => () => new Date());

// Multiple/Keyed registrations
services.addSingleton("Cache", () => primaryCache, {
  key: "primary",
  multiple: true,
});
services.addSingleton("Cache", () => secondaryCache, {
  key: "secondary",
  multiple: true,
});

const provider = services.build();
const scope = provider.createScope();

const config = scope.resolve<{ port: number }>("Config");
const requestId = scope.resolve<string>("RequestId");
const primary = scope.resolve("Cache", "primary");
const caches = scope.resolveAll("Cache"); // [secondary, primary] (last wins unless keyed)
const byKey = scope.resolveMap("Cache"); // { primary: primaryCache, secondary: secondaryCache }

// Optional resolution
const maybeMissing = scope.tryResolve("Missing"); // undefined instead of throw
const maybeMissing2 = scope.resolve(optional("Missing")); // undefined

// Async factories
services.addSingleton("AsyncDb", async () => connectDb());
const db = await provider.resolveAsync("AsyncDb");
// provider.resolve("AsyncDb") will throw while the async factory is in-flight

// Binding DSL with array/object deps and scope aliases
services
  .bind("Settings")
  .toHigherOrderFunction(
    (db, logger) => ({ db, logger }),
    ["AsyncDb", TYPES.Logger],
    { scope: "scoped", async: true },
  );
services.bind("Static").toValue("hi");
services.bind(TYPES.Logger).toClass(Logger);

// Factory/lazy helpers
services.addTransient("DbFactory", factory("AsyncDb"));
services.addScoped("LazyConfig", lazy("Config"));

Service locator helper

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

const TYPES = {
  Config: createToken<{ port: number }>("Config"),
  Db: createToken<{ query: (sql: string) => Promise<unknown> }>("Db"),
} as const;

const services = new ServiceCollection()
  .addSingleton(TYPES.Config, { port: 3000 })
  .addSingleton(TYPES.Db, () => ({ query: async (_sql: string) => [] }));

const provider = services.build();
const scope = provider.createScope();
const locator = createServiceLocator(
  scope,
  {
    config: TYPES.Config,
    db: { primary: TYPES.Db, cache: optional("Cache") },
  },
  { cache: false, strict: true },
);

const config = locator.config;
const db = locator.db.primary;
const maybeCache = locator.db.cache;

Fundamentals & best practices

  • Pick the right lifetime: GlobalSingleton for expensive process-wide things (DB pools); Singleton for app-level caches; Scoped per request/task; Transient for pure, cheap objects. Avoid scoped resolution from the root—always resolve through a scope/middleware.
  • Prefer tokens over strings: createToken<T>("Name") keeps types tight and avoids collision. Use optional(token) for soft dependencies.
  • Binder DSL for ergonomic wiring: bind(Token).toValue|toFactory|toClass|toHigherOrderFunction with array/object deps; use scope for lifetimes and { async: true } when dep factories are async.
  • Keyed multi-bindings: set { multiple: true, key: "primary" } and use resolveMap for clarity; avoid mixing keyed/unkeyed for the same token.
  • Async factories: always resolve with resolveAsync; sync resolve will throw while in flight. Use factory(token) to inject lazy calls and lazy(token) to memoize per scope.
  • Dispose eagerly: wrap work in provider.withScope or withRequestScope (Next) and call provider.dispose() on shutdown. Add disposePriority for ordered teardown.
  • Modules for features: group registrations with createModule().bind(...).to... and services.addModule(module) to keep domains isolated.
  • Environment guards: wrap optional services with ifProd/ifDev/ifTruthy to keep registration clean.
  • Validate and trace: run provider.validateGraph({ throwOnError: true }) locally to catch duplicates/missing tokens; pass trace to ServiceCollection to log resolution paths during debugging.
  • Testing overrides: set allowOverwrite: true in tests, re-register tokens with fakes, or compose a new ServiceCollection per test. Use useExisting to alias mocks without changing consumers.
  • Hot-reload safety: prefer GlobalSingleton or getGlobalProvider in dev servers/Next.js to avoid duplicate pools; keep disposers on value providers for clean reloads.
  • Edge vs Node: on Edge runtimes, avoid globalThis if not needed; prefer scoped lifetimes and per-request factories for lightweight objects.
  • Avoid hidden singletons: keep most services scoped/transient and only elevate to singleton/global when necessary; use trace to spot unintended sharing.
// Lifetime + binder examples
services
  .bind(createToken<Pool>("Db"))
  .toHigherOrderFunction(() => createPool(), [], { scope: "global" });
services
  .bind(createToken<RequestLogger>("Logger"))
  .toFactory((r) => makeRequestLogger(r.resolve("RequestId")), {
    scope: "scoped",
  });
services
  .bind(createToken<Feature>("Feature"))
  .toHigherOrderFunction((deps) => new Feature(deps), { config: "Config" });

provider.validateGraph({ throwOnError: true });

Hono Integration

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

const TYPES = { RequestId: createToken<string>("requestId") } as const;
const provider = services.build();
const app = new Hono();

bindToHono(app as any, provider, TYPES, { cache: true, strict: true });
app.use("*", decorateContext(TYPES, { targetVar: "svc" }) as any);

app.get("/ping", (c) => {
  return c.json({
    ok: true,
    requestId: (c as any).di.RequestId,
    viaVar: (c.var as any).svc.RequestId,
  });
});

Real-world examples

Next.js App Route (Node/Edge)

// app/api/users/route.ts
import {
  getGlobalProvider,
  withRequestScope,
  ServiceCollection,
} from "@circulo-ai/di";
import { NextRequest } from "next/server";

const TYPES = { Db: "Db", Logger: "Logger" } as const;

// Reuse across hot reloads and edge invocations
const provider = getGlobalProvider(() => {
  const services = new ServiceCollection();
  services
    .bind(TYPES.Db)
    .toHigherOrderFunction(() => createPool(), [], { scope: "global" });
  services
    .bind(TYPES.Logger)
    .toFactory(() => createRequestLogger(), { scope: "scoped" });
  return services.build();
});

export const GET = withRequestScope(
  provider,
  async (_req: NextRequest, ctx) => {
    const db = await ctx.container.resolveAsync(TYPES.Db);
    const logger = ctx.container.resolve(TYPES.Logger);
    const rows = await db.query("select * from users");
    logger.info("users fetched", { count: rows.length });
    return Response.json({ users: rows });
  },
);

Modular feature wiring

// user.module.ts
import { createModule } from "@circulo-ai/di";
export const TYPES = { UserRepo: "UserRepo", GetUser: "GetUser" } as const;
export const userModule = createModule()
  .bind(TYPES.UserRepo)
  .toClass(UserRepository, { db: "Db" })
  .bind(TYPES.GetUser)
  .toHigherOrderFunction(
    (repo) => (id: string) => repo.findById(id),
    [TYPES.UserRepo],
  );

// app container
import { ServiceCollection } from "@circulo-ai/di";
import { userModule, TYPES as USER } from "./user.module";

const services = new ServiceCollection()
  .addGlobalSingleton("Db", () => createPool(), { disposePriority: 10 })
  .addModule(userModule);

const provider = services.build();
const scope = provider.createScope();
await scope.resolveAsync(USER.GetUser)("123");

Background job scope with disposals

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

const TYPES = { Queue: "Queue", JobLogger: "JobLogger" } as const;
const services = new ServiceCollection()
  .addGlobalSingleton(TYPES.Queue, () => connectQueue(), { disposePriority: 5 })
  .bind(TYPES.JobLogger)
  .toFactory(() => createJobLogger(), { scope: "scoped" });

const provider = services.build();

export async function handleJob(payload: any) {
  return provider.withScope(async (scope) => {
    const queue = scope.resolve(TYPES.Queue);
    const log = scope.resolve(TYPES.JobLogger);
    log.info("processing job", payload);
    await queue.ack(payload.id);
  });
}

Lifetimes

  • Singleton: One instance for the app lifetime (per provider).
  • GlobalSingleton: One instance per process (hot-reload safe via globalThis).
  • Scoped: One instance per ServiceScope (commonly per request).
  • Transient: New instance every resolution.

Disposal

If a resolved instance exposes dispose, close, destroy, Symbol.dispose, or Symbol.asyncDispose, scopes and providers will call them when disposed. You can also register manual hooks with scope.onDispose / provider.onDispose, or run work in provider.withScope(fn) to auto-dispose.

  • Scoped instances dispose in reverse resolve order; use disposePriority to override (higher runs first). Singletons honor the same priority and order.
  • Custom disposers on value providers: addSingleton(token, { value, dispose }).

Recipes

  • Connection pool (global)
    addGlobalSingleton(CacheToken, () => createPool(), { disposePriority: 5 })
  • Per-request transaction
    addScoped(TxToken, (r) => startTx(r.resolve(DbToken)), { disposePriority: 10 })
  • Background job scope
    provider.withScope(async (scope) => { const job = scope.resolve(Job); await job.run(); })
  • Testing overrides
    Build a fresh ServiceCollection in tests and register fakes; set allowOverwrite: false in prod to catch duplicate registrations; use useExisting to alias/mirror tokens for mocks.
  • Keyed multi-binding
    resolveMap(Cache) to pick keyed implementations; validateGraph warns about mixed keyed/unkeyed.
  • Async factory pattern
    Use resolveAsync for async factories; sync resolve throws AsyncFactoryError while the promise is in flight.
const scope = provider.createScope();
// ...use services
await scope.dispose(); // cleans up scoped disposables
await provider.dispose(); // cleans up singletons

Developing

bun --cwd packages/di run typecheck
bun --cwd packages/di run build

Publishing

bun -cwd packages/di run release

The release script builds and publishes with --access public. prepack also runs the build automatically if you publish manually.

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

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors