Community Article

Dependency Injection Without a Framework

DI is a pattern, not a framework. The plain-language version (pass things in instead of newing them inside) covers ninety percent of cases. The trade-off versus DI containers, and where each one breaks down.

Dependency Injection Without a Framework

DI is a pattern, not a framework. The plain-language version (pass things in instead of newing them inside) covers ninety percent of cases. The trade-off versus DI containers, and where each one breaks down.

design-patterns
patterns
oop
abstraction
testing
ramijohansson

By @ramijohansson

December 4, 2025

·

Updated May 18, 2026

490 views

5

4.3 (14)

Walk into a Java team's code review and ask "how do we do dependency injection here" and you will hear about Guice or Spring. Walk into a TypeScript team's code review and ask the same and you will probably hear NestJS, InversifyJS, or tsyringe. Walk into a Go team's code review and ask the same and the answer is usually a flat "we just pass things in". The Go team is doing dependency injection. They are doing it the simplest way the pattern can be done, and that simplest way covers most of what the bigger frameworks promise.

My stance: DI is a pattern, not a framework. The pattern is "if a function or a class needs something, pass it in instead of constructing it inside". You do not need a container, an annotation, or a graph resolver to do that. Frameworks add value in specific cases (large applications with deep object graphs, lifecycle management across hundreds of components), but they are also a significant cost (compile-time errors become runtime errors, the container becomes a separate language to learn, the IDE's go-to-definition no longer works on injected fields). Most application code does not need them. I will show what "DI without a framework" looks like, where the cost-benefit flips toward a container, and the rules I follow for either path.

The pattern, stripped

Here is a class that does not use DI:

class UserService {
    async signUp(email: string, password: string) {
        const hash = await bcrypt.hash(password, 10);
        const user = await prisma.user.create({ data: { email, hash } });
        await new SendgridClient(process.env.SENDGRID_KEY!).send('welcome', email);
        return user;
    }
}

Three problems. The class reaches into globals (prisma, process.env) and constructs its own dependencies (new SendgridClient). To test it, you would have to mock at the module level (jest.mock('@/lib/prisma')) or set environment variables before each test. To run it against staging, you would need a different prisma import. To swap email providers, you would touch this file.

DI version:

interface Hasher { hash(plaintext: string): Promise<string>; }
interface UserRepo { create(data: { email: string; hash: string }): Promise<User>; }
interface Emailer { send(template: string, to: string): Promise<void>; }

class UserService {
    constructor(
        private hasher: Hasher,
        private users: UserRepo,
        private email: Emailer,
    ) {}

    async signUp(email: string, password: string) {
        const hash = await this.hasher.hash(password);
        const user = await this.users.create({ email, hash });
        await this.email.send('welcome', email);
        return user;
    }
}

Nothing fancy. Three constructor parameters, three interfaces. The class no longer knows that bcrypt exists, that Prisma exists, or that Sendgrid exists. Anyone constructing a UserService decides those things. That is the entire pattern.

Wiring it up at the edge

The wiring goes in one place, usually index.ts or app.ts:

import { PrismaClient } from '@prisma/client';
import { BcryptHasher } from './adapters/bcrypt-hasher';
import { PrismaUserRepo } from './adapters/prisma-user-repo';
import { SendgridEmailer } from './adapters/sendgrid-emailer';
import { UserService } from './domain/user-service';

const prisma = new PrismaClient();
const hasher = new BcryptHasher();
const users = new PrismaUserRepo(prisma);
const email = new SendgridEmailer(process.env.SENDGRID_KEY!);
const userService = new UserService(hasher, users, email);

export { userService };

This is what people mean by "composition root": the single function or file where every dependency is constructed and wired together. Everything else in the codebase imports the wired services, not the implementations. The composition root is the one place where global state, environment variables, and concrete classes are allowed to meet.

For a small or medium application, the composition root is fifty or a hundred lines. That is fine. It is also the part of the codebase you change least often, because adding a new feature usually means adding a new constructor parameter to one service, not rewiring the whole graph.

What you get for free

Four properties of the DI version that are worth their weight:

  • Tests pass in fakes. A test for UserService.signUp constructs a UserService with three in-memory fakes. No jest.mock, no resetting between tests, no flaky test order.
  • Environments are configuration, not code branches. Production wires SendgridEmailer, staging wires MailpitEmailer, tests wire InMemoryEmailer. Three composition roots, one domain. Domain code does not branch on environment.
  • Refactors are local. Replacing Sendgrid with Postmark means writing a new Emailer adapter and changing one line in the composition root. The five files that use email do not change.
  • The dependency graph is visible. The constructor signatures of every service tell you exactly what each one depends on. You can read UserService(hasher, users, email) and know the surface area in three seconds. With a global-import style, that surface is invisible.

Where it gets clunky without a framework

I want to be honest about where the manual version creaks.

The first creak is in big object graphs. Once you have forty services that each depend on six others, the composition root becomes a long block of new calls in the right order. It is still readable, but it is no longer fun. The pattern does not break; the ergonomics get tedious.

The second creak is in lifecycle management. Some dependencies need shutdown hooks (db.disconnect() on SIGTERM, httpServer.close() on graceful shutdown). Manually wiring those into a single shutdown() function works but tracks two parallel lists: the construction order and the teardown order, and they tend to drift.

The third creak is in request-scoped vs singleton-scoped dependencies. A RequestContext should be a different instance per HTTP request; a PrismaClient should be one per process. Mixing the two in the same composition root requires a layer of indirection (factories that return new instances, vs already-constructed singletons), which is exactly the kind of thing DI containers automate.

When any of those three creaks is the dominant pain in your codebase, a container starts to earn its keep. Until then, manual wiring is fine.

When I would reach for a container

Three cases where I have actively asked for DI containers in production:

  1. Hundreds of services, deep graph. Twenty services is fine to wire by hand. Two hundred is not. The container's value is automating the tedious bookkeeping of "what depends on what".
  2. Strict request-scoped lifecycle. When the per-request graph is non-trivial (auth context, transactional database client, request-scoped logger with trace ID), a container that knows the difference between scopes is worth the extra runtime indirection.
  3. Plug-in architectures. When third-party code can register implementations (e.g. a CMS that allows extensions to provide their own renderers), the container's registration story is the cleanest one I know.

Notice all three cases share a property: the cost of manual wiring would be high, and high in a way that grows with the codebase. For a single-team service, the answer is almost always "just wire it manually until you cannot".

A specific failure mode of containers

The failure mode I have seen in container-heavy codebases is what I call the "injection illusion". A test for a service constructs the service via the container, which resolves all the dependencies, including the database client. Suddenly the test is hitting the real database, because the container thought that is what the test wanted. Or worse, the test is hitting a half-mocked container where some dependencies were overridden and others were not, and the test fails in a way that is impossible to debug without reading the container configuration.

The root cause is that the container makes the wiring opaque. When a test fails because UserService got the production Emailer, you cannot trace it through git grep. You have to read the container's binding rules. That opacity is the price of automation, and it is real.

A constraint I would push for either way

Whether you use a framework or not, the constraint that pays off most is the simple one: domain code accepts collaborators through its constructor or function arguments, and never imports its dependencies directly. That constraint, alone, is most of the value of DI. Frameworks help you scale that constraint to large codebases. Manual wiring lets you meet that constraint with no infrastructure at all. Either way, the constraint is the thing that makes the code testable, swappable, and honest about its dependencies.

A related rule that helps: every concrete adapter (the BcryptHasher, the PrismaUserRepo, the SendgridEmailer) lives in a separate file from the domain interface it implements. The domain knows about Hasher, not about bcrypt. The adapter knows about both. That split keeps the import graph clean: no domain file imports a third-party library directly. Pair that with manual DI, and you can swap any adapter without touching domain code.

A small checklist for the wiring file

Before I land a composition root or merge a change to one, I run through:

  • Does every concrete class get constructed in this file, and only this file?
  • Are there any environment-specific branches inside domain code? (There should be none; the wiring file is the one place those branches live.)
  • Does every dependency that needs cleanup have a registered teardown step?
  • Is the construction order topologically sound? (Things constructed first do not depend on things constructed later.)
  • Are tests using their own composition roots, or are they using production wiring with overrides? (Own composition roots are simpler and more honest.)

Those five checks catch the most common bugs I have seen in DI-heavy code, and they apply equally to manual wiring and to container-based wiring. The pattern is simple. The discipline around the wiring is the part that takes practice.

Why most teams do not need the framework

The pitch for a DI framework is that it scales to large applications. That is true. The pitch I do not buy is that you should adopt the framework before you need it. Manual DI scales further than people expect (most codebases plateau at fifty or so services), the wiring file stays readable longer than people expect, and the tests are easier to write than container-based tests. By the time you genuinely need the framework, you will know, because the wiring file will be unreasonably long and the manual lifecycle bookkeeping will have caused a real bug. Until then, the framework is solving a problem you do not have, and it is replacing your IDE's static analysis with a runtime dictionary that loses every advantage TypeScript was supposed to give you. Ship the constructor parameters. The framework can wait.

Back to Articles