UTA DevHub

Multi-API Architecture

How to scale the API client pattern for multiple services and endpoints

Scalable Multi-API Client Architecture

Overview

This document describes how to extend our standard API client architecture to support connections to multiple distinct API services, each potentially with its own base URL, authentication strategy, and configuration. It builds upon the foundational patterns established in the API Client Architecture and Authentication Architecture guides.

Purpose & Scope

This guide is for developers who need to integrate the application with more than one backend API service. It covers:

  • Defining multiple API client instances.
  • Implementing different authentication strategies per API.
  • Managing environment-specific configurations for multiple APIs.

Prerequisites

To effectively set up and manage multiple API services as described here, it's important to first be familiar with:

  • API Client Architecture: A thorough understanding of the API Client Architecture is crucial, as this guide extends those mandatory patterns (BaseApiClient, PublicApiClient, AuthenticatedApiClient).
  • Authentication Architecture: Knowledge of the Authentication Architecture, particularly tokenService and the general principles of token management for authenticated clients.
  • Authentication Strategies: Basic familiarity with common API authentication mechanisms like Bearer tokens, API Keys, and OAuth2, as the examples will involve implementing strategies for these.
  • Environment Variables: Understanding how to use environment variables (e.g., process.env.MAIN_API_URL) for configuring service URLs and secrets.

Supporting Multiple API Services

As applications grow, they often need to connect to various backend services—such as a main application API, a payment gateway, an analytics service, or other third-party APIs. The architectural patterns described in this guide are designed to scale elegantly to support these multi-API scenarios, ensuring maintainability and flexibility:

This guide builds upon the foundational BaseApiClient defined in the API Client Architecture. For context, the BaseApiClient typically includes a constructor to set up an Axios instance with base URL and timeout, along with common HTTP methods (get, post, etc.) that handle responses and errors.

1. Base Client Configuration (Reference)

The BaseApiClient (detailed in the API Client Architecture) provides the common foundation. Specific configurations per API service (like baseURL, timeout, custom headers) are passed via an ApiClientConfig object when instantiating concrete clients.

// Conceptual path: core/shared/api/base.ts (Full definition in the API Client Architecture Guide)
// export abstract class BaseApiClient { ... }
 
// Configuration interface used when creating specific client instances for different APIs.
export interface ApiClientConfig {
  baseURL: string;
  timeout?: number;
  headers?: Record<string, string>;
  // other AxiosRequestConfig properties can be added if needed by specific clients
}

2. Different Authentication Strategies

To handle various authentication mechanisms for different API services, we define an AuthStrategy interface and concrete implementations. These strategies encapsulate the logic for applying authentication to requests and handling auth-specific errors.

The AuthStrategy interface defines the contract for all authentication strategies.

Note: The code examples throughout this document use conceptual imports with paths like @/core/domains/... or commented imports like // import { TokenService } from.... These are illustrative examples that would need to be adapted to match your actual project structure.

// core/shared/api/auth-strategies.ts
import { AxiosRequestConfig, AxiosError } from 'axios';
 
export interface AuthStrategy {
  applyAuth(config: AxiosRequestConfig): Promise<AxiosRequestConfig>;
  handleAuthError(error: AxiosError): Promise<any>;
}

3. Multiple API Clients

With the base clients and authentication strategies defined, we can now construct specific API client instances. The PublicApiClient is used for non-authenticated endpoints, and the AuthenticatedApiClient is configured with an appropriate AuthStrategy for endpoints requiring authentication.

PublicApiClient extends BaseApiClient and requires no special authentication handling.

// Conceptual path: core/shared/api/public.ts (extends BaseApiClient defined in the API Client Architecture Guide)
// import { BaseApiClient } from './base'; // If BaseApiClient is in a separate file
 
export class PublicApiClient extends BaseApiClient {
  // No specific authentication logic needed.
  // Inherits constructor and methods from BaseApiClient.
  constructor(config: ApiClientConfig) {
    super(config);
  }
}

