Vue REST API: DTO Thinking, Error Mapping, and Retry Strategies
AI generated
<v/>
{ }
Vue.js · REST API · TypeScript · Error Handling · Retry
Vue REST API: DTO Thinking,
Error Mapping and Retry

A direct fetch call that throws JSON at the template is not an API layer, it is a maintenance problem waiting to happen. DTOs, structured error mapping, and retry logic turn a Vue REST API integration into a stable layer you can actually rely on.

16 min read DTOs · Error Mapping · Retry · Repository Pattern · Axios Vue 3 · TypeScript · Axios · Pinia

1. Why a structured Vue REST API layer is indispensable

The most common mistake in growing Vue REST API projects is calling API endpoints directly from components. A component that calls fetch('/api/products') straight from the onMounted hook mixes presentation logic with data-fetching logic. That makes the component harder to test, the URL impossible to manage centrally, and error handling inconsistent. When the API structure changes, every component that makes direct API calls has to be adapted individually.

A dedicated API layer in a Vue REST API project is not overengineering, it is an investment in maintainability. The API layer takes on URL management, authentication token handling, request serialization, response deserialization, DTO mapping, error normalization, and retry logic. The component only knows the composable, the composable only knows the repository, the repository only knows the HTTP client. This layered architecture makes every layer independently testable and replaceable.

The investment in a clean Vue REST API layer pays off especially when the API changes. Backend teams rename fields, add versions, or change response structures. With a centralized API layer built on DTOs, such a change becomes a single adjustment in the DTO mapper. Without an API layer, it becomes a refactoring marathon through every component that uses that data. Experience shows that API structures change more often than expected, particularly in the early project phase.

2. DTO thinking: type safety between API and UI

A data transfer object (DTO) is a simple typed object that describes the exact structure of an API response. In a Vue REST API architecture there are two kinds of types: API DTOs describe what the server returns, with snake_case field names, ISO date strings, and backend-specific IDs. Domain models describe what the Vue application uses internally, with camelCase field names, Date objects, and frontend-friendly structures. Between the two sits a mapper that handles the transformation.

DTO thinking in Vue REST API projects prevents backend decisions from bleeding straight into the frontend. If the backend delivers a date as a Unix timestamp and the frontend wants to show a formatted date string in the UI, that transformation lives in the mapper, not in the component, and not in a utility function copied everywhere. If the backend delivers a pagination structure with current_page, per_page, and total, and the frontend works with a Pagination interface, the mapper is the only place that knows about this difference.


// src/api/dto/product.dto.ts
// DTO types reflect the exact API response structure (snake_case, raw values)

export interface ProductApiDto {
  id: number
  sku: string
  product_name: string
  price_amount: number
  price_currency: string
  category_id: number
  created_at: string        // ISO 8601 string from API
  is_active: boolean
  stock_quantity: number
  thumbnail_url: string | null
}

// src/domain/product.model.ts
// Domain model, frontend-friendly structure (camelCase, typed values)
export interface Product {
  id: number
  sku: string
  name: string
  price: { amount: number; currency: string }
  categoryId: number
  createdAt: Date           // Parsed Date object
  isActive: boolean
  stockQuantity: number
  thumbnailUrl: string | null
  isInStock: boolean        // Computed field, not in API response
}

// src/api/mappers/product.mapper.ts
// Mapper: transforms API DTO to domain model, single source of transformation
export function mapProductDto(dto: ProductApiDto): Product {
  return {
    id: dto.id,
    sku: dto.sku,
    name: dto.product_name,
    price: { amount: dto.price_amount / 100, currency: dto.price_currency },
    categoryId: dto.category_id,
    createdAt: new Date(dto.created_at),
    isActive: dto.is_active,
    stockQuantity: dto.stock_quantity,
    thumbnailUrl: dto.thumbnail_url,
    // Computed field derived from DTO data
    isInStock: dto.stock_quantity > 0,
  }
}

3. Configuring the Axios client: interceptors and base setup

