Skip to main content
Client interfaces define the contract between our application and external services. They play a crucial role in our architecture, allowing us to implement the dependency inversion principle and greatly simplify testing.

Purpose

Client interfaces serve two critical purposes in our architecture:
They allow our application to depend on abstractions rather than concrete implementations, making it possible to swap one implementation for another without changing the consuming code.
They make it easy to create mock implementations for testing, eliminating the need to interact with real external services during tests.

Interface Design Principles

1

Domain-Focused

• Design interfaces around domain concepts, not around the structure of external APIs
2

Minimal

• Include only the methods that our application needs
3

Consistent

• Follow consistent naming conventions and method signatures
4

Error Handling

• Define clear error handling patterns
5

Self-Contained

• Interfaces should include all necessary types and not depend on external API types

Example: Hubrise Client Interface

/**
 * Interface for interacting with the Hubrise API.
 */
export interface IHubriseClient {
  /**
   * Creates a new order in Hubrise.
   *
   * @param {Order} order - The order to create
   * @returns {Promise<HubriseOrderResponse>} The created Hubrise order
   * @throws {HubriseIntegrationError} When the API call fails
   */
  createOrder(order: Order): Promise<HubriseOrderResponse>;

  /**
   * Retrieves an order from Hubrise.
   *
   * @param {string} orderId - The ID of the order to retrieve
   * @returns {Promise<HubriseOrderResponse>} The Hubrise order
   * @throws {HubriseIntegrationError} When the API call fails
   */
  getOrder(orderId: string): Promise<HubriseOrderResponse>;

  /**
   * Updates the status of an order in Hubrise.
   *
   * @param {string} orderId - The ID of the order to update
   * @param {OrderStatus} status - The new status
   * @returns {Promise<HubriseOrderResponse>} The updated Hubrise order
   * @throws {HubriseIntegrationError} When the API call fails
   */
  updateOrderStatus(
    orderId: string,
    status: OrderStatus
  ): Promise<HubriseOrderResponse>;

  /**
   * Creates a new customer in Hubrise.
   *
   * @param {Customer} customer - The customer to create
   * @returns {Promise<HubriseCustomerResponse>} The created Hubrise customer
   * @throws {HubriseIntegrationError} When the API call fails
   */
  createCustomer(customer: Customer): Promise<HubriseCustomerResponse>;

  /**
   * Retrieves the catalog from Hubrise.
   *
   * @param {string} locationId - The ID of the location
   * @returns {Promise<HubriseCatalogResponse>} The Hubrise catalog
   * @throws {HubriseIntegrationError} When the API call fails
   */
  getCatalog(locationId: string): Promise<HubriseCatalogResponse>;
}

Example: Stripe Client Interface

/**
 * Interface for interacting with the Stripe API.
 */
export interface IStripeClient {
  /**
   * Creates a checkout session for an order.
   *
   * @param {Order} order - The order to create a checkout session for
   * @returns {Promise<StripeCheckoutSessionResponse>} The created checkout session
   * @throws {StripeIntegrationError} When the API call fails
   */
  createCheckoutSession(order: Order): Promise<StripeCheckoutSessionResponse>;

  /**
   * Retrieves a checkout session.
   *
   * @param {string} sessionId - The ID of the session to retrieve
   * @returns {Promise<StripeCheckoutSessionResponse>} The checkout session
   * @throws {StripeIntegrationError} When the API call fails
   */
  getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionResponse>;

  /**
   * Creates a payment intent.
   *
   * @param {number} amount - The amount to charge
   * @param {string} currency - The currency to use
   * @param {string} customerId - The ID of the customer
   * @returns {Promise<StripePaymentIntentResponse>} The created payment intent
   * @throws {StripeIntegrationError} When the API call fails
   */
  createPaymentIntent(
    amount: number,
    currency: string,
    customerId: string
  ): Promise<StripePaymentIntentResponse>;

  /**
   * Verifies a webhook signature.
   *
   * @param {string} payload - The webhook payload
   * @param {string} signature - The webhook signature
   * @returns {Promise<boolean>} True if the signature is valid
   * @throws {StripeIntegrationError} When verification fails
   */
  verifyWebhookSignature(payload: string, signature: string): Promise<boolean>;
}

