UTA DevHub

Header Customization Architecture

Architectural patterns for implementing and managing custom API headers across different use cases

Header Customization Architecture

Overview

This document outlines the architectural patterns for implementing custom headers in API requests. API headers provide critical context to backend services, enabling features like multi-tenancy, device tracking, session management, and request correlation.

Our approach integrates seamlessly with the established API client architecture while leveraging Zustand for state management, creating a flexible and maintainable system for header customization across the application.

Purpose & Scope

This guide establishes:

  • Core patterns for implementing custom API headers
  • Integration with Zustand state management
  • Extensible header provider architecture
  • Decision framework for when to use different header types
  • Testing strategies for header implementations

This document is intended for all developers working with API integrations and those needing to add custom contextual information to API requests.

Quick Start

If you're looking to quickly implement custom headers in your API client, here's how to get started:

  1. Define your header constants:

    // core/shared/constants/headers.ts
    export const API_HEADERS = {
      ORGANIZATION_ID: 'X-ORG-ID',
      DEVICE_UUID: 'X-DEVICE-UUID'
    };
  2. Create a header provider:

    // core/domains/tenant/headerProvider.ts
    import { HeaderProvider } from '@/core/shared/api/headers/types';
    import { API_HEADERS } from '@/core/shared/constants/headers';
    import { useTenantStore } from './store';
     
    export class TenantHeaderProvider implements HeaderProvider {
      id = 'tenant';
      priority = 100;
      
      async getHeaders() {
        const { organizationId } = useTenantStore.getState();
        return {
          [API_HEADERS.ORGANIZATION_ID]: organizationId
        };
      }
    }
  3. Register with your API client:

    // In your API client setup
    const apiClient = new AuthenticatedApiClient(config);
    apiClient.registerHeaderProvider(new TenantHeaderProvider());

For more detailed implementation, continue reading the sections below.

Prerequisites

To effectively use this guide, you should be familiar with:

Architecture Overview

Custom headers are implemented through a combination of header providers, Zustand stores, and interceptors in our API client architecture:

Header Types and Use Cases

Different header types serve different purposes in your application:

Header TypePurposeExamplesPriority
IdentityIdentifies tenant contextX-ORG-ID, X-PROJECT-IDHigh
DeviceProvides device contextX-DEVICE-UUID, User-AgentHigh
TrackingEnables request correlationX-REQUEST-ID, X-CORRELATION-IDMedium
SessionMaintains session contextX-SESSION-IDMedium
FeatureEnables feature flagsX-FEATURE-FLAGSLow
DebuggingProvides debug informationX-DEBUG-MODELow

Header priorities indicate their importance in the architecture. High-priority headers should be consistently implemented across all APIs, while low-priority headers might be optional or use-case specific.

Core Components

Conceptual Overview

Our header customization architecture consists of four main components working together:

  1. Header Providers: Modular components that supply specific header values
  2. Zustand Stores: State containers that hold header-related data
  3. Provider Registry: System within BaseApiClient to register and manage providers
  4. Interceptor Integration: Mechanism to apply headers to outgoing requests

Implementation Guides

For detailed implementation examples of specific header types, refer to these guides:

The remainder of this document details the core components of the header architecture that all these implementation guides build upon.

This architecture follows a plugin pattern, where different header providers can be registered with API clients, making the system highly extensible while maintaining a consistent implementation approach.

The header provider pattern creates a clear separation of concerns: state management is handled by Zustand stores, while the logic for transforming state into headers lives in providers.

1. Header Provider Interface

The foundation of our architecture is the HeaderProvider interface, which creates a consistent pattern for different header types:

// core/shared/api/headers/types.ts
export interface HeaderProvider {
  /**
   * Retrieves headers to be added to API requests
   * @returns Promise resolving to a map of header names to values
   */
  getHeaders(): Promise<Record<string, string | undefined>>;
  
  /**
   * Priority level for this provider (higher numbers applied first)
   */
  priority: number;
  
  /**
   * Unique identifier for this provider
   */
  id: string;
}

