Arrange Act Assert

Jag Reehals thinking on things, mostly product development

How fn(args, deps) supports SOLID-style design in TypeScript

18 Mar 2026

A lot of developers learn the SOLID principles through class-heavy examples.

That is probably why the conversation so often gets stuck there.

People start to associate SOLID with inheritance hierarchies, interface forests, service classes, and object-oriented ceremony.

But the useful part is not the ceremony.

It is the design pressure.

fn(args, deps) and SOLID principles

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

fn(args, deps)

Where args is the input for this call and deps is the set of collaborators the function needs. You can read that as: data in, capabilities in.

fn(args, deps) is not a replacement for SOLID. It is a simple function shape that makes several SOLID ideas easier to apply without forcing you into class-heavy design.

SOLID is about design pressure, not class ceremony

SOLID is often taught as though it naturally implies classes, interfaces, and object hierarchies.

But the principles themselves are broader than that.

They are really about questions like these:

Those questions still matter in plain TypeScript, even if you are writing functions instead of classes.

That is where fn(args, deps) becomes useful.

It gives you a simple way to separate:

That separation does not automatically make code good, but it does make good design easier to see and bad design harder to hide.

A small example

Consider a use case like this:

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

type CreateUserDeps = {
  userRepo: UserRepo;
  mailer: Mailer;
  logger: Logger;
};

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

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

  return user;
}

This is just a function.

But even in this small example, the shape already encourages several useful design properties:

That is why this shape works well with SOLID-style design pressure.

Not because it turns functions into objects.

Because it makes responsibilities and dependencies visible.

Single Responsibility Principle

The Single Responsibility Principle is often paraphrased badly, but the useful version is simple:

A unit of code should have one coherent reason to change.

fn(args, deps) helps because it encourages you to keep one function focused on one job, while pushing infrastructure concerns outward.

In the example above, createUser is responsible for the user-creation workflow.

It is not also responsible for:

Those concerns live elsewhere.

That matters because a lot of SRP violations show up when one unit mixes workflow logic with setup logic and environment logic.

This is a more obvious violation:

export async function createUser(args: CreateUserArgs) {
  const db = makeDb(process.env.DATABASE_URL!);
  const mailer = makeMailer();
  const logger = makeLogger();

  logger.info("Creating user", { email: args.email });

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

  return user;
}

Now the function has multiple reasons to change:

That is exactly the kind of responsibility bleed that fn(args, deps) helps prevent.

Open/Closed Principle

The Open/Closed Principle is usually summarized as:

Open for extension, closed for modification.

In practice, the useful question is:

Can I change how this function behaves by changing its collaborators, without rewriting the function itself?

With fn(args, deps), the answer is often yes.

You can change the repository, mailer, or logger without changing the core workflow:

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

That is a practical form of extension through composition.

You are not subclassing anything. You are not overriding a method. You are varying behavior by supplying different collaborators.

That is often a much simpler way to satisfy the spirit of OCP in TypeScript.

The important caveat is that this only works well when the function depends on meaningful capabilities rather than concrete setup details.

If your deps object is full of leaky implementation specifics, you are not really extending cleanly. You are just moving complexity around.

Liskov Substitution Principle

LSP is one of the principles people invoke most and understand least.

In class-heavy design, it is about whether a subtype can stand in for its base type without breaking expectations.

LSP does not map as directly to function-oriented design as SRP, ISP, or DIP. But the same substitution concern still shows up at dependency boundaries.

The useful question becomes:

Can one dependency implementation be substituted for another without breaking the expectations of the function that uses it?

If createUser depends on a Mailer, then different mailers should behave in ways the workflow can safely rely on.

For example, these might both be valid substitutions:

What matters is that they preserve the contract the use case expects.

That is not unique to fn(args, deps), but the pattern makes the substitution point explicit.

Instead of inheritance hiding the boundary, the dependency boundary is right there in the function signature.

That visibility is useful because substitution problems become easier to spot. If the function keeps needing special cases for one implementation, that is often a signal that the abstraction is wrong.

