Skip to main content

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

ScenarioWhy Not
Prototype / MVPOver-engineering — ship fast, refactor later
Only one provider existsNo alternative to switch to
Deep platform integrationAbstraction would lose critical features
Provider-specific features are essentialAbstraction 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

MistakeImpactFix
Leaking provider types through abstractionStill coupled to providerUse YOUR domain types
Abstracting too earlyWasted effortAbstract when you have a reason (testing, migration, fallback)
Lowest common denominatorLose provider-specific featuresAllow provider-specific extensions
Not testing the abstractionBugs in mapping layerUnit test each adapter
Abstracting stable, simple APIsUnnecessary complexityDirect calls for simple integrations
God interfaceOne interface for everythingSplit by domain (payments, email, auth)

Find the right APIs for your abstraction layer on APIScout — compare providers by interface compatibility, features, and switching cost.

Comments