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 only connect it to the framework last. The framework is a delivery mechanism. The logic is the product.

This is the core idea behind dependency injection. And once it clicks, you start noticing how much code gets simpler when you apply 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’d add later — is tangled directly into the React component. To test it, you need to render a component, mock fetch globally, and deal with useEffect timings. To reuse the fetching logic elsewhere, you copy it. To swap the data source, you edit the component.

The logic and the UI are one thing. They shouldn’t be.

Extract the logic first

Before we talk about dependency injection specifically, the prerequisite step is just: 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 you have a seam — a place where you can swap in something different 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");
});

No mocking framework. No global fetch monkey-patching. No jsdom. Just a function that takes its dependencies and plain assertions against 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();
});

No network. No global mocks. The component is just a function that receives its dependencies. 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, not on any specific implementation. The real HTTP implementation and the fake one are interchangeable because they satisfy the same interface.

Why this matters beyond testing

Testing is the most immediate benefit, but it’s not the only one.

Portability. Logic written against an interface works anywhere. getUser doesn’t know or care whether it’s running in a React component, a Bun script, a CLI tool, or a queue worker. You wrote it once and it runs everywhere you need it.

Framework upgrades. If you’ve kept your business logic out of React internals, migrating to a new version — or a different framework entirely — is a UI layer problem, not a logic problem. The functions don’t move.

Parallel development. When the data layer is behind an interface, frontend and backend work can proceed independently. Agree on the type, fake both sides, 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. The default means you don’t have to pass it manually in production code. But the seam exists, and when you need it — for tests, for different environments, for swapping implementations — it’s there.

The framework is how your code gets delivered. The logic is the thing worth protecting. Keep them separate and both become easier to work with.