Skip to content

Dependency Inversion in Frontend

expert17 min read

The Day You Need to Replace Stripe

Your payment integration took three weeks. Stripe calls are scattered across 47 files. Then the business decides to switch to LemonSqueezy because the pricing is better for your market. You now have two choices: three weeks of find-and-replace, or a weekend refactor. The difference is dependency inversion.

Mental Model

Think of an electrical outlet. Your laptop does not know if the power comes from a coal plant, solar panels, or a nuclear reactor. It just knows the interface: a standard plug that delivers electricity. The adapter pattern works the same way. Your app does not know if analytics come from Mixpanel, Amplitude, or PostHog. It just knows the interface: track(event, properties). Swap the provider by changing the adapter, not the entire codebase.

The Problem: Direct Dependencies

Here is what most codebases look like:

import Stripe from "stripe";

async function createCheckout(userId: string, priceId: string) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const session = await stripe.checkout.sessions.create({
    customer: userId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "subscription",
    success_url: `${process.env.NEXT_PUBLIC_URL}/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });
  return session.url;
}

This function is welded to Stripe. You cannot:

  • Test it without a Stripe account
  • Swap to another provider without rewriting
  • Use it in a different project with a different payment provider
  • Mock it cleanly in integration tests

Now multiply this across every file that touches Stripe, analytics, auth, email, and storage.

The Adapter Pattern

Define what you need, then adapt each provider to that interface.

interface PaymentProvider {
  createCheckoutSession(params: {
    customerId: string;
    priceId: string;
    successUrl: string;
    cancelUrl: string;
  }): Promise<{ url: string }>;

  cancelSubscription(subscriptionId: string): Promise<void>;

  getSubscription(subscriptionId: string): Promise<{
    id: string;
    status: "active" | "canceled" | "past_due";
    currentPeriodEnd: Date;
  }>;
}

Now build an adapter for Stripe:

import Stripe from "stripe";

function createStripeAdapter(secretKey: string): PaymentProvider {
  const stripe = new Stripe(secretKey);

  return {
    async createCheckoutSession({ customerId, priceId, successUrl, cancelUrl }) {
      const session = await stripe.checkout.sessions.create({
        customer: customerId,
        line_items: [{ price: priceId, quantity: 1 }],
        mode: "subscription",
        success_url: successUrl,
        cancel_url: cancelUrl,
      });
      return { url: session.url! };
    },

    async cancelSubscription(subscriptionId) {
      await stripe.subscriptions.cancel(subscriptionId);
    },

    async getSubscription(subscriptionId) {
      const sub = await stripe.subscriptions.retrieve(subscriptionId);
      return {
        id: sub.id,
        status: sub.status as "active" | "canceled" | "past_due",
        currentPeriodEnd: new Date(sub.current_period_end * 1000),
      };
    },
  };
}

Want to switch to LemonSqueezy? Build one adapter. The rest of your app never changes.

Quiz
What is the primary benefit of the adapter pattern for third-party services?

The Repository Pattern for Data Access

The same principle applies to data. Instead of scattering fetch calls across your components:

interface CourseRepository {
  getAll(): Promise<Course[]>;
  getById(id: string): Promise<Course | null>;
  getBySlug(slug: string): Promise<Course | null>;
  getProgress(courseId: string, userId: string): Promise<CourseProgress>;
  updateProgress(courseId: string, userId: string, topicId: string): Promise<void>;
}

Now you can implement this against any data source:

function createApiCourseRepository(baseUrl: string): CourseRepository {
  return {
    async getAll() {
      const res = await fetch(`${baseUrl}/courses`);
      return res.json();
    },

    async getById(id) {
      const res = await fetch(`${baseUrl}/courses/${id}`);
      if (!res.ok) return null;
      return res.json();
    },

    async getBySlug(slug) {
      const res = await fetch(`${baseUrl}/courses/by-slug/${slug}`);
      if (!res.ok) return null;
      return res.json();
    },

    async getProgress(courseId, userId) {
      const res = await fetch(`${baseUrl}/courses/${courseId}/progress/${userId}`);
      return res.json();
    },

    async updateProgress(courseId, userId, topicId) {
      await fetch(`${baseUrl}/courses/${courseId}/progress/${userId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ topicId }),
      });
    },
  };
}

For testing:

function createMockCourseRepository(courses: Course[]): CourseRepository {
  const progressMap = new Map<string, CourseProgress>();

  return {
    async getAll() {
      return courses;
    },
    async getById(id) {
      return courses.find((c) => c.id === id) ?? null;
    },
    async getBySlug(slug) {
      return courses.find((c) => c.slug === slug) ?? null;
    },
    async getProgress(courseId, userId) {
      return progressMap.get(`${courseId}:${userId}`) ?? { percentage: 0, completedTopics: [] };
    },
    async updateProgress(courseId, userId, topicId) {
      const key = `${courseId}:${userId}`;
      const current = progressMap.get(key) ?? { percentage: 0, completedTopics: [] };
      current.completedTopics.push(topicId);
      progressMap.set(key, current);
    },
  };
}

No mocking libraries. No spy gymnastics. Clean, readable test data.

Common Trap

