← Volver al blog

API Data Access in Vue 3: From Chaos to Hexagonal Architecture

How to structure API data access in Vue 3 and Nuxt 3 using hexagonal architecture (ports & adapters): layer separation, server-free testing and code that survives backend changes.

API Data Access in Vue 3: From Chaos to Hexagonal Architecture

In technical interviews, one question comes up again and again: "How do you structure API data access in your projects?" The answer tends to reveal more about a developer's real-world experience than many purely theoretical questions.

The most common anti-pattern in Vue projects is easy to spot from the very first line:

<script setup>
const { data } = await useFetch('/api/users')
</script>

One line. It works. And anyone who has spent time with Vue has written exactly that. The problems surface when you need to test it, change the backend, or reuse the same logic in another component.

This article walks through the natural evolution from that anti-pattern to an architecture that properly separates concerns: hexagonal architecture (ports & adapters), applied pragmatically to Vue 3 and Nuxt 3 projects.


The starting point: the component that does too much

This code is honest — it works and any developer can understand it at a glance:

// pages/profile.vue
const user = ref<{ id: string; name: string; email: string } | null>(null)
const loading = ref(false)

onMounted(async () => {
  loading.value = true
  const response = await fetch('/api/users/me')
  const data = await response.json()
  user.value = {
    id: data.id,
    name: `${data.first_name} ${data.last_name}`,
    email: data.email_address,
  }
  loading.value = false
})

What's the problem? Three separate responsibilities live in the same block:

  1. Infrastructure: knowing that the endpoint is /api/users/me and that it uses fetch
  2. Domain: the business rule that the name is composed of first_name + last_name
  3. Presentation: reacting to loading and error states

When the backend renames email_address to email, you search every file. When you want to test the mapping logic, you have to mock fetch. When two pages need the same user, you copy the code.


Hexagonal architecture: the concept without the academia

Alistair Cockburn introduced hexagonal architecture in 2005, but in frontend it comes down to one idea: your application should not know where the data comes from.

There are three layers:

  • Domain: types and business rules. Knows nothing about HTTP.
  • Port: a TypeScript interface that defines what the application needs.
  • Adapter: the concrete implementation of the port. This is where fetch lives.

The runtime flow is:

Component → Composable → Concrete adapter (HTTP or Mock)

The port does not exist at runtime — it is a TypeScript constraint that ensures the adapter has the shape the composable expects. This is the core of the pattern: the composable depends on the shape (interface), not on the concrete implementation. That is what allows swapping the HTTP adapter for a mock in tests without changing a single line of the composable.


Step 1: Define the domain

Start with types. The domain does not import fetch, does not know an API exists.

// domain/user.ts
export interface User {
  id: string
  name: string
  email: string
}

export function mapToUser(raw: Record<string, unknown>): User {
  return {
    id: String(raw.id),
    name: `${String(raw.first_name ?? '')} ${String(raw.last_name ?? '')}`.trim(),
    email: String(raw.email_address ?? ''),
  }
}

The mapToUser function centralises the business rule for how the name is built. If the backend changes its structure, you only touch this file.

Architecture note: mapToUser knows backend field names (first_name, email_address), which is technically infrastructure knowledge. In projects requiring strict separation, this mapper can live in infrastructure/ alongside the adapter. Placing it in the domain works well when you think of it as an Anti-Corruption Layer — translating the API's vocabulary into the domain's vocabulary. Both locations are valid.


Step 2: Define the port

The port is a TypeScript interface that describes what operations the application needs on users. It says nothing about how they are implemented.

// domain/ports/UserRepository.ts
import type { User } from '../user'

export interface UserRepository {
  getMe(): Promise<User>
  getById(id: string): Promise<User>
  update(id: string, data: Partial<Pick<User, 'name' | 'email'>>): Promise<User>
}

This interface is the contract. The component only knows the contract, never the implementation.


Step 3: Implement the HTTP adapter

The adapter is where infrastructure code lives. If you switch from fetch to Axios tomorrow, or from REST to GraphQL, you only touch this file.

// infrastructure/HttpUserRepository.ts
import type { UserRepository } from '~/domain/ports/UserRepository'
import type { User } from '~/domain/user'
import { mapToUser } from '~/domain/user'

export class HttpUserRepository implements UserRepository {
  async getMe(): Promise<User> {
    const data = await $fetch<Record<string, unknown>>('/api/users/me')
    return mapToUser(data)
  }

  async getById(id: string): Promise<User> {
    const data = await $fetch<Record<string, unknown>>(`/api/users/${id}`)
    return mapToUser(data)
  }

  async update(id: string, data: Partial<Pick<User, 'name' | 'email'>>): Promise<User> {
    const result = await $fetch<Record<string, unknown>>(`/api/users/${id}`, {
      method: 'PATCH',
      body: data,
    })
    return mapToUser(result)
  }
}

Step 4: The composable as a use case

The composable knows nothing about HTTP. It receives the repository as a dependency and orchestrates reactive state.

