Acceso a datos con APIs en Vue 3: del caos a la arquitectura hexagonal
Cómo estructurar el acceso a APIs en Vue 3 y Nuxt 3 usando arquitectura hexagonal (ports & adapters): separación de capas, testing sin servidor y código que sobrevive a cambios de backend.
Acceso a datos con APIs en Vue 3: del caos a la arquitectura hexagonal
En entrevistas técnicas, una de las preguntas que más se repiten es esta: "¿Cómo estructuras el acceso a datos con APIs en tus proyectos?" La respuesta suele revelar más sobre la experiencia real de un desarrollador que muchas preguntas puramente teóricas.
El antipatrón más común en proyectos Vue es fácil de reconocer desde la primera línea:
<script setup>
const { data } = await useFetch('/api/users')
</script>
Una línea. Funciona. Y cualquiera que lleve tiempo en Vue ha escrito exactamente eso. El problema aparece cuando hay que testearlo, cambiar el backend, o reutilizar la misma lógica en otro componente.
Este artículo recorre la evolución natural de ese antipatrón hacia una arquitectura que separa correctamente las responsabilidades: arquitectura hexagonal (ports & adapters), aplicada de forma pragmática a proyectos Vue 3 y Nuxt 3.
El punto de partida: el componente que hace demasiado
Este código es honesto — funciona y cualquier desarrollador lo entiende a primera vista:
// pages/perfil.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
})
¿Cuál es el problema? En el mismo bloque viven tres responsabilidades distintas:
- Infraestructura: saber que el endpoint es
/api/users/mey que usafetch - Dominio: la regla de negocio de que el nombre se compone de
first_name+last_name - Presentación: reaccionar a los estados de carga y error
Cuando el backend cambia email_address por email, buscas en todos los archivos. Cuando quieres testear la lógica de mapeo, tienes que mockear fetch. Cuando dos páginas necesitan el mismo usuario, copias el código.
Arquitectura hexagonal: el concepto sin la academia
La arquitectura hexagonal la planteó Alistair Cockburn en 2005, pero en frontend se resume en una idea: que tu aplicación no sepa de dónde vienen los datos.
Hay tres capas:
- Dominio: los tipos y las reglas de negocio. No sabe nada de HTTP.
- Puerto (port): una interfaz TypeScript que define qué necesita la aplicación.
- Adaptador (adapter): la implementación concreta del puerto. Aquí vive el
fetch.
El flujo en tiempo de ejecución es:
Componente → Composable → Adaptador concreto (HTTP o Mock)
El puerto no existe en tiempo de ejecución — es una restricción de TypeScript que garantiza que el adaptador tenga la forma que el composable espera. Esta es la clave del patrón: el composable depende de la forma (interfaz), no de la implementación concreta. Eso es lo que permite sustituir el adaptador HTTP por un mock en tests sin cambiar una sola línea del composable.
Paso 1: Define el dominio
Empieza por los tipos. El dominio no importa de fetch, no sabe que existe una API.
// 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 ?? ''),
}
}
La función mapToUser centraliza la regla de negocio sobre cómo se construye el nombre. Si el backend cambia la estructura, solo tocas este archivo.
Nota de arquitectura:
mapToUserconoce los nombres de campo del backend (first_name,email_address), lo que técnicamente es un detalle de infraestructura. En proyectos donde se quiera separación estricta, este mapper puede vivir eninfrastructure/junto al adaptador. Aquí lo ponemos en el dominio porque actúa como capa anticorrupción (Anti-Corruption Layer): traduce el vocabulario de la API al vocabulario del dominio. Ambas ubicaciones son válidas.
Paso 2: Define el puerto
El puerto es una interfaz TypeScript que describe qué operaciones necesita la aplicación. No dice cómo se implementan.
// 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>
}
Esta interfaz es el contrato. El componente solo conoce el contrato, nunca la implementación.
Paso 3: Implementa el adaptador HTTP
El adaptador es donde vive el código de infraestructura. Si mañana cambias de fetch a Axios, o de REST a GraphQL, solo tocas este archivo.
// 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)
}
}
Paso 4: El composable como caso de uso
El composable no sabe de HTTP. Recibe el repositorio como dependencia y orquesta la lógica reactiva.
// 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 = 'No se pudo cargar el perfil'
} 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 = 'No se pudo actualizar el perfil'
} finally {
loading.value = false
}
}
return { user, loading, error, load, update }
}
El composable solo sabe tres cosas: cómo llamar al repositorio, cómo gestionar el estado reactivo, y cómo manejar errores. Nada más.
Paso 5: Inyección de dependencias en Nuxt 3
La pregunta que surge siempre: ¿cómo llega el repositorio al composable? La forma más limpia en Nuxt 3 es un 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,
},
}
})
Y en el componente:
<script setup lang="ts">
const { $userRepository } = useNuxtApp()
const { user, loading, error, load } = useCurrentUser($userRepository)
onMounted(load)
</script>
<template>
<div v-if="loading">Cargando...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else-if="user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
</template>
El componente no importa HttpUserRepository. No sabe que existe. Solo conoce la forma del contrato.
El beneficio real: testing sin servidor
Aquí es donde la arquitectura paga su coste. Un adaptador mock tarda menos de diez líneas:
// 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: 'Ana García',
email: 'ana@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 }
}
}
Test real con Vitest, sin un servidor levantado, ejecutándose en milisegundos:
// composables/useCurrentUser.test.ts
import { describe, it, expect } from 'vitest'
import { useCurrentUser } from './useCurrentUser'
import { MockUserRepository } from '~/infrastructure/MockUserRepository'
describe('useCurrentUser', () => {
it('carga el usuario correctamente', async () => {
const { user, loading, load } = useCurrentUser(new MockUserRepository())
expect(loading.value).toBe(false)
await load()
expect(user.value?.name).toBe('Ana García')
expect(loading.value).toBe(false)
})
it('actualiza el nombre sin afectar el email', async () => {
const { user, load, update } = useCurrentUser(new MockUserRepository())
await load()
await update({ name: 'Carmen López' })
expect(user.value?.name).toBe('Carmen López')
expect(user.value?.email).toBe('ana@example.com')
})
})
¿Cuándo aplicarlo?
Esta arquitectura no es gratuita: añade archivos y requiere más decisiones iniciales. La guía práctica:
| Escenario | Recomendación |
|---|---|
| CRUD simple, 1-2 endpoints | useFetch directo está bien |
| Lógica de mapeo compleja | Extraer solo el mapper al dominio |
| Lógica compartida entre páginas | Composable con repositorio inyectado |
| Proyecto medio-grande con tests | Arquitectura hexagonal completa |
| Cambios de backend frecuentes | Hexagonal siempre |
La versión pragmática para proyectos medianos — sin clases, con funciones factory:
// 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)
},
}
}
Mismo contrato, menos ceremony.
Conclusión
La arquitectura hexagonal en frontend no es una decisión académica. Es la respuesta a tres problemas concretos: tests acoplados a HTTP, lógica de mapeo duplicada y componentes que saben demasiado.
Si solo aplicas una cosa de este artículo: que los composables no hagan fetch directamente. Extrae esa responsabilidad a un objeto separado que cumpla una interfaz. Ya ganas la mayor parte del beneficio sin el coste completo de la arquitectura.
El resto viene solo cuando el proyecto lo necesita.
¿Preguntas o quieres ver algún caso concreto? Escríbeme a hola@miguel-jimenez.dev.