Skip to main content

Clean API Client

The main rule of reliable architecture: the request layer must be an independent module. It should know nothing about components, pages, or UI frameworks.

The Problem: Chaos in Components

When requests are made directly inside components, the project quickly accumulates technical debt. You will inevitably face the following issues:

  • Duplication: the same endpoint is called in several different files.
  • Inconsistency: different error handling and header settings everywhere.
  • Refactoring Pain: when the API changes, you have to rewrite half of the UI components.
  • Reusability Difficulty: the logic cannot be moved to another project.

I avoid this using a simple and flat structure that strictly separates transport, domains, and the entry point.


Request Layer Architecture

Basic Directory Structure

services/client.ts
import * as products from "@/services/requests/products/index"
import * as users from "@/services/requests/users/index"

export const ApiClient = {
  products,
  users
}

Each element of this structure solves exactly one task:

FileResponsibility
request.tsLow-level transport: fetch/axios, timeouts, error normalization, and headers.
requests/<domain>Domain logic: endpoints (routes.ts) and typed methods (index.ts) for a specific entity.
client.tsUnified facade (ApiClient) that gathers all domains into one convenient entry point.

Request Layer (Transport)

Inside request.ts lies a universal wrapper that constructs URLs with query parameters, sets timeouts, and parses responses.

Implementation example:

services/request.ts
export const request = {
  get: <T>(url: string, options?: Omit<ApiRequestOptions, "method">) =>
    apiRequest<T>(url, { ...options, method: "GET" }),

  post: <T>(url: string, body?: unknown, options?: Omit<ApiRequestOptions, "method" | "body">) =>
    apiRequest<T>(url, { ...options, method: "POST", body }),

  put: <T>(url: string, body?: unknown, options?: Omit<ApiRequestOptions, "method" | "body">) =>
    apiRequest<T>(url, { ...options, method: "PUT", body })
}

Domain Modules

Each data source lives in its own folder. For example, products are in products/. We separate the requests themselves from their paths so that URLs aren't scattered throughout the code.

services/requests/products/routes.ts
export const routes = {
  list: () => `/products`,
  byId: (id: number) => `/products/${id}`
}

In index.ts, we use these routes and our base transport, wrapping everything in strict types:

services/requests/products/index.ts
import { request } from "@/services/request"
import { routes } from "./routes"
import type { Product } from "./types"

export const getProducts = async (): Promise<Product[]> => {
  try {
    return await request.get<Product[]>(routes.list())
  } catch (error) {
    // Централизованная обработка или фоллбэк
    return []
  }
}

export const getProduct = async (id: number): Promise<Product | null> => {
  try {
    return await request.get<Product>(routes.byId(id))
  } catch (error) {
    return null
  }
}

Unified API Client

When there are many modules, importing each one individually becomes inconvenient. I gather them into a common client:

services/client.ts
import * as products from "@/services/requests/products/index"
import * as users from "@/services/requests/users/index"

export const ApiClient = {
  products,
  users
}

Now the application has a predictable and convenient interface for data access: ApiClient.<domain>.<method>.


How It's Used in the UI

See how much cleaner the component's code becomes. It no longer knows about URLs, headers, tokens, or fetch details.

app/components/ProductList.vue
<script setup lang="ts">
import { onMounted, ref } from "vue"
import { ApiClient } from "@/services/client"

const products = ref([])
const loading = ref(true)

onMounted(async () => {
  try {
    products.value = await ApiClient.products.getProducts()
  } finally {
    loading.value = false
  }
})
</script>

The component focuses only on its direct task — state management and data display.


Summary

This separation prevents business logic and presentation from getting mixed up. A basic set consisting of a common request, domain folders, and a unified ApiClient is more than enough to keep the code from spreading and to ensure it remains readable even as the project scales significantly.

