Modern Angular
Adapter Pattern no Angular: isolando APIs e bibliotecas browser-only
Um adapter merece existir quando o código Angular começa a falar um contrato que a aplicação não controla: DTOs do backend, callbacks de terceiros, APIs do browser ou ciclo de vida de widgets. A Gang of Four definiu o pattern em 1994 como deixar 'classes com interfaces incompatíveis trabalharem juntas'. No Angular, o DateAdapter do Material é canônico: classe abstrata que deixa componentes baseados em CDK trabalharem com Date, Moment ou Luxon atrás de um contrato único.
Comece onde o contrato externo vaza
Eu uso Adapter Pattern no Angular quando uma feature começa a importar vocabulário de fora.
Um backend retorna campos em snake_case, centavos, códigos de status e timestamps. Uma biblioteca de chart pede um host imperativo no DOM e emite callbacks com payload próprio. Trabalho browser-only pertence ao pós-render, não ao server rendering ou prerendering. Nenhum desses contratos deveria virar vocabulário do componente da página.
Neste artigo, o contrato externo é o formato controlado fora da aplicação; o adapter é o limite de implementação que traduz esse formato para um contrato da aplicação.
Eu não criaria wrapper para toda dependência por princípio. Eu criaria um adapter quando o formato externo vaza para componentes, facades, templates, testes ou tipos de domínio compartilhados. Se o wrapper só renomeia um método e não traduz nada, ele é ruído.
| Contrato externo | Vazamento no código Angular | Limite do adapter |
|---|---|---|
| DTO do backend | Componentes conhecem report_id, centavos e códigos de status | Mapear DTOs para modelos da aplicação perto do service de API |
| Chart de terceiro | Componente assume setup imperativo de DOM e payload dos callbacks | Traduzir ciclo de vida e eventos em um adapter do chart |
| API browser-only | Código toca window ou DOM antes do render | Iniciar DOM depois do render e manter acesso ao browser fora do modelo da página |
| Widget customizado | Template conhece nomes de eventos e payload da biblioteca | Expor inputs, outputs ou signals com formato Angular |
| Um método com mesmo input/output | Wrapper adiciona classe sem tradução | Ainda não crie o adapter |
O cheiro: um componente fala três línguas
Este componente não é ruim por usar HttpClient, Signals ou afterNextRender. Essas são ferramentas normais do Angular. O problema é que ele fala DTO do backend, API da biblioteca de chart e estado da página ao mesmo tempo.
O acoplamento aparece em mudanças pequenas. Renomear um campo do backend muda o componente. Alterar o payload do vendor muda o componente. Um teste de ponto selecionado precisa conhecer point_id. O componente ainda transforma qualquer status não verde em attention, escondendo uma decisão do contrato do backend dentro da página.
Também existe um bug de ciclo de vida escondido: o chart pode ser destruído pelo onCleanup e depois de novo pelo DestroyRef. Esse tipo de duplicidade é comum quando o componente assume uma biblioteca imperativa que deveria consumir por um limite pequeno.
@Component({
selector: 'app-sales-dashboard-page',
templateUrl: './sales-dashboard-page.html',
})
export class SalesDashboardPage {
private readonly http = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
private readonly chartHost =
viewChild.required<ElementRef<HTMLElement>>('chartHost');
private readonly browserReady = signal(false);
readonly selectedPoint = signal<string | null>(null);
readonly report = toSignal(
this.http.get<SalesReportDto>('/api/reports/sales/current').pipe(
map((dto) => ({
id: dto.report_id,
generatedAt: new Date(dto.generated_at),
status: dto.health_code === 'GREEN' ? 'healthy' : 'attention',
totalRevenue: dto.total_revenue_cents / 100,
points: dto.points.map((point) => ({
id: String(point.point_id),
label: point.label_text,
revenue: point.revenue_cents / 100,
})),
})),
catchError(() => of(null))
),
{ initialValue: null }
);
constructor() {
afterNextRender(() => this.browserReady.set(true));
effect((onCleanup) => {
const report = this.report();
if (!this.browserReady() || !report) return;
const chart = window.acmeCharts.render(
this.chartHost().nativeElement,
report.points.map((point) => ({
key: point.id,
name: point.label,
value: point.revenue,
}))
);
chart.on('pointClick', (event) => {
this.selectedPoint.set(String(event.point_id));
});
onCleanup(() => chart.destroy());
this.destroyRef.onDestroy(() => chart.destroy());
});
}
}Adapte o contrato do backend uma vez
O service de API deve devolver um modelo que a aplicação aceita assumir. Isso não significa fingir que DTOs não existem. Significa impedir que DTOs atravessem o limite e cheguem a componentes e facades.
Mantenha esse adapter perto do módulo de API. Se o backend mudar health_code ou a representação de dinheiro, um ponto quebra de forma explícita. A página do dashboard não deveria saber se o backend guarda receita em centavos, strings decimais ou algum schema futuro de relatório.
O HttpClient do Angular já dá um limite claro para transporte, e DI deixa substituição explícita em testes. O adapter agrega valor porque muda o formato do contrato.
type SalesReportStatus = 'healthy' | 'attention' | 'blocked';
interface SalesReportDto {
report_id: string;
generated_at: string;
health_code: 'GREEN' | 'YELLOW' | 'RED';
total_revenue_cents: number;
points: SalesReportPointDto[];
}
interface SalesReportPointDto {
point_id: number;
label_text: string;
revenue_cents: number;
}
interface SalesReport {
id: string;
generatedAt: Date;
status: SalesReportStatus;
totalRevenue: number;
points: SalesReportPoint[];
}
interface SalesReportPoint {
id: string;
label: string;
revenue: number;
}
@Injectable({ providedIn: 'root' })
export class SalesReportAdapter {
fromDto(dto: SalesReportDto): SalesReport {
return {
id: dto.report_id,
generatedAt: new Date(dto.generated_at),
status: this.statusFrom(dto.health_code),
totalRevenue: dto.total_revenue_cents / 100,
points: dto.points.map((point) => ({
id: String(point.point_id),
label: point.label_text.trim(),
revenue: point.revenue_cents / 100,
})),
};
}
private statusFrom(code: SalesReportDto['health_code']): SalesReportStatus {
switch (code) {
case 'GREEN':
return 'healthy';
case 'YELLOW':
return 'attention';
case 'RED':
return 'blocked';
default: {
const exhaustive: never = code;
throw new Error(`Unsupported health code: ${exhaustive}`);
}
}
}
}
@Injectable({ providedIn: 'root' })
export class SalesReportApi {
private readonly http = inject(HttpClient);
private readonly adapter = inject(SalesReportAdapter);
current() {
return this.http
.get<SalesReportDto>('/api/reports/sales/current')
.pipe(map((dto) => this.adapter.fromDto(dto)));
}
}Adapte a biblioteca no limite do browser
Charts, editors, mapas e widgets de pagamento costumam usar APIs imperativas porque não são componentes Angular. Tudo bem. O problema é deixar cada página aprender ciclo de vida, nomes de evento, campos de payload e regra de teardown do vendor.
Um adapter de chart deve traduzir dados da aplicação para entrada do vendor, e eventos do vendor de volta para eventos da aplicação. Ele não deve assumir estado da página. O componente Angular ainda pode decidir que o ponto selecionado mora em um signal.
Isso mantém pequena a parte sensível ao render. Render callbacks do Angular são o lugar certo para iniciar trabalho manual de DOM depois do render, enquanto o adapter assume a interação com o vendor quando o elemento já existe.
interface SalesChartSelection {
pointId: string;
label: string;
}
interface SalesChartHandle {
destroy(): void;
}
@Injectable()
export class SalesChartAdapter {
connect(
host: HTMLElement,
report: SalesReport,
onSelect: (selection: SalesChartSelection) => void
): SalesChartHandle {
const chart = window.acmeCharts.render(host, {
series: report.points.map((point) => ({
key: point.id,
name: point.label,
value: point.revenue,
})),
});
const unsubscribe = chart.on(
'pointClick',
(event: AcmeChartPointEvent) => {
onSelect({
pointId: String(event.point_id),
label: event.label ?? 'Unknown point',
});
}
);
let destroyed = false;
return {
destroy: () => {
if (destroyed) return;
destroyed = true;
unsubscribe();
chart.destroy();
},
};
}
}A página consome contratos da aplicação
Depois dos adapters, o componente da página tem uma função mais estreita: ler um relatório no formato da aplicação, esperar o render no browser, conectar o chart e guardar o ponto selecionado em estado do componente.
Fornecer SalesChartAdapter no nível da página é intencional. A instância do chart está ligada a essa árvore de componentes, não à aplicação inteira. Providers root são úteis para adapters stateless de DTO e services de API; instâncias de widgets browser-only normalmente merecem um ciclo de vida menor.
Esta é a linha de produção que eu cobraria em review: componentes podem coordenar contratos da aplicação, mas não devem virar tradutores de contratos controlados por vendors, browsers ou times de backend.
@Component({
selector: 'app-sales-dashboard-page',
providers: [SalesChartAdapter],
templateUrl: './sales-dashboard-page.html',
})
export class SalesDashboardPage {
private readonly api = inject(SalesReportApi);
private readonly chartAdapter = inject(SalesChartAdapter);
private readonly chartHost =
viewChild.required<ElementRef<HTMLElement>>('chartHost');
private readonly browserReady = signal(false);
readonly selectedPoint = signal<SalesChartSelection | null>(null);
readonly report = toSignal(this.api.current(), { initialValue: null });
constructor() {
afterNextRender(() => this.browserReady.set(true));
effect((onCleanup) => {
const report = this.report();
if (!this.browserReady() || !report) return;
const handle = this.chartAdapter.connect(
this.chartHost().nativeElement,
report,
(selection) => this.selectedPoint.set(selection)
);
onCleanup(() => handle.destroy());
});
}
}Não envolva o que você não traduz
Adapter Pattern vira problema quando transforma toda dependência em cerimônia. Angular já tem services, providers, pipes, directives, components e funções simples. O nome adapter só faz sentido quando tradução é a função principal.
A regra de review mais fácil é perguntar qual contrato muda naquele limite. Se a resposta for nenhum, mantenha o código mais simples.
| Lógica | Dono mais adequado | Motivo |
|---|---|---|
| Parsing de DTO e normalização de dinheiro/status | Adapter perto do service de API | A aplicação deve controlar seu modelo de domínio |
| URL HTTP, params, headers e retry | SalesReportApi | Regras de transporte não são estado de página |
| Ponto selecionado no chart | Componente ou facade da feature | É estado de UI dessa tela |
| Setup do chart, payload de evento e teardown | SalesChartAdapter | O contrato do vendor não deve vazar para a página |
| Formatação de moeda para exibição | Pipe ou formatter | Nenhuma dependência externa está sendo adaptada |
Teste a tradução, não o vendor
Um bom adapter dá ao time um alvo de teste barato. O teste do adapter de DTO deve provar que códigos do backend, centavos, timestamps e convenções de nome não vazam para o modelo da UI.
O teste do adapter de chart pode usar uma implementação fake se o wrapper tiver comportamento suficiente para justificar isso. Não rode uma biblioteca real de chart no browser só para provar que um status foi mapeado corretamente.
O ganho prático é velocidade de review. Quando um contrato de backend muda, o teste que falha deveria apontar para o limite de tradução, não para um componente de dashboard que por acaso renderiza o relatório.
describe('SalesReportAdapter', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SalesReportAdapter],
});
});
it('keeps backend codes and cents out of the UI model', () => {
const adapter = TestBed.inject(SalesReportAdapter);
const report = adapter.fromDto({
report_id: 'sales-2026-05',
generated_at: '2026-05-18T10:00:00.000Z',
health_code: 'YELLOW',
total_revenue_cents: 129990,
points: [
{
point_id: 42,
label_text: 'Expansion revenue ',
revenue_cents: 49990,
},
],
});
expect(report.status).toBe('attention');
expect(report.totalRevenue).toBe(1299.9);
expect(report.points[0]).toEqual({
id: '42',
label: 'Expansion revenue',
revenue: 499.9,
});
});
});Artefato para reaproveitar
Checklist de review para adapter
- Crie um adapter apenas quando código Angular estiver vazando um contrato que a aplicação não controla.
- Mantenha mapeamento de DTO perto do service de API e retorne modelos da aplicação desse limite.
- Mantenha ciclo de vida, nomes de evento, payloads e teardown de widgets de terceiro dentro de um adapter focado.
- Deixe componentes ou facades assumirem estado de UI; não mova decisões da página para o adapter.
- Dê escopo de componente ou rota a adapters de widgets browser-only quando o ciclo de vida for local.
- Rejeite wrappers que fazem proxy de dependência sem mudar o contrato.
Fontes consultadas
- https://angular.dev/guide/di
- https://angular.dev/guide/di/dependency-injection-providers
- https://angular.dev/api/core/InjectionToken
- https://github.com/angular/components/blob/main/src/material/core/datetime/date-adapter.ts
- https://github.com/angular/components/blob/main/src/material/core/datetime/native-date-adapter.ts
- https://florimond.dev/en/posts/2018/09/consuming-apis-in-angular-the-model-adapter-pattern
- https://en.wikipedia.org/wiki/Design_Patterns
Modern Angular Playbook
Este artigo é um play.
O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.
Você recebe os dois PDFs por email, pela lista do Dojo IA.
André Ramosdisponível para vagas remotas, UTC−3Entre em contato →