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",[215,2022,2023,2047,2071,2091,2095,2109,2128,2132,2148,2154,2184,2193,2207,2211,2217],{"__ignoreMap":213},[218,2024,2025,2027,2030,2033,2036,2038,2040,2042,2044],{"class":220,"line":124},[218,2026,282],{"class":235},[218,2028,2029],{"class":335},"script",[218,2031,2032],{"class":227}," setup",[218,2034,2035],{"class":227}," lang",[218,2037,236],{"class":235},[218,2039,297],{"class":235},[218,2041,212],{"class":293},[218,2043,297],{"class":235},[218,2045,2046],{"class":235},">\n",[218,2048,2049,2051,2053,2056,2058,2061,2063,2065,2067,2069],{"class":220,"line":6},[218,2050,678],{"class":223},[218,2052,324],{"class":235},[218,2054,2055],{"class":231}," onMounted",[218,2057,270],{"class":235},[218,2059,2060],{"class":231}," ref",[218,2062,348],{"class":235},[218,2064,688],{"class":223},[218,2066,290],{"class":235},[218,2068,89],{"class":293},[218,2070,696],{"class":235},[218,2072,2073,2075,2077,2080,2082,2084,2086,2089],{"class":220,"line":306},[218,2074,678],{"class":223},[218,2076,324],{"class":235},[218,2078,2079],{"class":231}," ApiClient",[218,2081,348],{"class":235},[218,2083,688],{"class":223},[218,2085,290],{"class":235},[218,2087,2088],{"class":293},"@/services/client",[218,2090,696],{"class":235},[218,2092,2093],{"class":220,"line":357},[218,2094,361],{"emptyLinePlaceholder":360},[218,2096,2097,2100,2102,2104,2106],{"class":220,"line":364},[218,2098,2099],{"class":227},"const",[218,2101,1129],{"class":231},[218,2103,236],{"class":235},[218,2105,2060],{"class":244},[218,2107,2108],{"class":231},"([])\n",[218,2110,2111,2113,2116,2118,2120,2122,2126],{"class":220,"line":428},[218,2112,2099],{"class":227},[218,2114,2115],{"class":231}," loading ",[218,2117,236],{"class":235},[218,2119,2060],{"class":244},[218,2121,811],{"class":231},[218,2123,2125],{"class":2124},"sfNiH","true",[218,2127,579],{"class":231},[218,2129,2130],{"class":220,"line":474},[218,2131,361],{"emptyLinePlaceholder":360},[218,2133,2134,2137,2139,2142,2144,2146],{"class":220,"line":479},[218,2135,2136],{"class":244},"onMounted",[218,2138,811],{"class":231},[218,2140,2141],{"class":227},"async",[218,2143,614],{"class":235},[218,2145,617],{"class":227},[218,2147,239],{"class":235},[218,2149,2150,2152],{"class":220,"line":539},[218,2151,782],{"class":223},[218,2153,239],{"class":235},[218,2155,2156,2159,2161,2164,2167,2169,2171,2173,2176,2178,2181],{"class":220,"line":582},[218,2157,2158],{"class":231}," products",[218,2160,797],{"class":235},[218,2162,2163],{"class":231},"value",[218,2165,2166],{"class":235}," =",[218,2168,792],{"class":223},[218,2170,2079],{"class":231},[218,2172,797],{"class":235},[218,2174,2175],{"class":231},"products",[218,2177,797],{"class":235},[218,2179,2180],{"class":244},"getProducts",[218,2182,2183],{"class":335},"()\n",[218,2185,2186,2188,2191],{"class":220,"line":847},[218,2187,827],{"class":235},[218,2189,2190],{"class":223}," finally",[218,2192,239],{"class":235},[218,2194,2195,2198,2200,2202,2204],{"class":220,"line":852},[218,2196,2197],{"class":231}," loading",[218,2199,797],{"class":235},[218,2201,2163],{"class":231},[218,2203,2166],{"class":235},[218,2205,2206],{"class":2124}," false\n",[218,2208,2209],{"class":220,"line":857},[218,2210,844],{"class":235},[218,2212,2213,2215],{"class":220,"line":899},[218,2214,467],{"class":235},[218,2216,579],{"class":231},[218,2218,2219,2222,2224],{"class":220,"line":906},[218,2220,2221],{"class":235},"