How to Build an API Abstraction Layer in Your App
·APIScout Team
abstractiondesign patternsapi integrationarchitecturebest practices
How to Build an API Abstraction Layer in Your App
Every third-party API you integrate is a dependency you can't control. They change pricing, deprecate features, go down, or get acquired. An abstraction layer puts you in control — swap providers, add fallbacks, and test without touching real APIs.
Why Abstract?
Without Abstraction
// Stripe calls scattered across your codebase
// checkout.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!);
const session = await stripe.checkout.sessions.create({ ... });
// billing.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!);
const subscription = await stripe.subscriptions.create({ ... });
// webhooks.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!);
stripe.webhooks.constructEvent(body, sig, secret);
// Problem: Stripe is everywhere. Switching means changing 50+ files.
// Problem: Testing requires mocking Stripe in every test file.
// Problem: No fallback if Stripe is down.
With Abstraction
// One interface, one place to change
const payments = container.get<PaymentService>('payments');
await payments.createCheckout({ ... });
await payments.createSubscription({ ... });
await payments.verifyWebhook(body, sig);
// Switch provider: change ONE line in configuration
// Test: inject mock implementation
// Fallback: wrap with circuit breaker
The Abstraction Pattern
Step 1: Define the Interface
// services/payment/types.ts
interface PaymentService {
// Customers
createCustomer(params: CreateCustomerParams): Promise<Customer>;
getCustomer(id: string): Promise<Customer | null>;
updateCustomer(id: string, params: UpdateCustomerParams): Promise<Customer>;
// Checkout
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
// Subscriptions
createSubscription(params: SubscriptionParams): Promise<Subscription>;
cancelSubscription(id: string): Promise<void>;
// Webhooks
verifyWebhook(payload: string, signature: string): WebhookEvent;
}
// Use YOUR domain types, not the provider's types
interface Customer {
id: string;
email: string;
name: string;
metadata: Record<string, string>;
}
interface CheckoutSession {
id: string;
url: string;
status: 'open' | 'complete' | 'expired';
}
interface Subscription {
id: string;
customerId: string;
status: 'active' | 'canceled' | 'past_due' | 'trialing';
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
}
Key principle: Define types based on YOUR domain, not the provider's API. This is the abstraction.
Step 2: Implement the Adapter
// services/payment/stripe-adapter.ts
import Stripe from 'stripe';
class StripePaymentService implements PaymentService {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}
async createCustomer(params: CreateCustomerParams): Promise<Customer> {
const stripeCustomer = await this.stripe.customers.create({
email: params.email,
name: params.name,
metadata: params.metadata,
});
// Map Stripe's type to YOUR type
return this.mapCustomer(stripeCustomer);
}
async getCustomer(id: string): Promise<Customer | null> {
try {
const stripeCustomer = await this.stripe.customers.retrieve(id);
if (stripeCustomer.deleted) return null;
return this.mapCustomer(stripeCustomer as Stripe.Customer);
} catch (error: any) {
if (error.statusCode === 404) return null;
throw error;
}
}
async createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession> {
const session = await this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: params.mode === 'subscription' ? 'subscription' : 'payment',
line_items: params.items.map(item => ({
price: item.priceId,
quantity: item.quantity,
})),
success_url: params.successUrl,
cancel_url: params.cancelUrl,
});
return {
id: session.id,
url: session.url!,
status: session.status === 'complete' ? 'complete' :
session.status === 'expired' ? 'expired' : 'open',
};
}
verifyWebhook(payload: string, signature: string): WebhookEvent {
const event = this.stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Map Stripe events to your domain events
return this.mapWebhookEvent(event);
}
// Private mapping functions
private mapCustomer(sc: Stripe.Customer): Customer {
return {
id: sc.id,
email: sc.email!,
name: sc.name || '',
metadata: sc.metadata || {},
};
}
private mapWebhookEvent(event: Stripe.Event): WebhookEvent {
switch (event.type) {
case 'checkout.session.completed':
return { type: 'checkout.completed', data: { sessionId: (event.data.object as any).id } };
case 'customer.subscription.deleted':
return { type: 'subscription.canceled', data: { subscriptionId: (event.data.object as any).id } };
default:
return { type: 'unknown', data: event.data.object };
}
}
}
Step 3: Wire It Up
// services/payment/index.ts
export function createPaymentService(): PaymentService {
const provider = process.env.PAYMENT_PROVIDER || 'stripe';
switch (provider) {
case 'stripe':
return new StripePaymentService(process.env.STRIPE_SECRET_KEY!);
case 'paddle':
return new PaddlePaymentService(process.env.PADDLE_API_KEY!);
default:
throw new Error(`Unknown payment provider: ${provider}`);
}
}
// Or with dependency injection
// container.register('payments', StripePaymentService);
// Usage in your app — provider-agnostic
import { createPaymentService } from '@/services/payment';
const payments = createPaymentService();
// This code works with Stripe, Paddle, or any future provider
const customer = await payments.createCustomer({
email: 'user@example.com',
name: 'Jane Doe',
});
const checkout = await payments.createCheckoutSession({
customerId: customer.id,
items: [{ priceId: 'price_pro_monthly', quantity: 1 }],
successUrl: 'https://app.com/success',
cancelUrl: 'https://app.com/cancel',
});
Abstraction by API Category
Email Abstraction
interface EmailService {
send(params: {
to: string | string[];
subject: string;
html: string;
from?: string;
replyTo?: string;
attachments?: Array<{ filename: string; content: Buffer }>;
}): Promise<{ id: string }>;
sendBatch(emails: Array<Parameters<EmailService['send']>[0]>): Promise<{ ids: string[] }>;
}
// Implementations: ResendEmailService, SendGridEmailService, SESEmailService
Auth Abstraction
interface AuthService {
verifyToken(token: string): Promise<AuthUser | null>;
getUser(userId: string): Promise<AuthUser | null>;
getUserByEmail(email: string): Promise<AuthUser | null>;
createUser(params: CreateUserParams): Promise<AuthUser>;
deleteUser(userId: string): Promise<void>;
}
interface AuthUser {
id: string;
email: string;
name: string;
avatar: string | null;
emailVerified: boolean;
createdAt: Date;
}
// Implementations: ClerkAuthService, Auth0AuthService, SupabaseAuthService
Storage Abstraction
interface StorageService {
upload(key: string, data: Buffer, contentType: string): Promise<{ url: string }>;
download(key: string): Promise<Buffer>;
delete(key: string): Promise<void>;
getSignedUrl(key: string, expiresIn: number): Promise<string>;
list(prefix: string): Promise<Array<{ key: string; size: number; modified: Date }>>;
}
// Implementations: S3StorageService, R2StorageService, GCSStorageService
// All use S3-compatible API, so one implementation often covers multiple providers
AI Abstraction
interface AIService {
chat(params: {
model: string;
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
temperature?: number;
maxTokens?: number;
}): Promise<{
content: string;
usage: { inputTokens: number; outputTokens: number };
}>;
embed(text: string | string[]): Promise<number[][]>;
}
// Implementations: OpenAIService, AnthropicService, GroqService
// Or use an AI gateway (LiteLLM, Portkey) as the single implementation
Advanced Patterns
Pattern: Fallback Chain
class FallbackPaymentService implements PaymentService {
constructor(
private primary: PaymentService,
private fallback: PaymentService,
) {}
async createCustomer(params: CreateCustomerParams): Promise<Customer> {
try {
return await this.primary.createCustomer(params);
} catch (error) {
console.error('Primary payment provider failed, using fallback:', error);
return await this.fallback.createCustomer(params);
}
}
// ... same pattern for all methods
}
Pattern: Caching Decorator
class CachedStorageService implements StorageService {
constructor(
private inner: StorageService,
private cache: Map<string, { data: Buffer; expires: number }> = new Map(),
private ttl: number = 300000, // 5 minutes
) {}
async download(key: string): Promise<Buffer> {
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expires) {
return cached.data;
}
const data = await this.inner.download(key);
this.cache.set(key, { data, expires: Date.now() + this.ttl });
return data;
}
// Pass through non-cached operations
upload(key: string, data: Buffer, type: string) { return this.inner.upload(key, data, type); }
delete(key: string) { this.cache.delete(key); return this.inner.delete(key); }
getSignedUrl(key: string, exp: number) { return this.inner.getSignedUrl(key, exp); }
list(prefix: string) { return this.inner.list(prefix); }
}
Pattern: Logging Decorator
class LoggedEmailService implements EmailService {
constructor(private inner: EmailService) {}
async send(params: Parameters<EmailService['send']>[0]) {
const start = Date.now();
try {
const result = await this.inner.send(params);
console.log(`Email sent to ${params.to} in ${Date.now() - start}ms`, { id: result.id });
return result;
} catch (error) {
console.error(`Email failed to ${params.to} after ${Date.now() - start}ms`, error);
throw error;
}
}
}
// Compose: logging + caching + fallback
const emailService = new LoggedEmailService(
new FallbackEmailService(
new ResendEmailService(process.env.RESEND_KEY!),
new SESEmailService(process.env.AWS_REGION!),
)
);
When NOT to Abstract
| Scenario | Why Not |
|---|---|
| Prototype / MVP | Over-engineering — ship fast, refactor later |
| Only one provider exists | No alternative to switch to |
| Deep platform integration | Abstraction would lose critical features |
| Provider-specific features are essential | Abstraction forces lowest common denominator |
Rule of thumb: Abstract when you can realistically imagine switching providers, or when you need testability. Don't abstract just because it's "clean."
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Leaking provider types through abstraction | Still coupled to provider | Use YOUR domain types |
| Abstracting too early | Wasted effort | Abstract when you have a reason (testing, migration, fallback) |
| Lowest common denominator | Lose provider-specific features | Allow provider-specific extensions |
| Not testing the abstraction | Bugs in mapping layer | Unit test each adapter |
| Abstracting stable, simple APIs | Unnecessary complexity | Direct calls for simple integrations |
| God interface | One interface for everything | Split by domain (payments, email, auth) |
Find the right APIs for your abstraction layer on APIScout — compare providers by interface compatibility, features, and switching cost.