Skip to main content

Refactoring Wooster's API Layer: A Clean Service-Based Approach with Axios

AxiosTypeScriptREST APIRefactoringAuthenticationFrontend DevelopmentWooster

Why Move to Axios?

After building Wooster's initial MVP with the fetch API, it was time for an upgrade. While fetch worked well, Axios offers some helpful features out of the box that would make my code cleaner and more maintainable. More importantly, I wanted to establish a pattern that would scale as the application grows.

Following the Docs

Like any good developer, I first spent three hours looking for "the perfect way" to implement Axios, only to end up exactly where I should have started: the official documentation. Sometimes the simplest approach is the best one, even if it does make you feel a bit daft for not trying it first.

The Implementation

1. Setting Up the Instance

First, I created a configured Axios instance following the official pattern (after trying three "clever" approaches that all ended in tears):

const api = axios.create({
  baseURL: import.meta.env.VITE_BASE_URL || "http://localhost:4000",
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});

This gave me everything fetch did, plus some features I didn't know I needed until I had them:

  • A base URL for all requests
  • Automatic timeout handling
  • Default headers

Rather like getting a dishwasher - you don't realize how much time you've been wasting until you stop doing it the hard way.

2. Authentication

I added a simple interceptor for authentication:

export const setupAuth = (supabase: SupabaseClient) => {
  api.interceptors.request.use(async (config) => {
    const {
      data: { session },
    } = await supabase.auth.getSession();
    if (session?.access_token) {
      config.headers.Authorization = `Bearer ${session.access_token}`;
    }
    return config;
  });
};

3. Type Definitions

Before implementing the services, I defined clear interfaces for our API types:

// types/api/trips.ts
export interface Trip {
  tripId: string;
  destination: Destination;
  numDays: number;
  startDate: string;
  itinerary: ItineraryItem[];
}
 
export interface CreateTripData {
  days: number;
  location: string;
  startDate: Date | null;
  selectedCategories?: string[];
}
 
export interface TripResponse {
  message: string;
  trip: Trip;
}
 
// types/api/destinations.ts
export interface Destination {
  destinationId: number;
  destinationName: string;
  // ... other properties
}
 
export interface CreateDestinationResponse {
  message: string;
  destination: Destination;
}

4. Service-Based API Organization

Instead of having all API calls in one file, I organized them into domain-specific services:

// services/destinations.ts
export const destinationService = {
  getSaved: () =>
    api.get("/saved-destinations").then((response) => response.data),
 
  getAll: () => api.get("/destinations").then((response) => response.data),
 
  save: (destinationId: number) =>
    api.post(`/saved-destinations/${destinationId}`),
 
  unsave: (destinationId: number) =>
    api.delete(`/saved-destinations/${destinationId}`),
};
 
// services/trips.ts
export const tripService = {
  getAll: () => api.get("/trips").then((response) => response.data),
 
  create: (tripData: CreateTripData) =>
    api.post("/trips", tripData).then((response) => response.data),
};

This approach provides several benefits:

  1. Domain separation - related API calls are grouped together
  2. Type safety throughout the entire request/response cycle
  3. Consistent error handling
  4. Easier testing and mocking
  5. Better IDE autocompletion

Using the Services

Here's how these services simplify our API calls:

// Before: Direct API calls with fetch
try {
  const response = await fetch("/saved-destinations");
  const data = await response.json();
  // Handle the response...
} catch (error) {
  console.error("Error:", error);
  // What kind of error? 🤷‍♂️
}
 
// After: Clean service-based calls with type safety
try {
  const { destinations } = await destinationService.getSaved();
  // TypeScript knows exactly what's in destinations!
} catch (error) {
  if (axios.isAxiosError(error)) {
    console.error("API Error:", error.response?.data);
    // We know exactly what went wrong
  }
}

The service pattern gives us cleaner API calls with built-in type safety and better error handling, regardless of how we manage state.

Benefits of This Approach

  1. Type Safety: Full TypeScript coverage from request to response
  2. Domain Organization: API calls are grouped by feature
  3. Consistent Patterns: Each service follows the same structure
  4. Better Maintainability: Easy to find and modify related endpoints
  5. Scalability: New features can easily follow the established pattern
  6. Improved Developer Experience: Better autocomplete and type inference

Key Lessons

  1. Keep It Simple: Following documentation patterns often leads to cleaner code
  2. Think in Domains: Organizing by feature makes code more maintainable
  3. Type Everything: Strong typing catches errors before they reach production
  4. Consistent Patterns: Using consistent naming and structure makes the codebase more predictable

What's Next?

With this foundation in place, I have a clean, type-safe API layer that's ready to grow with my application. But first, I had to fix all those tests I broke... which led me down quite the rabbit hole with Mock Service Worker.

Next up: From Fetch Mocks to MSW: A Testing Journey