Arrange Act Assert

Jag Reehals thinking on things, mostly product development

fn(args, deps) and Composition — Know When to Nest the Dolls

18 Mar 2026

Just because two pieces of code look the same does not mean they are the same.

The most common architecture mistake is not too little abstraction. It is too much, too early. You see duplication, you extract a shared module, and six months later that module is a monster held together by special cases and boolean flags.

Dan Abramov gave a talk about this called The Wet Codebase. The core argument: the wrong abstraction is far more expensive than duplication. Once an abstraction exists, it creates inertia. Nobody wants to be the person who suggests copy-paste.

fn(args, deps) changes this calculus. It makes abstractions cheap to create, cheap to test, and cheap to undo.

When a function's deps grow too large, that can be a signal that some responsibility has stabilized into its own function — and that new function itself follows fn(args, deps). (This is basically SRP pressure showing up in your signature; see the SOLID post for that framing.)

Russian dolls. Each layer independently testable. Each layer reversible.

fn(args, deps) is all you need

The wrong abstraction creates inertia

Abramov tells a familiar story. Two modules share similar code. A colleague extracts a shared abstraction. Then a third use case arrives that is almost the same, but not quite. So you add a parameter. Then a special case. Then another.

Each step makes sense to the person writing and reviewing it. But if you lose track of what the abstraction was supposed to represent, you end up with a module nobody wants to touch.

The real damage is social, not technical. Even when the team agrees the abstraction is bad, unwinding it is expensive, especially if other teams depend on it, if nobody knows how to test the inlined version, or if nobody wants to be the one who decreases code coverage.

Abramov's advice: restrain yourself. Let the code stabilize before you extract. And when you do extract, test the concrete behavior, not the abstraction — so that refactoring stays safe.

That advice is exactly right. And fn(args, deps) is what makes it practical.

When deps are explicit data, inlining an abstraction becomes much more mechanical. Copy the function body back, merge the deps types, and let the compiler show you the remaining seams. There is no module mocking to untangle, no framework wiring to redo, no decorator chain to unravel. The abstraction is just a function. Removing it is just removing a function.

The smell: too many deps

The fn(args, deps) equivalent of "too many constructor parameters" is a deps type that keeps growing.

type ProcessOrderArgs = {
  productId: string;
  quantity: number;
  paymentMethod: PaymentMethod;
};

type ProcessOrderDeps = {
  findProduct: (id: string) => Promise<Product>;
  checkInventory: (productId: string, qty: number) => Promise<boolean>;
  reserveStock: (productId: string, qty: number) => Promise<void>;
  chargePayment: (amount: number, method: PaymentMethod) => Promise<ChargeResult>;
  sendConfirmation: (order: Order) => Promise<void>;
  log: (msg: string, meta?: unknown) => void;
  now: () => number;
};

Seven deps. That is a signal, not a sin. The question is not "should I abstract?" It is "have some of these deps stabilized into a coherent responsibility?"

If you are on day one and this is the first function that uses these deps — leave it flat. You do not know yet which deps belong together. Premature grouping is how you get Abramov's monster.

But if you have been living with this code for a while, and you see findProduct, checkInventory, and reserveStock showing up together in multiple functions, and they always represent the same concern — inventory fulfillment — then you probably have an abstraction waiting to be named.

Consolidate when the responsibility is clear

Some of those deps cluster naturally. findProduct, checkInventory, and reserveStock are all inventory concerns. chargePayment is a payment concern.

The move is to extract new functions that each follow fn(args, deps).

type FulfillOrderArgs = {
  productId: string;
  quantity: number;
};

type FulfillOrderDeps = {
  findProduct: (id: string) => Promise<Product>;
  checkInventory: (productId: string, qty: number) => Promise<boolean>;
  reserveStock: (productId: string, qty: number) => Promise<void>;
};

