Dependency Injection Without a Framework: A Practical Introduction

Dependency injection sounds like it requires a framework, a lot of ceremony, and configuration files full of XML or decorators. In its actual, original form, it’s a much simpler idea: instead of a piece of code creating the things it depends on, those dependencies get handed to it from the outside. That’s the entire concept — everything else is optional tooling built on top of it.

The Problem Dependency Injection Actually Solves

Consider a function that needs to send an email and, inside itself, creates its own instance of an email client, hardcoded to a real SMTP server. Every test of that function now either sends a real email or requires elaborate mocking tricks to intercept the network call. The function is difficult to test not because testing is inherently hard, but because the function decided for itself exactly which email client to use, with no way for anything calling it to substitute a different one.

Dependency injection just means the function receives its email client as a parameter, rather than constructing it internally: function sendWelcomeEmail(emailClient, user) { ... } instead of creating the client inside the function body. Now a test can pass in a fake client that just records what it was asked to send, without touching a real network at all — not because of any special testing framework, but because the function’s dependencies are now visible and swappable from the outside.

You Don’t Need a Framework to Do This

Frameworks that specialize in dependency injection (with “containers” that automatically figure out what to construct and inject) solve a specific problem: managing dependency injection across a large application with many interconnected pieces, where manually passing every dependency through every layer would become unwieldy. For a small to medium codebase, this problem often doesn’t exist yet, and reaching for a DI framework before you actually need one adds a layer of “magic” (implicit wiring that isn’t visible just by reading the code) in exchange for solving a scale problem you don’t have.

The plain version — just passing dependencies as function parameters or constructor arguments — gets you the actual benefit (testability, swappable implementations) without any framework at all. This is sometimes called “manual” or “poor man’s” dependency injection, and for most applications it’s not a lesser version of the real thing; it’s the real thing, without ceremony.

Where This Actually Helps Beyond Testing

Testability is the most commonly cited benefit, but it’s not the only practical one:

Swapping Implementations Without Changing Calling Code

If your code depends on an abstract “storage” interface rather than a specific database client directly, switching from one storage backend to another means writing a new implementation of that interface and injecting it — the calling code that uses storage doesn’t need to change at all, because it never knew which specific implementation it was using in the first place.

Making Hidden Dependencies Visible

A function that creates its own dependencies internally hides what it actually needs to work — you have to read the function body to discover it secretly depends on a specific configuration value or an external service. A function that receives its dependencies as parameters makes every real dependency visible in its signature, which makes the codebase easier to reason about without needing to trace through implementation details.

A Concrete Before-and-After Example

Consider a function that fetches a user’s order history: before dependency injection, it might directly import a database client and call it inline, meaning any test of this function hits a real database or requires patching the import itself, which is fragile and tightly coupled to how the module happens to be structured internally. After the change, the function accepts a database client as a parameter, and the calling code (or the application’s entry point) is responsible for constructing that client and passing it down. A test can now pass in an in-memory fake that returns canned order data instantly, with no database at all, and the function’s own logic — how it processes and formats that order history — becomes trivial to verify in isolation from the data-fetching concern entirely.

Where People Overdo It

The failure mode in the other direction is injecting everything, including things that never need to vary — a constant, a pure utility function, a value that will genuinely never be swapped for anything else in the codebase’s lifetime. Injecting dependencies that will never realistically change adds indirection without buying any real flexibility, and makes code harder to read for no corresponding benefit. A reasonable rule of thumb: inject things that have side effects (network calls, file system access, database access, anything involving real-world state) or things you can genuinely imagine swapping (a different implementation for tests, a different provider in the future); leave pure, deterministic logic as direct function calls without injecting it.

Constructor Injection vs. Passing Parameters Directly

In object-oriented codebases, dependency injection often takes the form of passing dependencies into a class’s constructor rather than into individual functions — the class stores them as instance properties and uses them across its methods. This matters when several methods on the same object need the same dependency; injecting once at construction avoids repeating the same parameter across every method signature. In simpler, more functional code without much shared state, injecting directly into individual functions as parameters is often clearer and avoids introducing a class purely to hold dependencies. Neither approach is universally correct — the choice follows from whether you already have persistent, stateful objects in your design, not from dependency injection itself requiring one style over the other.

A Practical Starting Point

If a codebase currently constructs its external dependencies (database clients, API clients, file system access) deep inside the functions that use them, a reasonable first refactor is lifting those constructions up to the entry point of the application — where the program starts — and passing the already-constructed instances down through function parameters or constructor arguments to wherever they’re actually used. This alone, without adopting any DI framework, is usually enough to make the affected code meaningfully easier to test and reason about, and it’s a change that can be done incrementally, one dependency at a time, rather than as a large, risky rewrite.

Leave a Reply

Your email address will not be published. Required fields are marked *