Dependency Injection: Write Once, Use Everywhere

Keeping your business logic separate from the framework it runs in isn't just good architecture — it makes your code portable, testable, and far easier to maintain.

There’s a pattern I keep coming back to regardless of what framework I’m working in: write the logic in plain functions first, and connect it to the framework last. The framework is how the logic gets delivered.

That’s the core idea behind dependency injection, and once it clicks you start noticing how much code gets simpler with it.

The problem

Here’s a typical React component that does too much:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

This works, but the logic (the fetching, the URL construction, whatever transformation you add later) is tangled into the component. To test it you have to render a component, mock fetch globally, and deal with useEffect timing. To reuse the fetching logic elsewhere you copy it, and to swap the data source you edit the component.

Extract the logic first

Before dependency injection, there’s a simpler first step: pull the logic out.

// users.ts
export async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`Failed to fetch user ${id}`);
  return res.json();
}

Now the component just calls the function:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    getUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

Better. But getUser still has fetch hardcoded inside it. You can’t test it without a real network, and you can’t swap the data source without editing the function itself.

Inject the dependency

The fix is to make the data source a parameter instead of a hardcoded assumption:

// users.ts
type HttpClient = (url: string) => Promise<Response>;

export async function getUser(id: string, http: HttpClient = fetch) {
  const res = await http(`/api/users/${id}`);
  if (!res.ok) throw new Error(`Failed to fetch user ${id}`);
  return res.json();
}

fetch is the default, so nothing changes at the call sites. But now there’s a seam, a place where you can swap in something else without touching the function’s internals.

Testing becomes trivial:

// users.test.ts
import { test, expect } from "bun:test";
import { getUser } from "./users";

test("getUser returns parsed user data", async () => {
  const fakeHttp = async (_url: string) =>
    new Response(JSON.stringify({ id: "1", name: "Alice" }), { status: 200 });

  const user = await getUser("1", fakeHttp);

  expect(user.name).toBe("Alice");
});

test("getUser throws on error response", async () => {
  const fakeHttp = async (_url: string) => new Response(null, { status: 404 });

  expect(getUser("1", fakeHttp)).rejects.toThrow("Failed to fetch user 1");
});

There’s no mocking framework, no monkey-patching of global fetch, and no jsdom, just a function that takes its dependencies and plain assertions on its output.

The React layer stays thin

Back in the component, you can pass the injected function through props if you want maximum testability:

type UserProfileProps = {
  userId: string;
  fetchUser?: (id: string) => Promise<{ name: string }>;
};

function UserProfile({ userId, fetchUser = getUser }: UserProfileProps) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId, fetchUser]);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

In production, fetchUser defaults to the real getUser. In tests:

import { render, screen } from "@testing-library/react";

test("renders user name", async () => {
  const fakeGetUser = async (_id: string) => ({ name: "Alice" });

  render(<UserProfile userId="1" fetchUser={fakeGetUser} />);

  expect(await screen.findByText("Alice")).toBeInTheDocument();
});

There’s no network and no global mock. The component receives its dependencies, so you control everything from outside.

A more realistic example: a service layer

As things grow, you typically move beyond single functions to a service object. Dependency injection scales cleanly to this:

// types.ts
export type UserService = {
  getUser: (id: string) => Promise<User>;
  updateUser: (id: string, data: Partial<User>) => Promise<User>;
};

// userService.ts
export function createUserService(http: HttpClient): UserService {
  return {
    async getUser(id) {
      const res = await http(`/api/users/${id}`);
      return res.json();
    },
    async updateUser(id, data) {
      const res = await http(`/api/users/${id}`, {
        method: "PATCH",
        body: JSON.stringify(data),
      });
      return res.json();
    },
  };
}

In your app entry point, you wire it together once:

// main.ts
const userService = createUserService(fetch);

In tests, you create a fake service:

const fakeUserService: UserService = {
  getUser: async () => ({ id: "1", name: "Alice" }),
  updateUser: async (_id, data) => ({ id: "1", name: "Alice", ...data }),
};

Your components and functions depend on the UserService type instead of a specific implementation. The real HTTP version and the fake are interchangeable because they satisfy the same interface.

Why this matters beyond testing

Testing is the most immediate benefit, but there are others.

Logic written against an interface is portable. getUser doesn’t care whether it runs in a React component, a Bun script, a CLI tool, or a queue worker. You write it once and use it wherever you need it.

Framework upgrades get easier. If your business logic stays out of React internals, migrating to a new version, or to a different framework, only touches the UI layer. The functions stay where they are.

Teams can work in parallel. With the data layer behind an interface, frontend and backend can agree on the type, fake each other’s side, and build without blocking each other.

The rule of thumb

If a function imports fetch, axios, a database client, or anything else that touches the outside world, make it a parameter with a sensible default. It costs almost nothing, and the default means you never pass it by hand in production code. The seam is there when you need it for tests, other environments, or swapping implementations.

Keep the logic separate from the framework that delivers it, and both get easier to work with.