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:
- Infrastructure: knowing that the endpoint is
/api/users/meand that it usesfetch - Domain: the business rule that the name is composed of
first_name+last_name - 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
fetchlives.
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:
mapToUserknows backend field names (first_name,email_address), which is technically infrastructure knowledge. In projects requiring strict separation, this mapper can live ininfrastructure/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:
| Scenario | Recommendation |
|---|---|
| Simple CRUD, 1-2 endpoints | Direct useFetch is fine |
| Complex mapping logic | Extract only the mapper to the domain |
| Logic shared between pages | Composable with injected repository |
| Medium-to-large project with tests | Full hexagonal architecture |
| Frequent backend changes | Hexagonal, 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.