Now, we can create and export instances for each API service your application needs to communicate with:

// core/shared/api/clients.ts (continued - Instantiation Example)
// Ensure necessary classes (PublicApiClient, AuthenticatedApiClient, Strategies) and services (tokenService, oauthService) are imported.
// import { PublicApiClient } from './public'; 
// import { AuthenticatedApiClient } from './authenticated'; 
// import { BearerTokenStrategy, ApiKeyStrategy, OAuth2Strategy } from './auth-strategies';
// import { tokenService } from '@/core/domains/auth/tokenService';
// import { oauthService } from '@/core/domains/auth/oauthService'; // Example service
// import { ApiClientConfig } from './base';
 
// Example: Main API with bearer token auth
export const mainApi = {
  public: new PublicApiClient({
    baseURL: process.env.MAIN_API_URL,
  }),
  authenticated: new AuthenticatedApiClient(
    { baseURL: process.env.MAIN_API_URL },
    new BearerTokenStrategy(tokenService)
  ),
};
 
// Example: Payment API with API key auth
export const paymentApi = new AuthenticatedApiClient(
  { baseURL: process.env.PAYMENT_API_URL },
  new ApiKeyStrategy(process.env.PAYMENT_API_KEY!)
);
 
// Example: Analytics API with OAuth2
// const oauthService = new YourOAuthService(); // Instantiate your OAuth service
export const analyticsApi = new AuthenticatedApiClient(
  { baseURL: process.env.ANALYTICS_API_URL },
  new OAuth2Strategy(oauthService) // oauthService needs to be defined/imported
);
 
// Example: A third-party API that is public
export const thirdPartyApi = new PublicApiClient({
  baseURL: 'https://api.thirdparty.com',
  headers: {
    'Accept': 'application/json',
    'X-Client-Version': '1.0.0',
  },
});

4. Domain-Specific Implementations

Once you have specific client instances (like paymentApi or analyticsApi), you would use them within your domain-specific API service files to define the actual methods your application will call. Here are a couple of examples:

// Conceptual path: core/domains/payment/api.ts
import { paymentApi } from '@/core/shared/api/clients'; // Assuming 'paymentApi' is exported from your client instances file
 
export const paymentDomainApi = {
  createPaymentIntent: async (amount: number, currency: string) => {
    const { data } = await paymentApi.post('/payment-intents', {
      amount,
      currency,
    });
    return data;
  },
 
  confirmPayment: async (paymentIntentId: string) => {
    const { data } = await paymentApi.post(`/payment-intents/${paymentIntentId}/confirm`);
    return data;
  },
 
  getPaymentMethods: async () => {
    const { data } = await paymentApi.get('/payment-methods');
    return data;
  },
};
// Conceptual path: core/domains/analytics/api.ts
import { analyticsApi } from '@/core/shared/api/clients'; // Assuming 'analyticsApi' is exported from your client instances file
// import { AnalyticsEvent } from './types'; // Assuming AnalyticsEvent type definition
 
export const analyticsDomainApi = {
  trackEvent: async (event: any /* AnalyticsEvent */) => { // Using 'any' as AnalyticsEvent is not defined in this snippet
    await analyticsApi.post('/events', event);
  },
 
  getUserMetrics: async (userId: string) => {
    const { data } = await analyticsApi.get(`/users/${userId}/metrics`);
    return data;
  },
};

5. Environment-Specific Configuration

// core/shared/api/config.ts
export const API_CONFIGS = {
  development: {
    mainApi: {
      baseURL: 'http://localhost:3000/api',
      timeout: 60000, // Longer timeout for dev
    },
    paymentApi: {
      baseURL: 'https://sandbox.payment.com/api',
    },
    analyticsApi: {
      baseURL: 'https://dev.analytics.com/api',
    },
  },
  staging: {
    mainApi: {
      baseURL: 'https://staging-api.myapp.com',
      timeout: 30000,
    },
    paymentApi: {
      baseURL: 'https://sandbox.payment.com/api',
    },
    analyticsApi: {
      baseURL: 'https://staging.analytics.com/api',
    },
  },
  production: {
    mainApi: {
      baseURL: 'https://api.myapp.com',
      timeout: 15000,
    },
    paymentApi: {
      baseURL: 'https://api.payment.com',
    },
    analyticsApi: {
      baseURL: 'https://analytics.com/api',
    },
  },
};
 
