UTA DevHub

API Client Architecture

Established patterns for API client implementation across the project

API Client Architecture Guide

Overview

This guide outlines the established API client architecture for our project, which we strongly recommend for all API implementations. It employs a public/protected pattern using separate client instances. Adopting this approach is key to ensuring type safety, clear intent, and maintainability across our services. The following sections detail this pattern and the reasons for its selection.

Purpose & Scope

This guide serves as the primary reference for understanding and implementing the standard API client architecture within our project. Its purpose is to:

  • Define the core structure for BaseApiClient, PublicApiClient, and AuthenticatedApiClient.
  • Establish the standard pattern for how domain-specific API services should be created and used.
  • Explain the rationale behind these architectural choices to ensure consistency, maintainability, and type safety.

This document is intended for all developers involved in creating or interacting with API services. It covers the foundational client setup; more specialized topics like detailed authentication flows, request queueing, and multi-API configurations are detailed in subsequent related documents.

Prerequisites

To fully grasp the concepts presented in this guide, we recommend a basic understanding of:

  • TypeScript: Familiarity with classes, inheritance, and interfaces.
  • HTTP Clients/Axios: Basic knowledge of how HTTP clients like Axios operate, including concepts like request/response interceptors (though interceptors are detailed further in linked documents).
  • API Design Fundamentals: General understanding of RESTful APIs and common authentication patterns.

Why This Pattern?

We evaluated several approaches before choosing this architecture:

Alternative Patterns Considered

  1. Skip Auth Flag Pattern (Not Recommended)

    // Requires runtime flags
    apiClient.post('/auth/login', data, { skipAuth: true })
    • ❌ Runtime complexity
    • ❌ Easy to forget flags
    • ❌ Not type-safe
  2. URL Pattern Configuration (Not Recommended)

    // Maintains list of public endpoints
    const PUBLIC_ENDPOINTS = ['/auth/login', '/auth/register'];
    • ❌ Centralized configuration to maintain
    • ❌ Can become outdated
    • ❌ Hidden behavior
  3. Method Prefix Convention (Not Recommended)

    // Uses naming to indicate auth
    api.publicGet('/products');
    api.get('/protected-data');
    • ❌ Still mixing concerns
    • ❌ Naming conventions can be inconsistent

Our Chosen Pattern: Type-Safe Domain API

We chose the inheritance-based public/protected pattern because it provides:

  1. Clear Separation: Two distinct client classes - PublicApiClient and AuthenticatedApiClient
  2. Explicit Intent: API structure clearly shows which endpoints are public vs authenticated
  3. Type Safety: TypeScript ensures proper usage without runtime checks
  4. Better Organization: Domain APIs are organized with public and protected namespaces
  5. No Magic Flags: No need for skipAuth or similar runtime configurations
  6. Inheritance Benefits: Authenticated client extends public client, avoiding duplication
  7. Scalability: Easy to add new API clients for different services

Our Standard API Client Architecture

Core API Client Classes

Our core API client architecture is built upon three key classes, typically organized into separate files as shown below:

The BaseApiClient provides foundational properties and methods common to all API clients.

// core/shared/api/base.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios';
// Conceptual imports for error classes, actual imports would depend on project structure
// import { ApiResponseError, NoInternetConnectionError, ExtendableError } from '@/core/shared/errors'; 
 
export abstract class BaseApiClient {
  protected client: AxiosInstance;
 
  constructor(config?: AxiosRequestConfig) {
    this.client = axios.create({
      baseURL: process.env.API_BASE_URL, // Standard base URL from environment
      timeout: 30000, // Default timeout
      ...config, // Allow overrides and additional Axios config
    });
  }
 
  /**
   * Handles successful responses, extracting the data.
   * @param response The AxiosResponse object.
   * @returns The data from the response.
   */
  protected handleResponse<T>(response: AxiosResponse<T>): T {
    return response.data;
  }
 
