How Plane Abstracts Its API Client Services with APIService

Plane uses a centralized APIService base class that wraps Axios and provides thin HTTP method wrappers (get, post, put, patch, delete), which concrete service classes extend to add domain-specific endpoints while inheriting consistent request configuration.

All HTTP communication in Plane flows through this abstraction layer, ensuring that authentication credentials, base URLs, and error handling remain uniform across every API call. This design eliminates duplicated networking code and makes the frontend services both testable and type-safe.

The Core Abstraction: APIService Base Class

The foundation of Plane's API client architecture lives in packages/services/src/api.service.ts. The APIService class is declared abstract, meaning it cannot be instantiated directly—only extended.

// packages/services/src/api.service.ts
export abstract class APIService {
  protected baseURL: string;
  private axiosInstance: AxiosInstance;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
    this.axiosInstance = create({ baseURL, withCredentials: true });
  }

  get(url: string, params = {}, config: AxiosRequestConfig = {}) {
    return this.axiosInstance.get(url, { ...params, ...config });
  }

  post(url: string, data = {}, config: AxiosRequestConfig = {}) {
    return this.axiosInstance.post(url, data, config);
  }

  put(url: string, data = {}, config: AxiosRequestConfig = {}) {
    return this.axiosInstance.put(url, data, config);
  }

  patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
    return this.axiosInstance.patch(url, data, config);
  }

  delete(url: string, config: AxiosRequestConfig = {}) {
    return this.axiosInstance.delete(url, config);
  }

  request<T>(config: AxiosRequestConfig) {
    return this.axiosInstance.request<T>(config);
  }
}

Key configuration details from the source:

  • withCredentials: true — ensures cookies and authentication headers are sent with every request
  • baseURL — injected at construction, defaulting to API_BASE_URL from environment constants
  • Method signatures — accept optional AxiosRequestConfig for per-request overrides (headers, timeouts, etc.)

How Concrete Services Extend APIService

Every domain-specific service in Plane inherits from APIService. This pattern appears consistently across the codebase, as seen in packages/services/src/workspace/workspace.service.ts:

// packages/services/src/workspace/workspace.service.ts
export class WorkspaceService extends APIService {
  constructor(BASE_URL?: string) {
    super(BASE_URL || API_BASE_URL);
  }

  async list(): Promise<IWorkspace[]> {
    return this.get("/api/users/me/workspaces/")
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async create(data: Partial<IWorkspace>): Promise<IWorkspace> {
    return this.post("/api/workspaces/", data)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async get(workspaceSlug: string): Promise<IWorkspace> {
    return this.get(`/api/workspaces/${workspaceSlug}/`)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async update(workspaceSlug: string, data: Partial<IWorkspace>): Promise<IWorkspace> {
    return this.patch(`/api/workspaces/${workspaceSlug}/`, data)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async delete(workspaceSlug: string): Promise<void> {
    return this.delete(`/api/workspaces/${workspaceSlug}/`)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }
}

Notice how WorkspaceService:

  • Inherits all HTTP machinery from APIService
  • Only specifies endpoint paths and payload shapes
  • Returns typed promises (Promise<IWorkspace[]>) using interfaces from @plane/types
  • Handles errors uniformly by extracting err?.response?.data

Environment-Driven Configuration

The base URL configuration is centralized in packages/constants/src/endpoints.ts:

// packages/constants/src/endpoints.ts
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";

This single constant drives all service instances. When a service is constructed without an explicit base URL, it falls back to this environment-derived value:

const workspaceService = new WorkspaceService();        // uses VITE_API_BASE_URL
const customService = new WorkspaceService("https://api.example.com"); // override

Creating a New Service: The Extension Pattern

Adding a new API domain requires minimal boilerplate. Here's how ProjectService would follow the established convention:

// packages/services/src/project/project.service.ts
import { APIService } from "../api.service";
import { API_BASE_URL } from "@plane/constants";
import type { IProject } from "@plane/types";

export class ProjectService extends APIService {
  constructor(BASE_URL?: string) {
    super(BASE_URL || API_BASE_URL);
  }

  async get(projectId: string): Promise<IProject> {
    return this.get(`/api/projects/${projectId}/`)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async list(workspaceSlug: string): Promise<IProject[]> {
    return this.get(`/api/workspaces/${workspaceSlug}/projects/`)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }

  async create(workspaceSlug: string, data: Partial<IProject>): Promise<IProject> {
    return this.post(`/api/workspaces/${workspaceSlug}/projects/`, data)
      .then(res => res?.data)
      .catch(err => { throw err?.response?.data; });
  }
}

Benefits of Plane's API Client Abstraction

Benefit How APIService Delivers
Consistency All services share identical Axios configuration (credentials, headers, interceptors)
DRY Code HTTP verb implementations exist once; services only define paths and types
Type Safety Generic methods return typed responses via @plane/types interfaces
Testability The base class can be mocked; unit tests verify service logic without network calls
Environment Flexibility Switching API environments only changes VITE_API_BASE_URL

Using Services in Application Code

import { WorkspaceService, UserService } from "@plane/services";

// Services are typically instantiated once per module or passed via context
const wsService = new WorkspaceService();
const userService = new UserService();

// Typed responses with full autocomplete
const workspaces: IWorkspace[] = await wsService.list();
const currentUser: IUser = await userService.getCurrentUser();

Summary

  • APIService in packages/services/src/api.service.ts is the single abstraction point for all HTTP communication in Plane.

  • Concrete services (e.g., WorkspaceService, UserService) extend this base class and only implement domain-specific endpoint paths.

  • Configuration flows from environment variables through API_BASE_URL in packages/constants/src/endpoints.ts, ensuring environment-agnostic service code.

  • Type safety is enforced by returning typed promises using interfaces from the @plane/types package.

  • Error handling is standardized: services extract response.data on success and response.data from Axios errors for consistent failure reporting.

Frequently Asked Questions

What HTTP library does Plane use under APIService?

Plane uses Axios as the underlying HTTP client. The APIService constructor creates a private AxiosInstance via the create() method from axios, configured with withCredentials: true for automatic cookie handling and the injected baseURL.

Can I override the base URL for a specific service instance?

Yes. Every concrete service accepts an optional BASE_URL constructor parameter that overrides the default API_BASE_URL. This enables pointing individual services to different endpoints without modifying the base class or environment variables.

How does Plane handle API errors consistently?

Services follow a uniform pattern: they chain .then(res => res?.data) to extract successful response bodies, and .catch(err => { throw err?.response?.data }) to bubble API error payloads. This ensures callers receive predictable error shapes regardless of which service method failed.

Why is APIService declared as an abstract class?

The abstract modifier prevents direct instantiation of APIService, enforcing that all API clients must extend it with domain-specific methods. This design choice guides developers toward the established pattern and ensures no unconfigured HTTP client exists in the codebase.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →