惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
Forbes - Security
Forbes - Security
The Last Watchdog
The Last Watchdog
TaoSecurity Blog
TaoSecurity Blog
Schneier on Security
Schneier on Security
SecWiki News
SecWiki News
V
Vulnerabilities – Threatpost
Project Zero
Project Zero
O
OpenAI News
W
WeLiveSecurity
Security Archives - TechRepublic
Security Archives - TechRepublic
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
H
Hacker News: Front Page
Cisco Talos Blog
Cisco Talos Blog
Spread Privacy
Spread Privacy
Help Net Security
Help Net Security
P
Privacy & Cybersecurity Law Blog
K
Kaspersky official blog
S
Security @ Cisco Blogs
Latest news
Latest news
AWS News Blog
AWS News Blog
U
Unit 42
Martin Fowler
Martin Fowler
阮一峰的网络日志
阮一峰的网络日志
S
Secure Thoughts
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Know Your Adversary
Know Your Adversary
Scott Helme
Scott Helme
博客园 - 司徒正美
B
Blog RSS Feed
C
Check Point Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
D
Docker
Google Online Security Blog
Google Online Security Blog
Jina AI
Jina AI
aimingoo的专栏
aimingoo的专栏
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Last Week in AI
Last Week in AI
月光博客
月光博客
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
SegmentFault 最新的问题
NISL@THU
NISL@THU
T
The Blog of Author Tim Ferriss
C
Cisco Blogs
Attack and Defense Labs
Attack and Defense Labs
小众软件
小众软件

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Composable Abstraction Layer: o pattern que faltava entre Pinia e seus componentes Vue
Gabriel Caiana · 2026-05-26 · via DEV Community

Nos últimos anos, trabalhei com arquiteturas frontend em diferentes escalas — desde projetos pequenos com um punhado de componentes até aplicações grandes, com múltiplos squads contribuindo no mesmo repositório. Passei por fases de Vuex, migrei para Pinia, experimentei patterns como Repository Pattern adaptado para o frontend, criei abstrações demais, abstrações de menos, e quebrei a cara com todas as combinações possíveis.

O que vou apresentar aqui não é uma ideia que surgiu de um artigo ou de uma spec. É um padrão que emergiu da prática — de ver os mesmos problemas se repetirem em projetos Vue 3 + Nuxt conforme eles cresciam, e de ir ajustando a arquitetura até chegar em algo que realmente funciona no dia a dia. Chamo de Composable Abstraction Layer (CAL).

A ideia é simples: inserir uma camada de composables entre suas stores Pinia e seus componentes Vue, eliminando acoplamento, centralizando lógica e tornando cada camada testável de forma independente. Parece óbvio quando você lê, mas a maioria dos projetos que encontro por aí não faz isso — e paga o preço conforme escala.


O problema

Vou usar um cenário que todo mundo que trabalha com e-commerce conhece: uma página de produto. O componente precisa buscar dados, armazená-los, exibir loading, tratar erros e expor ações. A abordagem mais intuitiva com Pinia:

<script setup lang="ts">
const store = useProductStore();

await store.fetchProduct(route.params.slug);

const product = computed(() => store.product);
const loading = computed(() => store.loading);
</script>

<template>
  <div v-if="loading">Carregando...</div>
  <div v-else>{{ product.name }}</div>
</template>

Parece razoável. E funciona — eu mesmo escrevi muito código assim. Mas agora multiplique isso por 40 componentes, 8 stores e 3 squads contribuindo no mesmo repositório. É aí que as coisas começam a desmoronar.

O que começa a dar errado

1. Acoplamento estrutural

Todo componente conhece a API interna da store: nomes de state, getters, actions. Renomear store.product para store.currentProduct exige alterar dezenas de arquivos .vue. Já vi refactors simples virarem PRs monstruosos por causa disso.

2. Lógica de fetch espalhada

Alguns componentes chamam store.fetchProduct(). Outros assumem que a store já está populada. Outros duplicam a chamada "por segurança". Não há um ponto único que orquestre o fetch e a sincronização do estado.

<!-- ComponenteA.vue -->
<script setup>
const store = useProductStore();
await store.fetchProduct(slug); // faz o fetch
</script>

<!-- ComponenteB.vue -->
<script setup>
const store = useProductStore();
// assume que já foi feito fetch — mas será que foi?
const name = store.productName;
</script>

Esse tipo de inconsistência é silenciosa. Funciona em dev, funciona no caminho feliz, e quebra em produção quando a ordem de montagem dos componentes muda.

