Arrange Act Assert

Jag Reehals thinking on things, mostly product development

Composition Roots and fn(args, deps)

16 Mar 2026

A lot of developers hear "dependency injection" and immediately think of containers, decorators, registration APIs, lifecycle scopes, and framework magic.

That reaction is understandable.

But that association often leads people to overcomplicate a problem that has a much simpler starting point.

Composition roots and fn(args, deps)

At its core, dependency injection just means this:

Pass collaborators in explicitly instead of reaching for them implicitly.

One of the simplest ways to do that in plain TypeScript is this shape:

fn(args, deps)

Where args is call-specific input and deps is the set of collaborators the function needs.

You can read that as: data in, capabilities in.

fn(args, deps) is flexible enough to support composition, testing, and clean application wiring without forcing you into a DI framework.

Why explicit dependencies matter

Consider a function like this:

type CreateUserArgs = {
  name: string;
  email: string;
};

type CreateUserDeps = {
  db: Database;
  mailer: Mailer;
  logger: Logger;
};

export async function createUser(
  args: CreateUserArgs,
  deps: CreateUserDeps,
) {
  deps.logger.info("Creating user", { email: args.email });

  const user = await deps.db.users.insert(args);
  await deps.mailer.sendWelcome(user);

  return user;
}

The signature tells you almost everything you need to know about this function:

Nothing is hidden in global state. Nothing is implied through a class instance. Nothing relies on framework wiring happening somewhere else.

That explicitness is the payoff.

This is just a function shape, not a framework

fn(args, deps) is not a DI framework.

It does not require a container, decorators, runtime lookup, reflection, registration, or auto-wiring:

await createUser(
  { name: "Ada", email: "ada@example.com" },
  { db, mailer, logger },
);

That is just a function with explicit collaborators, and that is exactly why it is useful.

If you can call a function, you already know how to use this pattern.

In many TypeScript codebases, that is enough, and it is often better than introducing a container prematurely.

The composition root is where the wiring happens

fn(args, deps) does not mean manually passing dependencies through every layer forever.

The function stays explicit about what it needs. The application satisfies those dependencies at the edge.

That edge is your composition root.

// main.ts
import { createUser } from "./create-user";
import { makeDb } from "./infra/db";
import { makeMailer } from "./infra/mailer";
import { makeLogger } from "./infra/logger";

const db = makeDb(process.env.DATABASE_URL!);
const mailer = makeMailer();
const logger = makeLogger();

const productionDeps = { db, mailer, logger };

await createUser(
  { name: "Ada", email: "ada@example.com" },
  productionDeps,
);

That startup file is the composition root, the place where infrastructure gets created and connected to application logic.

In a CLI, this might be bin/main.ts. In a web application, it might be the HTTP server bootstrap or the framework entrypoint.

It is usually worth creating small factory functions for reusable infrastructure, things like makeLogger, makeDb, and makeMailer.

These are the primitive building blocks of the composition root: small functions that construct infrastructure without mixing in domain logic.

They give the composition root reusable infrastructure pieces, while fn(args, deps) shows how those pieces are combined into application logic.

Bind dependencies when it helps ergonomics

If you do not want every call site to pass both args and deps, bind dependencies once and expose a simpler function:

export const makeCreateUser =
  (deps: CreateUserDeps) =>
  (args: CreateUserArgs) =>
    createUser(args, deps);

Then at the composition root:

const createUserWithServices = makeCreateUser({ db, mailer, logger });

await createUserWithServices({
  name: "Ada",
  email: "ada@example.com",
});

Tests can still call the base function directly:

await createUser(
  { name: "Ada", email: "ada@example.com" },
  { db: fakeDb, mailer: fakeMailer, logger: fakeLogger },
);

The pattern supports direct calls, partial application, and pre-wired functions without hiding the dependency boundary.

If you bind every function eagerly you can end up with a forest of factories. Reach for makeX when there are multiple call sites or when you are crossing a boundary such as from routes or controllers into domain logic. Otherwise, calling fn(args, deps) directly is fine.

When plain imports are better

Not every dependency needs to be injected.

For pure helpers with no side effects, direct imports are usually simpler:

import { normalizeEmail } from "./normalize-email";

export function toUserRecord(args: CreateUserArgs) {
  return {
    ...args,
    email: normalizeEmail(args.email),
  };
}

Passing it through deps would add ceremony without much value.

A useful rule of thumb:

You can think of deps as the things that talk to the world or differ by environment. Pure helpers are regular imports, not missing dependencies.

What this pattern prevents

A lot of the value comes from the mistakes it makes harder to hide.

When dependencies are explicit:

Compare these two versions:

// Hidden dependency: reaches into infrastructure directly
import { db } from "../infra/db";

export async function getUserHidden(userId: string) {
  return db.users.find(userId);
}

// Explicit dependency: db is part of the signature
export async function getUserExplicit(
  args: { userId: string },
  deps: { db: Database },
) {
  return deps.db.users.find(args.userId);
}

In the first version, tests have to mock the module import or spin up a real database. In the second, you pass in whatever you want.

The real case for fn(args, deps)

The case for this pattern is simple: it keeps dependencies explicit, wiring honest, and complexity optional.

Used consistently, it gives you a codebase where side effects are visible in signatures, tests are straightforward to wire, and application assembly happens in one honest place.

That is the appeal of fn(args, deps).

In plain TypeScript, it is a better default than reaching for framework-style DI before your code actually demands it.

typescript coding architecture di