Gabriel Caiana
imagem principal do site

Composable Abstraction Layer: o pattern que faltava entre Pinia e seus componentes Vue


Sumário
  1. O problema
  2. O que começa a dar errado
  3. A motivação
  4. A solução: Composable Abstraction Layer
  5. Arquitetura
  6. A regra é uma só
  7. Implementação passo a passo
  8. Passo 1: Store como estado puro
  9. Passo 2: Composable como camada de abstração
  10. Passo 3: Componente consome apenas o composable
  11. Padrões que emergem
  12. Padrão 1 — Composable de Leitura (Query)
  13. Padrão 2 — Composable de Ação (Mutation)
  14. Padrão 3 — Wrapper de Store Legada
  15. Padrão 4 — Composable Utilitário
  16. Organização de arquivos
  17. Convenções de nomenclatura
  18. Testabilidade
  19. Testando o composable (unit test)
  20. Testando o componente (component test)
  21. Enforcement: como garantir a adoção
  22. 1. Code review com checklist
  23. 2. Auditoria automatizada
  24. 3. Scaffolding que já nasce no padrão
  25. 4. Testes como barreira
  26. Quando NÃO usar
  27. Comparação: antes e depois
  28. Antes (acesso direto)
  29. Depois (CAL)
  30. Resumo

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

ArtefatoPadrãoExemplo
Storeuse[Feature]StoreuseFavoriteStore
Composable principaluse[Feature]useFavorites
Composable de açãouse[Feature]ActionuseRemoveFromFavorites
Composable de filtrouse[Feature]FiltersuseProductFilters

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

CamadaResponsabilidadeAcessa store?Acessa composable?
StoreEstado reativo + getters purosNão
ComposableFetch, lógica, side-effects, API públicaSimPode compor outros
ComponenteRenderização + interação do usuárioNuncaSim
Infraestrutura (middleware, plugin)Setup, guards, configSimPode 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.