3. Testes acoplados ao Pinia

Para testar qualquer componente, é necessário configurar Pinia, criar a store, popular o estado inicial e mockar actions. O setup de teste de um componente simples vira um ritual:

// Setup de teste acoplado
const pinia = createPinia();
setActivePinia(pinia);
const store = useProductStore();
store.$patch({ product: mockProduct, loading: false });

const wrapper = mount(ProductCard, {
  global: { plugins: [pinia] },
  props: { slug: 'racao-premium' },
});

Quando o setup do teste é mais longo que o teste em si, algo está errado.

4. Side-effects sem dono

Error tracking, analytics, notificações — tudo acaba nos componentes. Cada dev resolve de um jeito, em um lugar diferente. Já perdi horas rastreando por que um evento de analytics disparava duas vezes — era porque dois componentes diferentes tinham a mesma lógica copiada.

5. Reatividade frágil

Desestruturar diretamente da store perde reatividade. Devs precisam lembrar de usar storeToRefs — e inevitavelmente esquecem:

// Perde reatividade silenciosamente
const { product, loading } = useProductStore();

// Funciona, mas exige conhecimento específico do Pinia
const { product, loading } = storeToRefs(useProductStore());

Esse é o tipo de bug que não aparece em teste, não aparece em dev com dados estáticos, e aparece em produção quando o usuário navega entre páginas e o estado muda.

O resultado é um codebase onde a camada de UI sabe demais sobre gerenciamento de estado, e qualquer mudança na store tem blast radius imprevisível.


A motivação

Quando o Vue 3 trouxe a Composition API e com ela os composables — funções que encapsulam lógica reativa e reutilizável — o ecossistema rapidamente os adotou para lógica de UI (useMediaQuery, useDebounceFn), mas a maioria dos projetos parou aí.

A pergunta que me levou ao CAL foi simples:

Se composables já são o padrão para abstrair lógica reativa, por que a camada de estado — a parte mais crítica da aplicação — ainda é acessada diretamente?

A resposta é que não precisaria ser. Composables podem servir como contrato de API entre o estado da aplicação e a UI, exatamente como um controller serve de contrato entre o model e a view em arquiteturas MVC. A diferença é que, no Vue 3, a ferramenta já está na linguagem — não precisa de framework adicional.

Depois de testar variações desse pattern em projetos de complexidades diferentes, cheguei em cinco princípios que guiam a implementação:

  1. Encapsulamento: componentes não sabem que Pinia existe
  2. Separação de responsabilidades: Store (estado) → Composable (lógica) → Componente (UI)
  3. Contrato estável: o composable define a API pública; a implementação interna pode mudar
  4. Testabilidade em camadas: cada camada pode ser testada isoladamente
  5. Reatividade garantida: composables retornam computed() — sem armadilhas de desestruturação

A solução: Composable Abstraction Layer

Arquitetura

┌──────────────────────────────────────┐
│          COMPONENTES (UI)            │   ← Só consomem composables
│  ┌──────────────────────────────────┐│
│  │      COMPOSABLES (LÓGICA)        ││   ← Consomem stores + APIs, expõem API limpa
│  │  ┌──────────────────────────────┐││
│  │  │       STORES (ESTADO)        │││   ← Estado reativo puro + getters
│  │  └──────────────────────────────┘││
│  └──────────────────────────────────┘│
└──────────────────────────────────────┘

A regra é uma só

Arquivos .vue (pages e components) nunca chamam useXxxStore(). Toda leitura de estado e toda ação passam por composables.

// ❌ Proibido em .vue
const store = useProductStore();
const product = store.product;

// ✅ Permitido em .vue
const { product, loading, error } = useProduct(slug);

Composables e código de infraestrutura (middlewares, plugins, server routes) podem acessar stores diretamente — eles são a camada que consome a store.

Parece restritivo? É. E é justamente essa restrição que dá poder ao pattern. Quando todo acesso ao estado passa por um funil, você ganha um ponto único de controle, cache, logging, transformação e evolução.


Implementação passo a passo

Vou mostrar como implementar o CAL do zero usando o cenário de página de produto. A mesma estrutura se aplica a qualquer feature.

Passo 1: Store como estado puro

A store não faz fetch, não tem side-effects. Ela é um container de estado reativo com getters computados. Pense nela como um "banco de dados local" — ela guarda dados e responde perguntas sobre eles.

// store/product.ts
import type { Product, ReviewSummary } from '@/types/api';

