A lightweight, type-safe dependency injection toolkit with singleton, scoped, global-singleton, and transient lifetimes plus Hono and Next.js helpers. It is reflection-free by default, with optional class annotations that do not require reflect-metadata.
- 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. - Optional annotations:
injectable(dependencies)orannotate(Class, dependencies)for reflection-free constructor injection. - 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.
bun add @circulo-ai/diimport {
ServiceCollection,
createToken,
factory,
lazy,
optional,
} from "@circulo-ai/di";
const services = new ServiceCollection();
const TYPES = {
Logger: createToken<Logger>("Logger"),
Cache: createToken<Cache>("Cache"),
} as const;
// 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(TYPES.Cache, () => primaryCache, {
key: "primary",
multiple: true,
});
services.addSingleton(TYPES.Cache, () => secondaryCache, {
key: "secondary",
multiple: true,
});
// Async factories
services.addSingleton("AsyncDb", async () => connectDb());
// 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"));
const provider = services.build();
await provider.withScope(async (scope) => {
const config = scope.resolve<{ port: number }>("Config");
const requestId = scope.resolve<string>("RequestId");
const primary = scope.resolve(TYPES.Cache, "primary");
const caches = scope.resolveAll(TYPES.Cache); // registration order
const byKey = scope.resolveMap(TYPES.Cache);
const maybeMissing = scope.resolve(optional("Missing")); // undefined
const db = await scope.resolveAsync("AsyncDb");
});Annotations are explicit and do not inspect TypeScript design types or load reflect-metadata. Array dependencies are passed as positional constructor arguments; object dependencies are passed as one object.
import { ServiceCollection, createToken, injectable } from "@circulo-ai/di";
const LOGGER = createToken<Logger>("Logger");
@injectable([LOGGER])
class UserService {
constructor(readonly logger: Logger) {}
}
const services = new ServiceCollection().addSingleton(LOGGER, new Logger());
services.bind(UserService).toClass(UserService);
// If decorator syntax is disabled, use:
// annotate(UserService, [LOGGER]);For a Microsoft.Extensions.DependencyInjection-style workflow, use the class as both implementation and token:
const services = new ServiceCollection()
.addSingleton(Logger)
.addScoped(UserService)
.addTransient(CommandHandler);
const provider = services.buildServiceProvider({ validateOnBuild: true });
await provider.withScope(async (scope) => {
const users = scope.serviceProvider.getRequiredService(UserService);
const optionalCache = scope.getService(Cache); // undefined if unregistered
const handlers = scope.getServices(CommandHandler);
});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;- 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. - .NET-style vocabulary: annotated classes can self-register with
addSingleton(Class),addScoped(Class), oraddTransient(Class). UsebuildServiceProvider,getRequiredService,getService, andgetServiceswhen that reads more naturally to your team. - 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. Set an explicitglobalKeywhen the token itself is recreated during hot reload. - 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 });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,
});
});Executable examples are included with the package:
examples/annotations.ts: positional and named annotation metadata, optional dependencies, async constructor dependencies, scoped lifetimes, and graph validation.examples/real-world.ts: Next.js-style request scopes, feature modules, keyed tokens, and background workers.examples/hono: a complete Hono server using an annotated request-scoped service.
Run the annotation example with bun examples/annotations.ts after building the package, or validate every example with bun run test:examples.
// 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 });
},
);// user.module.ts
import { createModule } from "@circulo-ai/di";
export const TYPES = { UserRepo: "UserRepo", GetUser: "GetUser" } as const;
const userModule = createModule();
userModule.bind(TYPES.UserRepo).toClass(UserRepository, { db: "Db" });
userModule
.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");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);
});
}- Singleton: One instance for the app lifetime (per provider).
- GlobalSingleton: One instance per token identity in the process. Supply
globalKeyfor stable reuse when hot reload recreates a token. - Scoped: One instance per
ServiceScope(commonly per request). - Transient: New instance every resolution.
If a resolved object exposes dispose, close, destroy, Symbol.dispose, or Symbol.asyncDispose, scopes and providers call it when disposed. Function-valued services are never invoked as disposers. You can also register manual hooks with scope.onDispose / provider.onDispose, or run work in provider.withScope(fn) to auto-dispose. A disposed scope is terminal and throws DisposedScopeError if reused.
- 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 }).
- 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 singletonsbun --cwd packages/di run typecheck
bun --cwd packages/di run check
bun --cwd packages/di run test:examplesbun --cwd packages/di run releaseThe release script publishes with public access. npm runs prepack, which type-checks, tests, cleans stale output, and builds before creating the tarball.