  /**
   * Handles errors encountered during API requests.
   * This method transforms AxiosErrors into custom application errors 
   * consistent with the error handling architecture.
   * @param error The AxiosError object.
   * @throws {ApiResponseError | NoInternetConnectionError | ExtendableError} Throws a custom error.
   */
  protected handleError(error: AxiosError): never {
    if (error.response) {
      // Error with a response from the server (e.g., 4xx, 5xx)
      throw new ApiResponseError( // See "Error Handling Architecture" guide
        error.message,
        error.response.status,
        error.config?.url || 'unknown endpoint',
        error.response.data
      );
    } else if (error.request) {
      // Request was made but no response received (e.g., network error)
      throw new NoInternetConnectionError( // See "Error Handling Architecture" guide
        `Network error or no response for request to ${error.config?.url}`
      );
    } else {
      // Something else happened in setting up the request
      throw new ExtendableError(error.message, 'REQUEST_SETUP_ERROR'); // Base error, see "Error Handling Architecture" guide
    }
  }
 
  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    try {
      const response = await this.client.get<T>(url, config);
      return this.handleResponse(response);
    } catch (error) {
      this.handleError(error as AxiosError);
    }
  }
 
  async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    try {
      const response = await this.client.post<T>(url, data, config);
      return this.handleResponse(response);
    } catch (error) {
      this.handleError(error as AxiosError);
    }
  }
 
  async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    try {
      const response = await this.client.put<T>(url, data, config);
      return this.handleResponse(response);
    } catch (error) {
      this.handleError(error as AxiosError);
    }
  }
 
  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    try {
      const response = await this.client.delete<T>(url, config);
      return this.handleResponse(response);
    } catch (error) {
      this.handleError(error as AxiosError);
    }
  }
}

To make these API client classes usable, singleton instances are typically created and exported from a central module. This pattern ensures consistent client configuration and accessibility throughout the application. The example below shows how this might be structured:

// Example: core/shared/api/client.ts (Conceptual instantiation file)
// import { PublicApiClient } from './public'; // Assuming separate files as shown in Tabs
// import { AuthenticatedApiClient } from './authenticated'; // Assuming separate files as shown in Tabs
// import { AxiosRequestConfig } from 'axios';
// import { tokenService } from '@/core/domains/auth/tokenService'; // Needed for BearerTokenStrategy if used
 
// Placeholder for actual BaseApiClient, PublicApiClient, AuthenticatedApiClient definitions if not using imports from above tabs
// For the sake of this isolated example, assume they are defined or imported.
 
const commonConfig: AxiosRequestConfig = {
  baseURL: process.env.API_BASE_URL, // Make sure this env variable is set up
  // other common configurations can be added here
};
 
export const publicApi = new PublicApiClient(commonConfig);
export const authenticatedApi = new AuthenticatedApiClient(commonConfig);

Domain API Implementation Pattern

For consistency and to leverage the benefits of the core API clients, all domain APIs should adhere to the following pattern. This structure promotes clear separation of public and protected endpoints and ensures type-safe interactions:

// core/domains/[domain]/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type { DomainTypes } from './types';
 
class DomainApi {
  // Public endpoints - no authentication required
  public readonly public = {
    methodName: async (params): Promise<ReturnType> => {
      const { data } = await publicApi.get('/endpoint', { params });
      return data;
    },
  };
 
  // Protected endpoints - authentication required
  public readonly protected = {
    methodName: async (params): Promise<ReturnType> => {
      const { data } = await authenticatedApi.post('/endpoint', params);
      return data;
    },
  };
}
 
export const domainApi = new DomainApi();

Example Implementations

Product Domain API

// core/domains/products/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type { Product, CreateProductDto, UpdateProductDto } from './types';
 
class ProductApi {
  public readonly public = {
    // Anyone can view products
    getAll: async (): Promise<Product[]> => {
      return await publicApi.get('/products');
    },
 
    getById: async (id: string): Promise<Product> => {
      return await publicApi.get(`/products/${id}`);
    },
 
    search: async (query: string): Promise<Product[]> => {
      return await publicApi.get('/products/search', {
        params: { q: query }
      });
    },
  };
 
