Skip to content
Back to Ideas 7 min read

Inversion of Control Doesn't Need a DI Container

A
Anastasios Orfanidis
·

Inversion of Control is a decision about who wires your code together, not a library you install. You can get almost all of its benefits with plain functions and one place where everything is assembled.

The picture in most heads

Ask a room of developers what Inversion of Control looks like and many will describe a framework: an @Injectable() decorator, a container that "resolves" classes, some reflection metadata, and a configuration file that says which class goes where.

That container is a dependency injection container, or DI container: a library that builds your objects for you and plugs in their dependencies automatically. You register a recipe for each piece once, ask the container for a SearchService, and it works out that it needs an HTTP client and a logger, builds those first, and hands them in. It has nothing to do with Docker. When this post says container, it means this kind.

Those tools are real and sometimes useful. But they make Inversion of Control look like machinery you adopt, when it is a habit you practise. The machinery also hides the idea. When wiring happens by magic at runtime, it is hard to see that the important move was simply not letting a class build its own dependencies.

What Inversion of Control actually is

Inversion of Control means a piece of code stops deciding what it depends on. Something outside it makes that choice and hands the pieces in.

Here is a search service that is in control of everything, including its own dependencies:

export class SearchService {
  private http = new HttpClient('https://api.example.com', process.env.API_KEY!);
  private log = new ConsoleLogger();

  async search(q: string) {
    this.log.info('search', { q });
    return this.http.get(`/records?q=${encodeURIComponent(q)}`);
  }
}

It works, but it has quietly made three decisions that are not its business: which server to call, where the key comes from, and where the logs go. You cannot run it without a real network, and you cannot see what it sent.

Invert it, and the same logic receives its collaborators instead:

export function createSearchService(deps: { http: HttpClient; log: Logger }) {
  return {
    async search(q: string) {
      deps.log.info('search');
      return deps.http.get(`/records?q=${encodeURIComponent(q)}`);
    },
  };
}

That is the whole idea, and no container appeared. The search logic is the same. The difference is that it no longer owns the decisions about infrastructure. It also stopped logging the search term, which is a privacy habit worth keeping: logs leave the building.

Three ways to invert control without a container

1. Pass dependencies in. Through a function's parameters, a factory or a constructor. TypeScript checks every connection when you compile, and reading the signature tells you exactly what the code needs.

2. Wire everything in one composition root. One file, usually the entry point, builds the real pieces and connects them. Nothing else in the codebase calls new on infrastructure.

// server.ts: the only place that knows about real infrastructure
const log = Logger.create({ level: 'info' });
const http = HttpClient.create({ baseUrl: config.apiUrl, apiKey: config.apiKey });
const search = createSearchService({ http, log });

app.get('/api/search', async (req, res) => res.json(await search.search(String(req.query.q))));

When someone asks where the database is configured, there is exactly one answer.

3. Let the framework call you. This is the Hollywood principle: don't call us, we'll call you. An Express middleware, a React component, a test runner's it block, a plugin hook. In each case you hand over a function and the framework decides when to run it. In React, context plays the role of a tiny injector: a provider near the root hands the real pieces to every component below it, and a test renders the same tree with a provider that hands in stand ins.

<ServicesProvider services={{ search, clock }}>
  <App />
</ServicesProvider>

All three are Inversion of Control, and none of them needs a library.

The payoff shows up in your tests

Once code receives its dependencies, a test can hand it different ones. The common reflex is a mocking library. A calmer option is to give each piece of infrastructure two factories: create() for the real thing and createNull() for an in memory version that stubs only the lowest layer. James Shore calls this Nullables, in his pattern language Testing Without Mocks. We wrote up how we retrofitted it onto the Dojo's server, one small step at a time.

it('retries a temporary failure, then returns the results', async () => {
  const http = HttpClient.createNull({ failures: [503], responses: [{ items: [] }] });
  const requests = http.trackRequests();
  const search = createSearchService({ http, log: Logger.createNull() });

  await expect(search.search('kestrel')).resolves.toEqual({ items: [] });
  expect(requests.data).toHaveLength(2);
});

The real retry and parsing code runs; only the network is swapped. The test checks what was sent, rather than which internal method was called, so it survives refactoring. The composition root is the one place that calls create(), and tests call createNull(). Same code, different wiring: that is Inversion of Control earning its keep.

When a container does earn its place

Containers solve problems that appear at a certain size, so it is worth being honest about the trade.

Factories and a composition rootA DI container
Where wiring livesone file you can read from top to bottomspread across decorators and registrations
When a missing dependency is caughtwhen you compileoften at runtime, on first use
Lifetimes (per request, shared, per tenant)written by handbuilt in
Plugins registering their own servicesawkwardnatural
Cost to learn and debuglowhigher, with decorators and reflection

If your framework is built around a container, use it: working against the grain of a framework costs more than its magic does. If you are writing a library, a small application or a service, start with factories and a composition root, and reach for a container when wiring lifetimes or plugins by hand starts to hurt.

Making it a team habit

The useful part of this idea is a question rather than a pattern: who should decide this? It travels well in a code review. When a pull request adds a new HttpClient() deep inside a module, asking who decided the server, the key and the timeout starts a better conversation than "please use dependency injection" ever does.

A few agreements make it stick across a team. Infrastructure is created in one composition root per application. Every wrapper around the outside world offers create() and createNull(). Tests describe what was sent and what the user saw. And a newcomer can open one file and see the whole shape of the system. None of that needs a framework decision or a migration. It needs a team that keeps asking the question.

Food for thought

Take these back to the codebase you work in this week:

  • Search for new on anything that talks to the outside world: HTTP, databases, the clock, Math.random, localStorage. Who decided that, and could it be handed in instead?
  • Where is your composition root? If the honest answer is "everywhere", what would it take to make it one file?
  • How many of your tests use a mocking library to fake infrastructure? What would they look like if the infrastructure had a createNull()?
  • If you use a DI container, could a new teammate find out what gets injected into a class without running the application?
  • Which decisions does each module make that are not really its business?

What comes next

We are building these ideas into an open source SDK at EkoHacks, called Ariadne: a toolkit for applications that explore connected data, where every collaborator is handed in, every piece of infrastructure has a null twin, and the composition root is the only place that knows what is real. It is early. We will write about it here as it takes shape, the same way we build everything else: in the open, one tested step at a time.

A

Written by

Anastasios Orfanidis

More from Ideas

·7 min read

Unknown Is an Answer

A stub that holds no documents could not say whether a change still matched a query. Letting it answer unknown named a case the real database has too.

·7 min read

The Inbox That Delivers Nowhere

Signing in by emailed code puts a mailbox on the critical path. How we use Mailpit on Studomia, how our tests read the code, and what one question taught us.

·6 min read

What the Nullable Gave Back

One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.