Chapter 25: DI Container#
1. Stop Passing Dependencies Through Every Function Call#
Your database connection, email client, cache client, payment gateway, and logger are needed in dozens of places. You could import them all directly. You could pass them as function parameters. Both approaches make testing hard and create tight coupling.
Dependency injection (DI) separates what you need from how you get it. A container holds instances. You register once. You resolve anywhere. Tests swap implementations without touching production code.
Tina4's Container class is a lightweight DI container. Register classes or factories. Resolve by name. Singleton or transient. No reflection magic, no decorators required unless you want them.
2. The Container Class#
import { Container } from "tina4-nodejs";Container is a class you instantiate. You can have multiple containers for different subsystems, or use a single application-wide container.
3. Registering a Service#
register() stores a factory function or class constructor under a name:
import { Container } from "tina4-nodejs";โconst container = new Container();โ// Register a factory functioncontainer.register("logger", () => ({ info: (msg: string) => console.log(`[INFO] ${msg}`), error: (msg: string) => console.error(`[ERROR] ${msg}`)}));โ// Register a class constructorclass EmailService { send(to: string, subject: string, body: string) { console.log(`Sending "${subject}" to ${to}`); }}โcontainer.register("email", () => new EmailService());Every call to container.get("email") invokes the factory and returns a new instance by default.
4. Singleton Registration#
Use singleton() to register a service that is only instantiated once. Every call to get() returns the same instance:
import { Container } from "tina4-nodejs";import { Database } from "tina4-nodejs/orm";โconst container = new Container();โ// Database connections are expensive -- only create onecontainer.singleton("db", () => { return Database.getConnection();});โ// Both calls return the exact same connection objectconst db1 = container.get("db");const db2 = container.get("db");console.log(db1 === db2); // trueUse singleton for: database connections, cache clients, API clients, configuration objects. Use register (transient) for: request-scoped services, services with per-call state.
5. Resolving Services with get()#
const logger = container.get<Logger>("logger");logger.info("Application started");โconst email = container.get<EmailService>("email");email.send("alice@example.com", "Welcome!", "Thanks for signing up.");Provide a type parameter for TypeScript type safety. The container returns unknown without one; with a type parameter it returns the typed instance.
6. Checking Registration with has()#
if (container.has("payment")) { const payment = container.get("payment"); // ...} else { console.warn("Payment service not registered");}has() returns true if a name is registered, regardless of whether the service has been instantiated yet.
7. Resetting the Container#
Remove all registrations, useful in tests:
// Remove a specific registrationcontainer.unregister("email");โ// Clear everythingcontainer.reset();After reset(), any call to get() throws until services are re-registered.
8. Services That Depend on Other Services#
Factories receive the container as an argument, enabling nested resolution:
import { Container } from "tina4-nodejs";โconst container = new Container();โcontainer.singleton("config", () => ({ smtpHost: process.env.TINA4_MAIL_HOST ?? "localhost", smtpPort: parseInt(process.env.TINA4_MAIL_PORT ?? "587")}));โcontainer.singleton("mailer", (c) => { const config = c.get<{ smtpHost: string; smtpPort: number }>("config"); return new SMTPMailer(config.smtpHost, config.smtpPort);});โcontainer.singleton("notifier", (c) => { const mailer = c.get<SMTPMailer>("mailer"); return new NotificationService(mailer);});โ// All dependencies resolved automaticallyconst notifier = container.get<NotificationService>("notifier");9. Application-Wide Container Pattern#
Expose a single container instance from a module so it can be imported anywhere:
src/container.ts:
import { Container } from "tina4-nodejs";import { Database } from "tina4-nodejs/orm";import { Messenger } from "tina4-nodejs";import { Log } from "tina4-nodejs";โexport const app = new Container();โapp.singleton("db", () => Database.getConnection());โapp.singleton("mailer", () => new Messenger());โapp.singleton("log", () => Log);โapp.register("orderService", (c) => { return new OrderService( c.get("db"), c.get("mailer"), c.get("log") );});src/routes/orders.ts:
import { Router } from "tina4-nodejs";import { app } from "../container";โRouter.post("/api/orders", async (req, res) => { const orderService = app.get<OrderService>("orderService"); const result = await orderService.place(req.body); return res.status(201).json(result);});10. Testing with the DI Container#
Swap real services for test doubles without touching production code:
import { Container } from "tina4-nodejs";โ// Production containerimport { app } from "../src/container";โ// In tests: override specific servicesbeforeEach(() => { app.unregister("mailer"); app.singleton("mailer", () => ({ send: async () => ({ success: true, messageId: "test-123" }) }));});โafterEach(() => { // Restore after each test app.unregister("mailer"); app.singleton("mailer", () => new Messenger());});The test installs a no-op mailer. Route handlers use app.get("mailer"), so they never know the difference.
11. Exercise: Build a Service Container for a Store API#
Wire together a product store API using the DI container.
Requirements#
- Register three singletons:
productRepo(in-memory data),priceService(computes discounts), andlogger(structured logging) - Register a transient
productServicethat depends on all three - Create a
GET /api/store/{id}route that usesproductServiceto fetch a product with its discounted price
Test with:#
curl http://localhost:7148/api/store/1curl http://localhost:7148/api/store/99 # should return 40412. Solution#
src/store/container.ts:
import { Container, Log } from "tina4-nodejs";โexport const store = new Container();โ// Repository: source of truthstore.singleton("productRepo", () => ({ findById: (id: number) => { const products = [ { id: 1, name: "Wireless Keyboard", basePrice: 79.99, category: "Electronics" }, { id: 2, name: "Yoga Mat", basePrice: 29.99, category: "Fitness" }, { id: 3, name: "Standing Desk", basePrice: 549.99, category: "Electronics" } ]; return products.find(p => p.id === id) ?? null; }, findAll: () => []}));โ// Price service: applies category discountsstore.singleton("priceService", () => ({ calculate: (basePrice: number, category: string) => { const discounts: Record<string, number> = { Electronics: 0.05, Fitness: 0.10 }; const discount = discounts[category] ?? 0; return { original: basePrice, discount: parseFloat((basePrice * discount).toFixed(2)), final: parseFloat((basePrice * (1 - discount)).toFixed(2)) }; }}));โ// Logger singletonstore.singleton("logger", () => Log);โ// Product service: transient, depends on repo and price servicestore.register("productService", (c) => { const repo = c.get<ReturnType<typeof createProductRepo>>("productRepo"); const pricing = c.get<ReturnType<typeof createPriceService>>("priceService"); const log = c.get<typeof Log>("logger");โ return { getById: (id: number) => { log.debug("ProductService.getById", { id }); const product = repo.findById(id); if (!product) return null; const price = pricing.calculate(product.basePrice, product.category); return { ...product, price }; } };});โ// Type helpers (not real -- illustrative)function createProductRepo() { return {} as any; }function createPriceService() { return {} as any; }src/routes/store.ts:
import { Router } from "tina4-nodejs";import { store } from "../store/container";โRouter.get("/api/store/{id:int}", async (req, res) => { const id = req.params.id; const productService = store.get<{ getById: (id: number) => unknown }>("productService"); const product = productService.getById(id);โ if (!product) { return res.status(404).json({ error: `Product ${id} not found` }); }โ return res.json(product);});13. Gotchas#
1. Registering after first get()#
If you call get("db") before register("db", ...), the container throws. There is no lazy fallback.
Fix: Register all services at application startup before any code calls get(). Keep all registrations in one file (src/container.ts) that is imported first.
2. Circular dependencies#
serviceA depends on serviceB, which depends on serviceA. The container will recurse infinitely and crash.
Fix: Circular dependencies indicate a design problem. Extract shared logic into a third service that neither depends on the other.
3. Singletons holding stale state#
A singleton database connection created at startup may become invalid if the database server restarts. Every get() returns the same dead connection.
Fix: Implement reconnect logic inside the singleton service, not outside it. The container manages the lifecycle; the service manages its own health.
4. Over-registering transient services#
Registering everything as transient creates a new instance on every get() call, including heavyweight services with connection pools.
Fix: Use singleton() for anything that is expensive to create or that holds persistent state. Use register() only for services that must be fresh per request.