  public readonly protected = {
    // Only authenticated users can manage products
    create: async (product: CreateProductDto): Promise<Product> => {
      return await authenticatedApi.post('/products', product);
    },
 
    update: async (id: string, updates: UpdateProductDto): Promise<Product> => {
      return await authenticatedApi.put(`/products/${id}`, updates);
    },
 
    delete: async (id: string): Promise<void> => {
      await authenticatedApi.delete(`/products/${id}`);
    },
 
    getUserProducts: async (): Promise<Product[]> => {
      return await authenticatedApi.get('/users/me/products');
    },
  };
}
 
export const productApi = new ProductApi();

User Domain API

// core/domains/users/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type { User, UserProfile, UpdateProfileDto } from './types';
 
class UserApi {
  public readonly public = {
    // Public user endpoints
    getPublicProfile: async (userId: string): Promise<User> => {
      return await publicApi.get(`/users/${userId}/public`);
    },
 
    searchUsers: async (query: string): Promise<User[]> => {
      return await publicApi.get('/users/search', {
        params: { q: query }
      });
    },
  };
 
  public readonly protected = {
    // Protected user endpoints
    getCurrentUser: async (): Promise<UserProfile> => {
      return await authenticatedApi.get('/users/me');
    },
 
    updateProfile: async (updates: UpdateProfileDto): Promise<UserProfile> => {
      return await authenticatedApi.patch('/users/me', updates);
    },
 
    deleteAccount: async (): Promise<void> => {
      await authenticatedApi.delete('/users/me');
    },
 
    getPrivateData: async (): Promise<any> => {
      return await authenticatedApi.get('/users/me/private');
    },
  };
}
 
export const userApi = new UserApi();

Hook Implementation Pattern

Hooks must use the appropriate API methods:

// core/domains/products/hooks.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import { productApi } from './api';
 
// Public data - anyone can access
export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: productApi.public.getAll,
  });
}
 
export function useProduct(id: string) {
  return useQuery({
    queryKey: ['products', id],
    queryFn: () => productApi.public.getById(id),
    enabled: !!id,
  });
}
 
// Protected actions - require authentication
export function useCreateProduct() {
  return useMutation({
    mutationFn: productApi.protected.create,
  });
}
 
export function useUpdateProduct() {
  return useMutation({
    mutationFn: ({ id, updates }) => 
      productApi.protected.update(id, updates),
  });
}

Do's and Don'ts

Do's ✅

// Correct: Use the public/protected pattern
class OrderApi {
  public readonly public = {
    getOrderStatus: async (orderId: string) => {
      return await publicApi.get(`/orders/${orderId}/status`);
    },
  };
  
  public readonly protected = {
    createOrder: async (orderData) => {
      return await authenticatedApi.post('/orders', orderData);
    },
  };
}

Don'ts ❌

// Wrong: Direct API client usage
export const orderApi = {
  getOrders: () => apiClient.get('/orders'),
  createOrder: (data) => apiClient.post('/orders', data),
};
 
// Wrong: Using skipAuth flags
export const orderApi = {
  getPublicData: () => apiClient.get('/public', { skipAuth: true }),
  getPrivateData: () => apiClient.get('/private'),
};
 
// Wrong: Mixed patterns
export const orderApi = {
  public: {
    getOrders: () => apiClient.get('/orders', { skipAuth: true }),
  },
  createOrder: (data) => apiClient.post('/orders', data),
};

Error Handling

Each API client handles errors appropriately:

// core/shared/api/errors.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string,
    public data?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}
 
// Domain-specific error handling
class ProductApi {
  public readonly protected = {
    create: async (product: CreateProductDto): Promise<Product> => {
      try {
        return await authenticatedApi.post('/products', product);
      } catch (error) {
        if (error.response?.status === 409) {
          throw new ApiError(
            'Product already exists',
            409,
            'DUPLICATE_PRODUCT'
          );
        }
        throw error;
      }
    },
  };
}

