Files
agenciapsilmno/blueprints/composable-blueprint.md
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create
overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia
PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical
notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent
Dialog.vue.bak deletado (lixo de refator anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:45 -03:00

16 KiB

Composable Blueprint

Stack: Vue 3 Composition API + Pinia (para state global) + Supabase via repository Canônicos: src/features/agenda/composables/useAgendaEvents.js, useAgendaClinicEvents.js, useAgendaSettings.js Aplicável: todo composable que orquestra estado reativo sobre uma repository


1. Princípio

Composable é wrapper fino sobre a repository. Responsabilidade:

  • Manter estado reativo (data + loading + error)
  • Chamar a repository (delegação 1:1)
  • (Opcional) Cachear com stale-while-revalidate
  • (Opcional) Compor outros composables

Não faz:

  • Lógica de banco direta (vai no repository)
  • Lógica de UI (vai no componente)
  • Manipulação de DOM
  • I/O direto fora do repository

Regra de ouro: se o composable tem from('...') do Supabase, ele virou repository disfarçado — refatorar.


2. Estrutura de arquivos

src/features/<modulo>/composables/
├── use<Entity>.js              # CRUD básico (thin wrapper)
├── use<Entity>Clinic.js        # variant clinic-scoped (se aplicável)
├── use<Entity>Settings.js      # config/preferences (com cache opt-in)
├── use<Entity>Lifecycle.js     # orquestrador de estados (se domain complexo)
└── <entity>Helpers.js          # funções puras auxiliares (não-composable)

Convenção de nome: sempre use<Entity>.... Funções helpers de domínio NÃO usam prefixo use — não são composables.


3. State shape canônico

Todo composable expõe no mínimo este shape:

const rows = ref([]);       // ou single ref dependendo do domínio
const loading = ref(false); // boolean
const error = ref('');      // string vazia, não null — facilita v-if

Decisões importantes:

Refs Tipo Inicial Por quê
loading boolean false Padrão V3 — UI binda :disabled="loading" direto
error string '' (vazio) v-if="error" é falsy-friendly; sem null check
rows/data Array ou objeto [] ou null Reset pra [] em erro de load — UI fica previsível

Anti-pattern: misturar error = ref(null) num composable e error = ref('') em outro. Canonize '' no projeto inteiro.


4. Tipos de composable (3 patterns)

Tipo A — Thin wrapper (default) · referência: useAgendaClinicEvents.js

CRUD direto, sem cache, com loading/error em TODA operação:

import { ref } from 'vue';
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';

export function useX() {
    const rows = ref([]);
    const loading = ref(false);
    const error = ref('');

    async function loadRange({ startISO, endISO, ...scope } = {}) {
        loading.value = true;
        error.value = '';
        try {
            rows.value = await listX({ startISO, endISO, ...scope });
        } catch (e) {
            error.value = e?.message || 'Falha ao carregar.';
            rows.value = [];
        } finally {
            loading.value = false;
        }
    }

    async function create(payload, opts = {}) {
        loading.value = true;
        error.value = '';
        try {
            return await createX(payload, opts);
        } catch (e) {
            error.value = e?.message || 'Falha ao criar.';
            throw e;  // ← re-throw: composable repassa o erro pro componente decidir
        } finally {
            loading.value = false;
        }
    }

    async function update(id, patch, opts = {}) { /* idem */ }
    async function remove(id, opts = {}) { /* idem */ }

    return { rows, loading, error, loadRange, create, update, remove };
}

Por que re-throw nas mutações? Componente precisa saber se o await falhou pra:

  • Mostrar toast
  • Não fechar modal
  • Não navegar
  • Manter form com dados

error.value é só pra estado reativo persistente. Mutação síncrona precisa de throw também.

Tipo B — Thin wrapper "extra-leve" · referência: useAgendaEvents.js

Variant aceitável quando mutações não precisam de loading:

async function create(payload) {
    return createX(payload);  // ← repassa erro nativamente; componente try/catch
}

Quando usar: UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.

Tipo C — Cache com stale-while-revalidate · referência: useAgendaSettings.js

Para dados raros/pesados (settings, preferences, listas estáveis):

import { ref } from 'vue';
import { getX } from '../services/<feature>Repository';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';

export function useX(opts = {}) {
    const useCache = !!opts.cache;
    const cache = useCache ? useMelissaCacheStore() : null;

    const data = ref(null);
    const loading = ref(false);
    const error = ref('');

    async function _doFetch() {
        const result = await getX();
        data.value = result;
        if (cache) {
            const key = result?.owner_id || 'anon';
            cache.set('xKey', result, key);
        }
        return result;
    }

    async function load() {
        if (cache) {
            const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
            if (cached) {
                data.value = cached;
                _doFetch().catch((e) => console.warn('[useX] revalidate', e));
                return;
            }
        }

        loading.value = true;
        error.value = '';
        try {
            await _doFetch();
        } catch (e) {
            error.value = e?.message || 'Falha ao carregar.';
            data.value = null;
        } finally {
            loading.value = false;
        }
    }

    return { data, loading, error, load };
}

Decisões do Tipo C:

  • opts.cache default false — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
  • Cache key inclui scope (owner_id/tenant_id) — invalida automaticamente em troca de usuário/tenant.
  • TTL constants no storeMELISSA_CACHE_TTL.<feature> (não hardcoded no composable).
  • Stale-while-revalidate: retorna cached SE existe + dispara fetch em background (sem await).
  • Revalidate fail é warn, não error — UI já tem dados válidos do cache.

5. Convenções de nomenclatura

Funções

Operação Nome canônico Variantes aceitas
Listar com filtro loadRange / loadMy<X> load<Scope><Range>
Criar create create<Scope> (se houver ambiguidade)
Atualizar update update<Scope>
Remover remove remove<Scope> (nunca delete — palavra reservada)
Recarregar refresh reload
Limpar estado reset / clear

Scope sufixo quando o composable serve múltiplos contextos: loadMyRange (terapeuta) vs loadClinicRange (admin).

State refs

  • rows — coleção principal (array)
  • record — single (quando faz sentido)
  • data — genérico (settings, config)
  • loading — boolean único; se há múltiplos loading (load vs save), nomear: loadingList, saving
  • error — string única; mesmo princípio: loadError, saveError se precisar

6. Anatomia padrão de uma operação load*

async function loadXxx(args) {
    // 1. Validação leve (early return, não throw)
    if (!args?.required) return;

    // 2. State flag
    loading.value = true;
    error.value = '';

    try {
        // 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
        const result = await listX(args);

        // 4. Mutate state
        rows.value = result;
    } catch (e) {
        // 5. Erro humano + reset de data (UI fica previsível)
        error.value = e?.message || 'Mensagem PT-BR genérica.';
        rows.value = [];
    } finally {
        // 6. Sempre limpar loading
        loading.value = false;
    }
}

Por que early-return em vez de throw na validação? Composable é wrapper — chamadas inválidas (ex: ownerId ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.


7. Múltiplos fetches paralelos

Quando uma operação precisa de N queries:

async function _doFetch() {
    const [cfg, rules, profile] = await Promise.all([
        getMyAgendaSettings(),
        getMyWorkSchedule(),
        getMyProfile()
    ]);
    settings.value = cfg;
    workRules.value = rules;
    profile.value = profile;
}

Regras:

  • Promise.all (não Promise.allSettled) — falha de qualquer query falha a operação inteira
  • Exception: quando uma query é opcional/best-effort → Promise.allSettled + processa por result
  • Nunca sequenciar fetches independentes (await + await + await)

8. Composição de composables

Composable pode usar outros composables, mas:

// ✅ certo — composição estrutural
export function useAgendaEventLifecycle() {
    const events = useAgendaEvents();
    const billing = useAgendaFinanceiro();
    const settings = useAgendaSettings({ cache: true });

    async function realizar(eventId) {
        // orquestra os 3
    }

    return { ...events, realizar, ... };
}

// ❌ errado — não compor pra economizar 1 linha
export function useOnlyToWrapList() {
    const { rows, loadMyRange } = useAgendaEvents();
    return { rows, loadMyRange };  // ← isso é um re-export inútil
}

Regra: compõe quando há orquestração. Se é só forward, importa direto.


9. Anti-patterns (NÃO fazer)

Composable que tem supabase.from('...') direto

// ❌ — violação de camadas
export function useFoo() {
    async function load() {
        const { data } = await supabase.from('foo').select('*');
    }
}

Move pra repository, composable só delega.

error ora null, ora '', ora Error

Canonize string (default ''). Errors do JS dão e?.message || 'fallback PT-BR'.

Não resetar rows em erro de load

// ❌
async function loadRange() {
    try { rows.value = await listX(); } catch (e) { error.value = e.message; }
    // rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
}

Reset rows.value = [] no catch — UI fica determinística.

Não re-throw mutações

// ❌
async function create(payload) {
    try { return await createX(payload); }
    catch (e) { error.value = e.message; }
    // componente faz `await create()` e nunca sabe que falhou
}

Re-throw após setar error.value.

Promise.all quando uma falha é aceitável

Quando uma das queries pode falhar sem invalidar as outras, usar Promise.allSettled. Comum em listings que enriquece com lookups opcionais.

State global em variável módulo

// ❌ — vaza entre componentes que compartilham o composable
const rows = ref([]);
export function useFoo() {
    return { rows };
}

State sempre DENTRO da function useFoo(). Se precisar global, use Pinia store.

Composable que faz watch no próprio state pra "side effect"

// ❌
const rows = ref([]);
watch(rows, () => { /* save algo */ });

Mover watch pro componente — composable não decide quando salvar.

Exceção: watch pra sincronizar com prop externa do composable (watchEffect(() => loadRange(props.range))) é OK.

Composable retornando objeto enorme

Se o return tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.


10. Cache store (Tipo C complementar)

Quando criar um composable Tipo C, garantir que existe entry em:

  • src/stores/melissaCacheStore.jsMELISSA_CACHE_TTL.<feature> constante (TTL em ms)
  • .get(key, scope, ttl) retorna valor ou null
  • .set(key, value, scope) salva com timestamp
  • Invalidação manual: .invalidate('<feature>')

TTL guidelines:

Tipo de dado TTL sugerido
Settings/preferences 5 min
Listas estáveis (specialties, plans) 30 min
Catálogo (services, pricing) 10 min
Multi-tenant lookups 5 min
Anything user-edited NÃO cachear (Tipo A)

11. Checklist de auditoria por módulo

Quando rodar /audit-module <nome>, validar cada composable:

  • Não tem supabase.from(...) direto — só importa da repository
  • State shape: rows/data, loading: boolean, error: string
  • error é string, default ''
  • Reset de data em erro de load (rows.value = [])
  • Mutações re-throw após setar error.value
  • Nomenclatura: loadRange/load<Scope>, create, update, remove
  • remove não delete (palavra reservada)
  • Validação leve usa early-return (não throw)
  • Múltiplos fetches em Promise.all (não sequencial)
  • State DENTRO da function use*() (não em variável de módulo)
  • Sem watch em própria state pra side effect (mover pro componente)
  • Helpers de domínio em arquivo separado sem prefixo use
  • Se cacheia (Tipo C): opts.cache opt-in, default false; TTL em MELISSA_CACHE_TTL; cache key inclui scope
  • Return statement com chaves explícitas (não return { ...state, ...actions } opaco)
  • Return ≤ 15 chaves (>15 = composable fazendo coisa demais)

Divergências viram items em dev_auditoria_items com:

  • categoria: padronizacao
  • tag: padronizacao:<modulo>
  • severidade: alta se camada quebrada (composable com from()); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)

12. Exemplo completo (template)

/*
| Arquivo: src/features/patients/composables/usePatients.js
*/
import { ref } from 'vue';
import {
    listPatients,
    createPatient,
    updatePatient,
    deletePatient
} from '@/features/patients/services/patientsRepository';

export function usePatients() {
    const rows = ref([]);
    const loading = ref(false);
    const error = ref('');

    async function loadRange({ search, status, tenantId } = {}) {
        loading.value = true;
        error.value = '';
        try {
            rows.value = await listPatients({ search, status, tenantId });
        } catch (e) {
            error.value = e?.message || 'Falha ao carregar pacientes.';
            rows.value = [];
        } finally {
            loading.value = false;
        }
    }

    async function create(payload) {
        loading.value = true;
        error.value = '';
        try {
            return await createPatient(payload);
        } catch (e) {
            error.value = e?.message || 'Falha ao criar paciente.';
            throw e;
        } finally {
            loading.value = false;
        }
    }

    async function update(id, patch) {
        loading.value = true;
        error.value = '';
        try {
            return await updatePatient(id, patch);
        } catch (e) {
            error.value = e?.message || 'Falha ao atualizar paciente.';
            throw e;
        } finally {
            loading.value = false;
        }
    }

    async function remove(id) {
        loading.value = true;
        error.value = '';
        try {
            await deletePatient(id);
        } catch (e) {
            error.value = e?.message || 'Falha ao remover paciente.';
            throw e;
        } finally {
            loading.value = false;
        }
    }

    return { rows, loading, error, loadRange, create, update, remove };
}

13. Referências

  • Canônicos: src/features/agenda/composables/useAgendaEvents.js, useAgendaClinicEvents.js, useAgendaSettings.js
  • Repository pareado: blueprints/repository-blueprint.md
  • Cache store: src/stores/melissaCacheStore.js
  • Tracker: dev_auditoria_items com tag padronizacao:<modulo>
  • Estratégia: development/02-auditoria/PADRONIZACAO.md