Pular para o conteúdo principal

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:

  1. Desenhar a tela (React).
  2. Aplicar a regra de negócio (ex: "não pode ter dois objetivos do mesmo recorte", "o teto do grupo não pode estourar").
  3. 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ó chama createGoalsService(...).

Convenção: a classe GoalsService fica privada (não exportada). Só a fábrica é exportada. Assim ninguém dá new na 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 o getSupabaseServerClient().
  • 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 com import 'server-only';.
  • A classe XService é privada; só a fábrica createXService é exportada.
  • Para CRUD/escrita: o construtor recebe o client (SupabaseClient<Database>), e os métodos usam sempre this.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):

PastaO que vai dentroServer/Client
page.tsxOrquestração + layout da rotaServer
_components/Componentes de UI específicos da rotaClient
_lib/Utilitários e tipos client da rotaClient
_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:

  1. lê os filtros da URL (searchParams);
  2. resolve o escopo de permissão do usuário (scope);
  3. chama o service para buscar os dados (em paralelo com Promise.all);
  4. 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 de useState. Isso torna o relatório compartilhável por link e navegável com voltar/avançar.
  • scope sempre 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 BrandService continua privada e com a lógica; a factory adiciona o cache por cima. Quem chama nem sabe que há cache — só usa service.getReportData(...).
  • tags permitem invalidar o cache quando os dados do DW são atualizados; revalidate define 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 do postgres.js sã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.
  • scope em 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.cache para query repetida no mesmo request. getReportData e getMonthlyComparison rodam 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, resolveAssociationBrandKpi ficam em _lib/brand-grid.ts — sem React dentro, fáceis de testar (há testes para elas).
  • Formatação pt-BR centralizada (toLocaleString('pt-BR'), helpers formatFull/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 compartilhadoOndePara quê
CommonGridTablecomponents/reports/common-grid-table/Tabela de dados (desktop + mobile, subrows, paginação)
ReportsInfoCardcomponents/reports/common-info-card.tsxCard de KPI (número + variação %)
CommonTableExportButtoncomponents/reports/common-table-export-button.tsxBotão de exportar (xlsx/pdf/csv)
DefaultGridTableHeadercomponents/reports/default-table-header.tsxHeader da tabela com busca + abas
Filtros, tabs, skeletonscomponents/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 de searchParams, resolve scope, busca dados via service em Promise.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/ui antes de criar componente novo.
  • Importar componentes pesados via components/reports/lazy.tsx.
  • Configurar CommonGridTable por headers (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).