2. Header Constants

Centralized constants ensure consistent header names across the application:

// core/shared/constants/headers.ts
export const API_HEADERS = {
  // Identity headers
  ORGANIZATION_ID: 'X-ORG-ID',
  PROJECT_ID: 'X-PROJECT-ID',
  TENANT_ID: 'X-TENANT-ID',
  
  // Device headers
  DEVICE_UUID: 'X-DEVICE-UUID',
  DEVICE_TYPE: 'X-DEVICE-TYPE',
  APP_VERSION: 'X-APP-VERSION',
  
  // Tracking headers
  SESSION_ID: 'X-SESSION-ID',
  REQUEST_ID: 'X-REQUEST-ID',
  CORRELATION_ID: 'X-CORRELATION-ID',
  
  // Feature headers
  FEATURE_FLAGS: 'X-FEATURE-FLAGS',
  
  // Debugging headers
  DEBUG_MODE: 'X-DEBUG-MODE',
} as const;
 
export type ApiHeaderKey = keyof typeof API_HEADERS;
export type ApiHeaderValue = typeof API_HEADERS[ApiHeaderKey];

3. Extended BaseApiClient

The BaseApiClient is extended to support header providers:

// core/shared/api/base.ts - Extended portion
export abstract class BaseApiClient {
  // ... existing implementation
  
  protected headerProviders: HeaderProvider[] = [];
  
  /**
   * Registers a header provider with this client instance
   * @param provider The header provider to register
   */
  protected registerHeaderProvider(provider: HeaderProvider): void {
    // Ensure no duplicates by id
    this.headerProviders = this.headerProviders.filter(p => p.id !== provider.id);
    this.headerProviders.push(provider);
    // Sort by priority (highest first)
    this.headerProviders.sort((a, b) => b.priority - a.priority);
  }
  
  protected setupHeaderInterceptors(): void {
    this.client.interceptors.request.use(async (config) => {
      try {
        // Apply all registered header providers in priority order
        for (const provider of this.headerProviders) {
          const headers = await provider.getHeaders();
          for (const [key, value] of Object.entries(headers)) {
            if (value !== undefined) {
              config.headers[key] = value;
            }
          }
        }
      } catch (error) {
        // Log but don't fail the request due to header issues
        console.warn('Error applying headers:', error);
      }
      return config;
    });
  }
  
  // Constructor enhancement
  constructor(config?: AxiosRequestConfig) {
    // ... existing initialization
    
    // Setup standard interceptors
    this.setupErrorHandlingInterceptors();
    this.setupHeaderInterceptors();
  }
}

Header Implementation Patterns

We support three patterns for implementing headers, each with specific use cases:

1. Static Headers

Static headers remain constant across all requests and are typically configured during client initialization.

// Example of static header configuration
const commonConfig = {
  headers: {
    'X-API-VERSION': '2.0',
    'X-CLIENT-TYPE': 'mobile-app',
  },
};
 
export const publicApi = new PublicApiClient(commonConfig);
export const authenticatedApi = new AuthenticatedApiClient(commonConfig);

Use when: The header value is constant and known at initialization time.

2. Zustand-Based Dynamic Headers

For headers that depend on application state, we use Zustand stores rather than separate context services, aligning with our state management architecture:

// core/domains/tenant/store.ts
interface TenantState {
  organizationId: string | null;
  projectId: string | null;
  setOrganizationId: (id: string | null) => void;
  setProjectId: (id: string | null) => void;
}
 
export const useTenantStore = create<TenantState>((set) => ({
  organizationId: null,
  projectId: null,
  setOrganizationId: (id) => set({ organizationId: id }),
  setProjectId: (id) => set({ projectId: id }),
}));

Then implement a corresponding header provider:

// core/domains/tenant/headerProvider.ts
import { HeaderProvider } from '@/core/shared/api/headers/types';
import { API_HEADERS } from '@/core/shared/constants/headers';
import { useTenantStore } from './store';
 
