ADR-003 — Edge Function como Orquestrador (action-dispatch)
| Campo | Valor |
|---|---|
| Status | ✅ Aceito |
| Data | 2026-04-30 |
| Autores | Equipe OLP |
| Implementação | 60+ Edge Functions em supabase/functions/ |
Contexto
A plataforma tem ~60 Edge Functions agrupadas por domínio (gestao-alunos, admin-escolas, coordenador-controle, etc.). Cada domínio tipicamente tem 5–15 operações relacionadas (list, get, create, update, delete, importar, exportar, ações específicas).
Existiam 3 caminhos plausíveis:
- REST puro: 1 endpoint por operação (
GET /alunos,POST /alunos/:id/transferir). - GraphQL / RPC tipado: schema único com resolvers.
- Action-dispatch: 1 Edge Function por domínio, body com
{ action: 'create', ... }.
Decisão
Toda Edge Function de domínio segue o padrão action-dispatch:
// supabase/functions/gestao-alunos/index.ts
Deno.serve(async (req) => {
const { action, ...payload } = await req.json();
const user = await extractAuthenticatedUser(req);
switch (action) {
case 'list': return list(user, payload);
case 'create': return create(user, payload);
case 'transferir': return transferir(user, payload);
default: return jsonError(400, 'Action desconhecida');
}
});Contrato de resposta (invariante)
// Sucesso
{ success: true, data: T }
// Erro
{ success: false, message: string, code?: string }Regras
- 1 Edge Function = 1 domínio de negócio (não 1 operação).
- Toda action valida payload com Zod na entrada (memória
zod-validation-standard). extractAuthenticatedUser(req)é a primeira linha — fail-close.- CORS em todas as respostas, incluindo no
catchfinal. - Status HTTP semântico: 401 (sem auth), 403 (auth mas sem permissão / 0 rows affected), 400 (payload), 404, 409 (conflito), 500 (erro técnico). Nunca 500 para auth.
- Frontend chama via
invokeAction(funcao, action, params)(ADR-004) — nuncafetchdireto.
Alternativas consideradas
A. REST puro (1 endpoint por operação)
Pros: cacheável por path, padrão da indústria. Cons: cada operação exige nova entrada em config.toml + deploy; routing dentro do Deno é manual; cold-start por endpoint vira problema com 200+ operações. Veredicto: rejeitado — overhead operacional alto para Deno Edge.
B. GraphQL / RPC tipado (tRPC-like)
Pros: tipagem ponta-a-ponta. Cons: setup complexo em Deno; debugging de queries N+1; difícil casar com RLS row-level; curva alta para devs juniores. Veredicto: rejeitado — ganho de tipo não compensa quando Zod + EdgeResponse<T> já cobrem 90%.
C. PostgREST direto (sem Edge Function)
Pros: zero código backend. Cons: lógica de negócio (validação cruzada, side-effects, integração externa) impossível; logging fire-and-forget impossível; logs de auditoria precisariam de triggers complexos. Veredicto: rejeitado para qualquer operação com lógica — usado apenas em poucos casos read-only.
Consequências
Positivas
- 1 deploy = 1 domínio inteiro atualizado
- Compartilhar helpers (
_shared/) sem duplicação config.tomlcom 60 entradas em vez de 600- Permissão por ação centralizada no switch (fácil de auditar)
- Frontend tem 1 hook React Query por domínio com
mutationFnque troca oaction
Negativas
- Edge Function pode crescer (>400 linhas → splitting por arquivo de action, regra
code-splitting-policy) - Observabilidade: logs precisam carregar
actioncomo dimensão (já fazem viaLOG_ACTION_OPTIONS) - Não-cacheável por HTTP (todas as requests são POST) — mitigado por React Query no cliente
Trade-offs
| Eixo | Custo | Benefício |
|---|---|---|
| Granularidade HTTP | Tudo POST, não cacheável por CDN | Cache real fica no React Query (mais relevante) |
| Tamanho do arquivo | Pode passar 400 linhas | Splitting por action (helpers em _shared/) |
| Padrão fora do convencional | Onboarding precisa explicar | Padrão consistente em 60 funções |
Conformidade
Como verificar:
# Toda Edge Function nova deve ter entry em config.toml com verify_jwt = false
# (auth é feita em código)
rg "Deno.serve" supabase/functions --type ts -l | while read f; do
fname=$(basename $(dirname $f))
grep -q "\[functions.$fname\]" supabase/config.toml || echo "FALTA config.toml: $fname"
doneSinais de violação:
- Edge Function sem
extractAuthenticatedUserna primeira linha (exceto webhooks/pre-auth) - Resposta sem o invariante
{ success, ... } - Frontend chamando com
fetchdireto em vez deinvokeAction
Débito técnico conhecido
- Algumas funções (
mercadopago-webhook) não seguem action-dispatch porque recebem payload do provedor (formato externo). Aceito — webhooks externos não controlam o body. eventos-calendario-trialé variante doeventos-calendariopor necessidade de cliente diferente; consolidação em backlog.