Do not create a repository for every single API call. The repository pattern works best for entities with CRUD operations -- User, Course, Quiz. For one-off actions like "send a notification email," a simple service function is fine. Over-abstracting one-off operations creates unnecessary indirection.

React Context as a DI Container

React Context is not just for global state. It is a dependency injection container. You can provide different implementations at different levels of the component tree.

import { createContext, use } from "react";

const AnalyticsContext = createContext<AnalyticsProvider | null>(null);

function useAnalytics(): AnalyticsProvider {
  const analytics = use(AnalyticsContext);
  if (!analytics) {
    throw new Error("useAnalytics must be used within AnalyticsProvider");
  }
  return analytics;
}
interface AnalyticsProvider {
  track(event: string, properties?: Record<string, unknown>): void;
  identify(userId: string, traits?: Record<string, unknown>): void;
  page(name: string): void;
}

Production adapter:

function createMixpanelAnalytics(token: string): AnalyticsProvider {
  mixpanel.init(token);

  return {
    track(event, properties) {
      mixpanel.track(event, properties);
    },
    identify(userId, traits) {
      mixpanel.identify(userId);
      if (traits) mixpanel.people.set(traits);
    },
    page(name) {
      mixpanel.track("Page View", { page: name });
    },
  };
}

Development adapter (no external calls, just console output):

function createConsoleAnalytics(): AnalyticsProvider {
  return {
    track(event, properties) {
      console.log("[Analytics] Track:", event, properties);
    },
    identify(userId, traits) {
      console.log("[Analytics] Identify:", userId, traits);
    },
    page(name) {
      console.log("[Analytics] Page:", name);
    },
  };
}

Wire it up in your root layout:

function Providers({ children }: { children: React.ReactNode }) {
  const analytics = process.env.NODE_ENV === "production"
    ? createMixpanelAnalytics(process.env.NEXT_PUBLIC_MIXPANEL_TOKEN!)
    : createConsoleAnalytics();

  return (
    <AnalyticsContext value={analytics}>
      {children}
    </AnalyticsContext>
  );
}

Any component can now call useAnalytics().track("quiz_completed", { score: 95 }) without knowing or caring which analytics provider is behind it.

Quiz
In a test environment, you want analytics calls to be captured but not sent to any service. How do you achieve this with the DI pattern?
function createTestAnalytics() {
  const calls: Array<{ method: string; args: unknown[] }> = [];

  const provider: AnalyticsProvider = {
    track(event, properties) {
      calls.push({ method: "track", args: [event, properties] });
    },
    identify(userId, traits) {
      calls.push({ method: "identify", args: [userId, traits] });
    },
    page(name) {
      calls.push({ method: "page", args: [name] });
    },
  };

  return { provider, calls };
}

What to Abstract (and What Not To)

Not everything needs an adapter. Here is the decision framework:

DependencyAbstract?Why
Payment providerYesHigh switching cost, vendor lock-in, complex to mock
Analytics serviceYesVendors change frequently, need to disable in dev/test
Auth providerYesAuth0, Clerk, Supabase -- teams switch often
HTTP client (fetch)MaybeUseful for interceptors and testing, but thin wrapper is enough
Date library (date-fns)NoStandard API, unlikely to switch, no testing benefit
CSS framework (Tailwind)NoPervasive, not practical to abstract, zero switching cost
React itselfNoAbstracting your rendering framework is madness

The rule: abstract dependencies that are likely to change, hard to test, or create vendor lock-in.

Quiz
Your team uses date-fns for date formatting throughout the app. A teammate proposes abstracting it behind a DateFormatter interface. Is this a good idea?
Server Components and DI

With React Server Components, the DI pattern shifts. Server Components cannot use React Context (they run on the server, not in the component tree). Instead, you pass dependencies through function parameters or module-level singletons:

const courseRepo = createApiCourseRepository(process.env.API_URL!);

async function CoursePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const course = await courseRepo.getBySlug(slug);

  if (!course) notFound();

  return <CourseLayout course={course} />;
}

For Server Components, module-level initialization works well because server modules are instantiated once per request (in serverless) or once per process (in long-running servers). The DI container pattern is not needed -- plain imports suffice.

Context-based DI remains valuable for Client Components that need runtime provider swapping (analytics, feature flags, theming).

Key Rules
  1. 1Define interfaces for volatile dependencies -- payment, analytics, auth, storage
  2. 2Build thin adapters that translate vendor APIs to your interfaces
  3. 3Use React Context as a DI container for client-side dependencies
  4. 4Use module-level singletons for server-side dependencies in RSC
  5. 5Do not abstract stable, low-churn dependencies like date libraries or CSS frameworks
What developers doWhat they should do
Abstracting every single dependency including React and Tailwind
Over-abstraction adds layers of indirection that slow development without reducing risk. Abstract what changes, not what is stable.
Only abstracting volatile, high-cost dependencies
Using jest.mock() to mock third-party SDKs in every test
jest.mock() is brittle -- it breaks when the SDK refactors internals. DI-based testing depends only on your interface, which you control.
Injecting test adapters through Context or function parameters
Creating a single massive ServiceProvider context with all dependencies
A single context forces all consumers to re-render when any service changes. Separate contexts minimize re-render scope and improve code navigation.
Separate contexts per concern (AnalyticsContext, PaymentContext, AuthContext)