Skip to content

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.ts espelha a API exatamente — se a API mudar, apenas este arquivo muda
  • .contracts.ts e 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

Distribuído sob a licença MIT.