interface ProductState {
  product: Product | null;
  reviewSummary: ReviewSummary | null;
}

export const useProductStore = defineStore('product', {
  state: (): ProductState => ({
    product: null,
    reviewSummary: null,
  }),

  getters: {
    hasProduct: (state) => !!state.product,
    productName: (state) => state.product?.name ?? '',
    averageRating: (state) => state.reviewSummary?.ratingAvg ?? 0,
    totalReviews: (state) => state.reviewSummary?.reviewsCount ?? 0,
  },
});

A store não sabe de onde os dados vêm. Ela não importa $fetch, não chama APIs, não dispara notificações. Isso é proposital.

Passo 2: Composable como camada de abstração

O composable é onde tudo acontece: fetch, transformação, sincronização com a store e construção da API pública. Ele é o "backend-for-frontend" da sua feature.

// composables/useProduct.ts
import type { ProductResponse } from '@/types/api';

export function useProduct(slug: string) {
  const store = useProductStore();

  const { data, error, pending, refresh } = useLazyFetch<ProductResponse>(
    '/api/products',
    {
      key: `product-${slug}`,
      method: 'POST',
      body: { slug },
      transform: (response: ProductResponse) => {
        store.$patch((state) => {
          state.product = response.product ?? null;
          state.reviewSummary = response.reviewSummary ?? null;
        });
        return response;
      },
    }
  );

  return {
    // Dados — sempre computed() para garantir reatividade
    product: computed(() => store.product ?? data.value?.product),
    reviewSummary: computed(
      () => store.reviewSummary ?? data.value?.reviewSummary
    ),

    // Estado da requisição
    error,
    pending,
    refresh,

    // Getters derivados
    hasProduct: computed(() => store.hasProduct),
    productName: computed(() => store.productName),
    averageRating: computed(() => store.averageRating),
  };
}

Alguns detalhes que valem destaque:

  • $patch com callback: store.$patch((state) => { ... }) agrupa múltiplas atualizações em uma única notificação de reatividade. A forma com objeto literal (store.$patch({ key: value })) não tem o mesmo ganho e é menos type-safe. Parece um detalhe, mas em stores com muitos campos, faz diferença perceptível.
  • computed() em tudo que é reativo: o componente recebe refs computadas que rastreiam dependências automaticamente — sem risco de perder reatividade. Esse é um dos pontos que mais gosto no pattern: ele elimina uma classe inteira de bugs.
  • Fallback store.x ?? data.value?.x: garante que o dado está disponível tanto via store (se foi populada por outro composable ou por SSR) quanto via fetch direto.

Passo 3: Componente consome apenas o composable

<!-- pages/product/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const slug = route.params.slug as string;

const { product, pending, error, averageRating } = useProduct(slug);
</script>

<template>
  <div v-if="pending" class="product-skeleton">Carregando...</div>
  <div v-else-if="error" class="product-error">
    Não foi possível carregar o produto.
  </div>
  <div v-else-if="product" class="product-page">
    <h1 class="product-page__title">{{ product.name }}</h1>
    <ProductRating :average="averageRating" />
  </div>
</template>

O componente não sabe que Pinia existe. Ele não importa stores, não chama $patch, não faz fetch. Ele recebe dados reativos e renderiza. É assim que deveria ser.


Padrões que emergem

Conforme o CAL amadurece em um projeto, padrões recorrentes se consolidam. Na minha experiência, quatro se mostraram estáveis o suficiente para serem considerados "canônicos".

Padrão 1 — Composable de Leitura (Query)

O mais comum. Faz fetch, popula a store, expõe dados reativos. É o useProduct que mostrei acima, mas funciona para qualquer feature que precisa buscar dados:

export async function useFavorites() {
  const store = useFavoriteStore();
  const authStore = useAuthStore();

  const userId = computed(() => authStore.session?.id ?? '');

  const { data, error, refresh } = await useAsyncData('favorites', () =>
    fetchFavorites(userId.value)
  );

  watch(
    () => data.value?.items,
    (items) => {
      if (items) {
        store.$patch((state) => {
          state.items = items;
        });
      }
    },
    { immediate: true }
  );

  return {
    favorites: computed(() => store.items),
    hasFavorites: computed(() => store.items.length > 0),
    isEmpty: computed(() => store.items.length === 0),
    refresh,
    error,
  };
}

Note que o composable consome outra store (useAuthStore) para obter o userId. Isso é perfeitamente válido — composables são a camada que tem permissão para acessar stores. O componente nunca precisa saber que autenticação e favoritos estão relacionados internamente.

