web-arquitetura
Arquitetura do frontend Smaug (web/) — camadas UI → hooks → services → infra, estrutura por feature, política de import e alias, Server vs Client Component, proxy.ts com ?next=, o repasse /api (app/ap
Files1
View on GitHubSKILL.md.claude/skills/web-arquitetura/SKILL.md
GitHubArquitetura do web
Quando usar esta skill
Ao criar uma página, uma feature nova, ou sempre que a dúvida for "onde este arquivo vai" / "posso importar isso daqui".
Camadas (normativo — constituição, seção Frontend)
UI (components)
↓
Hooks
↓
Services / API Layer
↓
Infra / External
Sentido único. A UI depende de hooks, não de services nem de infra. Componentes não chamam
apiClient. Regra de negócio não vive dentro de JSX.
web/
app/ App Router: (auth)/{login,cadastro}, (app)/{dashboard,receitas,despesas,categorias,historico,dados}
+ api/[...path]/route.ts (repasse /api → API)
features/ auth · dashboard · receitas · despesas · categorias · historico · dados
cada uma com components/ hooks/ services/ types/
shared/ components (primitivos) · hooks · lib · types
infra/ api-client · auth-refresh · session · api-error · navigation · router-adapter
· query-client · file-download
proxy.ts (guarda de rotas; o antigo middleware da Next 15)
tests/ harness do Vitest + testes de integração de página
Imports
Aliases (declarados em dois lugares: web/tsconfig.json e web/vitest.config.ts — alias novo
exige editar os dois): @/* → web/, mais @/features/*, @/shared/*, @/infra/*.
Regra real, observada no código:
- Relativo quando fica dentro da mesma feature ou da mesma pasta de topo:
../services/DespesasService,../types,../lib/utils,./Modal,./session. - Alias ao cruzar fronteira de topo — mesmo de dentro de uma feature:
@/shared/hooks/useToast,@/infra/api-error. app/sempre alias, nunca relativo.- Testes são a exceção: alcançam o harness por caminho relativo longo
(
../../../tests/render).
Ordem dentro do arquivo (consistente, não imposta por lint):
react/next → terceiros → módulos com alias → módulos relativos → import type por último.
Não existem barris de re-export. Os únicos index.ts são features/<f>/types/index.ts e
shared/types/index.ts, e eles contêm as declarações. Importe sempre o arquivo concreto:
import { Button } from "@/shared/components/Button";
import { useExpenseMonth } from "@/features/despesas/hooks/useExpenseMonth";
Cross-feature
Compartilhamento acontece por @/shared ou @/infra. Na prática o código admite uma exceção:
- ✅ Tipos de outra feature, quando o domínio realmente se relaciona:
features/despesas/typesimportaCategoryde@/features/categorias/types;HistoricoServiceagrega tipos dereceitasedespesas— é um agregador por desenho. - ❌ Componente, hook ou service de outra feature. Quando uma segunda feature precisa dele,
promova para
shared/— foi o que aconteceu comMonthYearSelect(shared/components/), com a navegação de competência (shared/hooks/useCompetenceNavigation.ts) e com os blocos de extrato, assistente e Ficha usados por Receitas e Despesas (shared/components/lancamentos/). - ❌ Hook de outra feature, nem para dado auxiliar:
DespesasScreenprecisa das categorias, mas quem chamauseCategories()é a páginaapp/(app)/despesas/page.tsx, que passacategoriespor prop. - ❌
shared/importando defeatures/— sem exceção.AppShelleSidebarrecebem o usuário (ShellUser { name }) e as açõesonLogout/onLogoutAllpor props; quem chamauseAuth()e liga as props é o layoutapp/(app)/layout.tsx(camadaapp/, que pode compor features).
Server vs Client Component
Regra: manter a fronteira RSC o mais baixa possível. "use client" vai no componente que de
fato usa hook/estado/efeito/API de browser — sempre na primeira linha, seguida de linha em branco.
| Arquivo | Diretiva |
|---|---|
app/layout.tsx (shell + metadata) |
RSC |
app/page.tsx (só redirect("/login")) |
RSC |
app/(app)/layout.tsx (chama useAuth e liga o AppShell) |
"use client" |
app/(auth)/layout.tsx, cadastro/page.tsx |
RSC |
app/(auth)/login/page.tsx (async, lê searchParams) |
RSC |
app/api/[...path]/route.ts (repasse) |
route handler |
todas as app/(app)/*/page.tsx |
"use client" |
hooks de feature, providers.tsx, primitivos com estado |
"use client" |
componentes de apresentação puros (KpiCard, EntryRow, ConfirmDialog) |
sem diretiva |
metadata só existe em app/layout.tsx. Os providers globais (QueryClientProvider,
ToastContainer, devtools) vivem em app/providers.tsx, que consome o singleton
queryClient de @/infra/query-client — não crie useState(() => new QueryClient()).
Não existem loading.tsx, error.tsx nem not-found.tsx no projeto — e isso é deliberado
(o único route handler é o repasse app/api/[...path]/route.ts, descrito abaixo):
- Loading é early-return dentro da própria página client (
if (isLoading) return <Skeleton ... />) ou dentro do container de feature (LedgerLayoutmostra o esqueleto enquantoledgeréundefined). - Erro de mutação vira toast (disparado pelo hook); erro de formulário vira bloco inline
<div className="rounded-lg border border-red bg-red-light p-3 text-sm text-red">. shared/components/ErrorBoundary.tsxexiste e é testado, mas não está montado em nenhum lugar.
proxy.ts
Gate de rota server-side (na Next 16 o antigo middleware virou proxy; a função exportada é
proxy), lendo o indicador smaug_session que infra/session.ts escreve. O indicador não é
credencial (o refresh token está no cookie httpOnly da API) — ele só decide a rota:
const PUBLIC_ROUTES = ["/login", "/cadastro"];
const SESSION_HINT_COOKIE = "smaug_session";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublic = PUBLIC_ROUTES.some(
(route) => pathname === route || pathname.startsWith(`${route}/`),
);
const hasSession = request.cookies.get(SESSION_HINT_COOKIE)?.value === "1";
if (!isPublic && !hasSession) {
// Leva a rota pretendida em `?next=` para o login devolver o usuário a ela (design D8). A raiz
// não vai: depois do login ela cairia em `/dashboard` de qualquer jeito.
const login = new URL("/login", request.url);
if (pathname !== "/") login.searchParams.set("next", `${pathname}${request.nextUrl.search}`);
return NextResponse.redirect(login);
}
if (isPublic && hasSession) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
};
- O
nextgravado aqui não é confiável: quem o usa ésafeDestination(emfeatures/auth/hooks/useAuth.ts), que só aceita caminho interno — recusa//host,/\host, espaço/controle em qualquer posição e qualquer valor cuja origem mude ao ser resolvido como URL. - O matcher exclui
api: o repasse não passa pelo gate (a API faz a própria autenticação).
Repasse /api — app/api/[...path]/route.ts
O navegador só fala com a origem do web. apiClient usa baseURL: "/api", e este route handler
repassa /api/* → ${API_ORIGIN}/*, de modo que o cookie smaug_refresh emitido pela API vira
cookie da origem do web (sem CORS nem withCredentials):
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function forward(request: Request): Promise<Response> {
const apiOrigin = process.env.API_ORIGIN?.replace(/\/+$/, "");
if (!apiOrigin) return new Response(null, { status: 500 });
const url = new URL(request.url);
const target = `${apiOrigin}${url.pathname.slice("/api".length)}${url.search}`;
const hasBody = request.method !== "GET" && request.method !== "HEAD";
let upstream: Response;
try {
upstream = await fetch(target, {
method: request.method,
headers: upstreamRequestHeaders(request),
body: hasBody ? await request.arrayBuffer() : undefined,
redirect: "manual",
cache: "no-store",
});
} catch {
return new Response(null, { status: 502 });
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: downstreamResponseHeaders(upstream),
});
}
export const GET = forward;
// HEAD, POST, PUT, PATCH, DELETE, OPTIONS = forward
Regras:
API_ORIGINé variável de servidor, lida a cada requisição (semNEXT_PUBLIC_): a mesma imagem serve a qualquer destino. Ausente → 500 sem corpo; API inalcançável → 502 sem corpo — ambos transitórios para oapi-client, que não derruba a sessão.- Não troque por
rewrites()emnext.config.ts: reprovou no spike (congela o destino nonext builde falhou em H2/H3 —specs/010-autenticacao/plan.md, seção do spike). - Destino montado de
request.url, não deparams.path(o path decodificado perderia%2F/%20). - Cabeçalhos de salto (
HOP_BY_HOP) saem nos dois sentidos;content-encodingnão é repassado (ofetchjá descomprimiu); cadaSet-Cookieé reanexado separado viagetSetCookie(). - Corpo como
arrayBuffer— o uploadtext/csve o download do CSV atravessam byte a byte. - Testado em
route.test.tsao lado, com@vitest-environment nodeefetchsubstituído por dublê.
Anatomia de página — dois formatos (+ layouts de sessão)
(a) Página CRUD simples — app/(app)/categorias/page.tsx
A página é o container: chama o hook, guarda o estado dos modais e dispara as mutations.
"use client";
import { useState } from "react";
import { Plus, Tag } from "lucide-react";
import { useCategories } from "@/features/categorias/hooks/useCategories";
// componentes de feature → primitivos de shared → import type
export default function CategoriasPage() {
const { data: categories, isLoading, create, update, remove } = useCategories();
const [modal, setModal] = useState<null | "add" | "edit">(null);
const [selected, setSelected] = useState<CategoryWithCount | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CategoryWithCount | null>(null);
const handleSave = (data: { name: string }) => {
if (modal === "add") {
create.mutate(data.name, { onSuccess: () => setModal(null) });
} else if (modal === "edit" && selected) {
update.mutate({ id: selected.id, name: data.name }, { onSuccess: () => setModal(null) });
}
};
// ...
}
Regras:
- Um
useStatepor modal, seleção e alvo de exclusão (selected,deleteTarget). - A mutação é disparada pela página, fechando o modal num
onSuccesslocal. - Renderização em três ramos:
isLoading ? <Skeleton/> : !data?.length ? <EmptyState/> : <lista>. - Os modais ficam ao final, sempre montados, controlados por
isOpen; oConfirmDialogporisOpen={!!deleteTarget}com umif (deleteTarget)defensivo noonConfirm— é esse idioma que justifica o threshold relaxado deapp/**novitest.config.ts. - Container raiz:
className="p-4 sm:p-7 max-w-[1100px]". - O componente da página é
export default function <Rota>Page().
(b) Tela de feature — Receitas e Despesas
Quando a tela tem navegação, assistente e Fichas, a página só monta a tela da feature
(app/(app)/receitas/page.tsx é return <ReceitasScreen />;). Despesas passa as categorias:
export default function DespesasPage() {
const categories = useCategories();
return <DespesasScreen categories={categories.data ?? []} />;
}
features/despesas/components/DespesasScreen.tsx é o container:
type ModalState =
| null
| { t: "new" }
| { t: "edit"; item: OneTimeExpenseMonthItem }
| { t: "item"; item: ExpenseMonthItem; status: CompetenceStatus };
export function DespesasScreen({ categories }: DespesasScreenProps) {
const [modal, setModal] = useState<ModalState>(null);
const nav = useLedgerNavigation();
const month = useExpenseMonth(nav.selected);
const summary = useExpenseYearSummary(nav.selected.year);
nav.adoptReference(month.data?.reference ?? summary.data?.reference);
return (
<LedgerLayout /* textos, navigation={nav}, ledger={month.ledger}, onNew, onOpen */>
{(modal?.t === "new" || modal?.t === "edit") && <ExpenseWizard /* ... */ />}
{modal?.t === "item" && <ExpenseFicha /* ... */ />}
</LedgerLayout>
);
}
Regras:
- Um único
ModalState, união discriminada portcomnull; o que o modal precisa (item, situação da competência) vai dentro do estado, não emuseStates paralelos. - Modais são montados condicionalmente (
modal?.t === "..." && <X />): assistente e Ficha nascem com o estado inicial certo a cada abertura. - Quem dispara a mutation é o hook do assistente ou da Ficha (
useExpenseWizard,useExpenseMutations({ onSuccess })), não a tela. - O "agora" vem do servidor (
referenceda consulta), adotado vianav.adoptReference(...)durante o render — nuncanew Date()na tela. - Layout, faixa de meses, resumo, lista, estado vazio e erro vêm de
LedgerLayout(shared/components/lancamentos/); a tela de feature só fornece textos, dados e modais.
(c) Layout autenticado e páginas de (auth)
app/(app)/layout.tsx é client e é o ponto onde a sessão é retomada e o menu recebe o usuário:
"use client";
import { AppShell } from "@/shared/components/AppShell";
import { useAuth } from "@/features/auth/hooks/useAuth";
/**
* Layout cliente das rotas autenticadas: é aqui (camada `app/`, que pode compor features) que a
* sessão é retomada e o menu recebe o usuário e as ações de saída — `shared/` não conhece `auth`.
*/
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { user, logout, logoutAll } = useAuth();
return (
<AppShell user={user} onLogout={logout} onLogoutAll={logoutAll}>
{children}
</AppShell>
);
}
app/(auth)/login/page.tsx é Server Component async e lê o ?next= de searchParams
(no Next 16, uma Promise), repassando ao formulário por prop:
interface LoginPageProps {
searchParams: Promise<{ next?: string | string[] }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const { next } = await searchParams;
return (
// ... cartão com logo e texto
<LoginForm next={typeof next === "string" ? next : null} />
);
}
cadastro/page.tsx é RSC simples que monta <RegisterForm /> — o cadastro já abre a sessão e vai
para /dashboard.
Checklist — criar uma feature nova
-
features/<nome-pt-br>/{components,hooks,services,types}. -
types/index.tscom as interfaces de domínio;types/schemas.tssó se houver formulário RHF. -
services/<Feature>Service.ts(skillweb-feature-services). -
hooks/use<Coisa>.ts(skillweb-feature-hooks). - Componentes (skill
web-componentes): apresentacionais, e containers só no formato (b). - Página em
app/(app)/<rota>/page.tsxcom"use client"— a proteção vem de graça doproxy.tse do layout de(app); não verifique sessão na página. - Rota acrescentada à navegação (
shared/components/Sidebar.tsx/BottomNav.tsx). - Teste ao lado de cada fonte + integração de página em
web/tests/integration/(skillweb-testes).
Não faça
- Não chame
apiClientde dentro de um componente. - Não importe
useRouter/useSearchParamsdenext/navigationem hook de negócio — use@/infra/router-adapter. - Não crie barril
index.tsde re-export. - Não importe componente/hook/service de outra feature.
- Não crie
loading.tsx/error.tsx— siga o padrão de early-return + toast. - Não crie outro route handler para falar com a API nem use
rewrites(): tudo passa pelo repasseapp/api/[...path]/route.ts. - Não exponha o endereço da API ao browser (
NEXT_PUBLIC_*): o destino éAPI_ORIGIN, só no servidor. - Não use
useSearchParamsnum hook que roda no layout de(app)(exigiria<Suspense>e tiraria o menu do SSR) — leiasearchParamsna página Server Component e passe por prop, como faz o login. - Não navegue para um
nextvindo da URL semsafeDestination(redirecionamento aberto). - Não instancie um
QueryClientnovo emproviders.tsx.