跪拜 Guibai
← Back to the summary

Stop Hardcoding Your Axios Singleton: A Three-Layer Architecture for Maintainable Frontend Requests

How Should the Frontend Request Layer Be Wrapped? Why Does a Global Axios Singleton Become Harder to Maintain Over Time?

Many frontend projects' request layers start with a very simple piece of code:

import axios from 'axios';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
});

request.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = token;
  }
  return config;
});

request.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      router.push('/login');
    }
    return Promise.reject(error);
  },
);

export default request;

At first glance, there's nothing wrong with writing it this way.

It has a baseURL, a timeout, token injection, a 401 redirect to login, and it extracts response.data. Many projects' first version of request wrapping looks like this.

But as the project grows, you'll find this file gets heavier and heavier.

Today, a backend developer says the business success code is not 200, but 0.

Tomorrow, another backend developer says their response uses status/msg/result.

The day after, the product manager requires that errors for certain interfaces should not trigger a toast.

A few days later, another requirement comes in: some 401s should not redirect to login because they are just silent probe interfaces.

Later, the project starts integrating with multiple backend services, each with different token fields, error codes, and timeout durations.

So this request.ts starts growing all sorts of conditionals:

if (config.serviceType === 'crm') {
  // CRM's token and error codes
}

if (config.serviceType === 'mall') {
  // Mall's token and error codes
}

if (!config.hiddenNotify) {
  ElMessage.error(message);
}

if (!config.skipAuthRedirect) {
  router.push('/login');
}

Eventually, no one dares to touch it.

Because this file, ostensibly called request wrapping, has actually become a small application hub:

This article aims to discuss this problem: How should the frontend request layer be wrapped so it doesn't become harder to maintain over time?

1. The Easiest Mistake to Make in the Request Layer: Knowing Too Much

What is the responsibility of the request layer?

Many people will say: wrapping axios.

But that's just the implementation method, not the responsibility.

More accurately, the request layer should be responsible for:

It should not be responsible for:

The biggest problem with the request layer is often not insufficient functionality, but knowing too much.

The more a layer knows, the worse its reusability and the higher its migration cost.

Today it serves a web admin panel, and reading directly from localStorage is fine.

Tomorrow it needs to serve an Electron client, and the token might come from a preload bridge.

The day after, it needs to serve an in-app webview, and login failure might not mean a route redirect, but calling the client for re-authorization.

If token reading, login redirects, and error toasts are all hardcoded in the request layer, then every time the runtime environment changes, the request layer has to be modified.

This is coupling.

2. Why a Global Axios Singleton Becomes a Black Hole

Many projects like to have only one global axios instance.

export const request = axios.create(...);

It's fine for small projects.

But once a project starts integrating with multiple backends, the global singleton becomes awkward.

For example, a frontend simultaneously integrates with:

Each system may have its own rules:

Service Success Code Message Field Data Field Auth Method
User Center 200 message data Bearer token
Order System 0 msg result x-token
Data Dashboard 1 errorMsg payload cookie
Third-party API HTTP 2xx No unified format Raw response appKey

If you only have one global request, you'll likely end up stacking conditional branches in the interceptor.

if (service === 'user') {
  // User Center logic
}

if (service === 'order') {
  // Order System logic
}

if (service === 'dashboard') {
  // Data Dashboard logic
}

This is the beginning of the black hole.

All differences are sucked into one file, and everyone adds their own conditionals.

In the short term, it looks like you're reusing one wrapper, but in the long term, you're mixing the protocol differences of multiple services together.

A better approach is:

The request kernel can be reused, but different services should be allowed to have their own instances and interceptors.

That is, not "one global axios instance," but "a unified AxiosFactory + multiple service instances."

3. The Request Layer Should Be Split into Three Layers

I now prefer to split the request layer into three layers:

1. Request Kernel Layer

This layer is only responsible for creating the client, mounting interceptors, and exposing basic methods like get/post/put/delete/request.

It doesn't care about specific backend business codes, nor where the token comes from.

It's more like a factory.

const request = new AxiosFactory({
  timeout: 30000,
  interceptorHooks: apiInterceptor,
});

2. Service Protocol Layer

This layer handles the protocol differences of a specific backend service.

For example:

