Arquitetura de um dashboard em Angular: feature, service singleton, store com signals e configuração sem vazar segredo
Todo dashboard começa simples: uma chamada à API, uma tabela, um gráfico. Alguns meses depois ninguém quer mexer nele. A cotação aparece em cinco componentes, cada um trata o erro do seu jeito, o endereço da API está escrito em três arquivos e trocar de aba refaz todas as requisições.
O problema raramente é falta de recurso do framework. É falta de decisões de organização tomadas cedo. Este artigo percorre essas decisões com um caso concreto, um dashboard de ações da bolsa brasileira, e mostra o código de cada peça: o service que fala com a API, o mapper que traduz as respostas, a store que guarda o estado e os componentes que só desenham. No fim, extrai uma estratégia geral para quem precisa chamar várias APIs com excelência.
O caso: uma API pública com limite de uso
O dashboard mostra uma ação escolhida pela pessoa: cotação do dia, faixa de 52 semanas, gráfico de preço por período, indicadores, dividendos, balanço e perfil da empresa, além de um comparativo entre as ações disponíveis. Os dados vêm da brapi, que libera quatro tickers (PETR4, MGLU3, VALE3 e ITUB4) para consulta sem token, segundo o artigo oficial de acesso gratuito.
O endereço base da API é:
https://brapi.dev/api
Esse valor não está espalhado pelo código: ele mora na configuração tipada do app, que veremos adiante, e o service o recebe por injeção.
Os endpoints usados
A API expõe uma rota de cotação, GET /quote/{tickers}, e o que muda de uma tela para outra são os parâmetros. O dashboard usa três formas de chamá-la:
Detalhes de uma ação, tudo em uma requisição. Cotação, perfil, indicadores, balanço, dividendos e histórico de preços:
GET /quote/PETR4?range=1y&interval=1d &modules=summaryProfile,balanceSheetHistory,defaultKeyStatistics ÷nds=trueSó o histórico de preços. Usada quando a pessoa troca o período do gráfico, sem repetir os módulos pesados:
GET /quote/PETR4?range=6mo&interval=1dComparativo de várias ações em uma chamada. Os quatro tickers separados por vírgula, com os indicadores de cada um:
GET /quote/PETR4,MGLU3,VALE3,ITUB4?modules=defaultKeyStatistics
Em resumo: um endpoint, três formatos de chamada, e dá para pedir muita coisa de uma vez. Isso importa por uma razão prática. Ao inspecionar uma resposta em 27/10/2026, os cabeçalhos informavam RateLimit-Limit: 20, RateLimit-Reset: 60 e X-Brapi-Concurrency-Limit: 1: 20 requisições por janela de 60 segundos e uma requisição por vez. Esses números podem mudar com o tempo ou com o plano de acesso, mas o princípio fica: uma API pública tem limites, e o desenho do front-end precisa respeitá-los. Esse único fato justifica boa parte da arquitetura a seguir.
Visão geral: quem fala com quem
Antes dos detalhes, o mapa. O dashboard separa quatro responsabilidades e deixa o fluxo de dados em uma só direção:
COMPONENTE (seção) só desenha o que recebe
│ lê signals ▲ LoadState<T>
▼ │
STORE estado, cache, cancelamento
│ chama métodos ▲ modelos do app
▼ │
APISERVICE + MAPPER só HTTP e tradução
│ HTTP ▲ JSON
▼ │
API EXTERNA formato que o app não controla
O componente nunca vê o JSON da API. O service nunca guarda estado. A store é o único lugar que decide quando buscar, o que manter em cache e o que mostrar enquanto carrega. Cada regra existe uma vez.
Arquitetura de pastas: por feature, não por tipo
O guia de estilo oficial do Angular recomenda agrupar o código por funcionalidade ou tema, e não por tipo de código: evitar pastas chamadas components, directives ou services, manter arquivos relacionados juntos e os testes ao lado do código testado. O mesmo guia pede um conceito por arquivo e desencoraja nomes vagos como utils.ts ou helpers.ts.
A árvore real do dashboard fica assim:
src/app/
├── app.config.ts providers: roteador, HttpClient, configuração
├── app.routes.ts rota com carregamento tardio + guarda de ticker
│
├── core/ o motor do app: nada aqui conhece componentes
│ ├── api/
│ │ ├── brapi-api.service.ts HTTP e tradução de erros
│ │ ├── brapi.mapper.ts formato bruto da API → modelos do app
│ │ ├── brapi.types.ts tipos BRUTOS da resposta
│ │ ├── request-queue.ts fila: uma requisição por vez
│ │ └── serialize-requests.interceptor.ts aplica a fila às chamadas da API
│ ├── config/
│ │ └── app-config.ts AppConfig + InjectionToken
│ ├── models/
│ │ └── stock.model.ts modelos de domínio + LoadState<T>
│ ├── store/
│ │ └── quote.store.ts estado, cache e cancelamento
│ └── stocks.ts lista dos tickers
│
├── features/
│ └── dashboard/
│ ├── dashboard.page.ts container: liga a store e compõe as seções
│ ├── ticker-picker/ seletor de ação
│ ├── overview/ price/ dividends/ financials/ profile/ comparison/
│ └── (cada seção: um componente que lê a store)
│
└── shared/ peças reutilizáveis, sem regra de negócio
├── charts/ gráficos em SVG (preço, rosca, barras)
├── format/ formatação de números, moeda e datas
└── ui/ async-block, metric-card, segmented-control
E uma regra de dependência que torna a estrutura verificável em revisão de código:
features ──▶ core
features ──▶ shared ──▶ (no máximo os modelos de) core
core ──▶ (nenhuma outra pasta do app)
Quando alguém escreve um import na direção errada, a revisão percebe. E cada mudança tem um lugar óbvio: a API mudou, mexa em core/api; nova regra de cache, em core/store; novo gráfico, em shared/charts.
A estratégia de chamada
Respeitar 20 requisições por minuto, e uma por vez, não é questão de sorte: é consequência de poucas regras aplicadas em um só lugar.
- Consolidar. Tudo o que a tela de uma ação precisa vem em uma requisição (a primeira forma acima). O comparativo das quatro ações é uma requisição, não quatro.
- Separar o que muda sozinho. Trocar o período do gráfico busca só o histórico, e só se ainda não estiver em cache. Os módulos pesados (balanço, perfil) não são pedidos de novo.
- Cachear por chave. Detalhes por ticker, histórico por
ticker:período, comparativo uma vez. Voltar a uma ação já vista é instantâneo e custa zero requisições. - Cancelar o que ficou obsoleto. Ao trocar de ação, a requisição anterior é cancelada; do contrário, uma resposta atrasada poderia sobrescrever a tela com a ação errada.
- Enfileirar. Como a API atende uma requisição por vez, as chamadas passam por uma fila: a segunda só sai quando a primeira termina. Quem cancela uma chamada que ainda espera a tira da fila sem gastar requisição.
- Atualizar com cuidado. Um botão "Atualizar" e um intervalo configurável descartam o cache, e o intervalo pausa quando a aba está oculta.
- Falhar com educação. Todo erro vira uma mensagem que a pessoa entende e um botão "tentar novamente", por fatia de dados.
O resultado, em contagem de requisições: abrir o dashboard custa 2 (comparativo e detalhes da ação, enviadas uma depois da outra), trocar o período custa 1, voltar a uma ação em cache custa 0. A sequência de uma troca de ação é esta:
select('VALE3')
├─ 1. cancela requisições em andamento (detalhes e histórico)
├─ 2. há cache de VALE3?
│ ├─ sim → mostra na hora e termina (0 requisições)
│ └─ não → status 'loading'
│ (mantém os dados antigos só se forem da mesma ação)
├─ 3. GET /quote/VALE3?range=1y&interval=1d&modules=…÷nds=true
└─ 4. resposta → guarda detalhes e histórico "VALE3:1y" no cache → 'success'
O service: só HTTP e tradução
Um service é uma classe com uma responsabilidade que não é de tela. O de acesso à API monta a requisição, envia e devolve dados no formato que o app entende. Ele não guarda estado e não conhece componentes.
No Angular, providedIn: 'root' cria uma instância única para o app inteiro. A documentação descreve isso como "uma instância única e compartilhada para toda a aplicação", injetável em qualquer lugar e eliminável do bundle se ninguém a usar. Este é o service do dashboard (trechos resumidos), com os três métodos que correspondem às três chamadas:
@Injectable({ providedIn: 'root' })
export class BrapiApiService {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
/** Tudo de uma ação em UMA requisição. */
getDetails(ticker: Ticker, range: RangeKey): Observable<StockDetails> {
const params = new HttpParams()
.set('range', range)
.set('interval', '1d')
.set('modules', 'summaryProfile,balanceSheetHistory,defaultKeyStatistics')
.set('dividends', 'true');
return this.firstResult(ticker, params).pipe(map((r) => toDetails(r, range)));
}
/** Só o histórico: usada ao trocar o período. */
getHistory(ticker: Ticker, range: RangeKey): Observable<Candle[]> {
const params = new HttpParams().set('range', range).set('interval', '1d');
return this.firstResult(ticker, params).pipe(map((r) => toCandles(r.historicalDataPrice)));
}
/** Várias ações em uma chamada, com indicadores para o comparativo. */
getComparison(tickers: readonly Ticker[]): Observable<ComparisonRow[]> {
const params = new HttpParams().set('modules', 'defaultKeyStatistics');
return this.get(tickers.join(','), params).pipe(
map((res) => (res.results ?? []).map(toComparisonRow)),
);
}
private get(path: string, params: HttpParams): Observable<BrapiResponse> {
return this.http
.get<BrapiResponse>(`${this.config.apiBaseUrl}/quote/${path}`, { params })
.pipe(catchError((e) => throwError(() => toApiError(e))));
}
}
Repare em três escolhas. O endereço vem de APP_CONFIG, nunca de uma string no meio do código. Os parâmetros são montados com HttpParams, que escapa os valores e evita concatenar texto de URL à mão. E todo erro passa por um único tradutor, que transforma falhas técnicas em mensagens para pessoas:
export function toApiError(e: unknown): ApiError {
if (e instanceof ApiError) return e;
if (e instanceof HttpErrorResponse) {
if (e.status === 0) return new ApiError('Sem conexão com a API. Verifique sua internet.', 0);
if (e.status === 429) return new ApiError('Muitas requisições em pouco tempo. Aguarde e tente de novo.', 429);
if (e.status >= 500) return new ApiError('A API está instável no momento. Tente em instantes.', e.status);
return new ApiError(`A API respondeu com erro ${e.status}.`, e.status);
}
return new ApiError('Erro inesperado.', -1);
}
Singleton não é regra universal. Um service que guarda o rascunho de um formulário deve ter uma instância por componente, para que duas telas não compartilhem sem querer o mesmo rascunho. A pergunta que decide é: este estado deve sobreviver e ser compartilhado, ou deve morrer com a tela? O service de API não tem estado, então uma instância única é a escolha natural.
Uma requisição por vez: a fila e o interceptor
Ao abrir, o dashboard precisa de duas chamadas: o comparativo das quatro ações e os detalhes da ação escolhida. Se saírem juntas, e a API aceita uma requisição por vez, uma delas pode ser recusada. Resolver isso em cada componente, ou dentro da store, misturaria uma regra de transporte com regras de estado. O lugar certo é o próprio caminho HTTP.
A documentação do Angular descreve interceptors como middlewares do HttpClient: cada um recebe a requisição e a função next, que representa o próximo passo da cadeia. Um deles, junto com uma pequena fila, basta. A fila deixa passar uma requisição por vez, na ordem em que foram pedidas:
@Injectable({ providedIn: 'root' })
export class RequestQueue {
private readonly waiting: (() => void)[] = [];
private busy = false;
enqueue<T>(source: () => Observable<T>): Observable<T> {
return new Observable<T>((subscriber) => {
let inner: Subscription | undefined;
let started = false;
let released = false;
const release = () => {
if (!started || released) return;
released = true;
this.busy = false;
this.waiting.shift()?.(); // dá a vez à próxima
};
const start = () => {
started = true;
this.busy = true;
inner = source().subscribe({
next: (value) => subscriber.next(value),
error: (error) => { subscriber.error(error); release(); },
complete: () => { subscriber.complete(); release(); },
});
};
if (this.busy) this.waiting.push(start);
else start();
// cancelamento: sai da fila se ainda espera; se já corria, cancela e libera a vez
return () => {
const i = this.waiting.indexOf(start);
if (i >= 0) this.waiting.splice(i, 1);
inner?.unsubscribe();
release();
};
});
}
}
Três propriedades fazem a fila ser segura. Terminar, falhar ou ser cancelada sempre libera a vez, então um erro nunca trava as chamadas seguintes. Cancelar quem ainda espera a tira da fila: a requisição nunca chega à rede. E a vez só é pedida quando alguém se inscreve, como em qualquer chamada do HttpClient.
O interceptor é pequeno porque a fila já faz o trabalho. Ele só decide quais requisições entram, as que vão para o endereço da API:
export const serializeRequests: HttpInterceptorFn = (req, next) => {
const { apiBaseUrl } = inject(APP_CONFIG);
if (!req.url.startsWith(apiBaseUrl)) return next(req); // outros endereços seguem direto
return inject(RequestQueue).enqueue(() => next(req));
};
// app.config.ts
provideHttpClient(withFetch(), withInterceptors([serializeRequests]))
Nenhum componente, nenhuma store e nenhum método do service sabem que a fila existe. A regra mora em um ponto e pode ser desligada em um ponto. Os testes cobrem o contrato: a segunda chamada só chega à rede depois da primeira terminar, um erro libera a próxima e uma chamada cancelada na espera nunca é enviada. Em um navegador real, contra a API, a abertura do dashboard passou a ter no máximo uma requisição em andamento por vez, sem erro na tela.
Uma camada anticorrupção para os dados da API
A resposta dessa API tem dezenas de campos, números que vêm nulos e formatos próprios, como datas em segundos desde 1970. Um balanço de banco tem campos diferentes do de uma mineradora. Se os componentes lerem esse JSON diretamente, qualquer mudança da API quebra várias telas.
A solução é um mapper que traduz o formato externo para modelos do próprio app, com null explícito para "a API não informou". Só a pasta core/api conhece o formato bruto:
const num = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) ? v : null;
export function toCandles(list: BrapiCandle[] | null | undefined): Candle[] {
return (list ?? [])
.filter((c) => num(c.close) !== null && num(c.date) !== null)
.map((c) => ({
date: new Date(c.date * 1000), // a API usa segundos; o app usa Date
open: num(c.open) ?? (c.close as number),
close: c.close as number,
volume: num(c.volume) ?? 0,
/* ... */
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
}
Há um cuidado aritmético que aparece só com dados reais: na rosca de composição dos ativos de um banco, alguns campos parecem ser subconjuntos de outros. Nos números do balanço de um grande banco, o depósito compulsório está contido nos ativos financeiros, e a soma só fecha no ativo total sem contá-lo de novo. Somar tudo contaria o mesmo valor mais de uma vez, e só a conferência com dados reais pega isso.
Store com signals: o estado em um lugar só
Quem decide quando buscar na API, o que mostrar enquanto carrega e o que fazer ao trocar de ação com uma requisição em andamento? Se a resposta for "cada componente", o app vira uma coleção de regras repetidas. A resposta certa é uma store.
Com signals, o desenho é direto. A documentação define um signal como um invólucro de valor que avisa os interessados quando o valor muda, e o computed como um signal derivado, avaliado sob demanda e memorizado. Isso leva a quatro regras:
- o estado fica em signals privados;
- a leitura é exposta como somente leitura;
- o que dá para calcular é
computed, nunca guardado; - as mudanças acontecem por métodos da store.
O formato do estado: LoadState
Toda fatia de dados usa o mesmo formato, para a tela tratar todas de forma uniforme:
export type LoadStatus = 'idle' | 'loading' | 'success' | 'error';
export interface LoadState<T> {
status: LoadStatus;
data: T | null;
error: string | null;
}
export const idle = <T>(): LoadState<T> => ({ status: 'idle', data: null, error: null });
export const loading = <T>(previous: T | null = null): LoadState<T> =>
({ status: 'loading', data: previous, error: null });
export const success = <T>(data: T): LoadState<T> => ({ status: 'success', data, error: null });
export const failure = <T>(error: string, previous: T | null = null): LoadState<T> =>
({ status: 'error', data: previous, error });
Note que loading e failure aceitam os dados anteriores: assim a tela pode continuar mostrando o último valor bom, esmaecido, enquanto atualiza, em vez de piscar um esqueleto.
Montando a store
@Injectable({ providedIn: 'root' })
export class QuoteStore {
private readonly api = inject(BrapiApiService);
// seleção
private readonly _ticker = signal<Ticker>(DEFAULT_TICKER);
private readonly _range = signal<RangeKey>('1y');
readonly ticker = this._ticker.asReadonly();
readonly range = this._range.asReadonly();
// uma fatia de dados por tipo de chamada
private readonly _details = signal<LoadState<StockDetails>>(idle());
private readonly _history = signal<LoadState<Candle[]>>(idle());
private readonly _comparison = signal<LoadState<ComparisonRow[]>>(idle());
readonly details = this._details.asReadonly();
readonly history = this._history.asReadonly();
readonly comparison = this._comparison.asReadonly();
// derivados: calculados, nunca guardados
readonly quote = computed(() => this._details().data?.quote ?? null);
readonly periodChange = computed(() => {
const candles = this._history().data;
if (!candles || candles.length < 2) return null;
const first = candles[0].close;
return first ? (candles[candles.length - 1].close - first) / first : null;
});
// cache e requisições em andamento (para cancelar as obsoletas)
private readonly detailsCache = new Map<Ticker, StockDetails>();
private readonly historyCache = new Map<string, Candle[]>();
private detailsSub?: Subscription;
private historySub?: Subscription;
/* ... */
}
A variação do período não é guardada: deriva do histórico e se recalcula sozinha sempre que o histórico muda. Como a store também é singleton, o cache sobrevive à troca de rota.
Os comandos e o carregamento com cache e cancelamento
Quem usa a store chama comandos, não mexe em signals. Os comandos são curtos e delegam ao carregamento:
/** Escolhe a ação exibida. Usa o cache se já foi carregada. */
select(ticker: Ticker): void {
if (ticker === this._ticker() && this._details().status !== 'idle') return;
this._ticker.set(ticker);
this.loadDetails(ticker, false);
}
/** Troca o período do gráfico: só o histórico é buscado, e só se não estiver em cache. */
setRange(range: RangeKey): void {
if (range === this._range()) return;
this._range.set(range);
this.loadHistory(this._ticker(), range, false);
}
O carregamento concentra as três regras que protegem o limite da API: cancelar o obsoleto, usar o cache, e guardar o resultado.
private loadDetails(ticker: Ticker, force: boolean): void {
this.detailsSub?.unsubscribe(); // cancela a requisição anterior
this.historySub?.unsubscribe();
const cached = this.detailsCache.get(ticker);
if (cached && !force) {
this._details.set(success(cached)); // 0 requisições
this.loadHistory(ticker, this._range(), false);
return;
}
const range = this._range();
this._details.set(loading(this.keepPrevious(ticker)));
this.detailsSub = this.api.getDetails(ticker, range).subscribe({
next: (details) => {
this.detailsCache.set(ticker, details);
this.historyCache.set(`${ticker}:${range}`, details.history); // o histórico já veio junto
this._details.set(success(details));
this.loadHistory(ticker, this._range(), false); // o período pode ter mudado no caminho
},
error: (e) => this._details.set(failure(toApiError(e).message)),
});
}
Detalhe importante: keepPrevious só mantém na tela os dados anteriores se forem da mesma ação. Trocar de PETR4 para VALE3 nunca mostra números de PETR4 com o nome de VALE3 enquanto carrega.
Como a store é chamada
Na ponta da árvore, a página do dashboard é o container de mais alto nível. Ela recebe o ticker da URL e avisa a store; não formata nem desenha nada:
export class DashboardPage {
protected readonly store = inject(QuoteStore);
/** Parâmetro `:ticker` da rota (withComponentInputBinding o liga a este input). */
readonly ticker = input<string>();
constructor() {
this.store.start();
effect(() => {
const t = this.ticker();
if (isTicker(t)) this.store.select(t);
});
const stopAutoRefresh = this.store.startAutoRefresh();
inject(DestroyRef).onDestroy(stopAutoRefresh);
}
}
Cada seção lê só a fatia de que precisa. A seção de preço, por exemplo, lê o histórico, chama setRange quando a pessoa troca o período e deixa o bloco assíncrono tratar carregando, erro e sucesso (template simplificado):
<app-segmented-control
[options]="ranges"
[value]="store.range()"
(valueChange)="store.setRange($event)" />
<app-async-block [state]="store.history()" what="o gráfico" (retry)="store.retryHistory()">
@if (store.history().data; as candles) {
<app-price-chart [candles]="candles" />
}
</app-async-block>
O app-async-block é um componente de apresentação: recebe um LoadState por input(), projeta o conteúdo de sucesso e avisa por output() quando a pessoa pede para tentar de novo. Ele não conhece a store. Quem decide o que fazer no "tentar novamente" é o componente inteligente que o usa. O mesmo bloco serve ao gráfico, aos dividendos e ao balanço, então o tratamento de carregamento e de erro existe uma vez só.
Os gráficos do dashboard podem ser SVG puro. A geometria (escala, caminho, arcos de rosca) vira funções puras e testáveis, e cada gráfico recebe um rótulo acessível que descreve o que mostra e navegação por teclado.
Configuração e .env em um app de navegador
O endereço da API e o intervalo de atualização são configuração, e não devem ficar espalhados em números mágicos. A abordagem usada é uma interface tipada registrada com um InjectionToken: quem precisa do valor injeta o token, e nos testes basta trocar o valor.
export interface AppConfig {
apiBaseUrl: string; // sem barra final
refreshIntervalMs: number; // atualização automática
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
export function provideAppConfig(overrides: Partial<AppConfig> = {}): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: APP_CONFIG, useValue: { ...DEFAULT_CONFIG, ...overrides } },
]);
}
Quanto ao .env, vale um alerta da própria documentação do Angular sobre arquivos de ambiente: eles são incorporados ao aplicativo do cliente e ficam visíveis para qualquer pessoa que carregue a página; a documentação recomenda um proxy no servidor ou um gerenciador de segredos para chaves de API. A regra prática é que o .env de um front-end serve para configuração (endereços, intervalos), nunca para segredos.
No caso do dashboard, não há segredo: os tickers abertos dispensam token. Se houvesse, a chave teria de ficar em um servidor intermediário.
Testar sem rede
A separação em camadas paga o maior dividendo nos testes. Com HttpTestingController, a store é testada contra uma rede falsa, e a configuração apontada para um endereço de teste:
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideAppConfig({ apiBaseUrl: 'https://api.test', refreshIntervalMs: 60_000 }),
],
});
it('select busca tudo da ação em UMA requisição', () => {
store.select('PETR4');
const req = http.expectOne((r) => r.url === 'https://api.test/quote/PETR4' && r.params.has('modules'));
expect(req.request.params.get('dividends')).toBe('true');
expect(store.details().status).toBe('loading');
req.flush({ results: [fullResult('PETR4', 5)] });
expect(store.details().status).toBe('success');
});
O teste afirma o contrato que importa para o limite da API: uma requisição, com os módulos esperados. Um teste parecido garante que voltar a uma ação em cache não dispara requisição nenhuma.
Visão global: a estratégia para trabalhar com várias APIs
O exemplo usa uma API. Mas o desenho foi pensado para o caso mais comum em produto real: um app que combina várias fontes, cada uma com seu formato, seus limites e suas falhas. As decisões acima viram uma estratégia que se repete para cada API nova. O que segue extrapola o exemplo, então trate como orientação, não como código do dashboard.
core/api/
├── brapi/ brapi-api.service.ts · brapi.mapper.ts · brapi.types.ts
├── outra-api/ outra-api.service.ts · outra-api.mapper.ts · outra-api.types.ts
└── interceptors/ autenticação · fila por API · erros · log ← transversais
core/store/
├── quote.store.ts usa BrapiApiService
└── portfolio.store.ts combina BrapiApiService + OutraApiService
Em dez princípios:
- Uma fronteira por API. Service, mapper e tipos brutos ficam juntos, em uma pasta. Só essa pasta conhece o formato externo.
- Modelos de domínio próprios. O app fala a própria língua: se uma API trocar um campo, só o mapper muda.
- Service sem estado. Monta a requisição, envia, mapeia. Nada de cache, nada de decisão de tela.
- Combinação de fontes na store, não nos componentes. Quando uma tela precisa de duas APIs, é a store que as combina (por exemplo, com
forkJoinpara chamadas que podem andar juntas), de modo que o componente continue lendo um só estado. - Uma fatia de estado por chamada, todas com o mesmo
LoadState. Se uma fonte falha, só a seção dela mostra erro e "tentar novamente"; o resto da tela segue funcionando. - Orçamento de requisições. Para cada API, conheça o limite e o respeite: consolidar chamadas, cachear por chave, cancelar o obsoleto, pausar quando a aba está oculta, invalidar com critério.
- Preocupações transversais em interceptors. A documentação do Angular descreve interceptors como middlewares do
HttpClientpara padrões comuns como repetição de tentativas, cache, log e autenticação, registrados comprovideHttpClient(withInterceptors([...]))e executados na ordem da lista. - Erros traduzidos em um ponto só. Uma função converte falhas técnicas em mensagens para pessoas; os componentes nunca interpretam códigos HTTP.
- Configuração tipada, sem segredos no navegador. Endereços e intervalos por
InjectionToken; credenciais, só em um servidor intermediário. - Testes sem rede. Se cada camada se testa com uma rede falsa, a regra de cache e de erro fica protegida contra regressão.
O dashboard já usa essa peça: a fila da seção anterior é um interceptor. Com várias APIs, ele cresce naturalmente. Cada API tem seus limites, então a decisão de qual regra se aplica a cada requisição passa a depender do endereço de destino: uma fila por API, escolhida pelo host da URL, em vez de uma fila única. Autenticação por host, leitura do cabeçalho Retry-After de uma API que responde 429 e registro de tempos também moram aqui. Como os interceptors executam na ordem da lista, a ordem do registro faz parte do desenho. Um registro ilustrativo, que extrapola o exemplo:
provideHttpClient(
withFetch(),
withInterceptors([
authInterceptor, // credencial certa para cada host
serializeRequests, // fila por API (no dashboard: uma só)
errorInterceptor, // normaliza falhas de qualquer API
]),
)
Existe ainda uma alternativa para leituras simples. O httpResource é, segundo a documentação, um invólucro reativo sobre o HttpClient que expõe estado e resposta como signals: quando um signal usado na URL muda, uma nova requisição é disparada, e uma requisição pendente é cancelada. A própria documentação o indica para leituras e recomenda evitá-lo em mutações (POST, PUT). Para uma tela com uma leitura reativa, ele pode substituir parte da store; para o dashboard, onde há cache por chave, regras de invalidação e atualização automática, a store explícita deixa essas regras mais visíveis.
Vantagens, limites e quando não usar
Vantagens. Cada mudança tem um lugar óbvio. A regra de cache e de erro existe uma vez. Os testes ficam fáceis, porque funções puras, mapper, service e store têm formas simples de ser testados, inclusive com uma rede falsa. O app respeita o limite da API sem esforço dos componentes.
Limites.
- A divisão em camadas custa arquivos e disciplina; para um app com uma tela e uma chamada, é exagero.
- Uma store feita à mão serve a um conjunto pequeno de estados. Com muitos fluxos, efeitos colaterais e histórico de ações, bibliotecas dedicadas de gerenciamento de estado podem valer o investimento.
- O cache simples não invalida sozinho: é preciso um botão de atualizar e um intervalo, e escolher bem o tempo de vida dos dados.
- A fila garante concorrência de uma requisição, não a cota de 20 por janela. Quem protege a cota são o cache, a consolidação de chamadas e o cancelamento; uma sequência muito rápida de ações novas ainda pode esgotá-la, e então a store mostra a mensagem e o botão de tentar novamente. Um limitador de taxa seria um segundo interceptor, e o dashboard ainda não o tem.
- A fila vive na memória de uma aba: duas abas abertas não compartilham a vez nem a cota.
Quando não usar. Protótipos descartáveis, páginas estáticas e telas isoladas não precisam de nada disso. A arquitetura se justifica quando há várias fontes de dados, estado compartilhado entre telas e uma equipe que precisa saber onde mexer.
Conclusão
Um dashboard bem organizado é o resultado de poucas decisões consistentes: pastas por funcionalidade, um service que só fala com a API, um mapper que isola o formato externo, uma store com signals que concentra estado, cache e cancelamento, componentes que separam o que sabe do que mostra, e configuração tipada que nunca carrega segredos. Com várias APIs, o padrão se repete: uma fronteira por API, uma store por domínio e as preocupações transversais em interceptors.
Um bom exercício é pegar um painel que você já tem e fazer quatro perguntas: quantas requisições a tela custa ao abrir, e quantas precisaria custar? Onde está a regra de cache? Quem decide o que mostrar quando uma das APIs falha? E o que do .env iria parar no navegador de quem acessa a página? As respostas costumam apontar por onde começar a refatoração.
Links
- Tutorial link: https://cursos.danielfaustino.com.br/#/curso/07-dashboard-acoes
- View link: https://acoes-dashboard.vercel.app
- Repository link: https://gitlab.com/daniel.eng.dev/acoes-dashboard
Fontes consultadas
- https://angular.dev/style-guide
- https://angular.dev/guide/di/creating-and-using-services
- https://angular.dev/guide/signals
- https://angular.dev/tools/cli/environments
- https://angular.dev/guide/http/interceptors
- https://angular.dev/guide/http/http-resource
- https://brapi.dev/blog/acesso-gratuito-ilimitado-acoes-teste-brapi