# Composition Roots and fn(args, deps)

By Jag Reehal, 2026-03-16. Canonical: https://arrangeactassert.com/posts/composition-roots-and-fn-args-deps/

<!-- Excerpt Start -->

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)](/_astro/composition-roots-and-fn-args-deps.Bm4yRfMA_qUENc.webp)

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 (introduced in the series starting point: [fn(args, deps)](/posts/one-rule-for-ai-code-fn-args-deps/)):
```typescript
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.

<!-- Excerpt End -->

## Why explicit dependencies matter

Consider a function like this:

```typescript
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:

- what input the function needs
- what external capabilities it relies on
- what has to be provided in tests
- what has to be wired at the edge of the app

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:

```typescript
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.

```typescript
// 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:

```typescript
export const makeCreateUser =
  (deps: CreateUserDeps) =>
  (args: CreateUserArgs) =>
    createUser(args, deps);
```

Then at the composition root:

```typescript
const createUserWithServices = makeCreateUser({ db, mailer, logger });

await createUserWithServices({
  name: "Ada",
  email: "ada@example.com",
});
```

Tests can still call the base function directly:

```typescript
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:

```typescript
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 (also echoed in the [SOLID post](/posts/fn-args-deps-and-solid-principles/)):

- use direct imports for pure logic
- use `deps` for things that touch the outside world, vary by environment, or matter in tests

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:

- hidden coupling is harder to introduce
- tests are easier to write honestly
- dependency growth shows up in the signature
- wiring lives at the edge instead of being smeared across the codebase

Compare these two versions:

```typescript
// 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.