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:

  1. Untestable — You can’t test placeOrder without a real Postgres database, a real SMTP server, and a real filesystem.
  2. Inflexible — Switching from Postgres to MySQL means editing OrderService. Switching from SMTP to SendGrid means editing OrderService. Every change ripples.
  3. 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 ServiceAServiceBServiceC 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.