@cleverbrush/di

.NET-style dependency injection for TypeScript — schema-driven service registration, three lifetimes, function injection.

$npm install @cleverbrush/di

Uses @cleverbrush/schema for schema-driven service registration.

💡 Why @cleverbrush/di?

The Problem

As applications grow, manually wiring dependencies becomes painful. You end up threading configuration, loggers, and database connections through constructor chains. Lifetimes are managed ad-hoc — some services need to be shared, others need to be fresh per request, and keeping track of when to create and dispose them is error-prone.

The Solution

@cleverbrush/dibrings .NET's proven dependency injection model to TypeScript. Instead of inventing a new token system, it uses schema instances as service keys — the same @cleverbrush/schema objects that describe your data also identify your services. Register with a lifetime, build a provider, and resolve — fully typed, no decorators, no magic strings.

Three Lifetimes

  • Singleton — one instance for the entire application lifetime. Created on first resolution, reused from then on.
  • Scoped — one instance per scope (e.g. per HTTP request). Ideal for database connections and unit-of-work patterns.
  • Transient — a fresh instance on every resolution. Use for lightweight, stateless services.

Function Injection

Use FunctionSchemaBuilderto describe a function's dependencies as parameter schemas. The container resolves each parameter and calls your implementation with the resolved values — fully typed via InferType.

Automatic Disposal

Scoped services that implement Symbol.dispose or Symbol.asyncDispose are automatically cleaned up when the scope exits. Works with the using keyword for zero-boilerplate resource management.

Quick Start

Schemas are immutable — they work as safe, typed DI keys. Use .hasType() to brand a schema with a real class type for full autocomplete:

import { ServiceCollection } from '@cleverbrush/di';
import { object, string, number } from '@cleverbrush/schema';
import { Logger } from './Logger';
import { AppConfig } from './AppConfig';

// Schema instances as service keys — .hasType() gives real class typing
const ILogger = object().hasType<typeof Logger>();
const IConfig = object({ port: number(), host: string() });

const services = new ServiceCollection();
services.addSingleton(IConfig, { port: 3000, host: 'localhost' });
services.addSingleton(ILogger, () => new Logger());

const provider = services.buildServiceProvider();
const config = provider.get(IConfig); // typed: { port: number, host: string }
const logger = provider.get(ILogger); // typed: Logger

DI with Endpoint Handlers

The real power of @cleverbrush/di shows when paired with @cleverbrush/server. Use .inject() on an endpoint to declare services — they are resolved per request and passed as the second handler argument:

import { endpoint, createServer } from '@cleverbrush/server';
import { object, number } from '@cleverbrush/schema';
import { UserRepository } from './UserRepository';
import { EmailService } from './EmailService';

// Schema keys branded with real types
const IUserRepo = object().hasType<typeof UserRepository>();
const IEmailSvc = object().hasType<typeof EmailService>();

const GetUser = endpoint
    .get('/api/users')
    .query(object({ id: number().coerce() }))
    .inject({ repo: IUserRepo });

const CreateUser = endpoint
    .post('/api/users')
    .body(object({ name: string(), email: string() }))
    .inject({ repo: IUserRepo, email: IEmailSvc });

const server = createServer();

server
    .services(svc => {
        svc.addSingleton(IUserRepo, () => new UserRepository());
        svc.addSingleton(IEmailSvc, () => new EmailService());
    })
    .handle(GetUser, ({ query }, { repo }) => {
        // repo is typed as UserRepository
        return repo.findById(query.id);
    })
    .handle(CreateUser, async ({ body }, { repo, email }) => {
        // repo: UserRepository, email: EmailService
        const user = await repo.create(body);
        await email.sendWelcome(user.email);
        return user;
    });

Scoped Services — Per-Request Lifecycle

Register services as scoped to get a fresh instance per HTTP request. The server creates a scope automatically for each request and disposes it when the response is sent:

import { object } from '@cleverbrush/schema';
import { DbContext } from './DbContext';

const IDbContext = object().hasType<typeof DbContext>();

server
    .services(svc => {
        // Fresh connection per request, disposed on response
        svc.addScoped(IDbContext, () => {
            const db = new DbContext();
            return Object.assign(db, {
                [Symbol.asyncDispose]: () => db.close()
            });
        });
    })
    .handle(
        endpoint
            .post('/api/orders')
            .body(OrderSchema)
            .inject({ db: IDbContext }),
        async ({ body }, { db }) => {
            // db is a fresh DbContext for this request
            return db.orders.create(body);
        }
    );

Three Lifetimes

const services = new ServiceCollection();

// Singleton — one instance for the entire app
services.addSingleton(IConfig, { port: 3000, host: 'localhost' });

// Scoped — one instance per scope (per HTTP request)
services.addScoped(IDbContext, () => new DbContext());

// Transient — fresh instance on every resolution
services.addTransient(IRequestId, () => ({ id: crypto.randomUUID() }));

Function Injection

Describe a function's dependencies using FunctionSchemaBuilder. The container resolves each parameter schema and calls your implementation:

import { func } from '@cleverbrush/schema';

const handler = func()
    .addParameter(ILogger)
    .addParameter(IConfig)
    .hasReturnType(string());

// All parameters are resolved from the container
const result = provider.invoke(handler, (logger, config) => {
    logger.info(`Running on port ${config.port}`);
    return 'ok';
});
// result is typed as string