Example: Mapbox Client Interface

/**
 * Interface for interacting with the Mapbox API.
 */
export interface IMapboxClient {
  /**
   * Geocodes an address to get coordinates.
   *
   * @param {string} address - The address to geocode
   * @returns {Promise<MapboxGeocodingResponse>} The geocoding result
   * @throws {MapboxIntegrationError} When the API call fails
   */
  geocodeAddress(address: string): Promise<MapboxGeocodingResponse>;

  /**
   * Gets directions between two points.
   *
   * @param {Location} origin - The starting location
   * @param {Location} destination - The destination location
   * @returns {Promise<MapboxDirectionsResponse>} The directions
   * @throws {MapboxIntegrationError} When the API call fails
   */
  getDirections(
    origin: Location,
    destination: Location
  ): Promise<MapboxDirectionsResponse>;

  /**
   * Calculates the estimated time of arrival.
   *
   * @param {Location} origin - The starting location
   * @param {Location} destination - The destination location
   * @returns {Promise<number>} The estimated time in minutes
   * @throws {MapboxIntegrationError} When the API call fails
   */
  getEstimatedTimeOfArrival(
    origin: Location,
    destination: Location
  ): Promise<number>;
}

Implementing Interfaces

Once the interfaces are defined, we create concrete implementations for each external service:
/**
 * Implementation of the Hubrise client interface.
 */
export class HubriseClient implements IHubriseClient {
  constructor(
    private readonly baseUrl: string,
    private readonly apiKey: string
  ) {}

  async createOrder(order: Order): Promise<HubriseOrderResponse> {
    try {
      const response = await fetch(`${this.baseUrl}/orders`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${this.apiKey}`,
        },
        body: JSON.stringify(this.mapOrderToHubrise(order)),
      });

      if (!response.ok) {
        throw new Error(`Hubrise API error: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      throw new HubriseIntegrationError(
        "Failed to create order in Hubrise",
        error instanceof Error ? error : new Error(String(error))
      );
    }
  }
}

Testing with Mock Implementations

For testing, we create mock implementations of our interfaces:
/**
 * Mock implementation of the Hubrise client interface for testing.
 */
export class MockHubriseClient implements IHubriseClient {
  createOrder = jest.fn();
  getOrder = jest.fn();
  updateOrderStatus = jest.fn();
  createCustomer = jest.fn();
  getCatalog = jest.fn();

  // Helper methods for test setup
  mockCreateOrderSuccess(response: HubriseOrderResponse): void {
    this.createOrder.mockResolvedValue(response);
  }

  mockCreateOrderFailure(error: Error): void {
    this.createOrder.mockRejectedValue(
      new HubriseIntegrationError("Failed to create order in Hubrise", error)
    );
  }
}

Using Interfaces in Use Cases

In our use cases, we depend on interfaces rather than concrete implementations:
export class CreateOrderUseCase {
  constructor(
    @Inject(IOrderRepository)
    private readonly orderRepository: IOrderRepository,
    @Inject(IHubriseClient)
    private readonly hubriseClient: IHubriseClient
  ) {}

  async execute(order: Order): Promise<Order> {
    // Implementation that uses the interfaces...
  }
}

Dependency Injection Setup

We configure dependency injection to provide the correct implementations:
@Module({
  providers: [
    CreateOrderUseCase,
    {
      provide: IHubriseClient,
      useClass: HubriseClient,
    },
  ],
})
export class InfrastructureModule {}

Swapping Implementations

One of the key benefits of using interfaces is the ability to easily swap implementations:
// Switching from Mapbox to Google Maps
@Module({
  providers: [
    CreateOrderUseCase,
    {
      provide: IMapboxClient,
      useClass: GoogleMapsClientAdapter, // Implements IMapboxClient with Google Maps API
    },
  ],
})
export class OrderModule {}

Benefits of Using Interfaces

1

Dependency Inversion

• Our application depends on abstractions, not concrete implementations
2

Testability

• We can easily mock external services for testing
3

Flexibility

• We can swap implementations without changing consuming code
4

Isolation

• Our domain logic is isolated from external service details
5

Contract Definition

• Interfaces clearly define what our application needs from external services