export class TenantHeaderProvider implements HeaderProvider {
  id = 'tenant';
  priority = 100; // High priority
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    // Get current state values using Zustand's getState()
    const { organizationId, projectId } = useTenantStore.getState();
    
    return {
      [API_HEADERS.ORGANIZATION_ID]: organizationId || undefined,
      [API_HEADERS.PROJECT_ID]: projectId || undefined,
    };
  }
}

Use when: The header values depend on user selection, app state, or other runtime factors.

3. Per-Request Headers

Headers specified for individual API calls that override global defaults:

// Example of per-request headers
userApi.protected.getUserDetails(userId, { 
  headers: { 
    'X-CUSTOM-CONTEXT': 'specific-value',
  }
});

Use when: The header is specific to a particular request and not applicable globally.

Common Header Provider Implementations

Device Information Provider

// core/shared/api/headers/providers/deviceHeaderProvider.ts
import { HeaderProvider } from '../types';
import { API_HEADERS } from '@/core/shared/constants/headers';
import DeviceInfo from 'react-native-device-info';
 
export class DeviceHeaderProvider implements HeaderProvider {
  id = 'device';
  priority = 90;
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    // Get device information
    const deviceId = await DeviceInfo.getUniqueId();
    const appVersion = DeviceInfo.getVersion();
    const buildNumber = DeviceInfo.getBuildNumber();
    const deviceType = DeviceInfo.getDeviceType();
    
    return {
      [API_HEADERS.DEVICE_UUID]: deviceId,
      [API_HEADERS.APP_VERSION]: `${appVersion}+${buildNumber}`,
      [API_HEADERS.DEVICE_TYPE]: deviceType,
    };
  }
}

Request Tracking Provider

// core/shared/api/headers/providers/trackingHeaderProvider.ts
import { HeaderProvider } from '../types';
import { API_HEADERS } from '@/core/shared/constants/headers';
import { v4 as uuidv4 } from 'uuid';
import { storage } from '@/core/shared/utils/storage';
 
export class TrackingHeaderProvider implements HeaderProvider {
  id = 'tracking';
  priority = 80;
  private sessionId: string | null = null;
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    // Get or create session ID
    if (!this.sessionId) {
      this.sessionId = await this.getOrCreateSessionId();
    }
    
    // Generate a new request ID for each call
    const requestId = uuidv4();
    
    return {
      [API_HEADERS.SESSION_ID]: this.sessionId,
      [API_HEADERS.REQUEST_ID]: requestId,
    };
  }
  
  private async getOrCreateSessionId(): Promise<string> {
    let sessionId = await storage.getItem('session_id');
    if (!sessionId) {
      sessionId = uuidv4();
      await storage.setItem('session_id', sessionId);
    }
    return sessionId;
  }
}

Debug Mode Provider

// core/shared/api/headers/providers/debugHeaderProvider.ts
import { HeaderProvider } from '../types';
import { API_HEADERS } from '@/core/shared/constants/headers';
import { useDebugStore } from '@/core/shared/store/debugStore';
 
export class DebugHeaderProvider implements HeaderProvider {
  id = 'debug';
  priority = 50;
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    const { debugMode, verboseLogging } = useDebugStore.getState();
    
    if (!debugMode) {
      return {};
    }
    
    return {
      [API_HEADERS.DEBUG_MODE]: 'true',
      ...(verboseLogging ? { 'X-VERBOSE-LOGGING': 'true' } : {}),
    };
  }
}

Registering Header Providers with API Clients

// Enhancing authenticated client with header providers
export class AuthenticatedApiClient extends BaseApiClient {
  constructor(config?: AxiosRequestConfig) {
    super(config);
    
    // Register header providers
    this.registerHeaderProvider(new DeviceHeaderProvider());
    this.registerHeaderProvider(new TenantHeaderProvider());
    this.registerHeaderProvider(new TrackingHeaderProvider());
    // Debug headers last (lowest priority)
    this.registerHeaderProvider(new DebugHeaderProvider());
    
    // Setup auth interceptors
    this.setupAuthInterceptors();
  }
  
