Working Effectively with Libraries (facade pattern)

Working Effectively with Libraries (facade pattern)

Introduction

When building web apps, making HTTP requests is something you'll do a lot. Whether you're using fetch, Axios, or another library, having a simple way to handle these requests can make your code easier to manage.

The Problem

Here's how API calls often look in many codebases:

// Scattered throughout your app...
async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error("Failed to fetch user");
  return response.json();
}

async function updateUser(id: string, data: UserData) {
  const response = await fetch(`/api/users/${id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error("Failed to update user");
  return response.json();
}

Issues:

  • Error handling is repeated

  • Changing from fetch to Axios needs updates everywhere

  • Headers and request settings are spread out

A Better Approach

Let's create a simple request function that handles these concerns:

type RequestConfig = {
  method?: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  data?: unknown;
};

async function request<TResponse>(
  url: string,
  config: RequestConfig = {}
): Promise<TResponse> {
  const { method = "GET", headers = {}, data } = config;

  const response = await fetch(url, {
    method,
    headers: {
      "Content-Type": "application/json",
      ...headers,
    },
    ...(data && { body: JSON.stringify(data) }),
  });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return response.json();
}

// Usage
interface User {
  id: string;
  name: string;
}

async function getUser(id: string) {
  return request<User>(`/api/users/${id}`);
}

async function updateUser(id: string, data: Partial<User>) {
  return request<User>(`/api/users/${id}`, {
    method: "PUT",
    data,
  });
}

Want to Use Axios Instead?

Just swap the implementation, keeping the same interface:

import axios from "axios";

async function request<TResponse>(
  url: string,
  config: RequestConfig = {}
): Promise<TResponse> {
  const { method = "GET", headers, data } = config;

  const response = await axios({
    url,
    method,
    headers,
    data,
  });

  return response.data;
}

Your application code doesn't need to change at all!

Going Further

Need to handle authentication? Add retries? Just enhance your request function:

async function request<TResponse>(
  url: string,
  config: RequestConfig = {}
): Promise<TResponse> {
  const { method = "GET", headers = {}, data } = config;

  // Add auth token
  const token = getAuthToken();
  if (token) {
    headers.Authorization = `Bearer ${token}`;
  }

  try {
    const response = await fetch(url, {
      method,
      headers: {
        "Content-Type": "application/json",
        ...headers,
      },
      ...(data && { body: JSON.stringify(data) }),
    });

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
  } catch (error) {
    // Handle retries, logging, etc.
    throw error;
  }
}

The great thing about this approach is that all HTTP-related tasks are managed in one spot. Your application code remains clean and focused on the main business logic.

Remember

Good abstractions don't try to handle every possible case.

They handle the usual cases well and are flexible enough to extend when necessary.