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",[203,2011,2012,2036,2060,2080,2084,2098,2117,2121,2137,2143,2173,2182,2196,2200,2206],{"__ignoreMap":201},[206,2013,2014,2016,2019,2022,2025,2027,2029,2031,2033],{"class":208,"line":108},[206,2015,270],{"class":223},[206,2017,2018],{"class":323},"script",[206,2020,2021],{"class":215}," setup",[206,2023,2024],{"class":215}," lang",[206,2026,224],{"class":223},[206,2028,285],{"class":223},[206,2030,200],{"class":281},[206,2032,285],{"class":223},[206,2034,2035],{"class":223},">\n",[206,2037,2038,2040,2042,2045,2047,2050,2052,2054,2056,2058],{"class":208,"line":101},[206,2039,666],{"class":211},[206,2041,312],{"class":223},[206,2043,2044],{"class":219}," onMounted",[206,2046,258],{"class":223},[206,2048,2049],{"class":219}," ref",[206,2051,336],{"class":223},[206,2053,676],{"class":211},[206,2055,278],{"class":223},[206,2057,2009],{"class":281},[206,2059,684],{"class":223},[206,2061,2062,2064,2066,2069,2071,2073,2075,2078],{"class":208,"line":294},[206,2063,666],{"class":211},[206,2065,312],{"class":223},[206,2067,2068],{"class":219}," ApiClient",[206,2070,336],{"class":223},[206,2072,676],{"class":211},[206,2074,278],{"class":223},[206,2076,2077],{"class":281},"@/services/client",[206,2079,684],{"class":223},[206,2081,2082],{"class":208,"line":345},[206,2083,349],{"emptyLinePlaceholder":348},[206,2085,2086,2089,2091,2093,2095],{"class":208,"line":352},[206,2087,2088],{"class":215},"const",[206,2090,1117],{"class":219},[206,2092,224],{"class":223},[206,2094,2049],{"class":232},[206,2096,2097],{"class":219},"([])\n",[206,2099,2100,2102,2105,2107,2109,2111,2115],{"class":208,"line":416},[206,2101,2088],{"class":215},[206,2103,2104],{"class":219}," loading ",[206,2106,224],{"class":223},[206,2108,2049],{"class":232},[206,2110,799],{"class":219},[206,2112,2114],{"class":2113},"sfNiH","true",[206,2116,567],{"class":219},[206,2118,2119],{"class":208,"line":462},[206,2120,349],{"emptyLinePlaceholder":348},[206,2122,2123,2126,2128,2131,2133,2135],{"class":208,"line":467},[206,2124,2125],{"class":232},"onMounted",[206,2127,799],{"class":219},[206,2129,2130],{"class":215},"async",[206,2132,602],{"class":223},[206,2134,605],{"class":215},[206,2136,227],{"class":223},[206,2138,2139,2141],{"class":208,"line":527},[206,2140,770],{"class":211},[206,2142,227],{"class":223},[206,2144,2145,2148,2150,2153,2156,2158,2160,2162,2165,2167,2170],{"class":208,"line":570},[206,2146,2147],{"class":219}," products",[206,2149,785],{"class":223},[206,2151,2152],{"class":219},"value",[206,2154,2155],{"class":223}," =",[206,2157,780],{"class":211},[206,2159,2068],{"class":219},[206,2161,785],{"class":223},[206,2163,2164],{"class":219},"products",[206,2166,785],{"class":223},[206,2168,2169],{"class":232},"getProducts",[206,2171,2172],{"class":323},"()\n",[206,2174,2175,2177,2180],{"class":208,"line":835},[206,2176,815],{"class":223},[206,2178,2179],{"class":211}," finally",[206,2181,227],{"class":223},[206,2183,2184,2187,2189,2191,2193],{"class":208,"line":840},[206,2185,2186],{"class":219}," loading",[206,2188,785],{"class":223},[206,2190,2152],{"class":219},[206,2192,2155],{"class":223},[206,2194,2195],{"class":2113}," false\n",[206,2197,2198],{"class":208,"line":845},[206,2199,832],{"class":223},[206,2201,2202,2204],{"class":208,"line":887},[206,2203,455],{"class":223},[206,2205,567],{"class":219},[206,2207,2208,2211,2213],{"class":208,"line":894},[206,2209,2210],{"class":223},"