  // ... rest of the authenticated client implementation
}

Testing Header Implementations

// __tests__/core/shared/api/headers/providers/tenantHeaderProvider.test.ts
import { TenantHeaderProvider } from '@/core/domains/tenant/headerProvider';
import { useTenantStore } from '@/core/domains/tenant/store';
import { API_HEADERS } from '@/core/shared/constants/headers';
 
describe('TenantHeaderProvider', () => {
  beforeEach(() => {
    // Reset store state
    useTenantStore.setState({
      organizationId: null,
      projectId: null,
    });
  });
  
  it('should return empty headers when no tenant is selected', async () => {
    const provider = new TenantHeaderProvider();
    const headers = await provider.getHeaders();
    
    expect(headers[API_HEADERS.ORGANIZATION_ID]).toBeUndefined();
    expect(headers[API_HEADERS.PROJECT_ID]).toBeUndefined();
  });
  
  it('should return organization ID in headers when set', async () => {
    // Setup store with test data
    useTenantStore.setState({ organizationId: 'org-123' });
    
    const provider = new TenantHeaderProvider();
    const headers = await provider.getHeaders();
    
    expect(headers[API_HEADERS.ORGANIZATION_ID]).toBe('org-123');
  });
  
  it('should return both organization and project IDs when set', async () => {
    // Setup store with test data
    useTenantStore.setState({
      organizationId: 'org-123',
      projectId: 'proj-456',
    });
    
    const provider = new TenantHeaderProvider();
    const headers = await provider.getHeaders();
    
    expect(headers[API_HEADERS.ORGANIZATION_ID]).toBe('org-123');
    expect(headers[API_HEADERS.PROJECT_ID]).toBe('proj-456');
  });
});

Decision Framework: When to Use Which Headers

Use this framework to determine which headers to implement for your specific use case:

Identity Headers

When to use:

  • Multi-tenant applications where users belong to organizations
  • Resource segmentation based on organizational hierarchies
  • Project or team-based access controls

Common headers:

  • X-ORG-ID: Organization identifier
  • X-PROJECT-ID: Project identifier within an organization
  • X-TENANT-ID: Generic tenant identifier

Device Headers

When to use:

  • Device-specific features or optimizations
  • Analytics tracking device usage
  • Security auditing and device verification

Common headers:

  • X-DEVICE-UUID: Unique device identifier
  • X-DEVICE-TYPE: Device type (phone, tablet, etc.)
  • User-Agent: Custom or extended user agent information

Session and Tracking Headers

When to use:

  • Request correlation for troubleshooting
  • Analytics flow tracking
  • Performance monitoring

Common headers:

  • X-SESSION-ID: User session identifier
  • X-REQUEST-ID: Unique request identifier
  • X-CORRELATION-ID: Identifier for tracing requests across services

Implementation Considerations

Security Implications

Be careful about what information you include in headers. Headers can be exposed in network logs and could potentially leak sensitive information.

  • We strongly recommend against including sensitive information like passwords, tokens, or PII in custom headers
  • It's advisable to use non-sequential, non-predictable IDs for organization and project identifiers
  • Consider encrypting certain header values if they contain semi-sensitive information

Performance Impact

  • Headers add minimal overhead to requests, but caching header values can improve performance
  • Implement caching in header providers where appropriate to avoid redundant computations
  • Consider the frequency of state changes when designing Zustand stores for header values

Consistency and Maintainability

  • Use a single source of truth for header names (constants or enums)
  • Document headers thoroughly, including purpose, format, and fallback behavior
  • Follow the HeaderProvider interface consistently for all header types

Design Principles

Core Architectural Principles

  1. Modularity

    • Each header provider is responsible for a specific header type
    • Providers can be added, removed, or modified independently
    • Composable architecture allows for customization per API client
  2. State Management Integration

    • Consistent use of Zustand for state-driven headers
    • Aligns with the project's "Golden Rule" for state management
  3. Priority-Based Application

    • Header providers have explicit priorities
    • Providers are applied in priority order to ensure consistent resolution

