Dependency Injection Without a Framework
Learn how to implement dependency injection manually using constructor injection in TypeScript and Python — no framework required.
Dependency Injection (DI) is one of the most misunderstood patterns in software development. Many developers associate it with heavy frameworks like Spring, NestJS, or InversifyJS. But DI is just a principle: don’t create your dependencies — receive them.
You don’t need a framework. You don’t need decorators or containers. Constructor injection with plain code gets you 90% of the benefit with none of the magic.
The Problem: Hard-Coded Dependencies
Here’s a typical service that creates its own dependencies:
class OrderService {
private db = new PostgresDatabase();
private emailer = new SmtpEmailClient();
private logger = new FileLogger('/var/log/orders.log');
async placeOrder(order: Order): Promise<void> {
await this.db.save('orders', order);
await this.emailer.send(order.customerEmail, 'Order Confirmed', `Order #${order.id}`);
this.logger.info(`Order ${order.id} placed`);
}
}
This works. But it has three serious problems:
- Untestable — You can’t test
placeOrderwithout a real Postgres database, a real SMTP server, and a real filesystem. - Inflexible — Switching from Postgres to MySQL means editing
OrderService. Switching from SMTP to SendGrid means editingOrderService. Every change ripples. - Hidden dependencies — Looking at
new OrderService(), you have no idea it needs a database, an email client, and a logger. The constructor tells you nothing.
The Fix: Constructor Injection
Define interfaces for what you need, then accept them as constructor parameters:
interface Database {
save(table: string, record: unknown): Promise<void>;
findById(table: string, id: string): Promise<unknown>;
}
interface EmailClient {
send(to: string, subject: string, body: string): Promise<void>;
}
interface Logger {
info(message: string): void;
error(message: string, err?: Error): void;
}
class OrderService {
constructor(
private db: Database,
private emailer: EmailClient,
private logger: Logger,
) {}
async placeOrder(order: Order): Promise<void> {
await this.db.save('orders', order);
await this.emailer.send(
order.customerEmail,
'Order Confirmed',
`Order #${order.id} confirmed. Thank you!`,
);
this.logger.info(`Order ${order.id} placed`);
}
}
Now OrderService doesn’t know or care whether it’s talking to Postgres, SQLite, or a mock. It just needs something that implements Database.
Wiring It Up in Production
Create a composition root — one place where you assemble your real dependencies:
// src/main.ts — the composition root
function createApp() {
const db = new PostgresDatabase(process.env.DATABASE_URL!);
const emailer = new SmtpEmailClient({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT),
});
const logger = new ConsoleLogger();
const orderService = new OrderService(db, emailer, logger);
const userService = new UserService(db, emailer, logger);
const paymentService = new PaymentService(db, logger);
return { orderService, userService, paymentService };
}
const app = createApp();
All the new calls happen in one place. Every service receives its dependencies from above. This is the entire “framework” — a function that wires things together.
Testing Becomes Trivial
With DI, you can inject fakes or mocks during tests:
import { describe, it, expect, vi } from 'vitest';
function createMockDatabase(): Database {
return {
save: vi.fn().mockResolvedValue(undefined),
findById: vi.fn().mockResolvedValue(null),
};
}
function createMockEmailer(): EmailClient {
return {
send: vi.fn().mockResolvedValue(undefined),
};
}
function createMockLogger(): Logger {
return {
info: vi.fn(),
error: vi.fn(),
};
}
describe('OrderService', () => {
it('saves the order and sends a confirmation email', async () => {
const db = createMockDatabase();
const emailer = createMockEmailer();
const logger = createMockLogger();
const service = new OrderService(db, emailer, logger);
const order: Order = {
id: 'order-123',
customerEmail: '[email protected]',
items: [{ name: 'Widget', price: 9.99 }],
};
await service.placeOrder(order);
expect(db.save).toHaveBeenCalledWith('orders', order);
expect(emailer.send).toHaveBeenCalledWith(
'[email protected]',
'Order Confirmed',
expect.stringContaining('order-123'),
);
expect(logger.info).toHaveBeenCalledWith('Order order-123 placed');
});
it('propagates database errors', async () => {
const db = createMockDatabase();
(db.save as any).mockRejectedValue(new Error('Connection refused'));
const service = new OrderService(db, createMockEmailer(), createMockLogger());
await expect(service.placeOrder({} as Order)).rejects.toThrow('Connection refused');
});
});
No real database. No real email server. Tests run in milliseconds.
The Python Version
Python uses the same principle with protocols (structural typing) or abstract base classes:
from abc import ABC, abstractmethod
from dataclasses import dataclass
class Database(ABC):
@abstractmethod
async def save(self, table: str, record: dict) -> None: ...
@abstractmethod
async def find_by_id(self, table: str, record_id: str) -> dict | None: ...
class EmailClient(ABC):
@abstractmethod
async def send(self, to: str, subject: str, body: str) -> None: ...
class Logger(ABC):
@abstractmethod
def info(self, message: str) -> None: ...
@abstractmethod
def error(self, message: str, exc: Exception | None = None) -> None: ...
@dataclass
class OrderService:
db: Database
emailer: EmailClient
logger: Logger
async def place_order(self, order: dict) -> None:
await self.db.save("orders", order)
await self.emailer.send(
order["customer_email"],
"Order Confirmed",
f"Order #{order['id']} confirmed. Thank you!",
)
self.logger.info(f"Order {order['id']} placed")
And the test with unittest.mock:
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_place_order_saves_and_sends_email():
db = AsyncMock(spec=Database)
emailer = AsyncMock(spec=EmailClient)
logger = MagicMock(spec=Logger)
service = OrderService(db=db, emailer=emailer, logger=logger)
order = {"id": "order-123", "customer_email": "[email protected]"}
await service.place_order(order)
db.save.assert_called_once_with("orders", order)
emailer.send.assert_called_once()
logger.info.assert_called_once_with("Order order-123 placed")
When Factory Functions Beat Classes
For simpler cases, you can skip classes entirely and use closures:
interface NotificationSender {
notify(userId: string, message: string): Promise<void>;
}
function createNotificationSender(
emailer: EmailClient,
logger: Logger,
): NotificationSender {
return {
async notify(userId: string, message: string) {
const user = await fetchUser(userId);
await emailer.send(user.email, 'Notification', message);
logger.info(`Notified ${userId}`);
},
};
}
// In tests:
const sender = createNotificationSender(mockEmailer, mockLogger);
This is dependency injection without a single class keyword. Functions receiving their dependencies as arguments — that’s all DI is.
Common Mistakes
1. Injecting Too Deep
Don’t pass dependencies through five layers just because one leaf function needs them. If ServiceA → ServiceB → ServiceC all need a logger, inject it into each directly at the composition root. Don’t thread it through.
2. Service Locators Are Not DI
// This is a service locator, NOT dependency injection
class OrderService {
async placeOrder(order: Order) {
const db = Container.resolve<Database>('Database'); // Hidden dependency!
await db.save('orders', order);
}
}
Service locators hide dependencies behind a global registry. You lose the explicitness that makes DI valuable.
3. Over-Abstracting
Not everything needs an interface. If you only ever have one implementation of something and it’s unlikely to change, just inject the concrete class. Add the interface when you actually need the flexibility — typically when testing requires it.
When to Reach for a Framework
Manual DI works great for small-to-medium projects. Consider a DI container when:
- You have 50+ services with complex dependency graphs
- You need lifecycle management (singletons, request-scoped instances)
- You’re building a plugin system where implementations are loaded dynamically
Until then, a plain composition root function is simpler, more explicit, and easier to debug. No magic, no decorators, no runtime reflection. Just functions calling functions.