// Use environment-specific config
const config = API_CONFIGS[process.env.NODE_ENV];
 
export const apis = {
  main: {
    public: new PublicApiClient(config.mainApi),
    authenticated: new AuthenticatedApiClient(
      config.mainApi,
      new BearerTokenStrategy(tokenService)
    ),
  },
  payment: new AuthenticatedApiClient(
    config.paymentApi,
    new ApiKeyStrategy(process.env.PAYMENT_API_KEY)
  ),
  analytics: new AuthenticatedApiClient(
    config.analyticsApi,
    new OAuth2Strategy(oauthService)
  ),
};

6. Error Handling per Service

When dealing with multiple API services, it's often beneficial to have service-specific error types that can extend a common base ApiError. This allows for more granular error handling and identification of which service an error originated from.

Error Class Definitions:

The following classes would typically reside in a shared error definitions file, for example, core/shared/api/errors.ts.

// Conceptual path: core/shared/api/errors.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public service: string, // Added to identify the service
    public originalError?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}
 
// Specific error handlers that extend the base ApiError
export class PaymentApiError extends ApiError {
  constructor(message: string, status: number, public code?: string) {
    super(message, status, 'payment', /* pass originalError if available */);
  }
}
 
export class AnalyticsApiError extends ApiError {
  constructor(message: string, status: number) {
    super(message, status, 'analytics', /* pass originalError if available */);
  }
}

Example Usage in a Domain API:

This shows how PaymentApiError could be thrown from within a payment domain method.

// Conceptual path: core/domains/payment/api.ts (Example snippet)
// import { paymentApi } from '@/core/shared/api/clients'; // Assuming 'paymentApi' is available
// import { PaymentApiError } from '@/core/shared/api/errors'; // Assuming import of the error class
 
export const paymentDomainApi = {
  createPaymentIntent: async (amount: number, currency: string) => {
    try {
      // const { data } = await paymentApi.post('/payment-intents', { // paymentApi would be used here
      //   amount,
      //   currency,
      // });
      // return data;
      throw { response: { status: 402 } }; // Simulating an error for example purposes
    } catch (error: any) { // Typed error as any for example flexibility
      if (error.response?.status === 402) {
        throw new PaymentApiError(
          'Insufficient funds',
          402,
          'insufficient_funds'
        );
      }
      // For other errors, you might re-throw or use a more generic error handler
      throw error; 
    }
  },
};

7. Monitoring and Logging

// core/shared/api/middleware.ts
export class LoggingInterceptor {
  static request(config: AxiosRequestConfig) {
    console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
    return config;
  }
 
  static response(response: AxiosResponse) {
    console.log(`[API] Response ${response.status} from ${response.config.url}`);
    return response;
  }
 
  static error(error: AxiosError) {
    console.error(`[API] Error ${error.response?.status} from ${error.config?.url}`);
    return Promise.reject(error);
  }
}
 
// Apply to specific clients
export class MonitoredApiClient extends AuthenticatedApiClient {
  constructor(config: ApiClientConfig, authStrategy: AuthStrategy) {
    super(config, authStrategy);
    
    // Add monitoring
    this.client.interceptors.request.use(LoggingInterceptor.request);
    this.client.interceptors.response.use(
      LoggingInterceptor.response,
      LoggingInterceptor.error
    );
  }
}

8. Testing with Multiple APIs

// __tests__/api/multi-api.test.ts
import { mainApi, paymentApi, analyticsApi } from '@/core/shared/api/clients';
import MockAdapter from 'axios-mock-adapter';
 