Interface Segregation Principle

The Interface Segregation Principle says consumers should not be forced to depend on methods they do not use.

This is one place where fn(args, deps) can be better than large service objects.

Because dependencies are passed per function, you can keep them narrow.

For example:

type GetUserDeps = {
  userRepo: Pick<UserRepo, "findById">;
};

export async function getUser(
  args: { userId: string },
  deps: GetUserDeps,
) {
  return deps.userRepo.findById(args.userId);
}

That is a very small dependency surface.

The function does not need the entire repository API. It only needs the capability it actually uses.

This is an underrated advantage of the pattern.

When dependencies are explicit and local to the function, oversized interfaces start to feel awkward immediately.

That friction is good. It pushes you toward smaller, more honest contracts.

Dependency Inversion Principle

This is the SOLID principle most obviously related to dependency injection.

High-level policy should not depend directly on low-level details. Both should depend on abstractions or stable boundaries.

fn(args, deps) makes that easier because it naturally separates use-case logic from infrastructure wiring.

The use case says what it needs:

type CreateUserDeps = {
  userRepo: UserRepo;
  mailer: Mailer;
  logger: Logger;
};

The application edge decides what satisfies those needs:

import { createUser } from "./create-user";
import { makePostgresUserRepo } from "./infra/postgres-user-repo";
import { makeSesMailer } from "./infra/ses-mailer";
import { makeLogger } from "./infra/logger";

const userRepo = makePostgresUserRepo();
const mailer = makeSesMailer();
const logger = makeLogger();

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

That is Dependency Inversion without needing a container to make it feel real.

The use case does not know how the dependencies were created. It only knows the capabilities it was given.

That is the important part.

This does not mean "use abstractions everywhere"

This is where SOLID conversations often go off the rails.

Once people hear "Dependency Inversion" or "Interface Segregation," they start introducing abstractions for everything.

That is usually a mistake.

Not every helper needs to be abstracted. Not every utility needs to be injected. Not every module needs an interface.

For pure logic, direct imports are often the better choice:

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

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

That helper is deterministic, side-effect free, and not especially interesting as a substitution point. Passing it through deps would add ceremony without buying much.

A good rule of thumb:

That is how you keep SOLID from turning into ritual.

Composition still happens at the edge

One common objection is that explicit dependencies will make every call site noisy.

Sometimes they will.

That is why composition belongs at the edge of the application.

You can bind dependencies once and expose a simpler API:

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

Then your startup code can assemble the real version:

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

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

This is important for two reasons.

First, it keeps the use case explicit about what it needs.

Second, it keeps the wiring in one honest place instead of scattering it across the codebase.

That is good design whether you talk about composition roots, DI, or SOLID.

Where this pattern actually helps

The value of fn(args, deps) is not that it magically makes code SOLID.

No function signature can do that.

The value is that it nudges the codebase in useful directions:

That is why I think this shape is such a strong default for many application-level functions in TypeScript.

It supports the intent behind SOLID without forcing you into all the class-oriented baggage that often comes with SOLID discussions.

Why fn(args, deps) works well with SOLID-style design

If SOLID only works when wrapped in inheritance hierarchies, giant interfaces, and framework-controlled injection, then it is not much use in a lot of modern TypeScript code.

The better view is simpler:

SOLID is a set of design pressures. fn(args, deps) is one practical way to apply several of them in TypeScript.

It helps you keep responsibilities narrower. It helps you keep dependency boundaries visible. It helps you vary collaborators without hiding where they come from. And it helps you keep infrastructure wiring out of business logic.

In plain TypeScript, that is often more valuable than a perfectly textbook interpretation of SOLID.

Because the goal is not to perform the principles.

It is to write code that stays understandable as the application grows.

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 (this post)
  6. How fn(args, deps) supports DDD and aggregate roots in TypeScript
  7. fn(args, deps) — Bringing Order to Chaos Without Breaking Anything
typescript coding architecture solid di