Dependency Inversion in Frontend
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.
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.
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.
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.
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:
| Dependency | Abstract? | Why |
|---|---|---|
| Payment provider | Yes | High switching cost, vendor lock-in, complex to mock |
| Analytics service | Yes | Vendors change frequently, need to disable in dev/test |
| Auth provider | Yes | Auth0, Clerk, Supabase -- teams switch often |
| HTTP client (fetch) | Maybe | Useful for interceptors and testing, but thin wrapper is enough |
| Date library (date-fns) | No | Standard API, unlikely to switch, no testing benefit |
| CSS framework (Tailwind) | No | Pervasive, not practical to abstract, zero switching cost |
| React itself | No | Abstracting your rendering framework is madness |
The rule: abstract dependencies that are likely to change, hard to test, or create vendor lock-in.
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).
- 1Define interfaces for volatile dependencies -- payment, analytics, auth, storage
- 2Build thin adapters that translate vendor APIs to your interfaces
- 3Use React Context as a DI container for client-side dependencies
- 4Use module-level singletons for server-side dependencies in RSC
- 5Do not abstract stable, low-churn dependencies like date libraries or CSS frameworks
| What developers do | What 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) |