Arrange Act Assert

Jag Reehals thinking on things, mostly product development

If You Only Enforce One Rule for AI Code, Make It fn(args, deps)

04 Mar 2026

AI coding agents can generate code faster than you can reason about it.

There is one pattern that works for both new and legacy codebases because you can adopt it as a non-breaking change.

fn(args, deps) is all you need

Every function has exactly two inputs: data (args) and capabilities (deps).

Without a clear constraint, generated code becomes the wild west, you can't reliably reason about what a function depends on, what it does, or how to compose it.

fn(args, deps) is that constraint

For existing code, start with:

functionName(args, deps = defaultDeps)

Dependencies are visible, not implicit.

There’s no framework and no package to install.

Linters and reviews catch problems after the fact. It's better to get the shape right before the agent writes the code.

fn(args, deps) is simple but powerful.

args are the per-call inputs.

deps are the collaborators a function depends on.

That means:

The constraint makes it easier for both you and the agent to reason about what the code does.

The types explain intent. With this pattern, the agent has to colour within the lines.

It's boring. That's why it scales.

This isn't necessary everywhere. Pure functions don't need deps. UI event handlers don't need ceremony. Glue code can stay glue code. The rule matters where business logic meets infrastructure.

The core idea: make dependencies visible, not implicit

The problem with most code isn't that it's "untestable."

It's that the true dependency surface is invisible.

For you, that means jumping between files to understand a method.

For an AI coding agent, it means overmocking the whole constructor and producing tests that pass for the wrong reasons.

Hidden dependencies (often via constructor injection) are the most common culprit:

class UserService {
  constructor(
    private db: Database,
    private logger: Logger,
    private cache: Cache,
    private mailer: Mailer,
    private metrics: Metrics,
  ) {}

  async getUser(userId: string) {
    this.logger.info(`Getting user ${userId}`);
    return this.db.findUser(userId);
  }
}

getUser needs db and logger, but the constructor forces every caller (and every test) to satisfy five dependencies.

An agent sees five slots and mocks all five. It doesn't know which ones getUser actually uses.

When someone later adds a side effect inside getUser, the test still passes and verification silently drifts. The signature lies. The constructor hides the truth.

fn(args, deps) makes the truth the default

export type GetUserArgs = { userId: string };
export type GetUserDeps = { db: Database; logger: Logger };

export async function getUser(args: GetUserArgs, deps: GetUserDeps) {
  deps.logger.info(`Getting user ${args.userId}`);
  return deps.db.findUser(args.userId);
}

Now you can answer "what does this need?" by looking at its signature. So can the agent. The types are the contract. No guessing, no overmocking.

In practice, the agent's job becomes mechanical: read the types, mock those, call, assert.

If you're thinking "I'm not passing deps through ten layers," you don't have to. You wire them once at a composition boundary; the section below shows how.

Why this is a testing upgrade (and why agents benefit)

1) Minimal mocks, no constructor ceremony

import { mock } from "vitest-mock-extended";
import { getUser, type GetUserDeps } from "./get-user";

it("returns the user when found", async () => {
  const deps = mock<GetUserDeps>();
  deps.db.findUser.mockResolvedValue({ id: "1", name: "Alice" });

  const user = await getUser({ userId: "1" }, deps);
  expect(user?.name).toBe("Alice");
});

You're mocking exactly what the function uses. Nothing more.

When an agent writes this test, it has a fixed recipe: read Deps, mock those, call with Args, assert on the return.

No vi.mock() hoisting, no constructor archaeology. Tests stay honest and the agent stays on track.

2) Compile time pressure catches drift

When a function's real dependency surface changes, its Deps type changes. Tests fail at compile time instead of quietly becoming less meaningful.

For an agent, that's a clear correction signal: "Property 'logger' is missing." The agent adds the dep and moves on. With classes, errors are noisier (constructor mismatches, this context, mock type tangles) and the agent often fixes one thing and breaks another.

Deterministic pressure keeps the agent from drifting.

Ports and adapters, without the ceremony

The cleanest way to describe deps is: deps are ports. A "port" is just a capability your function needs: a database call, a logger, an email sender, the current time, an HTTP client. An "adapter" is the concrete implementation you wire at the boundary.