Axios is the established HTTP client for Vue REST API projects because, unlike the native fetch API, it supports request and response interceptors, parses JSON automatically, has timeout configuration, and offers consistent error handling across all browsers. An Axios instance with a baseURL, default headers, and interceptors forms the foundation of the API layer. Instead of creating multiple Axios instances, one central instance is shared across the entire application.

Request interceptors in Vue REST API projects handle automatically attaching the authorization token, refreshing an expired token before the request, and setting correlation IDs for server-side logging. Response interceptors normalize error structures from different backend services into a single frontend error format. That means: whether the backend returns a 422 Unprocessable Entity with a validated error structure or a load balancer returns a 503 Service Unavailable, the response interceptor transforms both into a frontend-compliant error structure.

4. Repository pattern: encapsulating API calls and making them testable

The repository pattern is the cleanest way to encapsulate API calls in Vue REST API projects. A repository is a class or object with methods for each API operation: getProduct(id), getProducts(filter), createProduct(data), updateProduct(id, data), deleteProduct(id). Each method performs the HTTP request, applies the mapper, and returns the domain model. The caller, the composable or the Pinia store, only ever works with domain models, never with raw API responses.

The biggest advantage of the repository pattern for Vue REST API tests is how easily it can be mocked. The repository interface describes the methods, a test implementation returns predefined values without making real HTTP requests. Composables and Pinia stores that receive the repository as a dependency can be fully tested in unit tests with a mock repository. This replaces MSW for unit tests and is often leaner to configure.

5. Error mapping: from HTTP errors to app errors

In a Vue REST API architecture there are different error classes with different UI requirements. A 401 Unauthorized requires a redirect to the login page or a token refresh. A 422 Unprocessable Entity with validation errors should mark the affected form fields. A 404 Not Found should trigger an empty-state display. A 503 Service Unavailable should activate a retry mechanism. These different error types require structured error mapping that turns a generic HTTP error into a typed app exception the component can act on.

Error mapping in Vue REST API projects is implemented as an enum or a class hierarchy: NetworkError for connectivity problems, AuthError for 401/403, ValidationError for 422 with field errors, NotFoundError for 404, and ServerError for 5xx. The Axios response interceptor checks the status code and the response body and throws the matching typed error class. Composables and stores catch these typed errors and react with the appropriate UI response, instead of relying on generic catch(err) blocks that have no idea what to display.


// src/api/errors.ts
// Typed error hierarchy for structured error mapping in Vue REST API layers

export class AppError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message)
    this.name = 'AppError'
  }
}

export class NetworkError extends AppError {
  constructor() { super('No connection to the server', 'NETWORK_ERROR') }
}

export class AuthError extends AppError {
  constructor() { super('Not authorized', 'AUTH_ERROR') }
}