\n","app/components/ProductList.vue","vue",[212,2020,2021,2045,2069,2089,2093,2107,2126,2130,2146,2152,2182,2191,2205,2209,2215],{"__ignoreMap":210},[215,2022,2023,2025,2028,2031,2034,2036,2038,2040,2042],{"class":217,"line":6},[215,2024,279],{"class":232},[215,2026,2027],{"class":332},"script",[215,2029,2030],{"class":224}," setup",[215,2032,2033],{"class":224}," lang",[215,2035,233],{"class":232},[215,2037,294],{"class":232},[215,2039,209],{"class":290},[215,2041,294],{"class":232},[215,2043,2044],{"class":232},">\n",[215,2046,2047,2049,2051,2054,2056,2059,2061,2063,2065,2067],{"class":217,"line":120},[215,2048,675],{"class":220},[215,2050,321],{"class":232},[215,2052,2053],{"class":228}," onMounted",[215,2055,267],{"class":232},[215,2057,2058],{"class":228}," ref",[215,2060,345],{"class":232},[215,2062,685],{"class":220},[215,2064,287],{"class":232},[215,2066,2018],{"class":290},[215,2068,693],{"class":232},[215,2070,2071,2073,2075,2078,2080,2082,2084,2087],{"class":217,"line":303},[215,2072,675],{"class":220},[215,2074,321],{"class":232},[215,2076,2077],{"class":228}," ApiClient",[215,2079,345],{"class":232},[215,2081,685],{"class":220},[215,2083,287],{"class":232},[215,2085,2086],{"class":290},"@/services/client",[215,2088,693],{"class":232},[215,2090,2091],{"class":217,"line":354},[215,2092,358],{"emptyLinePlaceholder":357},[215,2094,2095,2098,2100,2102,2104],{"class":217,"line":361},[215,2096,2097],{"class":224},"const",[215,2099,1126],{"class":228},[215,2101,233],{"class":232},[215,2103,2058],{"class":241},[215,2105,2106],{"class":228},"([])\n",[215,2108,2109,2111,2114,2116,2118,2120,2124],{"class":217,"line":425},[215,2110,2097],{"class":224},[215,2112,2113],{"class":228}," loading ",[215,2115,233],{"class":232},[215,2117,2058],{"class":241},[215,2119,808],{"class":228},[215,2121,2123],{"class":2122},"sfNiH","true",[215,2125,576],{"class":228},[215,2127,2128],{"class":217,"line":471},[215,2129,358],{"emptyLinePlaceholder":357},[215,2131,2132,2135,2137,2140,2142,2144],{"class":217,"line":476},[215,2133,2134],{"class":241},"onMounted",[215,2136,808],{"class":228},[215,2138,2139],{"class":224},"async",[215,2141,611],{"class":232},[215,2143,614],{"class":224},[215,2145,236],{"class":232},[215,2147,2148,2150],{"class":217,"line":536},[215,2149,779],{"class":220},[215,2151,236],{"class":232},[215,2153,2154,2157,2159,2162,2165,2167,2169,2171,2174,2176,2179],{"class":217,"line":579},[215,2155,2156],{"class":228}," products",[215,2158,794],{"class":232},[215,2160,2161],{"class":228},"value",[215,2163,2164],{"class":232}," =",[215,2166,789],{"class":220},[215,2168,2077],{"class":228},[215,2170,794],{"class":232},[215,2172,2173],{"class":228},"products",[215,2175,794],{"class":232},[215,2177,2178],{"class":241},"getProducts",[215,2180,2181],{"class":332},"()\n",[215,2183,2184,2186,2189],{"class":217,"line":844},[215,2185,824],{"class":232},[215,2187,2188],{"class":220}," finally",[215,2190,236],{"class":232},[215,2192,2193,2196,2198,2200,2202],{"class":217,"line":849},[215,2194,2195],{"class":228}," loading",[215,2197,794],{"class":232},[215,2199,2161],{"class":228},[215,2201,2164],{"class":232},[215,2203,2204],{"class":2122}," false\n",[215,2206,2207],{"class":217,"line":854},[215,2208,841],{"class":232},[215,2210,2211,2213],{"class":217,"line":896},[215,2212,464],{"class":232},[215,2214,576],{"class":228},[215,2216,2217,2220,2222],{"class":217,"line":903},[215,2218,2219],{"class":232},"