Guia prático: Service + Factory + DI e como um relatório é montado
Guia introdutório dos padrões de código mais comuns do projeto. Feito para quem está começando (estagiário / dev novo) entender o quê, o porquê e como escrever seguindo o padrão — tanto no backend quanto no frontend.
Os exemplos são do código real:
- CRUD com regra de negócio →
belite/_lib/server/goals.service.ts- Relatório de BI (leitura) →
emplacamento/home/[account]/reports/por-marca/
Parte 1 — Backend: Service + Factory + Injeção de Dependência
1.1 O problema que esse padrão resolve
Quando o usuário faz uma ação, três coisas precisam acontecer:
- Desenhar a tela (React).
- Aplicar a regra de negócio (ex: "não pode ter dois objetivos do mesmo recorte", "o teto do grupo não pode estourar").
- Falar com o banco (Supabase/Postgres ou o Data Warehouse).
Se você misturar as três coisas no mesmo arquivo, vira bagunça: a tela sabe SQL, a regra fica espalhada e testar fica quase impossível (todo teste precisaria de um banco de verdade). O padrão Service + Factory + DI dá um papel único a cada peça.
1.2 As três peças
Service — a classe com a regra de negócio
Junta, num lugar só, toda a lógica de um assunto (objetivos, usuários, relatórios...). Não sabe nada de tela; só sabe as regras e como conversar com o banco.
// goals.service.ts
class GoalsService {
constructor(private readonly client: SupabaseClient<Database>) {}
async listGoals(accountId: string): Promise<GoalRow[]> {
const { data, error } = await this.client
.from('goals')
.select('*')
.eq('account_id', accountId)
.order('kpi_kind', { ascending: true });
if (error) throw toUserFacingError('listGoals', error);
return data;
}
}
Note: o método não cria a conexão; usa this.client, que a classe recebeu
de fora. E a tradução de erro técnico em erro amigável (toUserFacingError)
mora aqui, não na tela.
Factory — a função que constrói o Service
Ninguém dá new direto. Existe uma função-fábrica cujo único trabalho é montar
o service e entregar pronto:
// goals.service.ts
export function createGoalsService(client: SupabaseClient<Database>) {
return new GoalsService(client);
}
Por quê:
- Centraliza a criação — se o construtor mudar, você ajusta um lugar só.
- Esconde o
new— quem usa só chamacreateGoalsService(...).
Convenção: a classe
GoalsServicefica privada (não exportada). Só a fábrica é exportada. Assim ninguém dánewna classe por fora.
Injeção de Dependência — entregar o que o Service precisa
O service precisa do client do banco, mas não busca sozinho — recebe pelo construtor:
class GoalsService {
constructor(private readonly client: SupabaseClient<Database>) {}
// └── "dependência": a coisa que o service precisa
}
Quem cria e entrega (injeta) é a server action:
// goals-actions.ts
const service = createGoalsService(getSupabaseServerClient());
// └── cria o client e injeta no service
Analogia: é a diferença entre um funcionário que traz o próprio computador de casa (ruim — a empresa não controla) e um que recebe o computador ao ser contratado (bom — a empresa decide qual).
1.3 Fluxo completo (CRUD: "listar objetivos")
// goals-actions.ts
export const listGoalsAction = authActionClient
.inputSchema(ListGoalsSchema) // valida a entrada
.action(async ({ parsedInput, ctx: { user } }) => {
const accountId = await resolveAccountId(user.id, parsedInput.accountSlug);
const service = createGoalsService(getSupabaseServerClient()); // cria + injeta
return service.listGoals(accountId); // delega a regra pro service
});
Quem faz o quê:
- Action = borda: validar entrada, resolver conta, criar client, revalidar cache, registrar auditoria.
- Service = regra de negócio pura.
1.4 Por que vale a pena: testabilidade
Como o service recebe o client, no teste você entrega um client falso e testa a regra sem banco nenhum:
const clientFalso = {
from: () => ({
select: () => ({ eq: () => ({ order: () => ({ data: [/* linhas fake */], error: null }) }) }),
}),
} as unknown as SupabaseClient<Database>;
const service = createGoalsService(clientFalso);
const goals = await service.listGoals('conta-123');
// afirma que goals veio certo — sem Supabase rodando
Se o service criasse o client por dentro, isso seria impossível. É por isso que a convenção é injetar, não criar por dentro.
1.5 Variação importante: services de leitura de BI
Nem todo service é CRUD. Os relatórios leem de um Data Warehouse (DW) separado do banco de aplicação e são otimizados para leitura. Dois detalhes que diferem do exemplo de metas:
// por-marca/_lib/server/brand-service.ts
import { cache } from 'react';
import { getSupabaseDwClient } from '../../../por-territorio/_lib/server/supabase-dw-client';
export function createBrandService() {
return new BrandService();
}
- Client do DW, não do app. Relatórios consultam o
getSupabaseDwClient()(conexão de leitura no Data Warehouse), não ogetSupabaseServerClient(). React.cache. Consultas pesadas são memoizadas por request, para não repetir a mesma query em vários pontos da mesma renderização.- A fábrica de um service de leitura pode não receber client (a conexão de DW é
resolvida internamente) — mas o escopo de permissão (
scope) sempre é passado de fora, para o DW filtrar só o que o usuário pode ver.
1.6 Checklist para escrever um service novo
- Arquivo em
_lib/server/<assunto>.service.ts, começando comimport 'server-only';. - A classe
XServiceé privada; só a fábricacreateXServiceé exportada. - Para CRUD/escrita: o construtor recebe o client
(
SupabaseClient<Database>), e os métodos usam semprethis.client. - Nunca chamar
getSupabaseServerClient()/getSupabaseServerAdminClient()dentro de um método (quebra a testabilidade). - Erros do banco viram mensagem amigável no service; o técnico vai só pro log.
- Quem cria o client e injeta é a server action (ou loader).
Parte 2 — Frontend: como um relatório de BI é montado
Esta é a parte que é o core dos produtos de BI. Vamos seguir o relatório
Por Marca (emplacamento/home/[account]/reports/por-marca/) de ponta a
ponta.
2.1 A anatomia de uma rota de relatório
reports/por-marca/
├── page.tsx # Server Component: orquestra dados + monta o layout
├── loading.tsx # skeleton da rota inteira (Next.js)
├── _components/ # componentes CLIENT específicos deste relatório
│ ├── brand-filter-bar.tsx # a barra de filtros
│ ├── brand-report-view.tsx # a "view" cliente (KPIs + tabelas)
│ ├── brand-grid-table.tsx # a tabela específica
│ └── ...
└── _lib/ # utilitários e tipos deste relatório
├── server/ # código server-only (o service, resolvers)
│ └── brand-service.ts
├── types.ts # tipos das linhas/dados do relatório
├── brand-grid.ts # funções puras (filtrar, calcular KPI) — testáveis
└── export-brand.ts
Convenção de pastas (vale para todos os produtos):
| Pasta | O que vai dentro | Server/Client |
|---|---|---|
page.tsx | Orquestração + layout da rota | Server |
_components/ | Componentes de UI específicos da rota | Client |
_lib/ | Utilitários e tipos client da rota | Client |
_lib/server/ | Services, loaders, resolvers (só roda no servidor) | Server |
2.2 O page.tsx é o maestro (Server Component)
O page.tsx roda no servidor. Ele não desenha KPI nem tabela — ele:
- lê os filtros da URL (
searchParams); - resolve o escopo de permissão do usuário (
scope); - chama o service para buscar os dados (em paralelo com
Promise.all); - passa tudo pronto para os componentes client.
// por-marca/page.tsx (resumido)
export default async function PorMarcaPage({ params, searchParams }) {
const [{ account }, sp] = await Promise.all([params, searchParams]);
const filters = /* ...lê categorias, modalidades, período de sp... */;
const workspace = await loadTeamWorkspace(account);
// ESCOPO: o que este usuário pode ver (aplicado no DW)
const scope = await resolveEmplacamentoReportScope(
getSupabaseServerClient(), workspace.account.id, workspace.user.id,
);
const service = createBrandService();
// Busca tudo em paralelo — cada peça vira prop de um componente client
const [brandData, monthlyData, areaAbrangencia, categorias, modalidades] =
await Promise.all([
service.getReportData(workspace.account.id, filters, scope),
service.getMonthlyComparison(workspace.account.id, filters, scope),
/* ...opções dos filtros... */
]);
return (
<PageBody>
<TeamAccountLayoutPageHeader title="Por Marca" /* breadcrumb */ />
<ReportLoadingShell skeleton={<ReportSkeleton />}>
<BrandFilterBar categorias={categorias} /* ...opções... */ />
<Suspense fallback={<GridSkeleton columns={8} rows={10} />}>
<BrandReportView data={brandData} monthlyData={monthlyData} />
</Suspense>
</ReportLoadingShell>
</PageBody>
);
}
Pontos de convenção aqui:
- Filtros moram na URL. O estado do relatório (mês, categorias, área) é lido
de
searchParams, não deuseState. Isso torna o relatório compartilhável por link e navegável com voltar/avançar. scopesempre presente. Toda consulta de relatório recebe o escopo de permissão; o DW filtra por ele. Segurança não é opcional.Promise.all. Dados independentes são buscados em paralelo.Suspense+ skeleton. A tela mostra um esqueleto enquanto os dados chegam, em vez de tela branca.
2.3 O backend do relatório (o service de leitura)
O page.tsx chamou createBrandService() e usou service.getReportData(...).
Vamos ver o que acontece por dentro — o backend de um relatório de BI é
diferente do CRUD da Parte 1, e vale conhecer o padrão real.
A factory devolve métodos cacheados (não a classe crua)
No CRUD, a factory fazia return new GoalsService(client). No relatório, a
factory embrulha cada método do service com cache (tags + TTL), porque
consultas de BI são caras e os dados mudam pouco:
// por-marca/_lib/server/brand-service.ts (resumido)
export function createBrandService() {
const svc = new BrandService();
const cachedGetReportData = cacheService.wrap(
['relatorio-por-marca'],
(grupoId, filters, scope) => svc.getReportData(grupoId, filters, scope),
{ tags: [REPORT_TAGS.MARCA], revalidate: CACHE_TTL.HISTORICO }, // TTL + tag de invalidação
);
const cachedGetMonthlyComparison = cacheService.wrap(/* ... */);
return {
getReportData: cachedGetReportData,
getMonthlyComparison: cachedGetMonthlyComparison,
};
}
- A classe
BrandServicecontinua privada e com a lógica; a factory adiciona o cache por cima. Quem chama nem sabe que há cache — só usaservice.getReportData(...). tagspermitem invalidar o cache quando os dados do DW são atualizados;revalidatedefine por quanto tempo o resultado é reaproveitado.
O método consulta o Data Warehouse com SQL parametrizado + scope
Diferente do CRUD (que usa o client do Supabase e .from(...)), o service de
relatório usa o client do Data Warehouse (getSupabaseDwClient(), baseado em
postgres.js) e escreve SQL com template tag. O scope de permissão entra
como uma cláusula em toda query:
class BrandService {
async getReportData(_grupoId, filters, scope): Promise<BrandGridData> {
const dw = getSupabaseDwClient();
// Filtros da tela viram cláusulas SQL parametrizadas (sem concatenar string)
const { categoriaClause, modalidadeClause, areaClause /* ... */ } =
buildDwFilterClauses(dw, filters);
const aggRows = await dw<ModelAggRow[]>`
SELECT m.fabricante, m.grupomodeloveiculo AS modelo,
SUM(CASE WHEN m.ano = ${ano} THEN m.volume ELSE 0 END)::text AS acum_ano
FROM dw_emplacamento_mart.emplacamento_kpi_dia m
WHERE m.ano = ANY(${partitions})
${categoriaClause}
${modalidadeClause}
${areaClause}
${buildScopeClause(dw, scope, 'm')} -- ← permissão aplicada aqui, sempre
GROUP BY m.fabricante, m.grupomodeloveiculo
HAVING SUM(CASE WHEN m.ano = ${ano} THEN m.volume ELSE 0 END) > 0
`;
// ...busca dados brutos (aggRows, dailyRows) em Promise.all...
const rows = buildGridRows(validAggRows, dailyRows, days, dayOrdinals);
return { rows, metadata: { /* ano, monthLabel, ... */ }, lastUpdated };
}
}
Três pontos de convenção importantes:
- SQL sempre parametrizado. Os
${...}da template tag dopostgres.jssão bind parameters, não interpolação de string — não há risco de SQL injection. Filtros são montados por helpers (buildDwFilterClauses,buildScopeClause), não escritos à mão em cada query. scopeem toda query.buildScopeClause(dw, scope, 'm')aplica o recorte de permissão do usuário direto no SQL. É o equivalente ao RLS do banco de aplicação — no DW, a segurança é aplicada assim.React.cachepara query repetida no mesmo request.getReportDataegetMonthlyComparisonrodam em paralelo e ambas precisam do "último dia com dado". Essa consulta fica num helper memoizado (cache(async () => ...)), então a 2ª chamada concorrente reaproveita o resultado da 1ª em vez de bater no DW de novo.
O método busca dados brutos; funções puras fazem o cálculo
Repare que o método não calcula KPI nem monta a hierarquia — ele busca as
linhas cruas e delega para funções puras (buildGridRows, buildMonthlyRows)
que agregam marca > modelo, calculam participação %, variação, etc. Essas funções
não têm SQL nem React dentro, ficam no _lib e são testadas (ver
por-marca/_lib/server/__tests__/brand-service.test.ts).
Essa separação é a mesma filosofia do CRUD: a borda (SQL/cache) separada da lógica pura (cálculo) — só que aqui a "lógica" é a agregação dos números do relatório.
2.4 A "view" client transforma dados em KPIs e tabelas
page.tsx entrega os dados brutos; o brand-report-view.tsx (client, 'use client') transforma em números e monta os cards e a tabela.
// brand-report-view.tsx (resumido)
'use client';
export function BrandReportView({ data, monthlyData, montadora }) {
const [search, setSearch] = useState('');
// Cálculos derivados ficam em useMemo (recalcula só quando a dependência muda)
const filteredRows = useMemo(() => filterBrandRows(data.rows, search), [data.rows, search]);
const filteredKpis = useMemo(() => { /* soma totais, calcula % */ }, [filteredRows]);
const brandKpi = useMemo(() => resolveAssociationBrandKpi(data.rows, montadora), [data.rows, montadora]);
return (
<>
<GridContainer>
<ReportsInfoCard title="Emplacados" content={formatFull(filteredKpis.totalMes)} /* ... */ />
{/* ...outros cards de KPI... */}
</GridContainer>
<DefaultGridTableHeader
title="Detalhamento por marca"
search={{ value: search, onChange: setSearch }}
actions={
<CommonTableExportButton
columns={exportBrandColumns(data)}
formats={['xlsx', 'pdf', 'csv']}
onExport={(options) => exportBrandReport(filteredData, options)}
/>
}
contents={[{ label: 'Evolução diária', content: <BrandGridTable data={filteredData} /> }]}
/>
</>
);
}
Convenções:
- Cálculo pesado em
useMemo. KPIs e filtragens só recalculam quando os dados/busca mudam. - Funções puras vão pro
_lib.filterBrandRows,resolveAssociationBrandKpificam em_lib/brand-grid.ts— sem React dentro, fáceis de testar (há testes para elas). - Formatação pt-BR centralizada (
toLocaleString('pt-BR'), helpersformatFull/formatPct).
2.5 Componentes compartilhados: components/reports/ e @kit/ui
Nada de KPI-card ou tabela é reescrito por relatório. Existe uma biblioteca de componentes de relatório compartilhada. Antes de criar um componente novo, procure aqui:
| Componente compartilhado | Onde | Para quê |
|---|---|---|
CommonGridTable | components/reports/common-grid-table/ | Tabela de dados (desktop + mobile, subrows, paginação) |
ReportsInfoCard | components/reports/common-info-card.tsx | Card de KPI (número + variação %) |
CommonTableExportButton | components/reports/common-table-export-button.tsx | Botão de exportar (xlsx/pdf/csv) |
DefaultGridTableHeader | components/reports/default-table-header.tsx | Header da tabela com busca + abas |
| Filtros, tabs, skeletons | components/reports/* | Barra de filtros, abas, esqueletos |
Regra geral (do AGENTS.md): shadcn / @kit/ui primeiro, components/reports
depois, criar do zero por último — só quando o componente for realmente
específico daquela tela.
2.6 Lazy loading é o padrão
Componentes de relatório são pesados (gráficos, tabelas). A convenção é
carregá-los sob demanda via next/dynamic, com um skeleton enquanto chegam.
Existe um arquivo central components/reports/lazy.tsx que exporta as versões
lazy:
// components/reports/lazy.tsx
export const CardKpi = dynamic(() => import('./card-kpi'), {
loading: () => <CardKpiSkeleton />,
});
export const CommonTableExportButton = dynamic(() => import('./common-table-export-button'));
// ...
Nos relatórios, importe a versão lazy (from '~/components/reports/lazy'), não o
componente direto — assim o bundle inicial fica leve e cada card aparece com seu
skeleton.
2.7 A regra de ouro do CommonGridTable: props via headers, não avulsas
O CommonGridTable é o componente mais reutilizado dos relatórios. A forma de
configurá-lo é o ponto que mais confunde quem chega. A regra:
Toda a lógica de apresentação de uma coluna vive no objeto
TableHeader, não em props avulsas no consumidor.
Você descreve as colunas com um array de TableHeader<T>. Cada header diz como
aquela coluna se renderiza:
import CommonGridTable, { type TableHeader } from '~/components/reports/common-grid-table';
interface MarcaRow {
marca: string;
jul: number;
jun: number;
sharePct: number;
modelos?: MarcaRow[];
}
const headers: TableHeader<MarcaRow>[] = [
{
name: 'Marca/Modelo',
accessorKey: 'marca',
getCellSubtitle: (row) => (row.modelos?.length ? `${row.modelos.length} modelos` : undefined),
},
{
name: 'Jul/26',
subtitle: 'emplacamentos',
accessorKey: 'jul',
renderCell: (row) => row.jul.toLocaleString('pt-BR'), // conteúdo custom da célula
getCellSubtitle: (row) => { // subtítulo abaixo do valor
const pct = (((row.jul - row.jun) / row.jun) * 100).toFixed(1);
return `${Number(pct) >= 0 ? '+' : ''}${pct}% vs jun`;
},
sortFn: (a, b) => a.jul - b.jul, // ordenação
align: 'right',
},
];
<CommonGridTable
headers={headers}
rows={rows}
rowConfig={{
getKey: (row) => row.marca,
getSubRows: (row) => row.modelos ?? [], // hierarquia: marca > modelos
}}
/>;
O que fica no header (nunca como prop solta): renderCell (conteúdo da
célula), getCellSubtitle (texto de apoio), sortFn/sortable (ordenação),
align, minWidth/maxWidth, getCsvValue (valor na exportação), icon,
subtitle. A lista completa está em common-grid-table/types.ts.
O que fica no rowConfig (estrutura, não visual): getKey (identidade da
linha) e getSubRows (hierarquia/expansão).
Documentação completa do componente, com todos os exemplos:
components/reports/common-grid-table/README.md.
2.8 Checklist para uma tela de relatório nova
-
page.tsxé Server Component: lê filtros desearchParams, resolvescope, busca dados via service emPromise.all, passa como props. - Estado de filtro na URL, não em
useState. - Consultas passam sempre pelo
scope(permissão aplicada no DW). - Componentes de UI em
_components/com'use client'; funções puras em_lib/(testáveis). - Reusar
components/reports/*e@kit/uiantes de criar componente novo. - Importar componentes pesados via
components/reports/lazy.tsx. - Configurar
CommonGridTableporheaders(apresentação) +rowConfig(estrutura) — nunca props de coluna avulsas. -
Suspense+ skeleton para o carregamento.
Onde ver bons exemplos no código
- CRUD:
belite/_lib/server/goals.service.ts+goals-actions.ts. - Relatório de BI:
emplacamento/home/[account]/reports/por-marca/(page.tsx,_components/brand-report-view.tsx,_lib/server/brand-service.ts). - Componente de tabela:
components/reports/common-grid-table/README.md. - Testes de funções puras:
__tests__ao lado dos_lib(ex:por-marca/_lib/__tests__/brand-grid.test.ts).