export class ValidationError extends AppError {
  constructor(public readonly fieldErrors: Record<string, string[]>) {
    super('Validation error', 'VALIDATION_ERROR')
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} was not found`, 'NOT_FOUND')
  }
}

export class RetryableError extends AppError {
  constructor(public readonly retryAfterMs: number = 1000) {
    super('Server temporarily unreachable', 'RETRYABLE')
  }
}

// src/api/http-client.ts
// Axios instance with response interceptor for error mapping
import axios from 'axios'
import { AuthError, NetworkError, NotFoundError, RetryableError, ValidationError } from './errors'

export const httpClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10_000,
  headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
})

httpClient.interceptors.response.use(
  (response) => response,
  (error) => {
    // No response, network error or timeout
    if (!error.response) throw new NetworkError()

    const { status, data } = error.response
    const retryAfter = error.response.headers['retry-after']

    switch (true) {
      case status === 401 || status === 403:
        throw new AuthError()
      case status === 404:
        throw new NotFoundError(data?.resource ?? 'Resource')
      case status === 422:
        throw new ValidationError(data?.errors ?? {})
      case status === 429 || status >= 500:
        throw new RetryableError(retryAfter ? parseInt(retryAfter) * 1000 : 1000)
      default:
        throw new AppError(data?.message ?? 'Unknown error', `HTTP_${status}`)
    }
  }
)

6. Retry strategies: exponential backoff and idempotent requests

Retry logic is indispensable in Vue REST API implementations for production-grade applications. Transient network errors, brief server outages, and rate limiting are everyday occurrences in real production environments. A retry strategy with exponential backoff resends a failed request after a wait period, and doubles the wait period with every further failure. After a defined number of attempts, the error is passed on. This pattern prevents short-lived server problems from turning into an error the user sees.

The most important constraint on retry logic in Vue REST API projects: only idempotent requests may be retried automatically. GET, HEAD, and DELETE requests can be retried safely. POST requests that create a resource should only be retried with idempotency tokens, otherwise a retry would create a duplicate resource. PUT and PATCH requests are usually idempotent and can be retried safely. The retry implementation has to respect this distinction.

7. The useApi composable: managing loading state and errors reactively

The useApi composable is the connective piece between the repository layer and the Vue component in a Vue REST API architecture. It reactively manages three states: data for the result of the API call, loading for the loading state, and error for any error that occurred. These three refs are the minimum every component needs to render an API call correctly. The composable also handles triggering the call, error handling, and optionally automatic loading when the component mounts.

A generic useApi composable that can wrap any asynchronous call significantly reduces boilerplate. Instead of implementing the same loading.value = true; try { ... } catch { ... } finally { loading.value = false } pattern in every composable, this flow is delegated to the generic useApi. The result: specific composables such as useProduct(id) or useProductList(filter) stay focused on domain logic and delegate technical request management to the generic composable. That makes specific composables noticeably leaner and more consistent in how they handle errors.


// src/composables/useApi.ts
// Generic composable for any async API call with loading, error, and data state
import { ref, type Ref } from 'vue'
import { AppError, NetworkError, RetryableError } from '@/api/errors'

interface UseApiReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<AppError | null>
  execute: (...args: unknown[]) => Promise<void>
}

export function useApi<T>(
  fn: (...args: unknown[]) => Promise<T>,
  options: { immediate?: boolean; retries?: number } = {}
): UseApiReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<AppError | null>(null)

  async function executeWithRetry(retryCount: number, ...args: unknown[]): Promise<T> {
    try {
      return await fn(...args)
    } catch (err) {
      if (err instanceof RetryableError && retryCount > 0) {
        // Exponential backoff: 1s, 2s, 4s...
        const delay = err.retryAfterMs * Math.pow(2, (options.retries ?? 3) - retryCount)
        await new Promise(resolve => setTimeout(resolve, delay))
        return executeWithRetry(retryCount - 1, ...args)
      }
      throw err
    }
  }

  async function execute(...args: unknown[]) {
    loading.value = true
    error.value = null
    try {
      data.value = await executeWithRetry(options.retries ?? 0, ...args)
    } catch (err) {
      error.value = err instanceof AppError
        ? err
        : new NetworkError()
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}

// Usage in a feature composable:
// const { data: product, loading, error, execute: loadProduct } = useApi(
//   (id: number) => productRepository.getProduct(id),
//   { retries: 3 }
// )

8. Optimistic UI without GraphQL: manual state rollback

Optimistic UI is possible in Vue REST API projects without Apollo, but it requires manual rollback code. The principle: the local state is updated immediately, as if the API call had already succeeded. At the same time, the API call runs asynchronously. If the call succeeds, the optimistic state is replaced by the real server response. If the call fails, the previous state is restored and the user is shown an error message. The result is a responsive UI that feels instant, even when the server connection is slow.

The implementation in Vue REST API projects follows a clear pattern: a snapshot of the current state is created before the API call. The optimistic state is written to the store immediately. The API call is executed. On success, the snapshot is discarded. On failure, the snapshot is restored and the store is reset to the previous state. This pattern works well for actions like toggles, status changes, and delete operations, where the outcome is very likely to match the optimism.

9. HTTP client options compared

The choice of HTTP client significantly influences the Vue REST API architecture. Different clients have different strengths and weaknesses that need to be weighed against project requirements.

Client Interceptors Bundle size Strengths
Axios Yes (request + response) ~13 KB gzip Full-featured, mature, large community
native fetch No (manual) 0 KB (browser built-in) No dependency, native API, SSR compatible
ofetch Yes (onRequest, onResponse) ~3 KB gzip Nuxt-native, modern API, small
ky Yes (hooks) ~5 KB gzip Fetch-based, retry built in, modern
TanStack Query Yes (global defaults) ~12 KB gzip Caching, retry, stale-while-revalidate

For typical mid-size Vue REST API projects, Axios is a good fit when the team is already familiar with it and interceptors get used heavily. ofetch is the first choice in Nuxt 3 projects. ky suits projects that want to use the fetch API but need built-in retry and hooks. TanStack Query for Vue (@tanstack/vue-query) is a complete solution for server-state management with caching, retry, background refetching, and stale-while-revalidate, a serious substitute for Apollo in REST projects.

Mironsoft

Vue REST API · TypeScript · Frontend Architecture · API Integration

Ready to build a structured Vue REST API layer?

We design and implement scalable Vue REST API layers with DTOs, structured error mapping, retry logic, and the repository pattern for frontend architectures that stay maintainable long term.

API architecture

Repository pattern, DTO mapping, and HTTP client setup for maintainable API layers

Error handling

Structured error mapping, typed error classes, and consistent UI responses

Robustness

Retry with exponential backoff, optimistic UI, and idempotency tokens

10. Summary

A professional Vue REST API architecture cleanly separates the HTTP client, the repository, the DTO mapper, and the composable. DTOs describe the API response exactly, domain models describe the internal frontend structure, and mappers bridge the difference. Axios interceptors normalize error structures into typed error classes before they leave the repository layer. The repository encapsulates API calls and makes them testable without HTTP requests. The useApi composable manages loading state, data, and errors reactively and consistently across every feature composable.

Retry logic with exponential backoff prevents transient server errors from showing up as user errors. Optimistic UI with manual rollback makes actions feel instantly responsive without waiting for the server response. The investment in this structured Vue REST API layer pays off at the latest when the backend team renames fields, introduces a new API version path, or reworks the error structure, at that point it is a single adjustment in the mapper or interceptor instead of a refactor through every component.

Vue REST API, the essentials at a glance

DTO mapping

API DTOs describe server responses, domain models describe the internal structure. Mappers isolate backend changes to a single place.

Error mapping

Axios interceptor transforms HTTP errors into typed error classes, AuthError, ValidationError, RetryableError. Components respond in a structured way.

Retry & robustness

Exponential backoff for transient errors. Only retry idempotent requests (GET, PUT, DELETE) automatically. POST only with an idempotency token.

Repository & composable

Repository encapsulates API calls, returns domain models. useApi composable manages loading, data, and error reactively and consistently.

11. FAQ: Vue REST API

1What is a DTO and why do I need one?
Describes the API response structure as a TypeScript interface. Separates backend from frontend data structure. Backend changes land in the mapper, not in every component.
2Axios or native fetch?
Axios for interceptors and broad browser compatibility. fetch for minimal dependencies and SSR. ky/ofetch as modern middle grounds.
3Test a repository without a real API?
Repository as an interface, mock implementation with predefined values. Inject via provide/inject in tests, no HTTP request needed.
4Why not retry POST automatically?
POST is not idempotent, retrying creates duplicates. Only retry with a server-side idempotency token.
5What is exponential backoff?
Wait time doubles with every failed attempt: 1s, 2s, 4s. Reduces load on an overloaded server and gives it time to recover.
6Distinguishing validation errors from server errors?
Typed error classes: if (error instanceof ValidationError) then form errors. if (error instanceof RetryableError) then trigger a retry. No switch(statusCode) in the component.
7Auth tokens in Axios interceptors?
Request interceptor attaches the token. On 401: refresh the token, retry the request. Coordinate parallel requests during refresh with a refresh promise.
8TanStack Query as an alternative?
Handles caching, retry, background refetching automatically. Complementary to repository and DTOs, TanStack Query calls the repository.
9Implementing optimistic UI in Pinia?
Create a state snapshot, update optimistically, run the API call, on failure restore via store.$patch(snapshot), show an error message.
10Versioning API endpoint URLs centrally?
Base URL and version in the Axios instance as baseURL. Repositories use relative paths. Version switch: only change the baseURL in the Axios configuration.