If You Only Enforce One Rule for AI Code, Make It fn(args, deps)
04 Mar 2026AI coding agents produce code faster than you can review and understand it.
One pattern works in both new and legacy codebases because you can adopt it incrementally, without breaking callers.

For business logic, treat every function as having two inputs: data (args) and capabilities (deps).
Without a clear constraint, generated code becomes harder to reason about: dependencies disappear, side effects spread, composition gets messy. This is why visible structure is essential.
fn(args, deps) is that constraint
For existing code, start with:
functionName(args, deps = defaultDeps)
Dependencies are explicit, not hidden.
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.
This pattern is functional DI with explicit seams, good design regardless. It also happens to keep AI-generated code tractable.
This means: no hidden infrastructure imports, no global singletons, and no constructors that quietly accumulate the whole world.
The types explain intent. The agent has to colour within the lines.
This approach is boring, but that's precisely why it scales.
The rule matters where business logic meets infrastructure. Pure functions don't need deps; UI event handlers and glue code don't need the ceremony. Request-scoped context (auth, tenant, transaction) follows the same rule: pass it in args or expose it as a dep (e.g. getCurrentUser); no need for an extra parameter.
The core idea: make dependencies visible, not implicit
Usually, the problem isn't testability; it's that the dependency surface is invisible. As a result, you jump between files to understand a method; an agent overmocks the whole constructor and produces 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 dependencies and mocks all five. It doesn't know which of getUser's methods are actually used.
When someone later adds a side effect inside getUser, the test still passes and verification 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);
}
When you ask, "What does this need?", you see it in the signature. So does the agent. There's no guessing or overmocking: the agent's job becomes mechanical—read the types, mock those, call, and assert. You don't thread deps through ten unrelated layers; you wire them once at a composition root.
Why this is a testing upgrade
You mock exactly what the function uses. No module interception, no vi.mock() hoisting, no constructor archaeology. Good tests stop describing object construction and start describing behaviour.
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");
});
When the dependency surface changes, the Deps type changes, and tests fail at compile time rather than drift. For an agent, that's a clear signal ("Property 'logger' is missing"); with classes, errors are noisier, and the agent often fixes one thing and breaks another.
Ports and adapters, without the ceremony
Deps are ports: capabilities the function needs (db, logger, sendEmail, current time). Adapters are what you wire at the boundary.
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: deps = { sendEmail: sendgridAdapter.sendEmail }. In tests: deps = { sendEmail: vi.fn().mockResolvedValue(undefined) }. The agent only needs to satisfy the type; it doesn't need to know SendGrid or SMTP. This is ports and adapters reduced to a function signature.
Adopting the pattern in an existing codebase
You don't need a big-bang rewrite. Add a seam (e.g. route through a factory), then migrate one function at a time; contract tests tell you if behaviour changes. The move that makes it backward-compatible: add deps as the last argument with a default so existing callers keep working.
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",
});
}
Test seam on day one, no call-site churn. Later: migrate callers to pass deps from the composition root, then remove the default. Same pattern for any function that currently imports infrastructure.
Wire once at the boundary
You don't pass deps at every call site. Wire once at a composition root (service factory, router, app bootstrap). Domain functions receive deps and pass them down only when calling other domain functions; if you're plumbing deps through five layers, your module boundaries are likely 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),
};
}
const userService = createUserService({ db, logger });
await userService.getUser({ userId: "123" });
The agent follows one rule: domain functions are fn(args, deps); wiring lives in the factory.
The "if you only do one thing" rules
Keep the rule set short. Agents do best with a small, repeatable set of constraints.
- Business logic is fn(args, deps).
- Domain code does not import infrastructure at runtime (no DB clients, HTTP clients, env reads).
- Inject what you want to mock (network, disk, clock, collaborators). Don't inject pure utilities—they don't need test doubles.
- Wire adapters at the boundary (a composition root or service factory).
- Defaults are allowed only for migration. Temporary scaffolding.
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, and so can you. Once the seams are visible, testing, composition, and refactoring stop fighting the code.
Start using this visible dependency surface today. Apply the fn(args, deps) rule in your next PR or refactor. By making seams visible, you make testing, composition, and refactoring smoother. The sooner you begin, the sooner you gain clarity for both you and the agent. Embrace the pattern; your codebase will thank you.