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 externoVazamento no código AngularLimite do adapter
DTO do backendComponentes conhecem report_id, centavos e códigos de statusMapear DTOs para modelos da aplicação perto do service de API
Chart de terceiroComponente assume setup imperativo de DOM e payload dos callbacksTraduzir ciclo de vida e eventos em um adapter do chart
API browser-onlyCódigo toca window ou DOM antes do renderIniciar DOM depois do render e manter acesso ao browser fora do modelo da página
Widget customizadoTemplate conhece nomes de eventos e payload da bibliotecaExpor inputs, outputs ou signals com formato Angular
Um método com mesmo input/outputWrapper adiciona classe sem traduçãoAinda 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.

Componente com vazamentots
@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.

Adapter de DTO e API servicets
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.

Adapter do chartts
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.

Limite da páginats
@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ógicaDono mais adequadoMotivo
Parsing de DTO e normalização de dinheiro/statusAdapter perto do service de APIA aplicação deve controlar seu modelo de domínio
URL HTTP, params, headers e retrySalesReportApiRegras de transporte não são estado de página
Ponto selecionado no chartComponente ou facade da featureÉ estado de UI dessa tela
Setup do chart, payload de evento e teardownSalesChartAdapterO contrato do vendor não deve vazar para a página
Formatação de moeda para exibiçãoPipe ou formatterNenhuma 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.

Teste do adapterts
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

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.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 9 min

Facade Pattern no Angular: quando o componente sabe demais

Um guia de produção sobre Facade Pattern no Angular: quando criar, o que ela deve assumir, o que não deve esconder e como testar esse limite.

Ler artigo →

Guia · 10 min

Strategy Pattern no Angular: trocando regras sem espalhar switches

Um guia de produção sobre Strategy Pattern no Angular: quando um switch vira fronteira, como selecionar implementações com DI e como evitar indireção decorativa.

Ler artigo →

Guia · 7 min

Standalone sem transformar NgModules em vilões

Uma regra prática para usar componentes standalone, providers por rota e NgModules remanescentes em aplicações Angular maduras.

Ler artigo →