describe('Multi-API Architecture', () => {
  let mainMock: MockAdapter;
  let paymentMock: MockAdapter;
  let analyticsMock: MockAdapter;
 
  beforeEach(() => {
    mainMock = new MockAdapter(mainApi.authenticated.client);
    paymentMock = new MockAdapter(paymentApi.client);
    analyticsMock = new MockAdapter(analyticsApi.client);
  });
 
  it('should handle different authentication strategies', async () => {
    // Main API uses Bearer token
    mainMock.onGet('/users/me').reply((config) => {
      expect(config.headers.Authorization).toMatch(/^Bearer /);
      return [200, { id: '123' }];
    });
 
    // Payment API uses API key
    paymentMock.onPost('/charges').reply((config) => {
      expect(config.headers['X-API-Key']).toBeDefined();
      return [200, { chargeId: 'ch_123' }];
    });
 
    // Test each API
    await mainApi.authenticated.get('/users/me');
    await paymentApi.post('/charges', { amount: 100 });
  });
});

Benefits of This Scalable Approach

  1. Service Isolation: Each API has its own client instance and configuration
  2. Authentication Flexibility: Different auth strategies per service
  3. Environment Management: Easy to switch between dev/staging/prod
  4. Error Handling: Service-specific error types and handling
  5. Type Safety: Full TypeScript support across all APIs
  6. Testability: Can mock each API independently
  7. Monitoring: Add logging/metrics per service
  8. Maintainability: Clear separation of concerns

Design Principles

Core Architectural Principles

  1. Polymorphic API Client Interfaces

    • Each API client shares common interface but may have unique implementation
    • Authentication strategies can be swapped without changing client code
    • Error handling adapted to each service's specific requirements
  2. Strategy Pattern for Authentication

    • Authentication logic encapsulated in strategy objects
    • Clients delegate authentication to the appropriate strategy
    • New auth mechanisms can be added without modifying existing clients
  3. Environment-Based Configuration

    • Configuration driven by environment rather than hardcoded
    • Clear separation between development, staging, and production environments
    • No code changes required when moving between environments

Trade-offs and Design Decisions

DecisionBenefitsDrawbacksRationale
Strategy pattern for authEasily add new auth methods, cleaner codeMore classes and interfacesProvides flexibility as app grows and adds more external services
Service-specific clientsClean domain boundaries, appropriate error handlingMore boilerplate compared to single clientDifferent APIs have different requirements and error structures
Environment config objectsSingle source of truth for all environmentsLarger config filesSimplifies deployment and environment switching
Base client with extensionsReduces code duplicationCan lead to inheritance issuesBalance between code reuse and flexibility

Constraints and Considerations

  • Each client should handle its own rate limiting and backoff strategies
  • Environment-specific configuration should never be committed with sensitive keys
  • Error handling must be customized per service but follow consistent patterns
  • Authentication state must be properly synchronized across clients when tokens refresh

Implementation Considerations

Performance Implications

  • Client Initialization: Initialize clients lazily when first needed, not all at startup
  • Request Batching: Group multiple calls to the same service when possible
  • Caching Strategy: Implement appropriate caching per API based on data volatility
  • Header Optimization: Minimize custom headers to reduce request size

Security Considerations

  • API Key Storage: Environment variables for development, secure storage for production
  • Token Refresh: Ensure secure handling of refresh tokens across different services
  • Error Exposure: Filter sensitive information from error messages before logging
  • Certificate Management: Properly manage client certificates for secure services

Scalability Aspects

  • The architecture scales easily with additional services
  • New authentication strategies can be implemented without disrupting existing ones
  • Domain-specific wrappers provide clear boundaries as the application grows
  • Environment configuration system adapts well to new deployment targets

Migration Strategy

To migrate to this multi-API architecture:

  1. Start with your main API using the pattern
  2. Add new API services one at a time
  3. Implement appropriate auth strategies
  4. Update domains to use specific API clients
  5. Add monitoring and error handling
  6. Update tests for each service

By adopting this scalable architecture, our team can confidently integrate with multiple API services while maintaining a clean, organized, and maintainable codebase.