// composables/useCurrentUser.ts
import type { UserRepository } from '~/domain/ports/UserRepository'
import type { User } from '~/domain/user'

export function useCurrentUser(repository: UserRepository) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function load() {
    loading.value = true
    error.value = null
    try {
      user.value = await repository.getMe()
    } catch {
      error.value = 'Could not load profile'
    } finally {
      loading.value = false
    }
  }

  async function update(data: Partial<Pick<User, 'name' | 'email'>>) {
    if (!user.value) return
    loading.value = true
    try {
      user.value = await repository.update(user.value.id, data)
    } catch {
      error.value = 'Could not update profile'
    } finally {
      loading.value = false
    }
  }

  return { user, loading, error, load, update }
}

The composable only knows three things: how to call the repository, how to manage reactive state, and how to handle errors. Nothing more.


Step 5: Dependency injection in Nuxt 3

The question that always comes up: how does the repository reach the composable? The cleanest way in Nuxt 3 is a plugin:

// plugins/repositories.ts
import { HttpUserRepository } from '~/infrastructure/HttpUserRepository'
import type { UserRepository } from '~/domain/ports/UserRepository'

export default defineNuxtPlugin(() => {
  return {
    provide: {
      userRepository: new HttpUserRepository() as UserRepository,
    },
  }
})

And in the component:

<script setup lang="ts">
const { $userRepository } = useNuxtApp()
const { user, loading, error, load } = useCurrentUser($userRepository)

onMounted(load)
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">{{ error }}</div>
  <div v-else-if="user">
    <h1>{{ user.name }}</h1>
    <p>{{ user.email }}</p>
  </div>
</template>

The component never imports HttpUserRepository. It doesn't know it exists. It only knows the shape of the contract.


The real payoff: testing without a server

This is where the architecture pays off. A mock adapter takes fewer than ten lines:

// infrastructure/MockUserRepository.ts
import type { UserRepository } from '~/domain/ports/UserRepository'
import type { User } from '~/domain/user'

export class MockUserRepository implements UserRepository {
  private store: User = {
    id: '1',
    name: 'Jane Smith',
    email: 'jane@example.com',
  }

  async getMe(): Promise<User> { return { ...this.store } }
  async getById(id: string): Promise<User> { return { ...this.store, id } }
  async update(_id: string, data: Partial<Pick<User, 'name' | 'email'>>): Promise<User> {
    this.store = { ...this.store, ...data }
    return { ...this.store }
  }
}

A real Vitest test, no server needed, running in milliseconds:

// composables/useCurrentUser.test.ts
import { describe, it, expect } from 'vitest'
import { useCurrentUser } from './useCurrentUser'
import { MockUserRepository } from '~/infrastructure/MockUserRepository'

describe('useCurrentUser', () => {
  it('loads the user correctly', async () => {
    const { user, loading, load } = useCurrentUser(new MockUserRepository())

    expect(loading.value).toBe(false)
    await load()
    expect(user.value?.name).toBe('Jane Smith')
    expect(loading.value).toBe(false)
  })

  it('updates the name without affecting the email', async () => {
    const { user, load, update } = useCurrentUser(new MockUserRepository())

    await load()
    await update({ name: 'Alice Johnson' })
    expect(user.value?.name).toBe('Alice Johnson')
    expect(user.value?.email).toBe('jane@example.com')
  })
})

When to use it

This architecture is not free — it adds files and requires more upfront decisions. A practical guide:

ScenarioRecommendation
Simple CRUD, 1-2 endpointsDirect useFetch is fine
Complex mapping logicExtract only the mapper to the domain
Logic shared between pagesComposable with injected repository
Medium-to-large project with testsFull hexagonal architecture
Frequent backend changesHexagonal, always

The pragmatic version for medium-sized projects — factory functions instead of classes:

// infrastructure/userRepository.ts
import type { UserRepository } from '~/domain/ports/UserRepository'
import type { User } from '~/domain/user'
import { mapToUser } from '~/domain/user'

export function createUserRepository(): UserRepository {
  return {
    async getMe(): Promise<User> {
      const data = await $fetch<Record<string, unknown>>('/api/users/me')
      return mapToUser(data)
    },
    async getById(id: string): Promise<User> {
      const data = await $fetch<Record<string, unknown>>(`/api/users/${id}`)
      return mapToUser(data)
    },
    async update(id: string, data: Partial<Pick<User, 'name' | 'email'>>): Promise<User> {
      const result = await $fetch<Record<string, unknown>>(`/api/users/${id}`, {
        method: 'PATCH',
        body: data,
      })
      return mapToUser(result)
    },
  }
}

Same contract, less ceremony.


Conclusion

Hexagonal architecture in frontend is not an academic decision. It is the answer to three concrete problems: tests coupled to HTTP, duplicated mapping logic, and components that know too much.

If you take one thing from this article: composables should not call fetch directly. Extract that responsibility to a separate object that fulfils an interface. You already get the majority of the benefit without the full architecture cost.

The rest follows naturally when the project needs it.

Questions or want to see a specific use case? Reach me at hola@miguel-jimenez.dev.