Padrão 2 — Composable de Ação (Mutation)

Encapsula uma operação de escrita. Gerencia pending e error internamente. Não precisa de store se o estado é efêmero — e na maioria dos casos de mutations, ele é.

export function useRemoveFromFavorites() {
  const pending = ref(false);
  const error = ref<Error | null>(null);

  async function remove(productIds: string[], onSuccess?: () => Promise<void>) {
    pending.value = true;
    error.value = null;

    try {
      await $fetch('/api/favorites/remove', {
        method: 'DELETE',
        body: { productIds },
      });
      await onSuccess?.();
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e));
      throw e;
    } finally {
      pending.value = false;
    }
  }

  return { remove, pending, error };
}

O detalhe interessante aqui é o onSuccess callback. Em vez de o composable de ação saber como atualizar a lista de favoritos (o que criaria acoplamento entre composables), ele aceita um callback genérico. Na prática, a page passa o refresh do composable de leitura:

<script setup lang="ts">
const { favorites, refresh } = await useFavorites();
const { remove, pending: removing } = useRemoveFromFavorites();

async function handleRemove(productId: string) {
  await remove([productId], refresh);
}
</script>

Essa composição é limpa e explícita. Cada composable tem uma responsabilidade, e o componente orquestra a interação entre eles.

Padrão 3 — Wrapper de Store Legada

Esse é o padrão que mais me salvou em migrações. Quando o projeto depende de stores que vieram de um pacote externo ou de um módulo legado, o composable atua como adapter:

// Store vem de pacote externo — não controlamos a API
// import { useSessionStore } from '@acme/auth-module'

export function useAuth() {
  const store = useSessionStore();

  return {
    isLoggedIn: computed(() => store.isLogged),
    user: computed(() => store.getSession),
    isSubscriber: computed(() => store.isClubMember ?? false),
  };
}

Isso é poderoso por dois motivos:

  1. Se o pacote externo mudar a API da store (e isso acontece), apenas o composable precisa ser atualizado. Já passei por uma atualização de major version de um módulo de auth onde 100% da migração ficou contida em um único arquivo de composable. Zero mudanças em componentes.
  2. Podemos normalizar nomenclatura — o componente nunca precisa saber que internamente o getter se chama getSession ou isLogged. A API que a UI consome é a que faz sentido para a UI.

Padrão 4 — Composable Utilitário

Funções que usam store internamente mas expõem uma API semântica de alto nível. O exemplo mais clássico são notificações:

export function useSuccessNotification(message: string) {
  useNotificationStore().show({
    message,
    type: 'positive',
  });
}

export function useErrorNotification(error: unknown, fallbackMessage?: string) {
  const normalized = error instanceof Error ? error : new Error(String(error));
  const message = fallbackMessage ?? extractMessage(normalized);

  useNotificationStore().show({
    message,
    type: 'negative',
  });

  // Centraliza error tracking
  useNuxtApp().$errorTracker?.notify(normalized, {
    context: { displayMessage: message },
  });
}

O componente não sabe como notificações funcionam internamente. Não sabe que existe um store por trás, nem que errors são enviados para um serviço de tracking:

try {
  await remove([product.id], refresh);
  useSuccessNotification('Produto removido dos favoritos');
} catch (error) {
  useErrorNotification(error, 'Falha ao remover. Tente novamente.');
}

Esse tipo de composable utilitário é onde o CAL brilha para centralizar side-effects que antes ficavam copiados em dezenas de componentes.


Organização de arquivos

O CAL se encaixa naturalmente em projetos Nuxt organizados por feature (layers). Se você ainda não está familiarizado com Nuxt Layers, o post Arquitetura Modular com Nuxt Layers em Projetos Vue é um bom ponto de partida — a estrutura abaixo faz ainda mais sentido com esse contexto.

layers/
├── favorites/
│   ├── store/
│   │   └── favorites.ts             # Estado puro + getters
│   ├── composables/
│   │   ├── useFavorites.ts          # Query principal
│   │   ├── useAddToFavorites.ts     # Mutation: adicionar
│   │   └── useRemoveFromFavorites.ts # Mutation: remover
│   ├── components/
│   │   ├── FavoriteCard.vue         # Consome via props (vindo da page)
│   │   └── FavoriteCard.test.ts
│   ├── pages/
│   │   └── favorites/
│   │       └── index.vue            # Consome composables
│   └── types/
│       └── favorites.ts             # Types derivados
├── product/
│   ├── store/
│   │   └── product.ts
│   ├── composables/
│   │   ├── useProduct.ts
│   │   ├── useReview.ts
│   │   └── useQuestion.ts
│   └── ...
└── shared/
    └── composables/
        ├── useAuth.ts               # Wrapper de store legada
        ├── useCart.ts               # Wrapper de store legada
        └── useNotification.ts       # Utilitários