Inside the function, you only depend on the port:

export type SendWelcomeEmailDeps = {
  sendEmail: (input: { to: string; template: string }) => Promise<void>;
};

export async function sendWelcomeEmail(
  args: { email: string },
  deps: SendWelcomeEmailDeps,
) {
  await deps.sendEmail({ to: args.email, template: "welcome" });
}

At the boundary, you plug in an adapter:

import { sendgridAdapter } from "../infra/sendgrid-adapter";

const deps = { sendEmail: sendgridAdapter.sendEmail };
await sendWelcomeEmail({ email: "a@b.com" }, deps);

In tests, you plug in a fake:

const deps = { sendEmail: vi.fn().mockResolvedValue(undefined) };
await sendWelcomeEmail({ email: "a@b.com" }, deps);
expect(deps.sendEmail).toHaveBeenCalled();

For an agent, the port is a clear boundary. The agent doesn't need to know SendGrid or SMTP; it only needs to satisfy the type. That keeps generated code focused and swappable. Same pattern everywhere: the agent learns it once and applies it across the codebase.

The pattern in one diagram

Business Logic
    |
    v
fn(args, deps)
    |
    v
Ports (types)
    |
    v
Adapters (infra implementations)

This is dependency injection, reduced to a single function signature.

Adopting the pattern in an existing codebase

What this migration achieves

Why? Hidden dependencies create coupling that's hard to test and hard to reason about.

Required pattern: fn(args, deps)

All business logic functions must follow this signature:

fn(args, deps);

In real TypeScript, that looks like:

export async function fn(args: Args, deps: Deps) {}

Tests enable a strangler fig migration

If tests already cover current behaviour, don't rewrite everything at once. Migrate behind a seam:

import { mailer } from "../infra/mailer"; // Concrete implementation that usually forces vi.mock in tests

export async function sendWelcomeEmail(recipient: User, sender: User) {
  return mailer.send({
    to: recipient.email,
    from: sender.email,
    template: "welcome",
  });
}

Introduce dependency injection with default deps (100% backward compatible)

import { mailer as _mailer, type Mailer } from "../infra/mailer";

export type SendWelcomeEmailDeps = { mailer: Mailer };

const defaultDeps: SendWelcomeEmailDeps = { mailer: _mailer };

export async function sendWelcomeEmail(
  recipient: User,
  sender: User,
  deps: SendWelcomeEmailDeps = defaultDeps,
) {
  const { mailer } = deps;
  return mailer.send({
    to: recipient.email,
    from: sender.email,
    template: "welcome",
  });
}

This gives you a test seam immediately, no call-site churn on day one, and a clear migration path: move from fn(args, deps = defaultDeps) to fn(args, deps) once callers are updated.

Safe and incremental. Existing callers continue to work. Now testable.

From here the migration is straightforward:

Wire once at the boundary

You don't pass deps around at every call site. You wire them once at a composition boundary (service factory, router setup, app bootstrap).

Domain functions pass deps downward only when they call other domain functions; infrastructure wiring happens once at the boundary. If you find yourself plumbing deps through five layers, that's a smell that your module boundaries are wrong.

export function createUserService(deps: { db: Database; logger: Logger }) {
  return {
    getUser: (args: { userId: string }) => getUser(args, deps),
    createUser: (args: { name: string; email: string }) => createUser(args, deps),
  };
}

Now your application code stays clean:

const userService = createUserService({ db, logger });
await userService.getUser({ userId: "123" });

The agent only needs to follow the rule: domain functions are fn(args, deps); wiring happens in the factory. When the agent adds a new function, it adds it to the right module and wires it in the factory. The structure stays predictable.

The "if you only do one thing" rules

If you want AI coding agents (and humans) to stay consistent, keep the rule set short. Agents do best with a small, repeatable set of constraints.

That's it.

Not a framework. Not a platform bet.

Just a dependency surface that stays visible. When the agent has that, it can reason about what, how, and why.

So can you. And when you have that, everything else (testing, composition, refactoring, stronger structural constraints later) gets easier because the code finally has seams.

Composition becomes a matter of type intersection.

The constraint is what makes it possible.

typescript coding testing ai