@circulo-ai/di
v3.1.0
Published
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).
Maintainers
Readme
@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/toHigherOrderFunctionwith array/object dependencies andscopealiases 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:
bindToHonofor one-liner setup;decorateContextfor “put it onc.var”; strict/memoized proxies. - Service Locator:
createServiceLocatorfor typed, lazily-resolved proxies from nested token trees. - Tokens:
createToken,optional(token)for optional resolution; keyed/multi registrations;resolveMapfor keyed lookups;factory/lazyhelpers. - Diagnostics:
validateGraph, runtime circular detection, structured errors with path/token. - Conditional registration:
ifProd,ifDev,ifTruthy.
Install
bun add @circulo-ai/diQuickstart
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:
GlobalSingletonfor expensive process-wide things (DB pools);Singletonfor app-level caches;Scopedper request/task;Transientfor 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. Useoptional(token)for soft dependencies. - Binder DSL for ergonomic wiring:
bind(Token).toValue|toFactory|toClass|toHigherOrderFunctionwith array/object deps; usescopefor lifetimes and{ async: true }when dep factories are async. - Keyed multi-bindings: set
{ multiple: true, key: "primary" }and useresolveMapfor clarity; avoid mixing keyed/unkeyed for the same token. - Async factories: always resolve with
resolveAsync; sync resolve will throw while in flight. Usefactory(token)to inject lazy calls andlazy(token)to memoize per scope. - Dispose eagerly: wrap work in
provider.withScopeorwithRequestScope(Next) and callprovider.dispose()on shutdown. AdddisposePriorityfor ordered teardown. - Modules for features: group registrations with
createModule().bind(...).to...andservices.addModule(module)to keep domains isolated. - Environment guards: wrap optional services with
ifProd/ifDev/ifTruthyto keep registration clean. - Validate and trace: run
provider.validateGraph({ throwOnError: true })locally to catch duplicates/missing tokens; passtracetoServiceCollectionto log resolution paths during debugging. - Testing overrides: set
allowOverwrite: truein tests, re-register tokens with fakes, or compose a newServiceCollectionper test. UseuseExistingto alias mocks without changing consumers. - Hot-reload safety: prefer
GlobalSingletonorgetGlobalProviderin dev servers/Next.js to avoid duplicate pools; keep disposers on value providers for clean reloads. - Edge vs Node: on Edge runtimes, avoid
globalThisif 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
traceto 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
disposePriorityto 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 freshServiceCollectionin tests and register fakes; setallowOverwrite: falsein prod to catch duplicate registrations; useuseExistingto alias/mirror tokens for mocks. - Keyed multi-binding
resolveMap(Cache)to pick keyed implementations;validateGraphwarns about mixed keyed/unkeyed. - Async factory pattern
UseresolveAsyncfor async factories; syncresolvethrowsAsyncFactoryErrorwhile the promise is in flight.
const scope = provider.createScope();
// ...use services
await scope.dispose(); // cleans up scoped disposables
await provider.dispose(); // cleans up singletonsDeveloping
bun --cwd packages/di run typecheck
bun --cwd packages/di run buildPublishing
bun -cwd packages/di run releaseThe release script builds and publishes with --access public. prepack also runs the build automatically if you publish manually.
