o nothing at runtime. To maintain type safety while providing runtime identifiers, we create typed tokens. These tokens carry the interface type but resolve to a unique runtime symbol.
import { token } from "@wyrly/core";
export interface InventoryClient {
checkStock(productId: string): Promise<number>;
reserve(productId: string, quantity: number): Promise<boolean>;
}
export interface NotificationProvider {
sendOrderConfirmation(orderId: string, email: string): Promise<void>;
}
// Typed tokens preserve compile-time safety while providing runtime identity
export const InventoryClientToken = token<InventoryClient>("InventoryClient");
export const NotificationProviderToken = token<NotificationProvider>("NotificationProvider");
Step 2: Register Implementations with Explicit Dependencies
Instead of relying on constructor parameter order or metadata, we declare dependencies explicitly using the deps array. This array serves as a static contract that tooling can analyze.
import { Injectable } from "@wyrly/core";
import { InventoryClientToken, NotificationProviderToken } from "./tokens";
@Injectable({
deps: [InventoryClientToken, NotificationProviderToken],
lifetime: "scoped", // Tied to request lifecycle
})
export class OrderProcessor {
constructor(
private readonly inventory: InventoryClient,
private readonly notifier: NotificationProvider
) {}
async process(orderId: string, productId: string, quantity: number, customerEmail: string): Promise<void> {
const available = await this.inventory.checkStock(productId);
if (available < quantity) {
throw new Error(`Insufficient stock for ${productId}`);
}
await this.inventory.reserve(productId, quantity);
await this.notifier.sendOrderConfirmation(orderId, customerEmail);
}
}
Architecture Rationale:
lifetime: "scoped" ensures a fresh instance per request, preventing cross-request state leakage.
- The
deps array decouples dependency resolution from constructor signature order, enabling safe refactoring.
- Using tokens instead of raw classes allows interface-based programming without runtime type loss.
Step 3: Build the Composition Root
The composition root centralizes all registrations. This is where infrastructure concerns (database connections, API keys, external clients) are wired to domain contracts.
import { createContainer } from "@wyrly/core";
import { InventoryClientToken, NotificationProviderToken } from "./tokens";
import { OrderProcessor } from "./OrderProcessor";
// Mock implementations for demonstration
class PostgresInventoryClient implements InventoryClient {
async checkStock(id: string) { return 100; }
async reserve(id: string, qty: number) { return true; }
}
class SendGridNotifier implements NotificationProvider {
async sendOrderConfirmation(orderId: string, email: string) { console.log(`Sent to ${email}`); }
}
export function buildContainer() {
const container = createContainer();
container.register(InventoryClientToken, { use: PostgresInventoryClient, lifetime: "singleton" });
container.register(NotificationProviderToken, { use: SendGridNotifier, lifetime: "singleton" });
container.register(OrderProcessor, { lifetime: "scoped" });
return container;
}
Step 4: Integrate Request Scopes with Web Frameworks
Web applications require strict isolation between requests. The pattern 1 HTTP request = 1 DI scope ensures that request-specific data (user context, transaction handles, request-scoped caches) never leaks across boundaries.
import { Hono } from "hono";
import { buildContainer } from "./container";
import { di, getDI, type HonoDIVariables } from "@wyrly/hono";
import { OrderProcessor } from "./OrderProcessor";
const app = new Hono<{ Variables: HonoDIVariables }>();
const rootContainer = buildContainer();
// Middleware creates a child scope per request
app.use(di(rootContainer));
app.post("/orders", async (c) => {
const scope = getDI(c);
const processor = scope.resolve(OrderProcessor);
const body = await c.req.json();
await processor.process(
body.orderId,
body.productId,
body.quantity,
body.email
);
return c.json({ status: "processed" });
});
Step 5: Validate the Dependency Graph
Explicit wiring enables static validation. Running graph checks in CI prevents architectural violations from reaching production.
import { buildContainer } from "./container";
const container = buildContainer();
const validation = container.validate();
if (!validation.ok) {
console.error("DI Graph Validation Failed:");
validation.issues.forEach(issue => console.error(`- ${issue.message}`));
process.exit(1);
}
console.log("Dependency graph is valid.");
Pitfall Guide
1. Lifetime Mismatch (Singleton Depending on Scoped)
Explanation: Registering a singleton that depends on a scoped or transient token causes the singleton to hold a reference to a request-specific instance, leading to state leakage and memory bloat.
Fix: Use container.validate() to catch this automatically. Architecturally, singletons should only depend on other singletons or factory functions that produce scoped instances on demand.
2. Interface Token Omission
Explanation: Attempting to register or resolve a TypeScript interface directly fails at runtime because interfaces are erased during compilation.
Fix: Always wrap interfaces with token<T>("Identifier"). Use the token for registration and resolution, while keeping the interface for compile-time type checking.
3. Transient Overuse for Heavy Resources
Explanation: Marking database connections, HTTP clients, or configuration loaders as transient creates a new instance on every resolution, exhausting connection pools and degrading performance.
Fix: Reserve transient for lightweight, stateless utilities. Use singleton for shared infrastructure and scoped for request-bound state. Profile memory usage if transient resolution counts spike.
4. Bypassing Scope in Middleware
Explanation: Resolving dependencies directly from the root container inside request handlers breaks scope isolation. Request-specific data (e.g., authenticated user, request ID) will be shared across concurrent requests.
Fix: Always resolve through the request-scoped container (getDI(c).resolve()). Middleware should only attach the scope to the context; handlers should never touch the root container.
5. Skipping Graph Validation in CI
Explanation: Dependency graphs drift over time. Without automated validation, circular dependencies, missing registrations, and lifetime violations accumulate silently.
Fix: Add a test step that calls container.validate() and fails the pipeline if result.ok === false. Treat DI graph health as a non-negotiable quality gate.
6. Circular Dependency Chains
Explanation: Service A depends on B, and B depends on A. Explicit DI surfaces this immediately during resolution or validation, whereas implicit DI might delay the failure until runtime.
Fix: Break the cycle using the mediator pattern, event bus, or by extracting shared logic into a third service. Use container.inspect() to visualize the graph and identify cycles before they reach production.
7. Service Locator Anti-Pattern
Explanation: Passing the container itself into classes and calling container.resolve() internally defeats the purpose of explicit dependency declaration. It hides dependencies, complicates testing, and couples business logic to the DI framework.
Fix: Enforce constructor injection via the deps array. If dynamic resolution is required, inject a factory function or a resolver interface, not the raw container.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small utility script or CLI | Manual instantiation or simple factory | DI overhead outweighs benefits; explicit wiring adds boilerplate | Low (saves setup time) |
| Enterprise web API (Hono/Express) | Explicit DI with request scopes + graph validation | Enforces boundaries, prevents state leakage, enables static analysis | Medium (initial setup) / Low (long-term maintenance) |
| Next.js App Router | @wyrly/next adapter with route-level scopes | Aligns with React Server Components lifecycle; isolates request data per route | Medium (framework integration) |
| GraphQL Server | @wyrly/graphql with DataLoader pattern | Prevents N+1 queries, scopes database transactions per request | High (performance gain) / Low (architectural cost) |
Legacy reflect-metadata codebase | Gradual migration via explicit tokens + adapter layer | Avoids rewrite; isolates new DI from legacy auto-wiring | High (migration effort) / Low (future-proofing) |
Configuration Template
// container.ts
import { createContainer, token } from "@wyrly/core";
import { OrderProcessor } from "./OrderProcessor";
export const DbClientToken = token<DbClient>("DbClient");
export const CacheClientToken = token<CacheClient>("CacheClient");
export function buildApplicationContainer() {
const container = createContainer();
// Infrastructure (singleton)
container.register(DbClientToken, { use: PostgresDbClient, lifetime: "singleton" });
container.register(CacheClientToken, { use: RedisCacheClient, lifetime: "singleton" });
// Domain services (scoped)
container.register(OrderProcessor, { lifetime: "scoped" });
// Validate before export
const validation = container.validate();
if (!validation.ok) {
throw new Error(`Container initialization failed: ${validation.issues.map(i => i.message).join(", ")}`);
}
return container;
}
Quick Start Guide
- Install core and adapter:
npm install @wyrly/core @wyrly/hono (or your preferred framework adapter)
- Define tokens: Create
token<T>("Name") for every interface you plan to inject
- Register implementations: Call
container.register(Token, { use: Class, lifetime: "..." }) in a composition root
- Attach middleware: Import the framework adapter (
di(container)) and apply it before your route handlers
- Resolve in handlers: Use
getDI(context).resolve(ServiceClass) to access request-scoped instances
- Validate: Run
container.validate() locally and in CI to catch structural issues early
Explicit dependency injection shifts the burden from runtime magic to compile-time contracts. By declaring dependencies upfront, enforcing lifetimes, and validating the graph before deployment, teams gain predictable wiring, safer refactoring, and architectural visibility that scales with application complexity.