Camadas de Responsabilidade
Cada camada na arquitetura tem uma responsabilidade unica e bem definida.
Fluxo Completo de Requisicao
Service — HTTP Puro
Services fazem a requisicao HTTP. Nada mais.
typescript
// services/products-service.ts
import { api } from '@/shared/services/api-client'
import type {
ProductListResponse,
ProductItemResponse,
CreateProductPayload,
} from '../types/products.types'
export const productsService = {
list(params: { page: number; pageSize: number; search?: string }) {
return api.get<ProductListResponse>('/v2/products', { params })
},
getById(id: string) {
return api.get<ProductItemResponse>(`/v2/products/${id}`)
},
create(payload: CreateProductPayload) {
return api.post<ProductItemResponse>('/v2/products', payload)
},
update(id: string, payload: Partial<CreateProductPayload>) {
return api.patch<ProductItemResponse>(`/v2/products/${id}`, payload)
},
delete(id: string) {
return api.delete(`/v2/products/${id}`)
},
}Regras:
- ✅ Chamadas HTTP com request/response tipados
- ✅ Um arquivo por dominio/recurso
- ✅ Exportar como objeto com metodos
- ❌ Sem try/catch (o chamador trata os erros)
- ❌ Sem transformacao de dados (o adapter faz isso)
- ❌ Sem logica de negocio
- ❌ Sem acesso a store/composable
Erro comum
Nao adicione try/catch nos services. O tratamento de erros pertence a camada de composable via onError do Vue Query.
Adapter — Parsers de Contrato
Adapters transformam dados entre o formato da API e o formato do app. Sao funcoes puras sem efeitos colaterais.
typescript
// adapters/products-adapter.ts
import type { ProductItemResponse } from '../types/products.types'
import type { Product } from '../types/products.contracts'
export const productsAdapter = {
// Inbound: API → App
toProduct(response: ProductItemResponse): Product {
return {
id: response.uuid,
name: response.name,
description: response.description,
vendor: response.vendor_name,
category: response.category_slug,
price: response.price_cents / 100,
isActive: response.status === 'active',
imageUrl: response.image_url,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
}
},
// Outbound: App → API
toCreatePayload(input: CreateProductInput): CreateProductPayload {
return {
name: input.name,
description: input.description,
vendor_name: input.vendor,
category_slug: input.category,
price_cents: Math.round(input.price * 100),
image_url: input.imageUrl,
}
},
}Regras:
- ✅ Funcoes puras (entrada → saida)
- ✅ Bidirecional: API → App (entrada) e App → API (saida)
- ✅ Renomear campos (snake_case → camelCase)
- ✅ Converter tipos (string → Date, centavos → decimal, status → booleano)
- ❌ Sem chamadas HTTP
- ❌ Sem acesso a store/composable
Types e Contracts
Dois arquivos separados para o mesmo recurso:
typescript
// types/products.types.ts — Resposta exata da API (snake_case)
export interface ProductItemResponse {
uuid: string
name: string
description: string
vendor_name: string
category_slug: string
price_cents: number
status: 'active' | 'inactive' | 'pending'
image_url: string | null
created_at: string // ISO 8601
updated_at: string // ISO 8601
}
export interface ProductListResponse {
data: ProductItemResponse[]
total_pages: number
current_page: number
}typescript
// types/products.contracts.ts — Contrato do app (camelCase)
export interface Product {
id: string
name: string
description: string
vendor: string
category: string
price: number // em moeda, nao centavos
isActive: boolean // derivado do status
imageUrl: string | null
createdAt: Date // Objeto Date, nao string
updatedAt: Date
}Por que dois arquivos?
.types.tsespelha a API exatamente — se a API mudar, apenas este arquivo muda.contracts.tse o que seus componentes realmente usam — interface estavel do app- O adapter faz a ponte entre eles
Composable — Orquestracao
Composables conectam tudo: chamam o service, passam pelo adapter, gerenciam loading/error, expoe dados reativos.
typescript
// composables/useProductsList.ts
import { computed, type MaybeRef, toValue } from 'vue'
import { useQuery, keepPreviousData } from '@tanstack/vue-query'
import { productsService } from '../services/products-service'
import { productsAdapter } from '../adapters/products-adapter'
export function useProductsList(options: {
page: MaybeRef<number>
pageSize?: MaybeRef<number>
search?: MaybeRef<string>
}) {
const page = computed(() => toValue(options.page))
const pageSize = computed(() => toValue(options.pageSize) ?? 20)
const search = computed(() => toValue(options.search) ?? '')
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: computed(() => [
'products', 'list',
{ page: page.value, pageSize: pageSize.value, search: search.value },
]),
queryFn: async () => {
const response = await productsService.list({
page: page.value,
pageSize: pageSize.value,
search: search.value,
})
return {
items: response.data.data.map(productsAdapter.toProduct),
totalPages: response.data.total_pages,
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
placeholderData: keepPreviousData,
})
const items = computed(() => data.value?.items ?? [])
const totalPages = computed(() => data.value?.totalPages ?? 0)
const isEmpty = computed(() => !isLoading.value && items.value.length === 0)
return { items, totalPages, isLoading, isFetching, isEmpty, error, refetch }
}Exemplo de mutation:
typescript
// composables/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { productsService } from '../services/products-service'
import { productsAdapter } from '../adapters/products-adapter'
import type { CreateProductInput } from '../types/products.contracts'
export function useCreateProduct() {
const queryClient = useQueryClient()
const { mutate, isPending, error } = useMutation({
mutationFn: (input: CreateProductInput) => {
const payload = productsAdapter.toCreatePayload(input)
return productsService.create(payload)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
return { createProduct: mutate, isPending, error }
}Regras:
- ✅ Orquestrar: service → adapter → dados reativos
- ✅ Gerenciar estados de loading, error e vazio
- ✅ Retornar refs/computed (nunca valores brutos)
- ✅ Nomeados
useXxx - ❌ Sem template/renderizacao
- ❌ Sem acesso direto a API
Pinia Store — Apenas Estado do Cliente
Pinia e para estado que nao vem do servidor: estado da UI, filtros, preferencias.
typescript
// stores/products-store.ts
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
export const useProductsStore = defineStore('products', () => {
// State
const selectedCategory = ref<string | null>(null)
const viewMode = ref<'grid' | 'list'>('grid')
const searchQuery = ref('')
const selectedIds = ref<Set<string>>(new Set())
// Getters
const hasActiveFilters = computed(() =>
!!selectedCategory.value || !!searchQuery.value
)
const selectedCount = computed(() => selectedIds.value.size)
// Actions
function setCategory(category: string | null) {
selectedCategory.value = category
}
function clearFilters() {
selectedCategory.value = null
searchQuery.value = ''
}
function toggleSelection(id: string) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id)
} else {
selectedIds.value.add(id)
}
}
return {
// Readonly state
selectedCategory: readonly(selectedCategory),
viewMode: readonly(viewMode),
searchQuery,
selectedIds: readonly(selectedIds),
// Getters
hasActiveFilters,
selectedCount,
// Actions
setCategory,
clearFilters,
toggleSelection,
}
})Regras:
- ✅ Apenas estado do cliente (UI, filtros, preferencias, sessao)
- ✅ Setup syntax
- ✅
readonly()no estado exposto - ✅
storeToRefs()ao desestruturar em componentes - ❌ Sem estado do servidor (dados da API vao no Vue Query)
- ❌ Sem chamadas HTTP