async function fulfillOrder(args: FulfillOrderArgs, deps: FulfillOrderDeps) {
  const product = await deps.findProduct(args.productId);
  const available = await deps.checkInventory(args.productId, args.quantity);

  if (!available) {
    return { ok: false as const, reason: 'out-of-stock' };
  }

  await deps.reserveStock(args.productId, args.quantity);
  return { ok: true as const, product };
}

Now the parent function's deps get simpler:

type ProcessOrderDeps = {
  fulfillOrder: (args: FulfillOrderArgs) => Promise<FulfillResult>;
  chargePayment: (amount: number, method: PaymentMethod) => Promise<ChargeResult>;
  sendConfirmation: (order: Order) => Promise<void>;
  log: (msg: string, meta?: unknown) => void;
};

From seven deps to four. And fulfillOrder is itself fn(args, deps). It has its own deps — findProduct, checkInventory, reserveStock — wired at the composition root.

And now the parent function reads like a plan:

async function processOrder(args: ProcessOrderArgs, deps: ProcessOrderDeps) {
  deps.log('Processing order', { productId: args.productId });

  const fulfillment = await deps.fulfillOrder({
    productId: args.productId,
    quantity: args.quantity,
  });

  if (!fulfillment.ok) return fulfillment;

  const charge = await deps.chargePayment(
    fulfillment.product.price * args.quantity,
    args.paymentMethod,
  );

  if (!charge.ok) return { ok: false as const, reason: 'payment-failed' };

  const order = { ...args, product: fulfillment.product, chargeId: charge.id };
  await deps.sendConfirmation(order);

  return { ok: true as const, order };
}

The parent does not reach into fulfillment's internals. It only knows the shape: give me a product and quantity, get back a result.

Each layer is independently testable. You can test fulfillOrder with fake product lookups. You can test processOrder with a fake fulfillOrder that always succeeds. The layers stay decoupled.

Extract only when the responsibility is clear

This is where Abramov's lesson matters most.

Do not create fulfillOrder on day one. Start with the flat deps list. Write the code. Ship it. Let it stabilize.

The signal that it is time to extract:

If you cannot name it clearly, you do not understand the responsibility yet. Wait.

fn(args, deps) makes this safe in both directions:

The right time to abstract is when you understand the responsibility, not when you notice the duplication.

Why this scales

Each layer is just fn(args, deps). Same testing story. Same composition root wiring. Same explicit dependency surface.

No inheritance hierarchies. No decorator chains. No middleware stacks. No framework registration.

You can zoom in and test fulfillOrder in isolation with three simple deps. Or you can stay at the higher layer and test processOrder with a fake fulfillOrder that returns a canned result.

The pattern does not fight you when you change your mind. Because every layer is just a function that takes data and capabilities. If a layer turns out to be wrong, you delete it and move on. The types tell you exactly what to fix.

The takeaway

Composition is not about having the right abstraction from the start. It is about making abstractions cheap to create, cheap to test, and cheap to undo.

fn(args, deps) gives you all three, because every layer is just a function that takes data and capabilities. Nest them when the responsibility is clear. Flatten them when it is not.

That is the Russian dolls model: layers you can add when the responsibility is clear, and remove when it is not.

fn(args, deps) Series

  1. If You Only Enforce One Rule for AI Code, Make It fn(args, deps)
  2. Why Matt Pocock Is Right About Making Codebases AI Agents Love
  3. Composition Roots and fn(args, deps)
  4. fn(args, deps) and Hexagonal Architecture
  5. How fn(args, deps) supports SOLID-style design in TypeScript
  6. What `fn(args, deps)` actually gives you in DDD-style TypeScript
  7. fn(args, deps) — Bringing Order to Chaos Without Breaking Anything
  8. fn(args, deps) Is Programming to Interfaces — And That's How You Control Nondeterminism
  9. fn(args, deps) and Composition — Know When to Nest the Dolls (this post)
fn-args-deps typescript composition architecture fn-args-deps-series