Different services can have different interceptors.

One for the User Center, one for the Order System, one for the Data Dashboard.

They share the request kernel but not all business judgments.

3. Application Assembly Layer

This layer injects runtime capabilities when the application starts.

For example:

These capabilities should not be decided by the services layer itself, but by the specific application.

A web admin panel can redirect to /login.

Electron can pop up a login window.

An in-app webview can call the client for authorization.

The request layer only exposes injection points, without hardcoding the runtime environment.

4. Don't Let the Services Layer Directly Import Router

Many projects write code like this:

import router from '@/router';

request.interceptors.response.use(undefined, (error) => {
  if (error.response?.status === 401) {
    router.push('/login');
  }
  return Promise.reject(error);
});

This code is very common and easy to understand.

But it has one problem: the services layer starts depending on the application layer's router.

On the surface, it's just redirecting to the login page, but in reality, you've already bound the request layer to a specific application.

Later, if you want to extract services into a shared package, you'll encounter several issues:

So a better direction is not to import the router in services, but to let the application inject a function.

configureServiceAuth({
  getToken: () => localStorage.getItem('__TOKEN__') ?? '',
  onAuthenticationFailure: () => {
    router.push('/login');
  },
});

This way, the services layer only knows: when authentication fails, call onAuthenticationFailure().

It doesn't care whether this function redirects, pops up a window, logs out, or calls a client bridge.

This is dependency inversion.

5. Don't Let the Services Layer Directly Depend on a UI Component Library

The same problem occurs with error prompts.

Many projects directly write this in the request layer:

import { ElMessage } from 'element-plus';

ElMessage.error(message);

It's convenient in the short term, but it will also bind you in the long term.

What if the project switches UI component libraries later?

What if a certain application doesn't use Element Plus?

What if you want to use a toast on mobile, a message on PC, and a system notification in Electron?

The way error messages are displayed essentially belongs to the application layer, not the services layer.

The services layer can decide "there is an error message to display here," but it should not decide "which UI component to use for displaying it."

So a better way is to inject an error messenger:

setErrorMessenger((message) => {
  ElMessage.error(message);
});

Inside the services layer, only call:

showErrorMessage(message);

How it's specifically displayed is injected when the application starts.

This way, the services layer won't be bound to a UI framework.

6. Why hiddenNotify and skipAuthRedirect Are Important

In real projects, requests don't have just one behavior.

Some interface failures should trigger an error prompt.

Some interface failures should not disturb the user.

For example:

If all errors uniformly trigger a toast, the user experience will be very poor.

So the request configuration usually needs options like this:

request.get('/api/xxx', {
  requestOptions: {
    hiddenNotify: true,
  },
});

Similarly, some 401s should not automatically redirect to login.

For example, if you are silently probing the user status, or a certain interface allows anonymous failure.

In this case, you can have:

request.get('/api/session/check', {
  requestOptions: {
    skipAuthRedirect: true,
  },
});

These options seem small, but they reflect a key point:

The request layer should provide controllable behavior switches, rather than a one-size-fits-all approach to all requests.

A one-size-fits-all wrapper is most easily broken in real business scenarios.

7. Using LYStack as an Example

The services design in LYStack is built around these problems.

It doesn't stuff all logic into a single global axios singleton, but splits it into several parts.

1. AxiosFactory: Request Kernel

AxiosFactory is an instantiable request factory.

Each backend service can create its own instance:

const request = new AxiosFactory({
  timeout: 30000,
  interceptorHooks: apiInterceptor,
});

It is only responsible for creating axios instances, mounting interceptors, and exposing request methods.

This allows multiple backend services to share the same request kernel but have their own protocol handling logic.

This is more suitable for enterprise projects than a single global request, because real projects often need to integrate with multiple heterogeneous backends.

2. apiInterceptor: Service Protocol Layer

LYStack's apiInterceptor is responsible for handling three typical things:

But it doesn't directly read localStorage, nor does it directly redirect the router.

It gets the token through servicesCoreHelper:

const token = servicesCoreHelper.getAuthenticationToken();

When encountering a 401, it only triggers:

servicesCoreHelper.handleAuthenticationFailure();

Where the token actually comes from and what to do after a 401 are injected when the application starts.