Testing API Implementations

// core/domains/products/__tests__/api.test.ts
import { productApi } from '../api';
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
 
jest.mock('@/core/shared/api/client');
 
describe('Product API', () => {
  it('should use public client for public endpoints', async () => {
    const mockProducts = [{ id: '1', name: 'Product 1' }];
    (publicApi.get as jest.Mock).mockResolvedValue({ data: mockProducts });
 
    const result = await productApi.public.getAll();
    
    expect(publicApi.get).toHaveBeenCalledWith('/products');
    expect(result).toEqual(mockProducts);
  });
 
  it('should use authenticated client for protected endpoints', async () => {
    const newProduct = { name: 'New Product' };
    const createdProduct = { id: '1', ...newProduct };
    (authenticatedApi.post as jest.Mock).mockResolvedValue({ data: createdProduct });
 
    const result = await productApi.protected.create(newProduct);
    
    expect(authenticatedApi.post).toHaveBeenCalledWith('/products', newProduct);
    expect(result).toEqual(createdProduct);
  });
});

Migration Guide

If you have existing code using different patterns, follow these steps:

  1. Create domain API class with public/protected structure
  2. Move public endpoints to the public object
  3. Move authenticated endpoints to the protected object
  4. Update all imports to use the new structure
  5. Remove any skipAuth flags or similar workarounds
  6. Update tests to mock the correct client

Enforcement

  • Code Reviews: Reject any PRs not following this pattern
  • Linting: ESLint rules to prevent direct API client imports in domains
  • Architecture Tests: Automated tests to verify pattern compliance
// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: [
          {
            group: ['@/core/shared/api/client'],
            message: 'Use publicApi and authenticatedApi exports instead',
          },
        ],
      },
    ],
  },
};

Scalability for Multiple APIs

This pattern scales excellently when your app needs multiple API services:

Different Authentication Strategies

// core/shared/api/auth-strategies.ts
export interface AuthStrategy {
  applyAuth(config: AxiosRequestConfig): Promise<AxiosRequestConfig>;
  handleAuthError(error: AxiosError): Promise<any>;
}
 
export class BearerTokenStrategy implements AuthStrategy {
  async applyAuth(config: AxiosRequestConfig) {
    const token = await tokenService.getAccessToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  }
}
 
export class ApiKeyStrategy implements AuthStrategy {
  constructor(private apiKey: string) {}
 
  async applyAuth(config: AxiosRequestConfig) {
    config.headers['X-API-Key'] = this.apiKey;
    return config;
  }
}

Multiple Service Clients

Note: This section provides a brief overview of how this architecture supports multiple API services. For a comprehensive treatment of this topic, including detailed implementation strategies, configuration approaches for different environments, and advanced authentication strategies, please refer to the Multi-API Architecture document.

// core/shared/api/clients.ts
// 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)
  ),
};
 
// Payment API with API key auth
export const paymentApi = new AuthenticatedApiClient(
  { baseURL: process.env.PAYMENT_API_URL },
  new ApiKeyStrategy(process.env.PAYMENT_API_KEY)
);
 
// Analytics API with OAuth2
export const analyticsApi = new AuthenticatedApiClient(
  { baseURL: process.env.ANALYTICS_API_URL },
  new OAuth2Strategy(oauthService)
);

For detailed multi-API implementation, see Multi-API Architecture.

Summary

Adhering to this API client pattern is highly encouraged for all API implementations in our project, as it provides significant benefits:

  1. Clear Intent: Obvious which endpoints require authentication
  2. Type Safety: Full TypeScript support
  3. Consistency: Same pattern across all domains
  4. Maintainability: Easy to add new endpoints
  5. Security: Reduced risk of accidental auth bypasses
  6. Developer Experience: Better IntelliSense and discoverability
  7. Scalability: Supports multiple API services with different auth strategies

By consistently applying this established standard, we ensure our API integrations are robust, maintainable, and easier for all team members to work with.