Convenções de nomenclatura

Artefato Padrão Exemplo
Store use[Feature]Store useFavoriteStore
Composable principal use[Feature] useFavorites
Composable de ação use[Feature]Action useRemoveFromFavorites
Composable de filtro use[Feature]Filters useProductFilters

Uma coisa que aprendi: manter o composable e seu teste no mesmo diretório (colocation) faz toda a diferença. Quando o teste está ao lado do arquivo, a barreira para escrevê-lo diminui. Quando está em uma pasta __tests__ distante, ninguém lembra que ele existe.


Testabilidade

Essa é, honestamente, a vantagem que mais me convenceu a adotar o CAL de forma consistente. Cada camada é testável de forma isolada, e o setup de cada teste é proporcionalmente simples.

Testando o composable (unit test)

import { describe, it, expect, vi } from 'vitest';

// Mock do fetch — único ponto de integração
vi.mock('#app', () => ({
  useLazyFetch: vi.fn(() => ({
    data: ref({ product: { name: 'Ração Premium', slug: 'racao-premium' } }),
    error: ref(null),
    pending: ref(false),
    refresh: vi.fn(),
  })),
}));

describe('useProduct', () => {
  it('should return product data when fetch succeeds', () => {
    const { product, hasProduct, pending } = useProduct('racao-premium');

    expect(product.value).toEqual({
      name: 'Ração Premium',
      slug: 'racao-premium',
    });
    expect(hasProduct.value).toBe(true);
    expect(pending.value).toBe(false);
  });

  it('should expose error when fetch fails', () => {
    vi.mocked(useLazyFetch).mockReturnValueOnce({
      data: ref(null),
      error: ref(new Error('Network error')),
      pending: ref(false),
      refresh: vi.fn(),
    });

    const { error, hasProduct } = useProduct('slug-invalido');

    expect(error.value).toBeTruthy();
    expect(hasProduct.value).toBe(false);
  });
});

Testando o componente (component test)

Aqui é onde a mágica aparece. O componente não sabe que Pinia existe — basta mockar o composable:

import { mountSuspended } from '@nuxt/test-utils/runtime';

// Mock do composable — a store nem entra na equação
vi.mock('@/composables/useProduct', () => ({
  useProduct: vi.fn(() => ({
    product: computed(() => ({ name: 'Ração Premium' })),
    pending: ref(false),
    error: ref(null),
    hasProduct: computed(() => true),
    averageRating: computed(() => 4.5),
  })),
}));

describe('ProductPage', () => {
  it('should render product name', async () => {
    const wrapper = await mountSuspended(ProductPage, {
      route: { params: { slug: 'racao-premium' } },
    });

    expect(wrapper.text()).toContain('Ração Premium');
  });

  it('should render loading state', async () => {
    vi.mocked(useProduct).mockReturnValueOnce({
      product: computed(() => null),
      pending: ref(true),
      error: ref(null),
      hasProduct: computed(() => false),
      averageRating: computed(() => 0),
    });

    const wrapper = await mountSuspended(ProductPage, {
      route: { params: { slug: 'racao-premium' } },
    });

    expect(wrapper.text()).toContain('Carregando');
  });
});

Compare isso com o setup que mostrei no início do artigo, onde era necessário configurar Pinia, criar store, popular estado. Com CAL, o teste do componente mocka uma função que retorna refs — o mesmo contrato que o componente já usa em produção. É direto, é previsível, e encoraja o time a escrever mais testes.


Enforcement: como garantir a adoção

Documentar o padrão não é suficiente — aprendi isso da maneira difícil. Sem enforcement ativo, a erosão arquitetural é inevitável. Basta um dev com pressa acessar a store direto "só dessa vez" e, duas semanas depois, metade do time está fazendo o mesmo. Estas são as estratégias que funcionaram para mim:

1. Code review com checklist

Incluir na checklist de PR:

  • [ ] Nenhum arquivo .vue importa useXxxStore()
  • [ ] Composables usam $patch com callback (não object literal)
  • [ ] Dados reativos expostos via computed()

2. Auditoria automatizada

Um script de auditoria que faz grep por violações:

# Busca imports de store em arquivos .vue
grep -rn "useXxxStore\|use.*Store()" --include="*.vue" layers/

# Busca $patch com object literal (sem callback)
grep -rn '\$patch({' --include="*.ts" layers/

Pode parecer rudimentar, mas é surpreendentemente eficaz. Em um projeto onde apliquei isso, rodamos o script no CI e bloqueamos merge quando encontrava violações. Em três meses, o time inteiro internalizou o pattern.

3. Scaffolding que já nasce no padrão

Templates de geração de código que criam store + composable + componente já seguindo o CAL. Devs não precisam lembrar do padrão — o boilerplate já está correto. Isso reduz fricção de adoção drasticamente, principalmente para quem está chegando no projeto.

4. Testes como barreira

Se componentes só testam via composables mockados, qualquer tentativa de acessar store diretamente no componente vai falhar no teste — porque a store não está configurada. Os testes viram uma barreira natural contra violações.


Quando NÃO usar

Seria desonesto da minha parte apresentar o CAL como bala de prata. Ele adiciona uma camada de indireção, e isso tem custo. Ao longo dos projetos em que apliquei, fui refinando o entendimento de onde ele faz sentido e onde é overhead:

  • Projetos pequenos (< 5 stores, 1-2 devs): o overhead de manter composables intermediários pode não compensar. Se todo o time é você, o acoplamento é gerenciável.
  • Protótipos e MVPs: velocidade importa mais que arquitetura. Acessar store direto é mais rápido de escrever. Você sempre pode refatorar depois — se o projeto sobreviver.
  • Estado puramente local: se o estado só existe dentro de um componente (ref() local), não precisa de store nem de composable. Não crie abstrações para estado que ninguém mais consome.
  • Composable que só repassa: se o composable não adiciona lógica (sem fetch, sem transformação, sem side-effects) e apenas repassa storeToRefs, avalie se a indireção se justifica. Às vezes um storeToRefs direto é honestamente a melhor opção.

A regra de ouro que uso: adote o CAL quando múltiplos componentes consomem a mesma store ou quando a lógica de sincronização de estado é não-trivial. Na minha experiência, em qualquer projeto que tende a escalar — seja em features, em devs ou em complexidade de estado — esse threshold é atingido rápido.


Comparação: antes e depois

Antes (acesso direto)

ComponenteA.vue ──→ useProductStore()
ComponenteB.vue ──→ useProductStore()
ComponenteC.vue ──→ useProductStore()
PageX.vue ─────────→ useProductStore() + fetch + error handling
PageY.vue ─────────→ useProductStore() + fetch + error handling (duplicado)

  • 5 pontos de acoplamento com a store
  • Lógica de fetch duplicada
  • Teste de cada componente exige setup de Pinia

Depois (CAL)

ComponenteA.vue ──→ props (dados vêm da page)
ComponenteB.vue ──→ props
ComponenteC.vue ──→ useProduct()
PageX.vue ─────────→ useProduct()
PageY.vue ─────────→ useProduct()
                          │
                    useProduct() ──→ useProductStore()

  • 1 ponto de acoplamento com a store (o composable)
  • Lógica de fetch centralizada
  • Componentes testáveis com mock simples

Resumo

Camada Responsabilidade Acessa store? Acessa composable?
Store Estado reativo + getters puros Não
Composable Fetch, lógica, side-effects, API pública Sim Pode compor outros
Componente Renderização + interação do usuário Nunca Sim
Infraestrutura (middleware, plugin) Setup, guards, config Sim Pode usar ambos

O Composable Abstraction Layer não é um framework, não é uma lib, não exige dependências. É um padrão arquitetural — uma convenção sobre onde colocar cada tipo de lógica no ecossistema Vue 3 + Pinia. A Composition API já nos deu a ferramenta. O CAL é apenas a disciplina de usá-la como camada de abstração, e não só como repositório de funções utilitárias.

Cheguei nesse pattern depois de anos experimentando, errando e refinando. Não é perfeito, não é para todo projeto, mas em aplicações que tendem a escalar — em features, em devs, em complexidade — essa disciplina é a diferença entre um codebase que escala junto e um que só sobrevive.

Se você está em um projeto Vue 3 + Pinia que está crescendo e sente que o estado está virando um emaranhado, experimente aplicar o CAL em uma feature nova. Não precisa refatorar tudo de uma vez. Comece por uma store, crie o composable, ajuste os componentes. Quando o time sentir a diferença nos testes e nos refactors, a adoção acontece naturalmente.