Trade-offs and Design Decisions

DecisionBenefitsDrawbacksRationale
HeaderProvider interfaceConsistent implementation, testabilityMore boilerplate compared to simple functionsCreates a pluggable system that scales better with multiple header types
Zustand integrationAligns with state management architectureRequires Zustand knowledgeMaintains consistency with our established patterns
Priority-based orderingClear resolution order, predictable behaviorAdditional complexityEnsures critical headers are consistently applied first
Type-safe constantsCompile-time validationRequires maintenance when adding headersPrevents typos and inconsistencies across the codebase

Implementation Examples

Here are examples of using header providers in different domains:

User Preferences in Profile API

// core/domains/profile/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type { UserProfile, ProfilePreferences } from './types';
 
export const profileApi = {
  public: {},
  protected: {
    // Headers from registered providers are automatically applied
    async getUserProfile(): Promise<UserProfile> {
      return authenticatedApi.get<UserProfile>('/profile');
    },
    
    // Use per-request headers to override preferences for a specific call
    async getProfileWithLocale(locale: string): Promise<UserProfile> {
      return authenticatedApi.get<UserProfile>('/profile', {
        headers: {
          'X-PREFERRED-LOCALE': locale // Override just for this request
        }
      });
    },
  }
};

Organization Context in Projects API

// core/domains/projects/api.ts
import { authenticatedApi } from '@/core/shared/api/client';
import { useTenantStore } from '@/core/domains/tenant/store';
import type { Project, ProjectDetails } from './types';
 
export const projectsApi = {
  public: {},
  protected: {
    // Tenant headers (X-ORG-ID) are automatically applied
    async getProjects(): Promise<Project[]> {
      return authenticatedApi.get<Project[]>('/projects');
    },
    
    // When context needs to be temporarily switched for a single request
    async getProjectFromDifferentOrg(projectId: string, orgId: string): Promise<ProjectDetails> {
      const { organizationId } = useTenantStore.getState();
      const originalOrgId = organizationId;
      
      try {
        // Temporarily change context
        useTenantStore.getState().setOrganizationId(orgId);
        
        // Make the request with the temporary context
        return await authenticatedApi.get<ProjectDetails>(`/projects/${projectId}`);
      } finally {
        // Restore original context
        useTenantStore.getState().setOrganizationId(originalOrgId);
      }
    }
  }
};

Troubleshooting

Here are solutions to common issues when implementing custom headers:

Headers Not Being Applied

Issue: Your custom headers aren't showing up in network requests

Solutions:

  • Ensure your header provider is properly registered with the API client
  • Check that your Zustand store is properly initialized
  • Verify header provider priority values (higher priorities are applied first)
  • Look for console warnings about header provider errors

Conflicting Headers

Issue: Multiple providers are trying to set the same header with different values

Solution: Adjust provider priorities to ensure the correct provider wins, or refactor providers to have clearer responsibilities.

Performance Issues

Issue: Header resolution is causing noticeable delays in API requests

Solutions:

  • Implement caching in your header providers
  • Reduce async operations where possible
  • Consider using a memory cache for frequently accessed header values

Migration Guide

If you're migrating from an older header implementation, follow these steps:

  1. Define header constants in a central location
  2. Create header providers for each header category
  3. Update API client initialization to register providers
  4. Remove any inline header creation from domain APIs
  5. Update tests to mock header providers rather than specific headers

Summary

This header customization architecture provides a flexible, maintainable approach to managing API headers across your application. By leveraging Zustand for state management and implementing a modular provider system, you can easily add, modify, or extend custom headers as your application requirements evolve.

Key takeaways:

  • The HeaderProvider interface creates a consistent pattern for header implementation
  • Zustand stores provide a clean way to manage state that affects headers
  • The priority system ensures predictable header resolution
  • This architecture scales well as your application grows
  • Each header provider can be tested independently

By following these established patterns, you can ensure your API headers remain well-organized and aligned with the rest of the application architecture.