3. configureServiceAuth: Authentication Capability Injection

When the application starts, authentication capabilities are injected in bootstrap():

configureServiceAuth({
  getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
  onAuthenticationFailure: () => {
    // In a real project, this would redirect to the login page
    console.warn('[bootstrap] Authentication failed, please integrate route redirect logic');
  },
});

The key to this design is not the complexity of the code, but the correct direction of dependencies.

The services layer does not depend on the app.

The app layer injects runtime capabilities into services at startup.

4. setErrorMessenger: Error Display Injection

In LYStack, the services layer also does not directly depend on Element Plus or other UI component libraries.

The error display method is injected when the application starts:

setErrorMessenger((msg: string) => {
  console.error(`[LYStack] ${msg}`);
});

After integrating a real UI framework, it can be replaced with:

setErrorMessenger((msg) => {
  ElMessage.error(msg);
});

The services layer doesn't need to change.

This is the key to a reusable request layer.

8. A More Reasonable Request Layer Directory

For a single application, I tend to organize it like this:

src/
├── app/
│   └── bootstrap.ts
├── services/
│   ├── core/
│   │   ├── axios-factory.ts
│   │   ├── helper.ts
│   │   └── index.ts
│   ├── interceptors/
│   │   ├── user-center.ts
│   │   └── order-system.ts
│   ├── modules/
│   │   ├── user.ts
│   │   └── order.ts
│   └── types.ts
└── shared/
    └── constants.ts

For a Monorepo, like LYStack, services can be extracted to packages:

packages/
├── services/
│   ├── src/core/
│   ├── src/api/
│   └── src/types/
└── shared/
    └── src/constants/

apps/
└── example-vite/
    └── src/bootstrap/

There is an important boundary here:

Don't let services import from app in reverse.

9. Several Criteria for Judging Request Layer Wrapping

When designing a request layer in the future, you can use these questions for self-check.

1. Does the request layer directly import router?

If so, it means it's coupled with a specific application.

Prioritize making the login failure handling an injectable function.

2. Does the request layer directly import a UI component library?

If so, it means it's bound to the presentation layer.

Prioritize injecting an error message displayer.

3. Is there only one global axios instance?

If the project only integrates with one backend, it's not a big problem.

If it integrates with multiple backends, consider multiple instances and different interceptors.

4. Are all backend protocol differences stuffed into one interceptor?

If a response interceptor starts to have a lot of if serviceType === xxx, it's time to split into service instances.

5. Are request-level behavior switches supported?

For example:

Real business scenarios will definitely need these exceptions.

6. Is the token acquisition method hardcoded?

If it's hardcoded to localStorage, it will be troublesome later when integrating with cookies, SSO, or Electron bridge.

7. Are errors clearly layered?

At least distinguish between:

If they are all mixed in one catch, it will definitely be hard to maintain later.

10. Not All Projects Need Complex Wrapping

At this point, it's easy to go to the other extreme: building a complex services architecture for every project.

It's unnecessary.

If you are just writing a campaign page, a demo, or a small tool, a simple request wrapper is enough.

Architecture serves complexity, not for appearing professional.

I suggest judging like this:

Scenarios where simple wrapping is enough

Scenarios where a carefully designed services layer is needed

Don't build a complex architecture for a small project.

But if a project is known to evolve over the long term, don't hardcode everything into a global request from the start.

11. Finally

The frontend request layer is most easily underestimated.

It looks like just wrapping axios, but it actually connects many boundaries:

If these boundaries are not clearly thought out, the request layer will increasingly resemble a black hole.

All special cases are stuffed in, everyone adds their own conditionals, and eventually no one dares to touch it.

A healthier direction is:

This set of ideas is not just for making the code look good, but for allowing the project to remain maintainable after the number of backends, applications, and runtime environments grows.

In the next article, I want to continue discussing:

Why do many frontend projects become harder and harder to maintain, even though every single line of code seems fine?

When business code starts directly knowing about axios, router, localStorage, UI component libraries, environment variables, and even build tools, it is no longer just business code.

In the next article, I will combine LYStack's services dependency injection, bootstrap composition root, env consolidation, and build adapter to talk about the most easily overlooked problem in a frontend project:

How much should business code really know?

Project address: